// Command scoreboard-apid serves the root-me.org scoreboard over HTTP/JSON and // gRPC. It scrapes the public Rankings fragment through a native Anubis // proof-of-work solver, caching the result in memory and refreshing on an // interval. package main import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "syscall" "time" "google.golang.org/grpc" "google.golang.org/grpc/reflection" pb "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1" "git.mkz.me/mycroft/root-me-api/internal/anubis" "git.mkz.me/mycroft/root-me-api/internal/config" "git.mkz.me/mycroft/root-me-api/internal/scoreboard" "git.mkz.me/mycroft/root-me-api/internal/scraper" "git.mkz.me/mycroft/root-me-api/internal/server" ) func main() { if err := run(os.Args[1:]); err != nil { slog.Error("fatal", "err", err) os.Exit(1) } } func run(args []string) error { cfg, err := config.Load(args) if err != nil { return err } logger := setupLogger(cfg.LogLevel) slog.SetDefault(logger) // Anubis-solving HTTP client. tr, err := anubis.New() if err != nil { return err } tr.UserAgent = cfg.UserAgent tr.Logger = logger client := tr.Client() client.Timeout = cfg.RequestTimeout // Scraper + store + refresher. sc := scraper.New(client, logger) store := scoreboard.NewStore() fetch := func(ctx context.Context) ([]scoreboard.Entry, error) { return sc.Fetch(ctx, cfg.Pages) } refresher := scoreboard.NewRefresher(store, fetch, cfg.RefreshInterval, logger) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // Warm start: try once before serving so /readyz flips green quickly. A // failure here is non-fatal — the refresher will retry on its interval. warmCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout*time.Duration(cfg.Pages+1)) if err := refresher.RefreshOnce(warmCtx); err != nil { logger.Warn("initial scoreboard load failed; serving will start unready", "err", err) } cancel() go refresher.Run(ctx) // HTTP server. httpSrv := &http.Server{ Addr: cfg.HTTPAddr, Handler: server.HTTPHandler(store, refresher, logger), ReadHeaderTimeout: 10 * time.Second, } // gRPC server. grpcSrv := grpc.NewServer() pb.RegisterScoreboardServiceServer(grpcSrv, server.NewGRPC(store)) reflection.Register(grpcSrv) grpcLis, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { return fmt.Errorf("grpc listen: %w", err) } errCh := make(chan error, 2) go func() { logger.Info("HTTP server listening", "addr", cfg.HTTPAddr) if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- fmt.Errorf("http: %w", err) } }() go func() { logger.Info("gRPC server listening", "addr", cfg.GRPCAddr) if err := grpcSrv.Serve(grpcLis); err != nil { errCh <- fmt.Errorf("grpc: %w", err) } }() select { case <-ctx.Done(): logger.Info("shutdown signal received") case err := <-errCh: logger.Error("server error", "err", err) } // Graceful shutdown. shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutCancel() if err := httpSrv.Shutdown(shutCtx); err != nil { logger.Warn("http shutdown", "err", err) } grpcSrv.GracefulStop() logger.Info("stopped") return nil } func setupLogger(level string) *slog.Logger { var lvl slog.Level if err := lvl.UnmarshalText([]byte(level)); err != nil { lvl = slog.LevelInfo } return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})) }