package echo import ( "context" "crypto/tls" "fmt" "html/template" "io/fs" "net/http" "os" "sync" "code.crute.us/mcrute/golib/clients/netbox" glautocert "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" glservice "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 } } type EchoConfig struct { Autocert glautocert.PrimingCertProvider NetboxClient netbox.NetboxClient ApplicationName string ApplicationVersion string DisableServerHeader bool Debug bool 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 Autocert glautocert.PrimingCertProvider middlewareJobs []glservice.RunnerFunc middlewareInitJobs []glservice.SyncRunnerFunc servers []*http.Server tlsServers []*http.Server templateFS fs.FS } 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) makeServerJob(s *http.Server, echoInit bool) glservice.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) makeServerJobs() []glservice.RunnerFunc { out := []glservice.RunnerFunc{} for i, s := range w.servers { // The first http (not https) server should do an echo.StartServer to // configure some internal echo state and print the banner (if // configured). out = append(out, w.makeServerJob(s, i == 0)) } for _, s := range w.tlsServers { out = append(out, w.makeServerJob(s, false)) } return out } func (w *EchoWrapper) AddJobsToRunner(r *service.AppRunner) { r.AddInitJob(w.Autocert.PrimeCache) r.AddJob(w.Autocert.PrimingReporter(w.Logger)) r.AddJobs(w.makeServerJobs()) r.AddInitJobs(w.middlewareInitJobs) r.AddJobs(w.middlewareJobs) } func (w *EchoWrapper) GetTemplateFS() fs.FS { return w.templateFS } // NewDefaultEchoWithConfig builds a wrapper around an Echo instance and // configures it in the default way that it should probably be configured in // all cases. The struct returned from this function can be treated like a // normal Echo instance (because, for the most part it is). func NewDefaultEchoWithConfig(c EchoConfig) (*EchoWrapper, error) { var err error mwjobs := []glservice.RunnerFunc{} mwinitjobs := []glservice.SyncRunnerFunc{} if os.Getenv("POMONA_DC_SITE") == "" { return nil, fmt.Errorf("POMONA_DC_SITE must be in the environment") } e := echo.New() e.Debug = c.Debug // 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 { e.IPExtractor, err = glmw.ExtractIPFromXFFHeaders(false, c.TrustedProxyIPRanges) if err != nil { return nil, fmt.Errorf("Error building XFF IP extractor: %w", err) } } else { e.IPExtractor = echo.ExtractIPDirect() } // 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 && c.Debug { // Debug Mode templates = c.DiskTemplates } else if c.EmbeddedTemplates != nil && !c.Debug { // Prod Mode templates = c.EmbeddedTemplates } // Only install template handlers if the path and glob are set if templates != nil { e.HTTPErrorHandler = ErrorHandler(templates, c.TemplateFunctions) tr, err := NewTemplateRenderer(templates, "*.tpl", c.TemplateFunctions) if err != nil { return nil, fmt.Errorf("Error loading template renderer: %w", err) } e.Renderer = tr for _, t := range mandatoryTemplates { if !tr.HaveTemplate(e.NewContext(nil, nil), t) { return nil, fmt.Errorf("Tempalate renderer is missing required template %s", t) } } } e.Logger.SetLevel(log.INFO) if c.Debug { e.Logger.SetLevel(log.DEBUG) } if c.CombinedHostLogFile != "" { lc, err := NginxCombinedHostConfigToFile(c.CombinedHostLogFile) if err != nil { return nil, fmt.Errorf("Error opening log file: %w", err) } e.Use(middleware.LoggerWithConfig(lc)) } bindings, err := ParseAddressPortBindings(c.BindAddresses) if err != nil { return nil, fmt.Errorf("Error parsing address/port bindings") } servers := make([]*http.Server, len(c.BindAddresses)) for i, a := range bindings.HttpBindings() { servers[i] = &http.Server{ Addr: a, Handler: e, } } tlsServers := make([]*http.Server, len(c.BindAddresses)) for i, a := range bindings.TlsBindings() { tlsServers[i] = &http.Server{ Addr: a, TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, GetCertificate: c.Autocert.GetCertificate, NextProtos: []string{"h2", "http/1.1"}, // enable HTTP/2 }, Handler: e, } } metricsSkipper := makeMiddlewareSkipper(c.PrometheusConfig) e.Use(middleware.Logger()) e.Use(glmw.Recover()) if c.BodySizeLimit == "" { e.Use(middleware.BodyLimit(defaultBodySizeLimit)) } else if c.BodySizeLimit != "0" { e.Use(middleware.BodyLimit(c.BodySizeLimit)) } e.Use(glmw.HTTPSRedirectWithConfig(glmw.HTTPSRedirectConfig{ Skipper: metricsSkipper, Port: bindings.TlsPort, })) if c.RedirectToWWW { e.Use(middleware.WWWRedirectWithConfig(middleware.RedirectConfig{ Skipper: metricsSkipper, })) } e.Use(middleware.Decompress()) // TODO: This mangles responses but only for Accept: */* (browsers). Why? e.Use(middleware.GzipWithConfig(middleware.GzipConfig{ Skipper: metricsSkipper, Level: 5, })) e.Use(glmw.StrictSecure()) if c.CORSConfig != nil { e.Use(middleware.CORSWithConfig(*c.CORSConfig)) } else { e.Use(middleware.CORS()) } if c.ContentSecurityPolicy != nil { e.Use(glmw.ContentSecurityPolicyWithConfig(*c.ContentSecurityPolicy)) } else { e.Use(glmw.ContentSecurityPolicyWithConfig(glmw.ContentSecurityPolicyConfig{ DefaultSrc: []glmw.CSPDirective{ glmw.CSPSelf, }, })) } if c.ApplicationName != "" && c.ApplicationVersion != "" && !c.DisableServerHeader { e.Use(glmw.AddServerHeader(c.ApplicationName, c.ApplicationVersion)) } 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: e.Logger, } mwinitjobs = append(mwinitjobs, f.Init) mwjobs = append(mwjobs, f.RunRefresh) var prom *prometheus.Prometheus if c.PrometheusConfig != nil { prom = prometheus.NewPrometheusWithConfig(c.PrometheusConfig) } else { prom = prometheus.NewPrometheus() } e.Use(prom.MiddlewareHandler) e.GET(prom.Config.MetricsPath, prom.MetricsHandler, f.Middleware) } return &EchoWrapper{ Echo: e, Autocert: c.Autocert, servers: servers, tlsServers: tlsServers, templateFS: templates, middlewareJobs: mwjobs, middlewareInitJobs: mwinitjobs, }, nil }