diff --git a/Dockerfile b/Dockerfile index 5ea8829..d06761d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,9 @@ RUN addgroup -S crowdsec-spoa && adduser -S -D -H -s /sbin/nologin -g crowdsec-s ## Create a socket for the spoa to inherit crowdsec-spoa:haproxy user from official haproxy image RUN mkdir -p /run/crowdsec-spoa/ && chown crowdsec-spoa:haproxy /run/crowdsec-spoa/ && chmod 770 /run/crowdsec-spoa/ +## Create log directory with proper permissions +RUN mkdir -p /var/log/crowdsec-spoa && chown crowdsec-spoa:crowdsec-spoa /var/log/crowdsec-spoa && chmod 755 /var/log/crowdsec-spoa + ## Copy Lua files (matching Debian/RPM paths) RUN mkdir -p /usr/lib/crowdsec-haproxy-spoa-bouncer/lua COPY --from=build /go/src/cs-spoa-bouncer/lua/* /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ @@ -34,8 +37,8 @@ COPY --from=build /go/src/cs-spoa-bouncer/lua/* /usr/lib/crowdsec-haproxy-spoa-b RUN mkdir -p /var/lib/crowdsec-haproxy-spoa-bouncer/html COPY --from=build /go/src/cs-spoa-bouncer/templates/* /var/lib/crowdsec-haproxy-spoa-bouncer/html/ -RUN chown -R root:haproxy /usr/lib/crowdsec-haproxy-spoa-bouncer/lua /var/lib/crowdsec-haproxy-spoa-bouncer/html - +RUN chown -R root:haproxy /usr/lib/crowdsec-haproxy-spoa-bouncer/lua /var/lib/crowdsec-haproxy-spoa-bouncer/html && \ + chmod -R 755 /usr/lib/crowdsec-haproxy-spoa-bouncer/lua /var/lib/crowdsec-haproxy-spoa-bouncer/html VOLUME [ "/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/", "/var/lib/crowdsec-haproxy-spoa-bouncer/html/" ] RUN chmod +x /docker_start.sh diff --git a/cmd/root.go b/cmd/root.go index 601415d..7e72fe7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( "github.com/crowdsecurity/crowdsec-spoa/pkg/cfg" "github.com/crowdsecurity/crowdsec-spoa/pkg/dataset" "github.com/crowdsecurity/crowdsec-spoa/pkg/host" + "github.com/crowdsecurity/crowdsec-spoa/pkg/httptemplate" "github.com/crowdsecurity/crowdsec-spoa/pkg/metrics" "github.com/crowdsecurity/crowdsec-spoa/pkg/spoa" csbouncer "github.com/crowdsecurity/go-cs-bouncer" @@ -178,12 +179,33 @@ func Execute() error { } } + // Create HTTP template server if enabled (after HostManager is created, but before starting) + var httpTemplateServer *httptemplate.Server + if config.HTTPTemplateServer.Enabled { + httpTemplateLogger := log.WithField("component", "http_template_server") + var err error + httpTemplateServer, err = httptemplate.NewServer(&config.HTTPTemplateServer, HostManager, httpTemplateLogger) + if err != nil { + return fmt.Errorf("failed to create HTTP template server: %w", err) + } + } + if config.HostsDir != "" { if err := HostManager.LoadFromDirectory(config.HostsDir); err != nil { return fmt.Errorf("failed to load hosts from directory: %w", err) } } + // Start HTTP template server after hosts are loaded to ensure host configurations are available + if httpTemplateServer != nil { + g.Go(func() error { + if err := httpTemplateServer.Serve(ctx); err != nil { + return fmt.Errorf("HTTP template server failed: %w", err) + } + return nil + }) + } + // Create single SPOA listener - ultra-simplified architecture spoaLogger := log.WithField("component", "spoa") @@ -219,13 +241,29 @@ func Execute() error { err = nil } - // Shutdown SPOA server gracefully after all goroutines finish - log.Info("Shutting down SPOA listener") + // Shutdown services gracefully in parallel after all goroutines finish + // Both services share a single 5-second timeout window + log.Info("Shutting down services") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if shutdownErr := singleSpoa.Shutdown(shutdownCtx); shutdownErr != nil { - log.Errorf("Failed to shutdown SPOA: %v", shutdownErr) + shutdownGroup, shutdownCtx := errgroup.WithContext(shutdownCtx) + + // Shutdown SPOA in parallel + shutdownGroup.Go(func() error { + return singleSpoa.Shutdown(shutdownCtx) + }) + + // Shutdown HTTP template server in parallel if it was started + if httpTemplateServer != nil { + shutdownGroup.Go(func() error { + return httpTemplateServer.Shutdown(shutdownCtx) + }) + } + + // Wait for both shutdowns to complete (or timeout) + if shutdownErr := shutdownGroup.Wait(); shutdownErr != nil { + log.Errorf("Failed to shutdown services gracefully: %v", shutdownErr) } return err diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index 770e113..418624c 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -24,3 +24,19 @@ prometheus: enabled: false listen_addr: 127.0.0.1 listen_port: 60601 + +## HTTP Template Server (Experimental) +# This feature allows HAProxy to use an HTTP backend instead of Lua scripts for template rendering +# When enabled, HAProxy can be configured to proxy requests to this server instead of using Lua +# Uses Go native templates (.tmpl files) instead of Lua-style templates +http_template_server: + enabled: false + listen_addr: 127.0.0.1 + listen_port: 8081 + # Optional: Custom template paths (defaults to .tmpl files in standard locations if not specified) + # ban_template_path: /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.tmpl + # captcha_template_path: /var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.tmpl + tls: + enabled: false + # cert_file: /path/to/cert.pem + # key_file: /path/to/key.pem diff --git a/config/haproxy-httptemplate.cfg b/config/haproxy-httptemplate.cfg new file mode 100644 index 0000000..a2a5e35 --- /dev/null +++ b/config/haproxy-httptemplate.cfg @@ -0,0 +1,74 @@ +# https://www.haproxy.com/documentation/hapee/latest/onepage/#home +# HAProxy configuration example using HTTP Template Server instead of Lua +# This demonstrates the experimental HTTP template server feature + +global + log stdout format raw local0 + +defaults + log global + option httplog + timeout client 1m + timeout server 1m + timeout connect 10s + timeout http-keep-alive 2m + timeout queue 15s + timeout tunnel 4h # for websocket + +frontend test + mode http + bind *:9090 + + unique-id-format %[uuid()] + unique-id-header X-Unique-ID + filter spoe engine crowdsec config /etc/haproxy/crowdsec.cfg + + ## Define ACLs for remediation types + acl is_ban var(txn.crowdsec.remediation) -m str "ban" + acl is_captcha var(txn.crowdsec.remediation) -m str "captcha" + acl is_allow var(txn.crowdsec.remediation) -m str "allow" + + ## Set headers for HTTP template server + ## IMPORTANT: Remove any user-provided headers first for security, then set from transaction variables + ## The server will look up host configuration using the Host header (automatically forwarded by HAProxy) + ## Only the remediation type needs to be sent - all other data comes from host configuration + http-request del-header X-Crowdsec-Remediation + http-request set-header X-Crowdsec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found } + + ## Set a custom header on the request for upstream services to use + http-request set-header X-Crowdsec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found } + + ## Handle 302 redirect for successful captcha validation (native HAProxy redirect) + http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if is_allow { var(txn.crowdsec.redirect) -m found } + + ## Route to HTTP template server for ban and captcha remediations + ## Instead of using Lua, we proxy to the HTTP template server + ## The server uses a catch-all route, so no path rewriting is needed + ## Use logical OR (||) to route to backend if ban OR captcha remediation + use_backend http_template_server if is_ban || is_captcha + + ## Handle captcha cookie management via HAProxy + ## Set captcha cookie when SPOA provides captcha_status (pending or valid) + http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found } + ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) + http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } + + ## Default backend for allowed requests + use_backend test_backend + +backend test_backend + mode http + server s1 whoami:2020 + +backend crowdsec-spoa + mode tcp + balance roundrobin + server s2 spoa:9000 + server s3 spoa:9001 + +backend http_template_server + mode http + # Proxy to the HTTP template server + # The server will read X-Crowdsec-Remediation header to determine which template to render + server template_server spoa:8081 + diff --git a/debian/rules b/debian/rules index 5172f2d..525e0d3 100644 --- a/debian/rules +++ b/debian/rules @@ -30,7 +30,9 @@ override_dh_auto_install: install -m 644 -D "lua/template.lua" "debian/$$PKG/usr/lib/$$PKG/lua/template.lua"; \ mkdir -p "debian/$$PKG/var/lib/$$PKG/html"; \ install -m 644 -D "templates/ban.html" "debian/$$PKG/var/lib/$$PKG/html/ban.html"; \ - install -m 644 -D "templates/captcha.html" "debian/$$PKG/var/lib/$$PKG/html/captcha.html" + install -m 644 -D "templates/captcha.html" "debian/$$PKG/var/lib/$$PKG/html/captcha.html"; \ + install -m 644 -D "templates/ban.tmpl" "debian/$$PKG/var/lib/$$PKG/html/ban.tmpl"; \ + install -m 644 -D "templates/captcha.tmpl" "debian/$$PKG/var/lib/$$PKG/html/captcha.tmpl" execute_after_dh_fixperms: @BOUNCER=crowdsec-spoa-bouncer; \ diff --git a/docker-compose.httptemplate-test.yaml b/docker-compose.httptemplate-test.yaml new file mode 100644 index 0000000..fd0d360 --- /dev/null +++ b/docker-compose.httptemplate-test.yaml @@ -0,0 +1,68 @@ +services: + spoa: + image: crowdsecurity/crowdsec-spoa:latest + build: + context: . + dockerfile: Dockerfile + depends_on: + - crowdsec + volumes: + - sockets:/run/ + - geodb:/var/lib/crowdsec/data/ + - ./config/crowdsec-spoa-bouncer.yaml.local:/etc/crowdsec/bouncers/crowdsec-spoa-bouncer.yaml.local + networks: + crowdsec: + ipv4_address: 10.5.5.254 + deploy: + resources: + limits: + cpus: "4.0" + memory: 250M + + whoami: + image: traefik/whoami:latest + networks: + - crowdsec + command: + - --port=2020 + + haproxy: + image: haproxy:2.9.7-alpine + volumes: + - ./config/haproxy-httptemplate.cfg:/usr/local/etc/haproxy/haproxy.cfg + - ./config/crowdsec.cfg:/etc/haproxy/crowdsec.cfg + - sockets:/run/ + ports: + - "9090:9090" + depends_on: + - crowdsec + - spoa + - whoami + networks: + - crowdsec + + crowdsec: + image: crowdsecurity/crowdsec:latest + environment: + - BOUNCER_KEY_SPOA=+4iYgItcalc9+0tWrvrj9R6Wded/W1IRwRtNmcWR9Ws + - DISABLE_ONLINE_API=true + - CROWDSEC_BYPASS_DB_VOLUME_CHECK=true + volumes: + - geodb:/staging/var/lib/crowdsec/data/ + networks: + - crowdsec + +volumes: + sockets: + driver: local + geodb: + driver: local + +networks: + crowdsec: + driver: bridge + ipam: + driver: default + config: + - subnet: "10.5.5.0/24" + diff --git a/internal/remediation/captcha/providers.go b/internal/remediation/captcha/providers.go index 7f92040..85b9384 100644 --- a/internal/remediation/captcha/providers.go +++ b/internal/remediation/captcha/providers.go @@ -35,3 +35,12 @@ func ValidProvider(provider string) bool { _, ok := providers[provider] return ok } + +// GetProviderInfo returns the frontend key and JS URL for a given provider +// Returns empty strings if the provider is not found +func GetProviderInfo(provider string) (frontendKey, frontendJS string) { + if info, ok := providers[provider]; ok { + return info.key, info.js + } + return "", "" +} diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index a98b041..ef36184 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -3,6 +3,7 @@ package cfg import ( "fmt" "io" + "os" "gopkg.in/yaml.v2" @@ -18,14 +19,30 @@ type PrometheusConfig struct { ListenPort string `yaml:"listen_port"` } +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +type HTTPTemplateServerConfig struct { + Enabled bool `yaml:"enabled"` + ListenAddress string `yaml:"listen_addr"` + ListenPort string `yaml:"listen_port"` + TLS TLSConfig `yaml:"tls"` + BanTemplate string `yaml:"ban_template_path"` + CaptchaTemplate string `yaml:"captcha_template_path"` +} + type BouncerConfig struct { - Logging cslogging.LoggingConfig `yaml:",inline"` - Hosts []*host.Host `yaml:"hosts"` - HostsDir string `yaml:"hosts_dir"` - Geo geo.GeoDatabase `yaml:",inline"` - ListenTCP string `yaml:"listen_tcp"` - ListenUnix string `yaml:"listen_unix"` - PrometheusConfig PrometheusConfig `yaml:"prometheus"` + Logging cslogging.LoggingConfig `yaml:",inline"` + Hosts []*host.Host `yaml:"hosts"` + HostsDir string `yaml:"hosts_dir"` + Geo geo.GeoDatabase `yaml:",inline"` + ListenTCP string `yaml:"listen_tcp"` + ListenUnix string `yaml:"listen_unix"` + PrometheusConfig PrometheusConfig `yaml:"prometheus"` + HTTPTemplateServer HTTPTemplateServerConfig `yaml:"http_template_server"` } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). @@ -73,5 +90,29 @@ func (c *BouncerConfig) Validate() error { return fmt.Errorf("configuration requires at least one listener: set listen_tcp or listen_unix") } + // Validate HTTP template server configuration if enabled + if c.HTTPTemplateServer.Enabled { + if c.HTTPTemplateServer.ListenAddress == "" { + return fmt.Errorf("http_template_server.listen_addr is required when http_template_server.enabled is true") + } + if c.HTTPTemplateServer.ListenPort == "" { + return fmt.Errorf("http_template_server.listen_port is required when http_template_server.enabled is true") + } + if c.HTTPTemplateServer.TLS.Enabled { + if c.HTTPTemplateServer.TLS.CertFile == "" { + return fmt.Errorf("http_template_server.tls.cert_file is required when http_template_server.tls.enabled is true") + } + if _, err := os.Stat(c.HTTPTemplateServer.TLS.CertFile); err != nil { + return fmt.Errorf("http_template_server.tls.cert_file does not exist or is not accessible: %w", err) + } + if c.HTTPTemplateServer.TLS.KeyFile == "" { + return fmt.Errorf("http_template_server.tls.key_file is required when http_template_server.tls.enabled is true") + } + if _, err := os.Stat(c.HTTPTemplateServer.TLS.KeyFile); err != nil { + return fmt.Errorf("http_template_server.tls.key_file does not exist or is not accessible: %w", err) + } + } + } + return nil } diff --git a/pkg/httptemplate/server.go b/pkg/httptemplate/server.go new file mode 100644 index 0000000..7de20e4 --- /dev/null +++ b/pkg/httptemplate/server.go @@ -0,0 +1,302 @@ +package httptemplate + +import ( + "bytes" + "context" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/crowdsecurity/crowdsec-spoa/internal/remediation/captcha" + "github.com/crowdsecurity/crowdsec-spoa/pkg/cfg" + "github.com/crowdsecurity/crowdsec-spoa/pkg/host" + "github.com/crowdsecurity/crowdsec-spoa/pkg/template" + log "github.com/sirupsen/logrus" +) + +// Server represents the HTTP template server +type Server struct { + config *cfg.HTTPTemplateServerConfig + logger *log.Entry + server *http.Server + banRenderer *template.Renderer + captchaRenderer *template.Renderer + hostManager *host.Manager +} + +// NewServer creates a new HTTP template server +func NewServer(config *cfg.HTTPTemplateServerConfig, hostManager *host.Manager, logger *log.Entry) (*Server, error) { + if config == nil { + return nil, fmt.Errorf("http template server config is nil") + } + + if !config.Enabled { + return nil, fmt.Errorf("http template server is not enabled") + } + + s := &Server{ + config: config, + logger: logger.WithField("component", "http_template_server"), + hostManager: hostManager, + } + + // Load ban template + banTemplatePath := config.BanTemplate + if banTemplatePath == "" { + banTemplatePath = "/var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.tmpl" + } + var err error + s.banRenderer, err = s.loadTemplate("ban", banTemplatePath) + if err != nil { + return nil, fmt.Errorf("failed to load ban template: %w", err) + } + + // Load captcha template + captchaTemplatePath := config.CaptchaTemplate + if captchaTemplatePath == "" { + captchaTemplatePath = "/var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.tmpl" + } + s.captchaRenderer, err = s.loadTemplate("captcha", captchaTemplatePath) + if err != nil { + return nil, fmt.Errorf("failed to load captcha template: %w", err) + } + + // Setup HTTP routes + mux := http.NewServeMux() + // Catch-all route that handles both ban and captcha based on header + // This allows HAProxy to route to any path without needing to set-path + mux.HandleFunc("/", s.handleRender) + + // Create HTTP server + listenAddr := net.JoinHostPort(config.ListenAddress, config.ListenPort) + s.server = &http.Server{ + Addr: listenAddr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return s, nil +} + +// loadTemplate loads a template from a file path and returns a renderer +func (s *Server) loadTemplate(name, path string) (*template.Renderer, error) { + // Use the provided path directly - it should be absolute or configured by the user + templatePath := path + if !filepath.IsAbs(path) { + // If relative path provided, make it relative to the standard template directory + const templateDir = "/var/lib/crowdsec-haproxy-spoa-bouncer/html" + templatePath = filepath.Join(templateDir, path) + } + + content, err := os.ReadFile(templatePath) + if err != nil { + return nil, fmt.Errorf("failed to read template file %s: %w", templatePath, err) + } + + renderer, err := template.NewRenderer(name, string(content)) + if err != nil { + return nil, fmt.Errorf("failed to create renderer: %w", err) + } + + s.logger.WithField("template", name).WithField("path", templatePath).Info("loaded template") + return renderer, nil +} + +// handleRender is the main endpoint that handles both ban and captcha rendering +// It reads the remediation type from X-Crowdsec-Remediation header (set by HAProxy) +// It looks up host configuration using the Host header to get ban/captcha settings +// Security: HAProxy must delete any user-provided headers and set them from transaction variables +func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { + // Get remediation from header (set by HAProxy from transaction variables) + // HAProxy configuration should use del-header before set-header to ensure user values are ignored + remediation := r.Header.Get("X-Crowdsec-Remediation") + if remediation == "" { + s.logger.Warn("X-Crowdsec-Remediation header not found, returning 400") + http.Error(w, "Bad Request: Missing X-Crowdsec-Remediation header", http.StatusBadRequest) + return + } + + // Get hostname from Host header (HAProxy forwards the original Host header) + // Security: Validate hostname to prevent header manipulation attacks + // Note: This assumes HAProxy properly sanitizes headers, but we add defense in depth + hostname := r.Host + if hostname == "" { + s.logger.Warn("Host header not found, cannot match host configuration") + http.Error(w, "Bad Request: Missing Host header", http.StatusBadRequest) + return + } + + // Validate hostname: check for invalid characters and reasonable length + // This prevents potential header injection or manipulation attacks + if len(hostname) > 253 { // Max DNS hostname length + s.logger.WithField("hostname_length", len(hostname)).Warn("Host header too long, rejecting") + http.Error(w, "Bad Request: Invalid Host header", http.StatusBadRequest) + return + } + if strings.ContainsAny(hostname, "\r\n\t") { + s.logger.WithField("hostname", hostname).Warn("Host header contains invalid characters, rejecting") + http.Error(w, "Bad Request: Invalid Host header", http.StatusBadRequest) + return + } + + // Look up host configuration + matchedHost := s.hostManager.MatchFirstHost(hostname) + if matchedHost == nil { + s.logger.WithField("hostname", hostname).Warn("no matching host configuration found") + // If captcha and no host, we can't proceed (same logic as SPOA) + if remediation == "captcha" { + s.logger.Warn("captcha remediation but no host found, cannot render captcha") + http.Error(w, "Internal Server Error: No host configuration for captcha", http.StatusInternalServerError) + return + } + // For ban, we can still render with empty data + matchedHost = nil + } + + // Build template data from host configuration + data := s.buildTemplateData(remediation, matchedHost) + + // Log for security monitoring + s.logger.WithFields(log.Fields{ + "remediation": remediation, + "hostname": hostname, + "remote_addr": r.RemoteAddr, + "host_found": matchedHost != nil, + }).Debug("handling render request") + + var renderer *template.Renderer + var statusCode int + + switch remediation { + case "ban": + renderer = s.banRenderer + statusCode = http.StatusForbidden + case "captcha": + // matchedHost nil check for captcha already done above at lines 137-140 + renderer = s.captchaRenderer + statusCode = http.StatusOK + case "allow": + // Allow should be handled by HAProxy redirect, but if it reaches here, return 200 + s.logger.Warn("handleRender called for 'allow' remediation - this should be handled by HAProxy redirect") + http.Error(w, "Bad Request: Allow remediation should use HAProxy redirect", http.StatusBadRequest) + return + default: + s.logger.WithField("remediation", remediation).Warn("unknown remediation type") + http.Error(w, fmt.Sprintf("Bad Request: Unknown remediation type: %s", remediation), http.StatusBadRequest) + return + } + + // Set headers + w.Header().Set("Cache-Control", "no-cache, no-store") + + // For HEAD requests or .ico file requests, always return 403 for ban and captcha remediations + if r.Method == http.MethodHead || strings.HasSuffix(strings.ToLower(r.URL.Path), ".ico") { + w.WriteHeader(http.StatusForbidden) + return + } + + // Check if client accepts HTML + if !s.acceptsHTML(r) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + if _, err := w.Write([]byte("Forbidden")); err != nil { + s.logger.WithError(err).Error("failed to write response") + } + return + } + + // Buffer template output before writing headers to ensure we can send proper error response + // if rendering fails + var buf bytes.Buffer + if err := renderer.Render(&buf, data); err != nil { + s.logger.WithError(err).Error("failed to render template") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Set headers and write buffered content + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(statusCode) + if _, err := w.Write(buf.Bytes()); err != nil { + s.logger.WithError(err).Error("failed to write response") + } +} + +// buildTemplateData builds template data from host configuration +// If matchedHost is nil, returns empty data (for ban without host config) +func (s *Server) buildTemplateData(remediation string, matchedHost *host.Host) template.TemplateData { + data := template.TemplateData{} + + if matchedHost == nil { + // No host configuration - return empty data + // This is acceptable for ban remediation but not for captcha + return data + } + + // Extract ban configuration + data.ContactUsURL = matchedHost.Ban.ContactUsURL + + // Extract captcha configuration + if remediation == "captcha" && matchedHost.Captcha.Provider != "" { + data.CaptchaSiteKey = matchedHost.Captcha.SiteKey + data.CaptchaFrontendKey, data.CaptchaFrontendJS = captcha.GetProviderInfo(matchedHost.Captcha.Provider) + } + + return data +} + +// acceptsHTML checks if the request accepts HTML content +func (s *Server) acceptsHTML(r *http.Request) bool { + accept := r.Header.Get("Accept") + if accept == "" { + return true // Default to HTML if no Accept header + } + return strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*") +} + +// Serve starts the HTTP server +func (s *Server) Serve(ctx context.Context) error { + listenAddr := s.server.Addr + protocol := "HTTP" + if s.config.TLS.Enabled { + protocol = "HTTPS" + } + + s.logger.WithFields(log.Fields{ + "address": listenAddr, + "protocol": protocol, + }).Info("starting HTTP template server") + + serverError := make(chan error, 1) + + go func() { + var err error + if s.config.TLS.Enabled { + err = s.server.ListenAndServeTLS(s.config.TLS.CertFile, s.config.TLS.KeyFile) + } else { + err = s.server.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { + serverError <- err + } + }() + + select { + case err := <-serverError: + return fmt.Errorf("HTTP template server error: %w", err) + case <-ctx.Done(): + return nil + } +} + +// Shutdown gracefully shuts down the HTTP server +func (s *Server) Shutdown(ctx context.Context) error { + s.logger.Info("shutting down HTTP template server") + return s.server.Shutdown(ctx) +} diff --git a/pkg/template/renderer.go b/pkg/template/renderer.go new file mode 100644 index 0000000..1f9a4e7 --- /dev/null +++ b/pkg/template/renderer.go @@ -0,0 +1,48 @@ +package template + +import ( + "fmt" + "html/template" + "io" +) + +// TemplateData holds the data for template rendering +type TemplateData struct { + // Ban template fields + ContactUsURL string + + // Captcha template fields + CaptchaSiteKey string + CaptchaFrontendKey string + CaptchaFrontendJS string +} + +// Renderer handles template rendering using Go's native html/template package +// html/template provides automatic HTML escaping to prevent XSS attacks +type Renderer struct { + tmpl *template.Template +} + +// NewRenderer creates a new template renderer with the given template content +func NewRenderer(name, templateContent string) (*Renderer, error) { + if name == "" { + name = "template" + } + tmpl, err := template.New(name).Parse(templateContent) + if err != nil { + return nil, fmt.Errorf("failed to parse template %q: %w", name, err) + } + + return &Renderer{ + tmpl: tmpl, + }, nil +} + +// Render renders the template with the given data directly to the provided io.Writer +func (r *Renderer) Render(w io.Writer, data TemplateData) error { + if err := r.tmpl.Execute(w, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} diff --git a/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec b/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec index cf03b1e..1b4e130 100644 --- a/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec +++ b/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec @@ -48,6 +48,8 @@ install -m 644 -D lua/utils.lua %{buildroot}/usr/lib/%{name}/lua/utils.lua install -m 644 -D lua/template.lua %{buildroot}/usr/lib/%{name}/lua/template.lua install -m 644 -D templates/ban.html %{buildroot}%{_localstatedir}/lib/%{name}/html/ban.html install -m 644 -D templates/captcha.html %{buildroot}%{_localstatedir}/lib/%{name}/html/captcha.html +install -m 644 -D templates/ban.tmpl %{buildroot}%{_localstatedir}/lib/%{name}/html/ban.tmpl +install -m 644 -D templates/captcha.tmpl %{buildroot}%{_localstatedir}/lib/%{name}/html/captcha.tmpl %clean rm -rf %{buildroot} @@ -65,6 +67,8 @@ rm -rf %{buildroot} /usr/lib/%{name}/lua/template.lua %{_localstatedir}/lib/%{name}/html/ban.html %{_localstatedir}/lib/%{name}/html/captcha.html +%{_localstatedir}/lib/%{name}/html/ban.tmpl +%{_localstatedir}/lib/%{name}/html/captcha.tmpl %post # Reload systemd units diff --git a/templates/ban.html b/templates/ban.html index 12494f4..2c1782d 100644 --- a/templates/ban.html +++ b/templates/ban.html @@ -2,7 +2,7 @@ CrowdSec Ban - + @@ -12,7 +12,7 @@
-

CrowdSec Access Forbidden

@@ -72,7 +72,7 @@

CrowdSec Access Forbidd + + + diff --git a/templates/captcha.html b/templates/captcha.html index 465cc62..ef25ef2 100644 --- a/templates/captcha.html +++ b/templates/captcha.html @@ -2,7 +2,7 @@ CrowdSec Captcha - + @@ -12,7 +12,7 @@
-

CrowdSec Captcha

@@ -69,7 +69,7 @@

CrowdSec Captcha

+ + +
+
+
+
+ +

CrowdSec Captcha

+
+
+
+
+
+

+ This security check has been powered by +

+ + + + + + + + + + + + + + + + + + + + + + CrowdSec + + +
+
+
+ + + +