Ctrl K
ComunidadGuías…

main.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"sync"
	"syscall"
	"time"

	"github.com/n0rdy/forq/api"
	"github.com/n0rdy/forq/common"
	"github.com/n0rdy/forq/configs"
	"github.com/n0rdy/forq/db"
	"github.com/n0rdy/forq/jobs/cleanup"
	"github.com/n0rdy/forq/jobs/maintenance"
	metricsJobs "github.com/n0rdy/forq/jobs/metrics"
	"github.com/n0rdy/forq/metrics"
	"github.com/n0rdy/forq/services"
	"github.com/n0rdy/forq/ui"

	_ "github.com/golang-migrate/migrate/v4/database/sqlite"

	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/source/iofs"
	"github.com/rs/zerolog/log"
)

const (
	minAuthSecretLength = 32
)

func main() {
	env := getEnv()
	authSecret := getAuthSecret()
	metricsEnabled, metricsAuthSecret := getMetricsConfigs(authSecret)
	queueTtlHours, dlqTtlHours := getTtlConfigs()
	apiAddr, uiAddr := getServerAddrs()
	trustProxyHeaders := getTrustProxyHeaders()

	dbPath := getDbPath()
	log.Info().Msgf("using database file at: %s", dbPath)

	runMigrations(dbPath)

	appConfigs := configs.NewAppConfig(metricsEnabled, queueTtlHours, dlqTtlHours)

	repo, err := db.NewSQLiteRepo(dbPath, appConfigs)
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create SQLite repository")
	}
	defer repo.Close()

	monitoringService := services.NewMonitoringService(repo)
	metricsService := metrics.NewMetricsService(metricsEnabled)
	queuesService := services.NewQueuesService(repo)
	messagesService := services.NewMessagesService(metricsService, repo, appConfigs)
	sessionsService := services.NewSessionsService()
	defer sessionsService.Close()
	// Separate throttle state per server: the API and UI are split into
	// distinct servers so exposing the API can't affect the UI. A shared
	// instance would re-couple them - behind a proxy every request collapses
	// to one client IP, so bad-key floods on the public API would surface as
	// lockouts on the UI login path (harmless today thanks to compare-first
	// auth, but coupling them invites a regression).
	apiThrottlingService := services.NewThrottlingService()
	defer apiThrottlingService.Close()
	uiThrottlingService := services.NewThrottlingService()
	defer uiThrottlingService.Close()

	expiredMessagesCleanupJob := cleanup.NewExpiredMessagesCleanupJob(metricsService, repo, appConfigs.JobsIntervals.ExpiredMessagesCleanupMs)
	defer expiredMessagesCleanupJob.Close()
	expiredDlqMessagesCleanupJob := cleanup.NewExpiredDlqMessagesCleanupJob(metricsService, repo, appConfigs.JobsIntervals.ExpiredDlqMessagesCleanupMs)
	defer expiredDlqMessagesCleanupJob.Close()
	failedMessagesCleanupJob := cleanup.NewFailedMessagesCleanupJob(metricsService, repo, appConfigs.JobsIntervals.FailedMessagesCleanupMs)
	defer failedMessagesCleanupJob.Close()
	failedDlqMessagesCleanupJob := cleanup.NewFailedDlqMessagesCleanupJob(metricsService, repo, appConfigs.JobsIntervals.FailedDqlMessagesCleanupMs)
	defer failedDlqMessagesCleanupJob.Close()
	staleMessagesCleanupJob := cleanup.NewStaleMessagesCleanupJob(metricsService, repo, appConfigs.JobsIntervals.StaleMessagesCleanupMs)
	defer staleMessagesCleanupJob.Close()
	dbOptimizationJob := maintenance.NewDbOptimizationJob(repo, appConfigs.JobsIntervals.DbOptimizationMs, appConfigs.JobsIntervals.DbOptimizationMaxDurationMs)
	defer dbOptimizationJob.Close()

	if metricsEnabled {
		queuesDepthMetricsJob := metricsJobs.NewQueuesDepthMetricsJob(metricsService, repo, appConfigs.JobsIntervals.QueuesDepthMetricsMs)
		defer queuesDepthMetricsJob.Close()
	}

	// shutdownCtx is cancelled on SIGINT/SIGTERM. It is also the BaseContext of
	// both servers, so request contexts (incl. in-flight long polls) are cancelled
	// immediately on shutdown instead of holding Shutdown() for up to 30s.
	shutdownCtx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stopSignals()

	serverFailedCh := make(chan struct{})
	var serverFailedOnce sync.Once

	apiRouter := api.NewRouter(monitoringService, messagesService, apiThrottlingService, authSecret, metricsEnabled, metricsAuthSecret, env, trustProxyHeaders)

	var apiProtocols http.Protocols
	apiProtocols.SetUnencryptedHTTP2(true)
	apiProtocols.SetHTTP1(true)

	apiServer := &http.Server{
		Addr:              apiAddr,
		Handler:           http.TimeoutHandler(apiRouter.NewRouter(), appConfigs.ServerConfig.Timeouts.Handle, "timeout"),
		WriteTimeout:      appConfigs.ServerConfig.Timeouts.Write,
		ReadTimeout:       appConfigs.ServerConfig.Timeouts.Read,
		ReadHeaderTimeout: appConfigs.ServerConfig.Timeouts.ReadHeader,
		IdleTimeout:       appConfigs.ServerConfig.Timeouts.Idle,
		Protocols:         &apiProtocols,
		BaseContext:       func(net.Listener) context.Context { return shutdownCtx },
	}

	uiRouter := ui.NewRouter(messagesService, sessionsService, queuesService, uiThrottlingService, authSecret, env, trustProxyHeaders)

	var uiProtocols http.Protocols
	uiProtocols.SetUnencryptedHTTP2(true)
	uiProtocols.SetHTTP1(true)

	uiServer := &http.Server{
		Addr:              uiAddr,
		Handler:           http.TimeoutHandler(uiRouter.NewRouter(), appConfigs.ServerConfig.Timeouts.Handle, "timeout"),
		WriteTimeout:      appConfigs.ServerConfig.Timeouts.Write,
		ReadTimeout:       appConfigs.ServerConfig.Timeouts.Read,
		ReadHeaderTimeout: appConfigs.ServerConfig.Timeouts.ReadHeader,
		IdleTimeout:       appConfigs.ServerConfig.Timeouts.Idle,
		Protocols:         &uiProtocols,
		BaseContext:       func(net.Listener) context.Context { return shutdownCtx },
	}

	// Start API server
	go func() {
		log.Info().Msgf("Starting API server on %s", apiAddr)
		err := apiServer.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Warn().Err(err).Msg("API server failed")
			serverFailedOnce.Do(func() { close(serverFailedCh) })
		}
	}()

	// Start UI server
	go func() {
		log.Info().Msgf("Starting UI server on %s", uiAddr)
		err := uiServer.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Warn().Err(err).Msg("UI server failed")
			serverFailedOnce.Do(func() { close(serverFailedCh) })
		}
	}()

	// Block until a shutdown signal arrives or one of the servers dies.
	select {
	case <-shutdownCtx.Done():
		log.Info().Msg("shutdown signal received")
	case <-serverFailedCh:
		log.Warn().Msg("server failure, shutting down")
	}

	// cancel shutdownCtx on BOTH trigger paths: on the server-failure path it
	// is not cancelled yet, and in-flight request contexts (incl. long polls
	// on the surviving server) hang off it via BaseContext - without this they
	// would hold Shutdown until its deadline instead of returning immediately
	stopSignals()

	// In-flight requests see their contexts cancelled via BaseContext, so this
	// deadline only needs to cover response writing, not the 30s long poll.
	gracefulCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := apiServer.Shutdown(gracefulCtx); err != nil {
		log.Warn().Err(err).Msg("graceful API server shutdown failed, closing forcefully")
		if err := apiServer.Close(); err != nil {
			log.Warn().Err(err).Msg("failed to close API server")
		}
	}
	if err := uiServer.Shutdown(gracefulCtx); err != nil {
		log.Warn().Err(err).Msg("graceful UI server shutdown failed, closing forcefully")
		if err := uiServer.Close(); err != nil {
			log.Warn().Err(err).Msg("failed to close UI server")
		}
	}

	log.Info().Msg("servers stopped, closing jobs and database")
	// jobs and repo are closed by the deferred Close() calls above (LIFO: jobs
	// first, repo last).
}

func getEnv() string {
	env := os.Getenv("FORQ_ENV")
	if env == "" {
		env = common.ProEnv // Default to production environment
	}

	if !common.SupportedEnvs[env] {
		log.Fatal().Msgf("unsupported environment: %s", env)
	}
	return env
}

func getAuthSecret() string {
	authSecret := os.Getenv("FORQ_AUTH_SECRET")
	if authSecret == "" {
		log.Fatal().Msg("auth secret is not provided: set FORQ_AUTH_SECRET environment variable")
	}
	if len(authSecret) < minAuthSecretLength {
		log.Fatal().Msgf("auth secret is too short: must be at least %d characters", minAuthSecretLength)
	}
	return authSecret
}

func getTrustProxyHeaders() bool {
	v := os.Getenv("FORQ_TRUST_PROXY_HEADERS")
	if v == "" {
		return false
	}
	trust, err := strconv.ParseBool(v)
	if err != nil {
		log.Fatal().Err(err).Msg("failed to parse FORQ_TRUST_PROXY_HEADERS env var")
	}
	if trust {
		log.Warn().Msg("FORQ_TRUST_PROXY_HEADERS=true: client IP will be read from X-Forwarded-For. " +
			"Forq MUST be behind a reverse proxy that strips or replaces incoming X-Forwarded-For from clients, " +
			"otherwise IPs are spoofable and throttling can be bypassed.")
	}
	return trust
}

func getMetricsConfigs(authSecret string) (bool, string) {
	metricsEnabledEnv := os.Getenv("FORQ_METRICS_ENABLED")
	if metricsEnabledEnv == "" {
		return false, "" // Metrics disabled by default
	}

	metricsEnabled, err := strconv.ParseBool(metricsEnabledEnv)
	if err != nil {
		log.Fatal().Err(err).Msg("failed to parse FORQ_METRICS_ENABLED env var")
	}

	if !metricsEnabled {
		return false, ""
	}

	metricsAuthSecret := os.Getenv("FORQ_METRICS_AUTH_SECRET")
	if metricsAuthSecret == "" {
		log.Fatal().Msg("FORQ_METRICS_AUTH_SECRET env var is required when metrics are enabled")
	}
	if len(metricsAuthSecret) < minAuthSecretLength {
		log.Fatal().Msgf("metrics auth secret is too short: must be at least %d characters", minAuthSecretLength)
	}
	// The metrics secret is a low-privilege scrape credential; keeping it
	// distinct from the full-access API token limits the blast radius of a
	// leaked Prometheus config.
	if metricsAuthSecret == authSecret {
		log.Fatal().Msg("FORQ_METRICS_AUTH_SECRET must differ from FORQ_AUTH_SECRET")
	}
	return true, metricsAuthSecret
}

func getTtlConfigs() (int, int) {
	queueTtlEnv := os.Getenv("FORQ_QUEUE_TTL_HOURS")
	dlqTtlEnv := os.Getenv("FORQ_DLQ_TTL_HOURS")

	// Default values
	queueTtlHours := 24   // 1 day
	dlqTtlHours := 7 * 24 // 7 days

	if queueTtlEnv != "" {
		parsed, err := strconv.Atoi(queueTtlEnv)
		if err != nil {
			log.Fatal().Err(err).Msg("failed to parse FORQ_QUEUE_TTL_HOURS env var")
		}
		if parsed < 1 {
			log.Fatal().Msg("FORQ_QUEUE_TTL_HOURS must be at least 1 hour")
		}
		queueTtlHours = parsed
	}

	if dlqTtlEnv != "" {
		parsed, err := strconv.Atoi(dlqTtlEnv)
		if err != nil {
			log.Fatal().Err(err).Msg("failed to parse FORQ_DLQ_TTL_HOURS env var")
		}
		if parsed < 1 {
			log.Fatal().Msg("FORQ_DLQ_TTL_HOURS must be at least 1 hour")
		}
		dlqTtlHours = parsed
	}

	return queueTtlHours, dlqTtlHours
}

func getServerAddrs() (string, string) {
	apiAddr := os.Getenv("FORQ_API_ADDR")
	if apiAddr == "" {
		apiAddr = "localhost:8080" // safe default localhost only
	}

	uiAddr := os.Getenv("FORQ_UI_ADDR")
	if uiAddr == "" {
		uiAddr = "localhost:8081" // safe default localhost only
	}

	return apiAddr, uiAddr
}

func getDbPath() string {
	dbPath := os.Getenv("FORQ_DB_PATH")
	if dbPath == "" {
		log.Fatal().Msg("database path is not provided: set FORQ_DB_PATH environment variable")
	}

	absPath, err := filepath.Abs(dbPath)
	if err != nil {
		log.Fatal().Err(err).Msgf("failed to resolve absolute path for FORQ_DB_PATH=%s", dbPath)
	}

	if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
		log.Fatal().Err(err).Msgf("failed to create directory for database file at %s", absPath)
	}

	return absPath
}

func runMigrations(dbPath string) {
	// x-no-tx-wrap=true to disable transaction wrapping for PRAGMA statements, as otherwise it fails:
	// https://github.com/golang-migrate/migrate/issues/346
	dbURL := fmt.Sprintf("sqlite://file:%s?cache=shared&mode=rwc&x-no-tx-wrap=true", dbPath)

	source, err := iofs.New(db.MigrationsFS, "migrations")
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create migrations source")
	}

	m, err := migrate.NewWithSourceInstance("iofs", source, dbURL)
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create migration instance")
	}
	defer func() {
		srcErr, dbErr := m.Close()
		if srcErr != nil {
			log.Warn().Err(srcErr).Msg("failed to close migration source")
		}
		if dbErr != nil {
			log.Warn().Err(dbErr).Msg("failed to close migration database connection")
		}
	}()

	err = m.Up()
	if err != nil {
		if errors.Is(err, migrate.ErrNoChange) {
			log.Info().Msg("no migrations to run")
			return
		}
		log.Fatal().Err(err).Msg("failed to run migrations")
	} else {
		log.Info().Msg("migrations applied successfully")
	}
}