package echo import ( "context" "crypto/tls" "fmt" "html/template" "io/fs" "net/http" "os" "sync" "code.crute.us/mcrute/golib/clients/netbox/v4" "code.crute.us/mcrute/golib/crypto/acme/autocert" glmw "code.crute.us/mcrute/golib/echo/middleware" "code.crute.us/mcrute/golib/echo/prometheus" "code.crute.us/mcrute/golib/service" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" "github.com/quic-go/quic-go/http3" ) // Docs: https://echo.labstack.com/guide/ const defaultBodySizeLimit = "10M" func makeMiddlewareSkipper(c *prometheus.PrometheusConfig) middleware.Skipper { var path string if c != nil { path = c.MetricsPath } if path == "" { path = prometheus.DefaultPrometheusConfig.MetricsPath } return func(c echo.Context) bool { if c.Path() == path { return true } return false } } // EchoConfig is the configuration for an EchoWrapper // // Service clients are generally passed in an only by interface even // though we could create these internally so as to provide some // isolation between what is (theoretically) a re-usable, though // opinionated, Echo instance vs something that is completely custom to // internal use-cases. type EchoConfig struct { Autocert autocert.PrimingCertProvider NetboxClient netbox.NetboxClient ApplicationName string ApplicationVersion string BindAddresses []string BodySizeLimit string TrustedProxyIPRanges []string EmbeddedTemplates fs.FS DiskTemplates fs.FS DiskTemplatesPath string ProvideTemplateStore []WantsTemplateStore TemplateFunctions template.FuncMap CombinedHostLogFile string RedirectToWWW bool ContentSecurityPolicy *glmw.ContentSecurityPolicyConfig DisablePrometheus bool DisableHttp3 bool PrometheusConfig *prometheus.PrometheusConfig CORSConfig *middleware.CORSConfig EnableTrace bool } type EchoWrapper struct { *echo.Echo AddressPortConfig *AddressPortConfig runner *service.AppRunner autocert autocert.PrimingCertProvider templateFS fs.FS errorHandler ErrorHandler netboxIpFilter *glmw.NetboxIPFilter } // NewEchoWrapper creates a new instance of Echo and wraps it in an // internal wrapper that does configuration and service running. // // It is expected that Configure is called before using this Echo // instance. func NewEchoWrapper(ctx context.Context, debug bool) (*EchoWrapper, error) { e := echo.New() e.Debug = debug e.Logger.SetLevel(log.INFO) if debug { e.Logger.SetLevel(log.DEBUG) } e.Use(glmw.JsonLoggerWithConfig(glmw.JsonLoggerConfig{ Format: map[string]string{ "time": "time_rfc3339_nano", "id": "id", "remote_ip": "remote_ip", "host": "host", "method": "method", "uri": "uri", "user_agent": "user_agent", "status": "status", "error": "error", "latency": "latency", "latency_human": "latency_human", "bytes_in": "bytes_in", "bytes_out": "bytes_out", "protocol": "protocol", }, Output: e.Logger.Output(), })) e.Use(glmw.Recover()) return &EchoWrapper{ Echo: e, runner: service.NewAppRunner(ctx, e.Logger), }, nil } func (w *EchoWrapper) Runner() *service.AppRunner { return w.runner } func (w *EchoWrapper) CachedStaticRoute(prefix, path string) { if w.templateFS == nil { panic("Attempted to set a static route with no templates loaded") } StaticFS(w.GET, w.templateFS, prefix, path, glmw.CacheOneMonthMiddleware) } func (w *EchoWrapper) NeverCacheStaticRoute(prefix, path string) { if w.templateFS == nil { panic("Attempted to set a static route with no templates loaded") } StaticFS(w.GET, w.templateFS, prefix, path, glmw.CacheNeverMiddleware) } func (w *EchoWrapper) StaticRoute(prefix, path string) { if w.templateFS == nil { panic("Attempted to set a static route with no templates loaded") } StaticFS(w.GET, w.templateFS, prefix, path) } func (w *EchoWrapper) RunForever(enableBackgroundJobs bool) { w.runner.RunForever(enableBackgroundJobs) } func (w *EchoWrapper) GetTemplateFS() fs.FS { return w.templateFS } func (w *EchoWrapper) AddErrorHandler(h ContentErrorHandler, mime ...string) { w.errorHandler.AddHandler(h, mime...) } func (w *EchoWrapper) Configure(c EchoConfig) (err error) { w.configureAutocert(&c) if err := w.configureIpExtractor(&c); err != nil { return err } if err := w.configureTemplates(&c); err != nil { return err } if err := w.configureCombinedLogging(&c); err != nil { return err } w.AddressPortConfig, err = ParseAddressPortBindings(c.BindAddresses) if err != nil { return fmt.Errorf("Error parsing address/port bindings") } w.buildServers(&c) w.configureBodyLimit(&c) w.configureRedirects(&c) w.configureCompression(&c) w.Use(glmw.StrictSecure()) w.Use(glmw.MergeHeaders()) w.configureCORS(&c) w.configureCSP(&c) w.configureServerHeader(&c) // These all depend on w.netboxIpFilter w.configureNetboxIpFilter(&c) w.configurePrometheus(&c) w.configurePprof(&c) return nil } func (w *EchoWrapper) configureAutocert(c *EchoConfig) { w.autocert = c.Autocert w.runner.AddInitJob(w.autocert.PrimeCache) w.runner.AddJob(w.autocert.PrimingReporter(w.Logger)) } func (w *EchoWrapper) configureIpExtractor(c *EchoConfig) error { var err error // This is only required if the app is behind a proxy, for apps that // take traffic directly from the internet the IP should just be // extracted from the requests RemoteAddr. if c.TrustedProxyIPRanges == nil { w.IPExtractor, err = glmw.ExtractIPFromXFFHeaders(false, c.TrustedProxyIPRanges) if err != nil { return fmt.Errorf("Error building XFF IP extractor: %w", err) } } else { w.IPExtractor = echo.ExtractIPDirect() } return nil } func (w *EchoWrapper) configureTemplates(c *EchoConfig) error { // TODO: v1, deprecate DiskTemplates var templates fs.FS if (c.DiskTemplatesPath != "" || c.DiskTemplates != nil) && w.Debug { // Debug Mode // If the fs.FS for DiskTemplates is provided use that if c.DiskTemplates != nil { w.Logger.Debug("Debug mode, using disk templates from passed fs.FS") w.Logger.Warn("Using disk templates from an fs.FS is deprecated behavior") templates = c.DiskTemplates } else { // Use templates from disk in debug mode and the embedded ones that are // built-in to the binary for prod mode. if _, err := os.Stat(c.DiskTemplatesPath); err == nil { w.Logger.Debug("Debug mode, using disk templates from filesystem") templates = os.DirFS(c.DiskTemplatesPath) } else { w.Logger.Warnf("Disk templates not found, using embedded templates only") templates = c.EmbeddedTemplates } } } else if c.EmbeddedTemplates != nil { // Prod Mode templates = c.EmbeddedTemplates } // Configure plugins that want access to the template store for _, w := range c.ProvideTemplateStore { w.ConfigureTemplateStore(templates) } // Only install template handlers if the path is set if templates != nil { tr, err := NewTemplateRenderer(templates, "*.tpl", c.TemplateFunctions, w.Debug) if err != nil { return fmt.Errorf("Error loading template renderer: %w", err) } w.errorHandler = NewDefaultErrorHandler(tr) w.Renderer = tr } else { w.errorHandler = NewNoHTMLErrorHandler() } w.HTTPErrorHandler = w.errorHandler.HandleError w.templateFS = templates return nil } func (w *EchoWrapper) configureCombinedLogging(c *EchoConfig) error { if c.CombinedHostLogFile != "" { lc, err := NginxCombinedHostConfigToFile(c.CombinedHostLogFile) if err != nil { return fmt.Errorf("Error opening log file: %w", err) } w.Use(middleware.LoggerWithConfig(lc)) } return nil } func (w *EchoWrapper) makeServerJob(s webServer) service.RunnerFunc { return func(ctx context.Context, wg *sync.WaitGroup) error { wg.Add(1) defer wg.Done() s.LogStart(w.Logger) err := make(chan error) go func() { err <- s.ListenAndServe() }() select { case e := <-err: return e default: } select { case <-ctx.Done(): w.Logger.Info("Shutting down web server") return s.Shutdown(ctx) } } } func (w *EchoWrapper) buildServers(c *EchoConfig) { for _, a := range w.AddressPortConfig.HttpBindings() { s := &http.Server{ Addr: a, Handler: w, ErrorLog: w.StdLogger, } w.runner.AddJob(w.makeServerJob(&netHttpWrapper{s})) } for _, a := range w.AddressPortConfig.TlsBindings() { s := &http.Server{ Addr: a, TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, GetCertificate: c.Autocert.GetCertificate, NextProtos: []string{"h3", "h2", "http/1.1"}, }, Handler: w, ErrorLog: w.StdLogger, } w.runner.AddJob(w.makeServerJob(&netHttpWrapper{s})) } if !c.DisableHttp3 { for _, a := range w.AddressPortConfig.QuicBindings() { q := &http3.Server{ Addr: a, TLSConfig: http3.ConfigureTLSConfig(&tls.Config{ MinVersion: tls.VersionTLS13, GetCertificate: c.Autocert.GetCertificate, }), Handler: w, } w.runner.AddJob(w.makeServerJob(&http3Wrapper{q})) w.Use(glmw.Http3AltSvcMiddleware(w.AddressPortConfig.QuicPort)) } } } func (w *EchoWrapper) configureBodyLimit(c *EchoConfig) { if c.BodySizeLimit == "" { w.Use(middleware.BodyLimit(defaultBodySizeLimit)) } else if c.BodySizeLimit != "0" { w.Use(middleware.BodyLimit(c.BodySizeLimit)) } } func (w *EchoWrapper) configureRedirects(c *EchoConfig) { metricsSkipper := makeMiddlewareSkipper(c.PrometheusConfig) w.Use(glmw.HTTPSRedirectWithConfig(glmw.HTTPSRedirectConfig{ Skipper: metricsSkipper, Port: w.AddressPortConfig.TlsPort, })) if c.RedirectToWWW { w.Use(glmw.WWWRedirectWithConfig(glmw.WWWRedirectConfig{ Skipper: metricsSkipper, })) } } func (w *EchoWrapper) configureCompression(c *EchoConfig) { w.Use(middleware.Decompress()) w.Use(middleware.GzipWithConfig(middleware.GzipConfig{ Level: 6, // Nginx default compression level })) } func (w *EchoWrapper) configureCORS(c *EchoConfig) { if c.CORSConfig != nil { w.Use(middleware.CORSWithConfig(*c.CORSConfig)) } else { w.Use(middleware.CORS()) } } func (w *EchoWrapper) configureCSP(c *EchoConfig) { if c.ContentSecurityPolicy != nil { w.Use(glmw.ContentSecurityPolicyWithConfig(*c.ContentSecurityPolicy)) } else { w.Use(glmw.ContentSecurityPolicyWithConfig(glmw.ContentSecurityPolicyConfig{ DefaultSrc: []glmw.CSPDirective{ glmw.CSPSelf, }, })) } } func (w *EchoWrapper) configureServerHeader(c *EchoConfig) { if c.ApplicationName != "" && c.ApplicationVersion != "" { w.Use(glmw.AddServerHeader(c.ApplicationName, c.ApplicationVersion)) } } func (w *EchoWrapper) configureNetboxIpFilter(c *EchoConfig) { // TODO: Should constrain this by site probably but monitoring happens // across sites so those prefixes need to be included w.netboxIpFilter = &glmw.NetboxIPFilter{ NetboxClient: c.NetboxClient, Tag: "management", IncludeLocalhost: true, Logger: w.Logger, } w.runner.AddInitJob(w.netboxIpFilter.Init) w.runner.AddJob(w.netboxIpFilter.RunRefresh) } func (w *EchoWrapper) configurePrometheus(c *EchoConfig) { if !c.DisablePrometheus && w.netboxIpFilter != nil { var prom *prometheus.Prometheus if c.PrometheusConfig != nil { prom = prometheus.NewPrometheusWithConfig(c.PrometheusConfig) } else { prom = prometheus.NewPrometheus() } w.Use(prom.MiddlewareHandler) w.GET(prom.Config.MetricsPath, prom.MetricsHandler, w.netboxIpFilter.Middleware) } } func (w *EchoWrapper) configurePprof(c *EchoConfig) { if (w.Debug || c.EnableTrace) && w.netboxIpFilter != nil { glmw.RegisterPprofWithConfig( w.Echo, glmw.DefaultPprofConfig, w.netboxIpFilter.Middleware, ) } }