package main import ( "context" "fmt" "io" "log/slog" "os" "os/signal" "strings" "syscall" "time" _ "github.com/joho/godotenv/autoload" _ "net/http/pprof" "tangled.org/bnewbold.net/scrumble/store" "github.com/bluesky-social/indigo/atproto/identity" "github.com/earthboundkid/versioninfo/v2" "github.com/jackc/pgx/v5" "github.com/urfave/cli/v3" ) func main() { if err := run(os.Args); err != nil { slog.Error("exiting process", "err", err.Error()) os.Exit(-1) } } func run(args []string) error { app := cli.Command{ Name: "scrumble", Usage: "scrumble.social server instance", Version: versioninfo.Short(), } app.Flags = []cli.Flag{ &cli.StringSliceFlag{ Name: "admin-password", Usage: "secret password/token for accessing admin endpoints (multiple values allowed)", Sources: cli.EnvVars("SCRUMBLE_ADMIN_PASSWORD", "SCRUMBLE_ADMIN_KEY"), }, &cli.StringFlag{ Name: "log-level", Usage: "log verbosity level (eg: warn, info, debug)", Sources: cli.EnvVars("SCRUMBLE_LOG_LEVEL", "GO_LOG_LEVEL", "LOG_LEVEL"), }, } app.Commands = []*cli.Command{ &cli.Command{ Name: "serve", Usage: "run the scrumble daemon", Action: runServer, Flags: []cli.Flag{ &cli.StringFlag{ Name: "db-url", Usage: "database connection string for relay database", Required: true, Sources: cli.EnvVars("DATABASE_URL"), }, &cli.StringFlag{ Name: "plc-host", Usage: "method, hostname, and port of PLC registry", Value: "https://plc.directory", Sources: cli.EnvVars("SCRUMBLE_PLC_HOST", "ATP_PLC_HOST"), }, &cli.StringFlag{ Name: "bind", Usage: "IP or address, and port, to listen on for HTTP APIs (including firehose)", Value: ":3000", Sources: cli.EnvVars("SCRUMBLE_BIND"), }, &cli.IntFlag{ Name: "ident-cache-size", Value: 10_000, Usage: "size of in-process identity cache (eg, DID docs)", Sources: cli.EnvVars("SCRUMBLE_IDENT_CACHE_SIZE"), }, &cli.StringFlag{ Name: "metrics-listen", Usage: "IP or address, and port, to listen on for prometheus metrics", Value: ":2471", Sources: cli.EnvVars("SCRUMBLE_METRICS_LISTEN"), }, &cli.StringFlag{ Name: "session-secret", Usage: "random string/token used for session cookie security", Required: true, Sources: cli.EnvVars("SESSION_SECRET"), }, }, }, &cli.Command{ Name: "dev", Usage: "run simplified dev/demo mode", Action: runDev, Flags: []cli.Flag{ &cli.StringFlag{ Name: "db-url", Usage: "database connection string for relay database", Value: "postgres://scrumbledev:scrumbledev@localhost:5454/scrumbledev?sslmode=disable", Sources: cli.EnvVars("DATABASE_URL"), }, &cli.StringFlag{ Name: "bind", Usage: "IP or address, and port, to listen on for HTTP APIs (including firehose)", Value: ":3000", Sources: cli.EnvVars("SCRUMBLE_BIND"), }, &cli.StringFlag{ Name: "session-secret", Usage: "random string/token used for session cookie security", Value: "dev", Sources: cli.EnvVars("SESSION_SECRET"), }, }, }, } return app.Run(context.Background(), args) } func configLogger(cmd *cli.Command, writer io.Writer) *slog.Logger { var level slog.Level switch strings.ToLower(cmd.String("log-level")) { case "error": level = slog.LevelError case "warn": level = slog.LevelWarn case "info": level = slog.LevelInfo case "debug": level = slog.LevelDebug default: level = slog.LevelInfo } logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{ Level: level, })) slog.SetDefault(logger) return logger } func runServer(ctx context.Context, cmd *cli.Command) error { logger := configLogger(cmd, os.Stdout) // Trap SIGINT to trigger a shutdown. signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) dburl := cmd.String("db-url") // TODO: don't print db URL (with credentials) logger.Info("configuring database", "url", dburl) db, err := pgx.Connect(ctx, dburl) if err != nil { return err } defer db.Close(ctx) baseDir := identity.BaseDirectory{ SkipHandleVerification: true, SkipDNSDomainSuffixes: []string{".bsky.social"}, TryAuthoritativeDNS: true, PLCURL: cmd.String("plc-host"), UserAgent: UserAgent, } dir := identity.NewCacheDirectory(&baseDir, cmd.Int("ident-cache-size"), time.Hour*24, time.Minute*2, time.Minute*5) // run database migrations if err := store.MigrateUp(dburl); err != nil { return fmt.Errorf("database migration failed: %w", err) } config := ServerConfig{ Directory: dir, CookieSessionSecret: []byte(cmd.String("session-secret")), } srv, err := NewServer(db, config) if err != nil { return err } // start metrics endpoint go func() { if err := srv.StartMetrics(cmd.String("metrics-listen")); err != nil { logger.Error("failed to start metrics endpoint", "err", err) os.Exit(1) } }() srvErr := make(chan error, 1) go func() { err := srv.StartHTTP(cmd.String("bind")) srvErr <- err }() go func() { err := srv.StartIndexer() srvErr <- err }() logger.Info("startup complete") select { case <-signals: logger.Info("received shutdown signal") errs := srv.Shutdown() for err := range errs { logger.Error("error during shutdown", "err", err) } case err := <-srvErr: if err != nil { logger.Error("error during startup", "err", err) } logger.Info("shutting down") errs := srv.Shutdown() for err := range errs { logger.Error("error during shutdown", "err", err) } } logger.Info("shutdown complete") return nil } func runDev(ctx context.Context, cmd *cli.Command) error { logger := configLogger(cmd, os.Stdout) // Trap SIGINT to trigger a shutdown. signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) dburl := cmd.String("db-url") // TODO: don't print db URL (with credentials) logger.Info("configuring database", "url", dburl) // wipe database and run migrations if err := store.WipeDatabase(dburl); err != nil { return err } if err := store.MigrateUp(dburl); err != nil { return err } db, err := pgx.Connect(ctx, dburl) if err != nil { return err } defer db.Close(ctx) dir := identity.NewMockDirectory() config := ServerConfig{ Directory: dir, CookieSessionSecret: []byte(cmd.String("session-secret")), DevMode: true, } srv, err := NewServer(db, config) if err != nil { return err } // insert example data mockDir, err := srv.store.InsertExampleData(ctx) if err != nil { return err } srv.dir = mockDir srvErr := make(chan error, 1) go func() { err := srv.StartHTTP(cmd.String("bind")) srvErr <- err }() logger.Info("startup complete") select { case <-signals: logger.Info("received shutdown signal") errs := srv.Shutdown() for err := range errs { logger.Error("error during shutdown", "err", err) } case err := <-srvErr: if err != nil { logger.Error("error during startup", "err", err) } logger.Info("shutting down") errs := srv.Shutdown() for err := range errs { logger.Error("error during shutdown", "err", err) } } logger.Info("shutdown complete") return nil }