package echo import ( "context" "crypto/tls" "fmt" "html/template" "io/fs" "net/http" "sync" "code.crute.us/mcrute/golib/clients/netbox" "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" ) // Docs: https://echo.labstack.com/guide/ // TODO: // - Integrate CSRF // - Integrate session const defaultBodySizeLimit = "10M" var mandatoryTemplates = []string{ "404.tpl", "40x.tpl", "50x.tpl", "header.tpl", "footer.tpl", } 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 TemplateFunctions template.FuncMap CombinedHostLogFile string RedirectToWWW bool ContentSecurityPolicy *glmw.ContentSecurityPolicyConfig DisablePrometheus bool PrometheusConfig *prometheus.PrometheusConfig CORSConfig *middleware.CORSConfig } type EchoWrapper struct { *echo.Echo runner *service.AppRunner autocert autocert.PrimingCertProvider templateFS fs.FS } // 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(middleware.Logger()) 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) RunForever(enableBackgroundJobs bool) { w.runner.RunForever(enableBackgroundJobs) } func (w *EchoWrapper) GetTemplateFS() fs.FS { return w.templateFS } func (w *EchoWrapper) Configure(c EchoConfig) 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 } bindings, err := ParseAddressPortBindings(c.BindAddresses) if err != nil { return fmt.Errorf("Error parsing address/port bindings") } w.buildServers(&c, bindings) w.configureBodyLimit(&c) w.configureRedirects(&c, bindings) w.configureCompression(&c) w.Use(glmw.StrictSecure()) w.configureCORS(&c) w.configureCSP(&c) w.configureServerHeader(&c) w.configurePrometheus(&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 { // Use templates from disk in debug mode and the embedded ones that are // built-in to the binary for prod mode. var templates fs.FS if c.DiskTemplates != nil && w.Debug { // Debug Mode templates = c.DiskTemplates } else if c.EmbeddedTemplates != nil && !w.Debug { // Prod Mode templates = c.EmbeddedTemplates } // Only install template handlers if the path and glob are set if templates != nil { w.HTTPErrorHandler = ErrorHandler(templates, c.TemplateFunctions) tr, err := NewTemplateRenderer(templates, "*.tpl", c.TemplateFunctions) if err != nil { return fmt.Errorf("Error loading template renderer: %w", err) } w.Renderer = tr for _, t := range mandatoryTemplates { if !tr.HaveTemplate(w.NewContext(nil, nil), t) { return fmt.Errorf("Tempalate renderer is missing required template %s", t) } } } 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 *http.Server, echoInit bool) service.RunnerFunc { return func(ctx context.Context, wg *sync.WaitGroup) error { wg.Add(1) defer wg.Done() w.Logger.Infof("Starting server with address: %s", s.Addr) err := make(chan error) go func() { if s.TLSConfig == nil && echoInit { err <- w.Echo.StartServer(s) } else if s.TLSConfig == nil && !echoInit { err <- s.ListenAndServe() } else { err <- s.ListenAndServeTLS("", "") } }() 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, bindings *AddressPortConfig) { for i, a := range bindings.HttpBindings() { s := &http.Server{ Addr: a, Handler: w, } w.runner.AddJob(w.makeServerJob(s, i == 0)) } for _, a := range bindings.TlsBindings() { s := &http.Server{ Addr: a, TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, GetCertificate: c.Autocert.GetCertificate, NextProtos: []string{"h2", "http/1.1"}, // enable HTTP/2 }, Handler: w, } w.runner.AddJob(w.makeServerJob(s, false)) } } 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, bindings *AddressPortConfig) { metricsSkipper := makeMiddlewareSkipper(c.PrometheusConfig) w.Use(glmw.HTTPSRedirectWithConfig(glmw.HTTPSRedirectConfig{ Skipper: metricsSkipper, Port: bindings.TlsPort, })) if c.RedirectToWWW { w.Use(middleware.WWWRedirectWithConfig(middleware.RedirectConfig{ Skipper: metricsSkipper, })) } } func (w *EchoWrapper) configureCompression(c *EchoConfig) { metricsSkipper := makeMiddlewareSkipper(c.PrometheusConfig) w.Use(middleware.Decompress()) // TODO: This mangles responses but only for Accept: */* (browsers). Why? w.Use(middleware.GzipWithConfig(middleware.GzipConfig{ Skipper: metricsSkipper, Level: 5, })) } 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) configurePrometheus(c *EchoConfig) { if !c.DisablePrometheus && c.NetboxClient != nil { // TODO: Should constrain this by site probably but monitoring happens // across sites so those prefixes need to be included f := &glmw.NetboxIPFilter{ NetboxClient: c.NetboxClient, Tag: "management", IncludeLocalhost: true, Logger: w.Logger, } w.runner.AddInitJob(f.Init) w.runner.AddJob(f.RunRefresh) 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, f.Middleware) } }