package main import ( "context" "errors" "log" "net/http" "os/signal" "strings" "syscall" "time" "gitea.tbdevent.eu/TBD/reforger_crawler_main/admin" "gitea.tbdevent.eu/TBD/reforger_crawler_main/auth" "gitea.tbdevent.eu/TBD/reforger_crawler_main/controllers" "gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers" "gitea.tbdevent.eu/TBD/reforger_crawler_main/metrics" "gitea.tbdevent.eu/TBD/reforger_crawler_main/web" "github.com/gin-gonic/gin" ) // buildCommit is set at build time: -ldflags "-X main.buildCommit=" var buildCommit = "" func init() { initializers.Load() initializers.ConnectToDB() } func main() { warnAboutConfig() r := gin.New() r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: skipLog}), gin.Recovery()) if len(initializers.TrustedProxies) > 0 { if err := r.SetTrustedProxies(initializers.TrustedProxies); err != nil { log.Fatalf("Invalid TRUSTED_PROXIES: %v", err) } } metrics.SetEnabled(initializers.MetricsEnabled) if initializers.MetricsEnabled { r.Use(metrics.Middleware()) } r.Use(CORSMiddleware()) r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) back := r.Group("/back") { back.GET("/addon", controllers.CheckAllowed, controllers.GetCurrentState) back.POST("/addon", controllers.CheckAllowed, controllers.CreateAddon) back.GET("/nextToBeIndexed", controllers.CheckAllowed, controllers.GetNextToBeIndexed) back.POST("/submitAddon", controllers.CheckAllowed, controllers.SaveIndexingResult) back.DELETE("/deleteAddon", controllers.CheckAllowed, controllers.DeleteAddon) back.POST("/addonDependencies", controllers.CheckAllowed, controllers.SetAddonDependencies) back.POST("/telemetry", controllers.CheckAllowed, controllers.SubmitTelemetry) } // admin JSON API, legacy admin endpoint and the embedded dashboard admin.Register(r) web.Register(r) if initializers.MetricsToken != "" { r.GET("/metrics", requireMetricsToken, func(c *gin.Context) { c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") metrics.WritePrometheus(c.Writer) }) } v1 := r.Group("/v1") { v1.GET("/addon/:id", controllers.GetDuplicates) v1.GET("/addon/:id/dependents", controllers.GetDependents) v1.GET("/addon/:id/dependencies", controllers.GetDependencies) v1.GET("/getPossible", controllers.GetPossibleAddons) } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() version := buildCommit if version == "" { version = "dev" } admin.BuildCommit = buildCommit metrics.SetVersion(version) if initializers.MetricsEnabled { metrics.Start(ctx) } admin.StartBackground(ctx) srv := &http.Server{ Addr: initializers.ServerHost + ":" + initializers.ServerPort, Handler: r, ReadHeaderTimeout: 10 * time.Second, } go func() { log.Printf("Listening on %s (admin dashboard at /admin/ui/)", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Server error: %v", err) } }() <-ctx.Done() log.Println("Shutting down...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Printf("Shutdown error: %v", err) } if initializers.MetricsEnabled { metrics.FlushAll() } } // skipLog keeps health checks, dashboard assets and dashboard polling out of // the access log. func skipLog(c *gin.Context) bool { p := c.Request.URL.Path if p == "/health" || strings.HasPrefix(p, "/admin/ui") { return true } return strings.HasPrefix(p, "/admin/api") && c.Request.Method == http.MethodGet && c.Writer.Status() < 400 } func requireMetricsToken(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if auth.SecretEqual(token, initializers.MetricsToken) { return } // also allow a logged-in admin to look at the raw exposition if auth.SecretEqual(c.GetHeader("X-ADMIN-KEY"), initializers.AdminSecret) { return } if cookie, err := c.Cookie(auth.CookieName); err == nil { if _, ok := auth.VerifyToken(cookie, time.Now()); ok { return } } c.Header("WWW-Authenticate", `Bearer realm="metrics"`) c.AbortWithStatus(http.StatusUnauthorized) } func warnAboutConfig() { if initializers.SecretKey == "" { log.Println("Warning: SECRET_KEY is not set - /back endpoints are disabled") } if initializers.AdminSecret == "" { log.Println("Warning: ADMIN_SECRET is not set - the admin dashboard is disabled") } else if len(initializers.AdminSecret) < 16 { log.Println("Warning: ADMIN_SECRET is shorter than 16 characters") } if len(initializers.TrustedProxies) == 0 { log.Println("Warning: TRUSTED_PROXIES is not set - client IPs from X-Forwarded-For are trusted from any source") } } func CORSMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // the admin API and worker endpoints are never called cross-origin p := c.Request.URL.Path if strings.HasPrefix(p, "/admin") || strings.HasPrefix(p, "/back") || p == "/metrics" { c.Next() return } orig := c.Request.Header.Clone().Get("Origin") c.Writer.Header().Set("Access-Control-Allow-Origin", orig) c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } }