package echo import ( "context" "crypto/tls" "fmt" "html/template" "io/fs" "net" "net/http" "path" "strconv" "sync" gltls "code.crute.us/mcrute/golib/crypto/tls" glmw "code.crute.us/mcrute/golib/echo/middleware" "code.crute.us/mcrute/golib/echo/prometheus" glnet "code.crute.us/mcrute/golib/net" 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 // - Enable auto cert management by passing hostnames 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 } } type EchoConfig struct { Debug bool Hostnames []string BindAddresses []string BindTLSAddresses []string TLSCacheDir string BodySizeLimit string TrustedProxyIPRanges []string ManagementIPRanges []string EmbeddedTemplates fs.FS DiskTemplates fs.FS TemplateGlob *string TemplateFunctions template.FuncMap CombinedHostLogFile string RedirectToWWW bool ContentSecurityPolicy *glmw.ContentSecurityPolicyConfig DisablePrometheus bool PrometheusConfig *prometheus.PrometheusConfig CORSConfig *middleware.CORSConfig } type EchoWrapper struct { *echo.Echo servers []*http.Server tlsServers []*http.Server templateFS fs.FS ocspErrors chan gltls.OcspError ocspManager *gltls.OcspManager initDone bool } // Init does "expensive" work that requires network calls but must be called // before using the returned echo instance func (w *EchoWrapper) Init() error { if err := w.ocspManager.Init(); err != nil { return fmt.Errorf("Error loading TLS certificates and stapling: %w", err) } w.initDone = true return nil } func (w *EchoWrapper) CachedStaticRoute(prefix, path string) { StaticFS(w.GET, w.templateFS, prefix, path, glmw.CacheOneMonthMiddleware) } func (w *EchoWrapper) OcspErrors() chan gltls.OcspError { return w.ocspErrors } func (w *EchoWrapper) RunCertificateManager(ctx context.Context, wg *sync.WaitGroup) error { return w.ocspManager.Run(ctx, wg) } func (w *EchoWrapper) makeServerJob(s *http.Server, echoInit bool) glservice.RunnerFunc { return func(ctx context.Context, wg *sync.WaitGroup) error { if !w.initDone { return fmt.Errorf("Echo is not initialized. Call Init()") } 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) 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). // // Consumers must call Init() on the returned object before serving with it. func NewDefaultEchoWithConfig(c EchoConfig) (*EchoWrapper, error) { var err error e := echo.New() e.Debug = c.Debug e.IPExtractor, err = glmw.ExtractIPFromXFFHeaders(false, c.TrustedProxyIPRanges) if err != nil { return nil, fmt.Errorf("Error building XFF IP extractor: %w", err) } // 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 } else { return nil, fmt.Errorf("No templates available for use") } // Only install template handlers if the path and glob are set if templates != nil && c.TemplateGlob != nil { // TODO: Should assert the presence of required templates: 404.tpl // 40x.tpl 50x.tpl header.tpl footer.tpl e.HTTPErrorHandler = ErrorHandler(templates, c.TemplateFunctions) tr, err := NewTemplateRenderer(templates, *c.TemplateGlob, c.TemplateFunctions) if err != nil { return nil, fmt.Errorf("Error loading template renderer: %w", err) } e.Renderer = tr } 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)) } cmek := make(chan gltls.OcspError) cm := &gltls.OcspManager{ CertPath: path.Join(c.TLSCacheDir, "cert.pem"), KeyPath: path.Join(c.TLSCacheDir, "key.pem"), Errors: cmek, } servers := make([]*http.Server, len(c.BindAddresses)) for i, a := range c.BindAddresses { servers[i] = &http.Server{ Addr: a, Handler: e, } } tlsServers := make([]*http.Server, len(c.BindTLSAddresses)) for i, a := range c.BindTLSAddresses { tlsServers[i] = &http.Server{ Addr: a, TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, GetCertificate: cm.GetCertificate, }, 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)) } _, tlsPort, err := net.SplitHostPort(tlsServers[0].Addr) if err != nil { return nil, fmt.Errorf("Unable to split TLS addr and port: %w", err) } tlsPortI, err := strconv.Atoi(tlsPort) if err != nil { return nil, fmt.Errorf("Unable to convert TLS port to int: %w", err) } e.Use(glmw.HTTPSRedirectWithConfig(glmw.HTTPSRedirectConfig{ Skipper: metricsSkipper, Port: tlsPortI, })) 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 { return nil, fmt.Errorf("ContentSecurityPolicy is required") } if !c.DisablePrometheus { mips, err := glnet.ParseCIDRSlice(c.ManagementIPRanges) if err != nil { return nil, err } 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, glmw.NewIPFilter(mips)) } return &EchoWrapper{ Echo: e, servers: servers, tlsServers: tlsServers, ocspErrors: cmek, ocspManager: cm, templateFS: templates, }, nil }