Files
root-me-api/cmd/scoreboard-apid/main.go
T
mycroft cf961ab94f
build / test (push) Successful in 9s
build / build (push) Successful in 10s
build / build-image (push) Successful in 1m4s
feat: initial commit
2026-06-04 14:19:37 +02:00

131 lines
3.5 KiB
Go

// 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}))
}