package proxy import ( "context" "fmt" "html/template" "log" "net" "net/http" "net/http/httputil" "net/http/pprof" "net/url" "path/filepath" "strconv" "time" "forge.cadoles.com/Cadoles/go-proxy" "forge.cadoles.com/cadoles/bouncer/internal/cache/memory" "forge.cadoles.com/cadoles/bouncer/internal/cache/ttl" bouncerChi "forge.cadoles.com/cadoles/bouncer/internal/chi" "forge.cadoles.com/cadoles/bouncer/internal/config" "forge.cadoles.com/cadoles/bouncer/internal/proxy/director" "forge.cadoles.com/cadoles/bouncer/internal/store" "github.com/Masterminds/sprig/v3" "github.com/getsentry/sentry-go" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus/promhttp" "gitlab.com/wpetit/goweb/logger" ) type Server struct { serverConfig config.ProxyServerConfig redisConfig config.RedisConfig directorLayers []director.Layer directorCacheTTL time.Duration proxyRepository store.ProxyRepository layerRepository store.LayerRepository } func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) { errs := make(chan error) addrs := make(chan net.Addr) go s.run(ctx, addrs, errs) return addrs, errs } func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan error) { defer func() { close(errs) close(addrs) }() ctx, cancel := context.WithCancel(parentCtx) defer cancel() if err := s.initRepositories(ctx); err != nil { errs <- errors.WithStack(err) return } listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.serverConfig.HTTP.Host, s.serverConfig.HTTP.Port)) if err != nil { errs <- errors.WithStack(err) return } addrs <- listener.Addr() defer func() { if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { errs <- errors.WithStack(err) } }() go func() { <-ctx.Done() if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { log.Printf("%+v", errors.WithStack(err)) } }() router := chi.NewRouter() logger.Info(ctx, "http server listening") director := director.New( s.proxyRepository, s.layerRepository, director.WithLayers(s.directorLayers...), director.WithLayerCache( ttl.NewCache( memory.NewCache[string, []*store.Layer](), memory.NewCache[string, time.Time](), s.directorCacheTTL, ), ), director.WithProxyCache( ttl.NewCache( memory.NewCache[string, []*store.Proxy](), memory.NewCache[string, time.Time](), s.directorCacheTTL, ), ), ) if s.serverConfig.HTTP.UseRealIP { router.Use(middleware.RealIP) } router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter())) if s.serverConfig.Sentry.DSN != "" { logger.Info(ctx, "enabling sentry http middleware") sentryMiddleware := sentryhttp.New(sentryhttp.Options{ Repanic: true, }) router.Use(sentryMiddleware.Handle) } if s.serverConfig.Metrics.Enabled { metrics := s.serverConfig.Metrics logger.Info(ctx, "enabling metrics", logger.F("endpoint", metrics.Endpoint)) router.Group(func(r chi.Router) { if metrics.BasicAuth != nil { logger.Info(ctx, "enabling authentication on metrics endpoint") r.Use(middleware.BasicAuth( "metrics", metrics.BasicAuth.CredentialsMap(), )) } r.Handle(string(metrics.Endpoint), promhttp.Handler()) }) } if s.serverConfig.Profiling.Enabled { profiling := s.serverConfig.Profiling logger.Info(ctx, "enabling profiling", logger.F("endpoint", profiling.Endpoint)) router.Group(func(r chi.Router) { if profiling.BasicAuth != nil { logger.Info(ctx, "enabling authentication on profiling endpoint") r.Use(middleware.BasicAuth( "profiling", profiling.BasicAuth.CredentialsMap(), )) } r.Route(string(profiling.Endpoint), func(r chi.Router) { r.HandleFunc("/", pprof.Index) r.HandleFunc("/cmdline", pprof.Cmdline) r.HandleFunc("/profile", pprof.Profile) r.HandleFunc("/symbol", pprof.Symbol) r.HandleFunc("/trace", pprof.Trace) r.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") pprof.Handler(name).ServeHTTP(w, r) }) }) }) } router.Group(func(r chi.Router) { r.Use(director.Middleware()) handler := proxy.New( proxy.WithRequestTransformers( director.RequestTransformer(), ), proxy.WithResponseTransformers( director.ResponseTransformer(), ), proxy.WithReverseProxyFactory(s.createReverseProxy), proxy.WithDefaultHandler(http.HandlerFunc(s.handleDefault)), ) r.Handle("/*", handler) }) if err := http.Serve(listener, router); err != nil && !errors.Is(err, net.ErrClosed) { errs <- errors.WithStack(err) } logger.Info(ctx, "http server exiting") } func (s *Server) createReverseProxy(ctx context.Context, target *url.URL) *httputil.ReverseProxy { reverseProxy := httputil.NewSingleHostReverseProxy(target) dialConfig := s.serverConfig.Dial dialer := &net.Dialer{ Timeout: time.Duration(*dialConfig.Timeout), KeepAlive: time.Duration(*dialConfig.KeepAlive), FallbackDelay: time.Duration(*dialConfig.FallbackDelay), DualStack: bool(dialConfig.DualStack), } httpTransport := s.serverConfig.Transport.AsTransport() httpTransport.DialContext = dialer.DialContext reverseProxy.Transport = httpTransport reverseProxy.ErrorHandler = s.handleError return reverseProxy } func (s *Server) handleDefault(w http.ResponseWriter, r *http.Request) { err := errors.Errorf("no proxy target found") logger.Error(r.Context(), "proxy error", logger.CapturedE(err)) sentry.CaptureException(err) s.renderErrorPage(w, r, err, http.StatusBadGateway, http.StatusText(http.StatusBadGateway)) } func (s *Server) handleError(w http.ResponseWriter, r *http.Request, err error) { err = errors.WithStack(err) logger.Error(r.Context(), "proxy error", logger.CapturedE(err)) sentry.CaptureException(err) s.renderErrorPage(w, r, err, http.StatusBadGateway, http.StatusText(http.StatusBadGateway)) } func (s *Server) renderErrorPage(w http.ResponseWriter, r *http.Request, err error, statusCode int, status string) { templateData := struct { StatusCode int Status string Err error Debug bool }{ Debug: bool(s.serverConfig.Debug), StatusCode: statusCode, Status: status, Err: err, } w.WriteHeader(statusCode) s.renderPage(w, r, "error", strconv.FormatInt(int64(statusCode), 10), templateData) } func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, block string, templateData any) { ctx := r.Context() templatesConf := s.serverConfig.Templates pattern := filepath.Join(string(templatesConf.Dir), page+".gohtml") logger.Info(ctx, "loading proxy templates", logger.F("pattern", pattern)) tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern) if err != nil { logger.Error(ctx, "could not load proxy templates", logger.CapturedE(errors.WithStack(err))) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Add("Cache-Control", "no-cache") blockTmpl := tmpl.Lookup(block) if blockTmpl == nil { blockTmpl = tmpl.Lookup("default") } if blockTmpl == nil { logger.Error(ctx, "could not find template block nor default one", logger.F("block", block)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } if err := blockTmpl.Execute(w, templateData); err != nil { logger.Error(ctx, "could not render proxy page", logger.CapturedE(errors.WithStack(err))) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } } func NewServer(funcs ...OptionFunc) *Server { opt := defaultOption() for _, fn := range funcs { fn(opt) } return &Server{ serverConfig: opt.ServerConfig, redisConfig: opt.RedisConfig, directorLayers: opt.DirectorLayers, directorCacheTTL: opt.DirectorCacheTTL, } }