// Package main is the entry point for the Charon backend API. package main import ( "context" "encoding/json" "fmt" "io" "log" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "github.com/Wikid82/charon/backend/internal/api/handlers" "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/api/routes" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/cerberus" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/database" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/server" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/version" _ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin" // Register built-in DNS providers "github.com/gin-gonic/gin" "gopkg.in/natefinch/lumberjack.v2" ) // parsePluginSignatures reads the CHARON_PLUGIN_SIGNATURES environment variable // and returns the parsed signature allowlist for plugin verification. // // Modes: // - nil return (permissive): Env var unset/empty — all plugins allowed // - empty map (strict): Env var set to "{}" — no external plugins allowed // - populated map: Only plugins with matching signatures are allowed func parsePluginSignatures() map[string]string { envVal := os.Getenv("CHARON_PLUGIN_SIGNATURES") if envVal == "" { logger.Log().Info("Plugin signature verification: PERMISSIVE mode (CHARON_PLUGIN_SIGNATURES not set)") return nil } var signatures map[string]string if err := json.Unmarshal([]byte(envVal), &signatures); err != nil { logger.Log().WithError(err).Error("Failed to parse CHARON_PLUGIN_SIGNATURES JSON — falling back to permissive mode") return nil } // Validate all signatures have sha256: prefix for name, sig := range signatures { if !strings.HasPrefix(sig, "sha256:") { logger.Log().Errorf("Invalid signature for plugin %q: must have sha256: prefix — falling back to permissive mode", name) return nil } } if len(signatures) == 0 { logger.Log().Info("Plugin signature verification: STRICT mode (empty allowlist — no external plugins permitted)") } else { logger.Log().Infof("Plugin signature verification: STRICT mode (%d plugin(s) in allowlist)", len(signatures)) } return signatures } func main() { // Setup logging with rotation logDir := "/app/data/logs" // #nosec G301 -- Log directory with standard permissions if err := os.MkdirAll(logDir, 0o755); err != nil { // Fallback to local directory if /app/data fails (e.g. local dev) logDir = "data/logs" // #nosec G301 -- Fallback log directory with standard permissions _ = os.MkdirAll(logDir, 0o755) } logFile := filepath.Join(logDir, "charon.log") rotator := &lumberjack.Logger{ Filename: logFile, MaxSize: 10, // megabytes MaxBackups: 3, MaxAge: 28, // days Compress: true, } // Ensure legacy cpmp.log exists as symlink for compatibility (cpmp is a legacy name for Charon) legacyLog := filepath.Join(logDir, "cpmp.log") if _, err := os.Lstat(legacyLog); os.IsNotExist(err) { _ = os.Symlink(logFile, legacyLog) // ignore errors } // Log to both stdout and file mw := io.MultiWriter(os.Stdout, rotator) log.SetOutput(mw) gin.DefaultWriter = mw // Initialize a basic logger so CLI and early code can log. logger.Init(false, mw) // Handle CLI commands if len(os.Args) > 1 { switch os.Args[1] { case "migrate": cfg, err := config.Load() if err != nil { log.Fatalf("load config: %v", err) } db, err := database.Connect(cfg.DatabasePath) if err != nil { log.Fatalf("connect database: %v", err) } logger.Log().Info("Running database migrations for all models...") if err := db.AutoMigrate( // Core models &models.ProxyHost{}, &models.Location{}, &models.CaddyConfig{}, &models.RemoteServer{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityHeaderProfile{}, &models.User{}, &models.Setting{}, &models.ImportSession{}, &models.Notification{}, &models.NotificationProvider{}, &models.NotificationTemplate{}, &models.NotificationConfig{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.UptimeNotificationEvent{}, &models.Domain{}, &models.UserPermittedHost{}, // Security models &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}, &models.CrowdsecPresetEvent{}, &models.CrowdsecConsoleEnrollment{}, &models.EmergencyToken{}, // Phase 2: Database-backed emergency tokens // DNS Provider models (Issue #21) &models.DNSProvider{}, &models.DNSProviderCredential{}, // Plugin model (Phase 5) &models.Plugin{}, ); err != nil { log.Fatalf("migration failed: %v", err) } logger.Log().Info("Migration completed successfully") return case "reset-password": if len(os.Args) != 4 { log.Fatalf("Usage: %s reset-password ", os.Args[0]) } email := os.Args[2] newPassword := os.Args[3] cfg, err := config.Load() if err != nil { log.Fatalf("load config: %v", err) } db, err := database.Connect(cfg.DatabasePath) if err != nil { log.Fatalf("connect database: %v", err) } var user models.User if err := db.Where("email = ?", email).First(&user).Error; err != nil { log.Fatalf("user not found: %v", err) } if err := user.SetPassword(newPassword); err != nil { log.Fatalf("failed to hash password: %v", err) } // Unlock account if locked user.LockedUntil = nil user.FailedLoginAttempts = 0 if err := db.Save(&user).Error; err != nil { log.Fatalf("failed to save user: %v", err) } logger.Log().Infof("Password updated successfully for user %s", email) return } } logger.Log().Infof("starting %s backend on version %s", version.Name, version.Full()) cfg, err := config.Load() if err != nil { log.Fatalf("load config: %v", err) } db, err := database.Connect(cfg.DatabasePath) if err != nil { log.Fatalf("connect database: %v", err) } // Note: All database migrations are centralized in routes.Register() // This ensures migrations run exactly once and in the correct order. // DO NOT add AutoMigrate calls here - they cause "duplicate column" errors. // Reconcile CrowdSec state after migrations, before HTTP server starts // This ensures CrowdSec is running if user preference was to have it enabled crowdsecBinPath := os.Getenv("CHARON_CROWDSEC_BIN") if crowdsecBinPath == "" { crowdsecBinPath = "/usr/local/bin/crowdsec" } crowdsecDataDir := os.Getenv("CHARON_CROWDSEC_DATA") if crowdsecDataDir == "" { crowdsecDataDir = "/app/data/crowdsec" } crowdsecExec := handlers.NewDefaultCrowdsecExecutor() services.ReconcileCrowdSecOnStartup(db, crowdsecExec, crowdsecBinPath, crowdsecDataDir, nil) // Initialize plugin loader and load external DNS provider plugins (Phase 5) logger.Log().Info("Initializing DNS provider plugin system...") pluginDir := os.Getenv("CHARON_PLUGINS_DIR") if pluginDir == "" { pluginDir = "/app/plugins" } pluginLoader := services.NewPluginLoaderService(db, pluginDir, parsePluginSignatures()) if err := pluginLoader.LoadAllPlugins(); err != nil { logger.Log().WithError(err).Warn("Failed to load external DNS provider plugins") } logger.Log().Info("Plugin system initialized") router := server.NewRouter(cfg.FrontendDir) // Initialize structured logger with same writer as stdlib log so both capture logs logger.Init(cfg.Debug, mw) // Request ID middleware must run before recovery so the recover logs include the request id router.Use(middleware.RequestID()) // Log requests with request-scoped logger router.Use(middleware.RequestLogger()) // Attach a recovery middleware that logs stack traces when debug is enabled router.Use(middleware.Recovery(cfg.Debug)) // Shared Caddy manager and Cerberus instance for API + emergency server caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) cerb := cerberus.New(cfg.Security, db) // Pass config to routes for auth service and certificate service if err := routes.RegisterWithDeps(router, db, cfg, caddyManager, cerb); err != nil { log.Fatalf("register routes: %v", err) } // Register import handler with config dependencies routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile) // Check for mounted Caddyfile on startup if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil { logger.Log().WithError(err).Warn("WARNING: failed to process mounted Caddyfile") } // Initialize emergency server (Tier 2 break glass) emergencyServer := server.NewEmergencyServerWithDeps(db, cfg.Emergency, caddyManager, cerb) if err := emergencyServer.Start(); err != nil { logger.Log().WithError(err).Fatal("Failed to start emergency server") } // Setup graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // Start main HTTP server in goroutine go func() { addr := fmt.Sprintf(":%s", cfg.HTTPPort) logger.Log().Infof("starting %s backend on %s", version.Name, addr) if err := router.Run(addr); err != nil { logger.Log().WithError(err).Fatal("server error") } }() // Wait for interrupt signal sig := <-quit logger.Log().Infof("Received signal %v, initiating graceful shutdown...", sig) // Graceful shutdown with timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Stop emergency server if err := emergencyServer.Stop(ctx); err != nil { logger.Log().WithError(err).Error("Emergency server shutdown error") } logger.Log().Info("Server shutdown complete") }