package bootstrap import ( "context" "crypto/tls" "errors" "fmt" "log/slog" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/fsnotify/fsnotify" sloggin "github.com/gin-contrib/slog" "github.com/gin-gonic/gin" "github.com/italypaleale/francis/builtin/ratelimit" "github.com/italypaleale/go-kit/servicerunner" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/otel/trace" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/frontend" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/controller" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/tracing" "github.com/pocket-id/pocket-id/backend/internal/utils/systemd" ) // This is used to register additional controllers for tests var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) { r, err := initEngine() if err != nil { return nil, err } err = registerRoutes(r, db, svc, rateLimitServices) if err != nil { return nil, err } serverConfig, err := initServer(r) if err != nil { return nil, err } runFn := func(ctx context.Context) error { return runServer(ctx, serverConfig) } return runFn, nil } type serverConfig struct { addr string certProvider *tlsCertProvider listener net.Listener server *http.Server tlsConfig *tls.Config } func initEngine() (*gin.Engine, error) { setGinMode() r := gin.New() initLogger(r) err := configureEngine(r) if err != nil { return nil, err } registerGlobalMiddleware(r) return r, nil } func setGinMode() { // Set the appropriate Gin mode based on the environment switch common.EnvConfig.AppEnv { case common.AppEnvProduction: gin.SetMode(gin.ReleaseMode) case common.AppEnvDevelopment: gin.SetMode(gin.DebugMode) case common.AppEnvTest: gin.SetMode(gin.TestMode) } } func configureEngine(r *gin.Engine) error { err := r.SetTrustedProxies(common.EnvConfig.TrustProxy) if err != nil { return fmt.Errorf("failed to configure trusted proxies: %w", err) } if common.EnvConfig.TrustedPlatform != "" { r.TrustedPlatform = common.EnvConfig.TrustedPlatform } r.Use(otelgin.Middleware( common.Name, otelgin.WithFilter(shouldTraceRequest)), ) return nil } // shouldTraceRequest reports whether an incoming request should be traced. // It traces only requests handled by real backend routes (the API, the OIDC/OAuth endpoints, and the well-known documents). // Everything else falls through to the frontend NoRoute handler, which serves the SPA shell and static assets; tracing those would produce noisy, unparented spans named just "GET" with an empty http.route. func shouldTraceRequest(r *http.Request) bool { p := r.URL.Path switch { case strings.HasPrefix(p, "/api/"), strings.HasPrefix(p, "/.well-known/"), p == "/authorize": return true default: return false } } func registerGlobalMiddleware(r *gin.Engine) { r.Use(middleware.HeadMiddleware()) r.Use(middleware.NewCacheControlMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCspMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add()) } func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) error { err := frontend.RegisterFrontend(r) if errors.Is(err, frontend.ErrFrontendNotIncluded) { slog.Warn("Frontend is not included in the build. Skipping frontend registration.") } else if err != nil { return fmt.Errorf("failed to register frontend: %w", err) } // Initialize middleware for specific routes authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices) apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI) apiGroup := r.Group("/api", apiRateLimitMiddleware) // Decode "~" client ID path params (used for CIMD URL client IDs). apiGroup.Use(middleware.NewClientIDParamMiddleware().Add()) baseGroup := r.Group("/", apiRateLimitMiddleware) svc.apiKeyModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().Add(), authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), ) svc.webauthnModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().Add(), authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin), rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate), ) svc.deviceLoginModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginCreate), rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginExchange), rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification), ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) controller.NewUserController(apiGroup, authMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule) svc.ldapSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add()) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) controller.NewUserGroupController(apiGroup, authMiddleware, svc.appConfigService, svc.userGroupService) svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add()) controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewVersionController(apiGroup, authMiddleware, svc.versionService) svc.scimSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add()) svc.userSignUpModule.RegisterRoutes(apiGroup, authMiddleware.Add(), rateLimitMiddleware.Add(middleware.RateLimitSignup), ) svc.oneTimeAccessModule.RegisterRoutes(apiGroup, authMiddleware.Add(), rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), ) svc.emailVerificationModule.RegisterRoutes( apiGroup, authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), ) optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add() browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add() svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth) registerTestRoutes(apiGroup, db, svc) controller.NewWellKnownController(baseGroup, svc.jwtService, svc.appConfigService.GetCIMDURLAllowlist) // These are not rate-limited. controller.NewHealthzController(r) // Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled. // Outside /api, so it's unauthenticated and not traced, but it is rate-limited. tracing.NewTelemetryController(r, rateLimitMiddleware.Add(middleware.RateLimitInternal)) return nil } func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { if common.EnvConfig.AppEnv.IsProduction() { return } for _, f := range registerTestControllers { f(apiGroup, db, svc) } } func initServer(r *gin.Engine) (*serverConfig, error) { protocols, tlsConfig, certProvider, err := initServerProtocols() if err != nil { return nil, err } var socketFn func() (*socket, error) switch { case common.EnvConfig.SystemdSocket: socketFn = systemdSocket case common.EnvConfig.UnixSocket != "": socketFn = unixSocket default: socketFn = tcpSocket } socket, err := socketFn() if err != nil { return nil, err } addr := socket.addr listener := socket.listener // Wrap the listener with a proxy protocol listener if configured and not using a Unix socket if len(common.EnvConfig.ProxyProtocol) > 0 && common.EnvConfig.UnixSocket == "" { listener, err = newProxyProtocolListener(socket.listener, common.EnvConfig.ProxyProtocol) if err != nil { _ = socket.listener.Close() return nil, err } } server := newHTTPServer(r, protocols) return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil } func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) { protocols := new(http.Protocols) protocols.SetHTTP1(true) tlsConfigured := common.EnvConfig.TLSCert != "" || common.EnvConfig.TLSKey != "" || common.EnvConfig.TLSCertFile != "" || common.EnvConfig.TLSKeyFile != "" if !tlsConfigured { protocols.SetUnencryptedHTTP2(true) return protocols, nil, nil, nil } protocols.SetHTTP2(true) certProvider, err := newCertProvider( common.EnvConfig.TLSCert, common.EnvConfig.TLSKey, common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile, ) if err != nil { return nil, nil, nil, fmt.Errorf("failed to load TLS certificate: %w", err) } tlsConfig := &tls.Config{ GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS13, NextProtos: []string{"h2"}, } slog.Info("TLS enabled") return protocols, tlsConfig, certProvider, nil } func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server { return &http.Server{ MaxHeaderBytes: 1 << 20, ReadHeaderTimeout: 10 * time.Second, Protocols: protocols, Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // HEAD requests don't get matched by Gin routes, so we convert them to GET // middleware.HeadMiddleware will convert them back to HEAD later if req.Method == http.MethodHead { req.Method = http.MethodGet ctx := context.WithValue(req.Context(), middleware.IsHeadRequestCtxKey{}, true) req = req.WithContext(ctx) } r.ServeHTTP(w, req) }), } } func runServer(ctx context.Context, config *serverConfig) error { slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil)) certWatcher, err := startCertWatcher(ctx, config.certProvider) if err != nil { return err } defer closeCertWatcher(certWatcher) startHTTPServer(config) notifySystemdReady() <-ctx.Done() // We do not pass the context because it's already been canceled //nolint:contextcheck return shutdownServer(config.server) } func startCertWatcher(ctx context.Context, certProvider *tlsCertProvider) (*fsnotify.Watcher, error) { if certProvider == nil || certProvider.certFile == "" || certProvider.keyFile == "" { return nil, nil } certWatcher, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create certificate watcher: %w", err) } watchedDirectories := make(map[string]struct{}, 2) for _, file := range []string{certProvider.certFile, certProvider.keyFile} { directory := filepath.Dir(file) if _, ok := watchedDirectories[directory]; ok { continue } if err := certWatcher.Add(directory); err != nil { _ = certWatcher.Close() return nil, fmt.Errorf("failed to watch TLS directory %q: %w", directory, err) } watchedDirectories[directory] = struct{}{} } go certProvider.StartWatching(ctx, certWatcher) return certWatcher, nil } func closeCertWatcher(certWatcher *fsnotify.Watcher) { if certWatcher != nil { _ = certWatcher.Close() } } func startHTTPServer(config *serverConfig) { go func() { defer config.listener.Close() listener := config.listener if config.tlsConfig != nil { listener = tls.NewListener(config.listener, config.tlsConfig) } srvErr := config.server.Serve(listener) if !errors.Is(srvErr, http.ErrServerClosed) { slog.Error("Error starting app server", "error", srvErr) os.Exit(1) } }() } func notifySystemdReady() { err := systemd.SdNotifyReady() if err != nil { // Log the error only slog.Warn("Unable to notify systemd that the service is ready", "error", err) } } func shutdownServer(srv *http.Server) error { // Note we use the background context here as ctx has been canceled already shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck shutdownCancel() if shutdownErr != nil { // Log the error only (could be context canceled) slog.Warn("App server shutdown error", "error", shutdownErr) } return nil } func initLogger(r *gin.Engine) { loggerSkipPathsPrefix := []string{ "GET /api/application-images/logo", "GET /api/application-images/background", "GET /api/application-images/favicon", "GET /api/application-images/email", "GET /_app", "GET /fonts", "GET /healthz", "HEAD /healthz", } r.Use(sloggin.SetLogger( sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger { // gin-contrib/slog calls Handler.Handle directly instead of Logger.LogAttrs // Wrapping the default handler in MultiHandler restores the Enabled check that enforces LOG_LEVEL return slog.New(slog.NewMultiHandler(slog.Default().Handler())) }), sloggin.WithClientErrorLevel(slog.LevelInfo), sloggin.WithSpecificLogLevelByStatusCode(map[int]slog.Level{ http.StatusTooManyRequests: slog.LevelWarn, }), sloggin.WithContext(enrichRequestLog), // Skip logging for certain paths to reduce noise in the logs sloggin.WithSkipper(func(c *gin.Context) bool { for _, prefix := range loggerSkipPathsPrefix { if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) { return true } } return false }), )) } // enrichRequestLog enriches the slog.Record with additional attributes from the request context, such as request ID, error code, and trace/span IDs. func enrichRequestLog(c *gin.Context, record *slog.Record) *slog.Record { enriched := slog.NewRecord(record.Time, record.Level, "HTTP request completed", record.PC) // Add request ID if present in the context if requestID := middleware.RequestID(c); requestID != "" { enriched.AddAttrs(slog.String("request_id", requestID)) } // Add error code if present in the context if errorCode := middleware.RequestErrorCode(c); errorCode != "" { enriched.AddAttrs(slog.String("error_code", string(errorCode))) } // Add trace and span IDs if present in the context if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() { enriched.AddAttrs( slog.String("trace_id", spanContext.TraceID().String()), slog.String("span_id", spanContext.SpanID().String()), ) } record.Attrs(func(attr slog.Attr) bool { enriched.AddAttrs(attr) return true }) return &enriched } // tlsCertProvider holds certificates that can be dynamically reloaded type tlsCertProvider struct { certMutex sync.RWMutex cert *tls.Certificate certFile string keyFile string } // GetCertificate implements tls.GetCertificate interface for dynamic certificate loading func (p *tlsCertProvider) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { p.certMutex.RLock() defer p.certMutex.RUnlock() return p.cert, nil } // newCertProvider creates a certificate provider from either inline data or reloadable files func newCertProvider(certPEM, keyPEM, certFile, keyFile string) (*tlsCertProvider, error) { inlineConfigured := certPEM != "" || keyPEM != "" fileConfigured := certFile != "" || keyFile != "" switch { case inlineConfigured && fileConfigured: return nil, errors.New("inline and file-based TLS configuration cannot be combined") case certPEM != "" && keyPEM == "", certPEM == "" && keyPEM != "": return nil, errors.New("inline TLS certificate and key must both be configured") case certFile != "" && keyFile == "", certFile == "" && keyFile != "": return nil, errors.New("TLS certificate and key files must both be configured") case !inlineConfigured && !fileConfigured: return nil, errors.New("TLS certificate and key must both be configured") } var cert tls.Certificate var err error if inlineConfigured { cert, err = tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) } else { certFile, err = filepath.Abs(certFile) if err != nil { return nil, fmt.Errorf("failed to resolve TLS certificate path: %w", err) } keyFile, err = filepath.Abs(keyFile) if err != nil { return nil, fmt.Errorf("failed to resolve TLS key path: %w", err) } cert, err = tls.LoadX509KeyPair(certFile, keyFile) } if err != nil { return nil, err } return &tlsCertProvider{ cert: &cert, certFile: certFile, keyFile: keyFile, }, nil } // reloadCertificate reloads the certificate from disk func (p *tlsCertProvider) reloadCertificate() error { cert, err := tls.LoadX509KeyPair(p.certFile, p.keyFile) if err != nil { return fmt.Errorf("failed to reload TLS certificate: %w", err) } p.certMutex.Lock() p.cert = &cert p.certMutex.Unlock() return nil } // StartWatching begins monitoring the certificate files for changes with debouncing func (p *tlsCertProvider) StartWatching(ctx context.Context, watcher *fsnotify.Watcher) { const debounceDuration = time.Second reloadTimer := time.NewTimer(debounceDuration) reloadTimer.Stop() defer reloadTimer.Stop() for { select { case <-ctx.Done(): return case event, ok := <-watcher.Events: if !ok { return } // Ignore events that are not related to the certificate or key files if !p.isCertificateEvent(event) { continue } // Reset the debounce timer so both files can settle before the pair is reloaded reloadTimer.Reset(debounceDuration) slog.Debug("TLS file change detected, debouncing", slog.String("path", event.Name)) case <-reloadTimer.C: // Reload the pair atomically after the certificate directories have settled slog.Info("Reloading TLS certificate") if err := p.reloadCertificate(); err != nil { slog.Error("Failed to reload TLS certificate", "error", err) } else { slog.Info("TLS certificate reloaded successfully") } case err, ok := <-watcher.Errors: if !ok { return } slog.Error("Certificate watcher error", "error", err) } } } func (p *tlsCertProvider) isCertificateEvent(event fsnotify.Event) bool { if !event.Has(fsnotify.Write | fsnotify.Create | fsnotify.Rename | fsnotify.Remove) { return false } eventPath := filepath.Clean(event.Name) return eventPath == p.certFile || eventPath == p.keyFile }