diff --git a/RELEASE.md b/RELEASE.md index 697934e..b55e512 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -54,7 +54,7 @@ EasyHAProxy follows [Semantic Versioning](https://semver.org/): - **MINOR**: New features, plugin additions, backward-compatible changes - **PATCH**: Bug fixes, documentation updates, minor improvements -**Current Version:** `6.1.0` (as of Chart.yaml) +**Current Version:** `6.1.1` (as of Chart.yaml) ## Automated Release (Recommended) @@ -329,6 +329,7 @@ helm show chart byjg/easyhaproxy | Version | Release Date | Type | Highlights | |---------|--------------|-------|---------------------------------------------------------------------------------------------------------------------------------------| +| 6.1.1 | 2026-06-25 | Patch | Add pass_headers support to FastCGI plugin, fix flaky ACME e2e test timeout | | 6.1.0 | 2026-06-24 | Minor | FastCGI protocol support, certbot sidecar plugin, dependency updates | | 6.0.1 | 2026-02-23 | Patch | HAProxy dashboard, real-time monitoring dashboard with live traffic charts, ACME e2e test fixes | | 6.0.0 | 2026-XX-XX | Major | Python module refactoring (one-class-per-file), IngressClassName support (spec.ingressClassName + deprecated annotation fallback), modernized build system (uv/pyproject.toml), improved version management and release tooling, GitHub Actions workflow_dispatch push control | diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 86cbc06..6eff1f6 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: easyhaproxy: - image: byjg/easy-haproxy:6.1.0 + image: byjg/easy-haproxy:6.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock - certs_certbot:/etc/easyhaproxy/certs/certbot diff --git a/deploy/kubernetes/easyhaproxy-clusterip.yml b/deploy/kubernetes/easyhaproxy-clusterip.yml index ef546b2..429a19e 100644 --- a/deploy/kubernetes/easyhaproxy-clusterip.yml +++ b/deploy/kubernetes/easyhaproxy-clusterip.yml @@ -6,10 +6,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm --- # Source: easyhaproxy/templates/clusterrole.yaml @@ -19,10 +19,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm rules: - apiGroups: @@ -83,10 +83,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -105,10 +105,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm annotations: {} @@ -140,10 +140,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -164,7 +164,7 @@ spec: - name: easyhaproxy securityContext: {} - image: "byjg/easy-haproxy:6.1.0" + image: "byjg/easy-haproxy:6.1.1" imagePullPolicy: Always ports: - name: http @@ -217,10 +217,10 @@ kind: IngressClass metadata: name: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm spec: controller: byjg.com/easyhaproxy diff --git a/deploy/kubernetes/easyhaproxy-daemonset.yml b/deploy/kubernetes/easyhaproxy-daemonset.yml index f31c57c..2666d13 100644 --- a/deploy/kubernetes/easyhaproxy-daemonset.yml +++ b/deploy/kubernetes/easyhaproxy-daemonset.yml @@ -6,10 +6,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm --- # Source: easyhaproxy/templates/clusterrole.yaml @@ -19,10 +19,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm rules: - apiGroups: @@ -83,10 +83,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -104,10 +104,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm spec: selector: @@ -136,7 +136,7 @@ spec: - name: easyhaproxy securityContext: {} - image: "byjg/easy-haproxy:6.1.0" + image: "byjg/easy-haproxy:6.1.1" imagePullPolicy: Always ports: - name: http @@ -189,10 +189,10 @@ kind: IngressClass metadata: name: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm spec: controller: byjg.com/easyhaproxy diff --git a/deploy/kubernetes/easyhaproxy-nodeport.yml b/deploy/kubernetes/easyhaproxy-nodeport.yml index 91948c1..dab8a3c 100644 --- a/deploy/kubernetes/easyhaproxy-nodeport.yml +++ b/deploy/kubernetes/easyhaproxy-nodeport.yml @@ -6,10 +6,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm --- # Source: easyhaproxy/templates/clusterrole.yaml @@ -19,10 +19,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm rules: - apiGroups: @@ -83,10 +83,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -105,10 +105,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm annotations: {} @@ -140,10 +140,10 @@ metadata: name: ingress-easyhaproxy namespace: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -164,7 +164,7 @@ spec: - name: easyhaproxy securityContext: {} - image: "byjg/easy-haproxy:6.1.0" + image: "byjg/easy-haproxy:6.1.1" imagePullPolicy: Always ports: - name: http @@ -217,10 +217,10 @@ kind: IngressClass metadata: name: easyhaproxy labels: - helm.sh/chart: easyhaproxy-2.0.3 + helm.sh/chart: easyhaproxy-2.0.4 app.kubernetes.io/name: easyhaproxy app.kubernetes.io/instance: ingress - app.kubernetes.io/version: "6.1.0" + app.kubernetes.io/version: "6.1.1" app.kubernetes.io/managed-by: Helm spec: controller: byjg.com/easyhaproxy diff --git a/docs/getting-started/kubernetes.md b/docs/getting-started/kubernetes.md index 2c1bbb4..aca6f9a 100644 --- a/docs/getting-started/kubernetes.md +++ b/docs/getting-started/kubernetes.md @@ -41,7 +41,7 @@ Exposes HAProxy on NodePort `31080` (HTTP), `31443` (HTTPS), and `31936` (stats) ```bash kubectl apply -f \ - https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-nodeport.yml + https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-nodeport.yml ``` ### ClusterIP (behind a LoadBalancer) @@ -50,7 +50,7 @@ Cluster-internal only. Pair with an external cloud LoadBalancer or `kubectl port ```bash kubectl apply -f \ - https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-clusterip.yml + https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-clusterip.yml ``` ### DaemonSet (special cases — requires node label) @@ -64,7 +64,7 @@ The node label must be reapplied after any node replacement. Failing to do so wi kubectl label nodes node-01 "easyhaproxy/node=master" kubectl apply -f \ - https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml + https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml ``` If you need to configure environment variables (log levels, stats password, etc.), see the [environment variable reference](../reference/environment-variables.md). diff --git a/docs/getting-started/swarm.md b/docs/getting-started/swarm.md index 31653e9..7707d99 100644 --- a/docs/getting-started/swarm.md +++ b/docs/getting-started/swarm.md @@ -28,7 +28,7 @@ docker network create -d overlay --attachable easyhaproxy ```yaml services: haproxy: - image: byjg/easy-haproxy:6.1.0 + image: byjg/easy-haproxy:6.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock deploy: diff --git a/docs/guides/acme.md b/docs/guides/acme.md index e43f997..be78be2 100644 --- a/docs/guides/acme.md +++ b/docs/guides/acme.md @@ -157,7 +157,7 @@ Here's a complete `docker-compose.yml` showing proper ACME configuration: ```yaml services: easyhaproxy: - image: byjg/easy-haproxy:6.1.0 + image: byjg/easy-haproxy:6.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock # REQUIRED: Persist Certbot certificates (ACME) diff --git a/docs/guides/ssl.md b/docs/guides/ssl.md index cc0d413..b85995a 100644 --- a/docs/guides/ssl.md +++ b/docs/guides/ssl.md @@ -83,7 +83,7 @@ docker cp single.pem easyhaproxy:/etc/easyhaproxy/certs/haproxy/example.com.pem ```yaml services: easyhaproxy: - image: byjg/easy-haproxy:6.1.0 + image: byjg/easy-haproxy:6.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock - certs_haproxy:/etc/easyhaproxy/certs/haproxy diff --git a/docs/reference/plugins/fastcgi.md b/docs/reference/plugins/fastcgi.md index f606a3f..068576c 100644 --- a/docs/reference/plugins/fastcgi.md +++ b/docs/reference/plugins/fastcgi.md @@ -18,14 +18,57 @@ Automatically generates HAProxy `fcgi-app` configuration that defines required C ## Configuration Options -| Option | Description | Default | -|-------------------|-----------------------------------------|------------------------------------| -| `enabled` | Enable/disable plugin | `true` | -| `document_root` | Document root path | `/var/www/html` | -| `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | -| `index_file` | Default index file | `index.php` | -| `path_info` | Enable PATH_INFO support | `true` | -| `custom_params` | Dictionary of custom FastCGI parameters | (optional) | +| Option | Description | Default | +|-------------------|--------------------------------------------|--------------------------------------| +| `enabled` | Enable/disable plugin | `true` | +| `document_root` | Document root path | `/var/www/html` | +| `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | +| `index_file` | Default index file | `index.php` | +| `path_info` | PATH_INFO preset or custom regex | `php` | +| `log_stderr` | Forward FastCGI stderr to HAProxy logs | `false` | +| `keep_conn` | Reuse FastCGI connections between requests | `true` | +| `custom_params` | Dictionary of custom FastCGI parameters | (optional) | +| `pass_headers` | HTTP headers to forward to the FastCGI app | (optional) | + +### `path_info` — PATH_INFO Presets + +The `path_info` option accepts a preset name or a custom regex. Built-in presets: + +| Preset | Language | Regex | +|----------|----------------|---------------------------------| +| `php` | PHP (default) | `^(/.+\.php)(/.*)?$` | +| `python` | Python FastCGI | `^(/.+\.py)(/.*)?$` | +| `perl` | Perl/CGI | `^(/.+\.(pl\|cgi))(/.*)?$` | +| `ruby` | Ruby FastCGI | `^(/.+\.rb)(/.*)?$` | +| `any` | Generic | `^(.+?)(/.*)?$` | + +Pass `false` to disable PATH_INFO entirely, or any other string to use it as a custom regex directly. + +Setting `path_info: true` maps to `php` for backward compatibility. + +### `pass_headers` — Forwarding HTTP Headers + +:::important +HAProxy strips `Authorization`, `Proxy-Authorization`, and all hop-by-hop headers before forwarding requests to the FastCGI backend. Applications that rely on these headers (e.g. APIs using Bearer tokens, Basic auth, or JWT) will fail silently without this option. +::: + +`pass_headers` accepts a comma-separated string or a list of strings/dicts: + +```yaml +# Simple — single header +pass_headers: "Authorization" + +# Multiple — comma-separated +pass_headers: "Authorization, Proxy-Authorization" + +# With ACL condition — list of dicts +pass_headers: + - Authorization + - name: X-Custom-Header + condition: "if { ssl_fc }" +``` + +> **Note:** `Content-Type` and `Content-Length` cannot be passed here — they are automatically converted to the CGI parameters `CONTENT_TYPE` and `CONTENT_LENGTH`. ## Configuration Examples @@ -78,6 +121,7 @@ metadata: easyhaproxy.plugin.fastcgi.document_root: "/var/www/html" easyhaproxy.plugin.fastcgi.index_file: "index.php" easyhaproxy.plugin.fastcgi.script_filename: "/var/www/html/index.php" + easyhaproxy.plugin.fastcgi.pass_headers: "Authorization, Proxy-Authorization" spec: ingressClassName: easyhaproxy rules: @@ -113,13 +157,13 @@ easymapping: ### Environment Variables -| Environment Variable | Config Key | Type | Default | Description | -|----------------------------------------------|-------------------|----------|------------------------|---------------------------------------| -| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | -| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path | -| `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME | -| `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file | -| `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support | +| Environment Variable | Config Key | Type | Default | Description | +|----------------------------------------------|-------------------|----------|-------------------------|---------------------------------------| +| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path | +| `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME | +| `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file | +| `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support | ## Generated HAProxy Configuration @@ -128,7 +172,10 @@ easymapping: fcgi-app fcgi_phpapp_local docroot /var/www/html index index.php + option keep-conn path-info ^(/.+\.php)(/.*)?$ + pass-header Authorization + pass-header Proxy-Authorization # Backend configuration (added to the backend section) backend srv_phpapp_local_80 diff --git a/helm/easyhaproxy/Chart.yaml b/helm/easyhaproxy/Chart.yaml index d2b51de..ded9935 100644 --- a/helm/easyhaproxy/Chart.yaml +++ b/helm/easyhaproxy/Chart.yaml @@ -15,12 +15,12 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 2.0.3 +version: 2.0.4 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "6.1.0" +appVersion: "6.1.1" icon: https://opensource.byjg.com/img/easy_haproxy_logo.png diff --git a/helm/easyhaproxy/templates/deployment.yaml b/helm/easyhaproxy/templates/deployment.yaml index 48ad3c7..2e38645 100644 --- a/helm/easyhaproxy/templates/deployment.yaml +++ b/helm/easyhaproxy/templates/deployment.yaml @@ -99,3 +99,6 @@ spec: {{- end }} - name: EASYHAPROXY_STATUS_UPDATE_INTERVAL value: {{ .Values.ingressStatus.updateInterval | quote }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} diff --git a/helm/easyhaproxy/values.yaml b/helm/easyhaproxy/values.yaml index 496d930..97bc8ab 100644 --- a/helm/easyhaproxy/values.yaml +++ b/helm/easyhaproxy/values.yaml @@ -96,5 +96,17 @@ easyhaproxy: # When service.create is true (Deployment with ClusterIP/NodePort), this is ignored masterNode: label: easyhaproxy/node - values: + values: - master + +# Extra environment variables to inject into the container +# Supports all Kubernetes env var forms: value, valueFrom (secretKeyRef, configMapKeyRef, fieldRef) +# extraEnv: +# - name: MY_VAR +# value: "my-value" +# - name: SECRET_VAR +# valueFrom: +# secretKeyRef: +# name: my-secret +# key: my-key +extraEnv: [] diff --git a/pyproject.toml b/pyproject.toml index 523f0cb..cb76e84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easyhaproxy" -version = "6.1.0" +version = "6.1.1" description = "HAProxy label based routing with service discovery for Docker, Swarm, and Kubernetes" readme = "README.md" license = {file = "LICENSE"} diff --git a/src/plugins/builtin/fastcgi.py b/src/plugins/builtin/fastcgi.py index 7078900..b2720cb 100644 --- a/src/plugins/builtin/fastcgi.py +++ b/src/plugins/builtin/fastcgi.py @@ -15,6 +15,8 @@ - index_file: Default index file (default: index.php) - path_info: Enable PATH_INFO support (default: true) - custom_params: Dictionary of custom FastCGI parameters (optional) + - pass_headers: Headers to forward to the FastCGI app (optional). + Comma-separated string or list of strings/dicts with optional condition. Example YAML config: plugins: @@ -34,6 +36,7 @@ easyhaproxy.plugins: "fastcgi" easyhaproxy.plugin.fastcgi.document_root: /var/www/myapp easyhaproxy.plugin.fastcgi.index_file: index.php + easyhaproxy.plugin.fastcgi.pass_headers: "Authorization, Proxy-Authorization" """ import os @@ -48,13 +51,25 @@ class FastcgiPlugin(PluginInterface): """Plugin to configure FastCGI parameters for PHP-FPM""" + # Built-in path-info regex presets + PATH_INFO_PRESETS = { + "php": r"^(/.+\.php)(/.*)?$", + "python": r"^(/.+\.py)(/.*)?$", + "perl": r"^(/.+\.(?:pl|cgi))(/.*)?$", + "ruby": r"^(/.+\.rb)(/.*)?$", + "any": r"^(.+?)(/.*)?$", + } + def __init__(self): self.enabled = True self.document_root = "/var/www/html" self.script_filename = "%[path]" self.index_file = "index.php" - self.path_info = True + self.path_info = "php" # False | preset name | custom regex string + self.log_stderr = False + self.keep_conn = True self.custom_params = {} + self.pass_headers = [] # list of {"name": str, "condition": str|None} @property def name(self) -> str: @@ -76,6 +91,29 @@ def configure(self, config: dict) -> None: - index_file: Default index file - path_info: Enable PATH_INFO support - custom_params: Dictionary of custom FastCGI parameters + - path_info: Enable PATH_INFO splitting. Accepts: + - False / "false" / "no" / "0": disabled + - "php" (default): ^(/.+[.]php)(/.*)?$ + - "python": ^(/.+[.]py)(/.*)?$ + - "perl": ^(/.+[.](pl|cgi))(/.*)?$ + - "ruby": ^(/.+[.]rb)(/.*)?$ + - "any": ^(.+?)(/.*)?$ + - any other string: used as-is as the regex + - log_stderr: Forward FastCGI app's stderr to HAProxy logs (default: false) + - keep_conn: Reuse FastCGI connections between requests (default: true) + - pass_headers: Headers to forward to the FastCGI application. + HAProxy omits Authorization, Proxy-Authorization, and hop-by-hop headers + by default — this directive is required to pass them through. + Note: Content-Type and Content-Length are never passable here; they are + already converted to CGI parameters (CONTENT_TYPE, CONTENT_LENGTH). + + Accepts a comma-separated string or a list of strings/dicts: + Simple: "Authorization" + Multiple: "Authorization, Proxy-Authorization" + With ACL: [{"name": "Authorization", "condition": "if { ssl_fc }"}] + + HAProxy syntax: pass-header [ { if | unless } ] + Ref: https://docs.haproxy.org/dev/configuration.html """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] @@ -90,11 +128,40 @@ def configure(self, config: dict) -> None: self.index_file = config["index_file"] if "path_info" in config: - self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"] + val = str(config["path_info"]).lower() + if val in ["false", "0", "no"]: + self.path_info = False + elif val in ["true", "1", "yes"]: + self.path_info = "php" # backward-compatible: true → php preset + else: + self.path_info = str(config["path_info"]) # preset name or custom regex + + if "log_stderr" in config: + self.log_stderr = str(config["log_stderr"]).lower() in ["true", "1", "yes"] + + if "keep_conn" in config: + self.keep_conn = str(config["keep_conn"]).lower() in ["true", "1", "yes"] if "custom_params" in config: self.custom_params = config["custom_params"] + if "pass_headers" in config: + val = config["pass_headers"] + if isinstance(val, str): + self.pass_headers = [{"name": h.strip(), "condition": None} + for h in val.split(",") if h.strip()] + elif isinstance(val, list): + result = [] + for item in val: + if isinstance(item, str): + result.append({"name": item.strip(), "condition": None}) + elif isinstance(item, dict): + result.append({ + "name": item["name"], + "condition": item.get("condition") + }) + self.pass_headers = result + def process(self, context: PluginContext) -> PluginResult: """ Process the plugin and generate FastCGI configuration @@ -121,9 +188,16 @@ def process(self, context: PluginContext) -> PluginResult: fcgi_app_lines.append(f" docroot {self.document_root}") fcgi_app_lines.append(f" index {self.index_file}") + if self.log_stderr: + fcgi_app_lines.append(" log-stderr") + + if self.keep_conn: + fcgi_app_lines.append(" option keep-conn") + # PATH_INFO support if self.path_info: - fcgi_app_lines.append(" path-info ^(/.+\\.php)(/.*)?$") + regex = self.PATH_INFO_PRESETS.get(self.path_info, self.path_info) + fcgi_app_lines.append(f" path-info {regex}") # Set SCRIPT_FILENAME if customized if self.script_filename and self.script_filename != "%[path]": @@ -134,6 +208,13 @@ def process(self, context: PluginContext) -> PluginResult: for param_name, param_value in self.custom_params.items(): fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}") + # Pass headers + for header in self.pass_headers: + line = f" pass-header {header['name']}" + if header.get("condition"): + line += f" {header['condition']}" + fcgi_app_lines.append(line) + fcgi_app_definition = "\n".join(fcgi_app_lines) # Build metadata @@ -143,7 +224,10 @@ def process(self, context: PluginContext) -> PluginResult: "document_root": self.document_root, "index_file": self.index_file, "path_info": self.path_info, - "custom_params_count": len(self.custom_params) + "log_stderr": self.log_stderr, + "keep_conn": self.keep_conn, + "custom_params_count": len(self.custom_params), + "pass_headers_count": len(self.pass_headers) } return PluginResult( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 495bf91..e6a7086 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -950,8 +950,11 @@ def test_fastcgi_plugin_initialization(self): assert plugin.enabled is True assert plugin.document_root == "/var/www/html" assert plugin.index_file == "index.php" - assert plugin.path_info is True + assert plugin.path_info == "php" + assert plugin.log_stderr is False + assert plugin.keep_conn is True assert plugin.custom_params == {} + assert plugin.pass_headers == [] def test_fastcgi_plugin_configuration(self): """Test plugin configuration""" @@ -1046,6 +1049,128 @@ def test_fastcgi_plugin_disabled(self): assert result.haproxy_config is None or result.haproxy_config == "" + def test_fastcgi_plugin_pass_headers_string(self): + """Test pass_headers parsed from comma-separated string""" + plugin = FastcgiPlugin() + plugin.configure({"pass_headers": "Authorization, Proxy-Authorization"}) + + assert plugin.pass_headers == [ + {"name": "Authorization", "condition": None}, + {"name": "Proxy-Authorization", "condition": None}, + ] + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + fcgi_app_def = result.global_configs[0] + + assert "pass-header Authorization" in fcgi_app_def + assert "pass-header Proxy-Authorization" in fcgi_app_def + assert result.metadata["pass_headers_count"] == 2 + + def test_fastcgi_plugin_pass_headers_list(self): + """Test pass_headers parsed from list of strings and dicts with condition""" + plugin = FastcgiPlugin() + plugin.configure({ + "pass_headers": [ + "Authorization", + {"name": "X-Custom-Header", "condition": "if { ssl_fc }"}, + ] + }) + + assert plugin.pass_headers == [ + {"name": "Authorization", "condition": None}, + {"name": "X-Custom-Header", "condition": "if { ssl_fc }"}, + ] + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + fcgi_app_def = result.global_configs[0] + + assert "pass-header Authorization" in fcgi_app_def + assert "pass-header X-Custom-Header if { ssl_fc }" in fcgi_app_def + assert result.metadata["pass_headers_count"] == 2 + + def test_fastcgi_plugin_log_stderr(self): + """Test log-stderr directive is emitted when enabled""" + plugin = FastcgiPlugin() + plugin.configure({"log_stderr": "true"}) + assert plugin.log_stderr is True + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert "log-stderr" in result.global_configs[0] + assert result.metadata["log_stderr"] is True + + def test_fastcgi_plugin_keep_conn_disabled(self): + """Test option keep-conn is omitted when disabled""" + plugin = FastcgiPlugin() + plugin.configure({"keep_conn": "false"}) + assert plugin.keep_conn is False + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert "option keep-conn" not in result.global_configs[0] + assert result.metadata["keep_conn"] is False + + def test_fastcgi_plugin_path_info_presets(self): + """Test path_info presets resolve to correct regexes""" + presets = { + "php": r"^(/.+\.php)(/.*)?$", + "python": r"^(/.+\.py)(/.*)?$", + "perl": r"^(/.+\.(?:pl|cgi))(/.*)?$", + "ruby": r"^(/.+\.rb)(/.*)?$", + "any": r"^(.+?)(/.*)?$", + } + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + for preset, expected_regex in presets.items(): + plugin = FastcgiPlugin() + plugin.configure({"path_info": preset}) + assert plugin.path_info == preset + result = plugin.process(context) + assert f"path-info {expected_regex}" in result.global_configs[0], \ + f"Preset '{preset}' did not emit expected regex" + + def test_fastcgi_plugin_path_info_custom_regex(self): + """Test path_info accepts a custom regex""" + plugin = FastcgiPlugin() + plugin.configure({"path_info": r"^(/.+\.fcgi)(/.*)?$"}) + assert plugin.path_info == r"^(/.+\.fcgi)(/.*)?$" + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert r"path-info ^(/.+\.fcgi)(/.*)?$" in result.global_configs[0] + + def test_fastcgi_plugin_path_info_backward_compat(self): + """Test path_info: true maps to php preset (backward compatibility)""" + plugin = FastcgiPlugin() + plugin.configure({"path_info": "true"}) + assert plugin.path_info == "php" + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert r"path-info ^(/.+\.php)(/.*)?$" in result.global_configs[0] + class TestPluginManager: """Test cases for PluginManager""" diff --git a/tests_e2e/docker/docker-compose-acme.yml b/tests_e2e/docker/docker-compose-acme.yml index d24d535..5cbeb5c 100644 --- a/tests_e2e/docker/docker-compose-acme.yml +++ b/tests_e2e/docker/docker-compose-acme.yml @@ -60,7 +60,7 @@ services: haproxy: - image: byjg/easy-haproxy:6.1.0 + image: byjg/easy-haproxy:6.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock # Persist the CERTBOT to avoid re-challenge when the server restarts diff --git a/tests_e2e/docker/docker-compose-portainer.yml b/tests_e2e/docker/docker-compose-portainer.yml index 98704cc..97f43ad 100644 --- a/tests_e2e/docker/docker-compose-portainer.yml +++ b/tests_e2e/docker/docker-compose-portainer.yml @@ -59,7 +59,7 @@ services: easyhaproxy: - image: byjg/easy-haproxy:6.1.0 + image: byjg/easy-haproxy:6.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock - certs_certbot:/etc/easyhaproxy/certs/certbot diff --git a/tests_e2e/kubernetes/cloudflare.yml b/tests_e2e/kubernetes/cloudflare.yml index e099f3f..33a33ad 100644 --- a/tests_e2e/kubernetes/cloudflare.yml +++ b/tests_e2e/kubernetes/cloudflare.yml @@ -12,7 +12,7 @@ # ```bash # # 1. Ensure EasyHAProxy is installed in your cluster # kubectl create namespace easyhaproxy -# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml # # # 2. Download Cloudflare IP ranges # curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst diff --git a/tests_e2e/kubernetes/ip-whitelist.yml b/tests_e2e/kubernetes/ip-whitelist.yml index 81c9685..e07016c 100644 --- a/tests_e2e/kubernetes/ip-whitelist.yml +++ b/tests_e2e/kubernetes/ip-whitelist.yml @@ -12,7 +12,7 @@ # ```bash # # 1. Ensure EasyHAProxy is installed in your cluster # kubectl create namespace easyhaproxy -# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml # # # 2. IMPORTANT: Edit this file (line 80) and update allowed_ips # # with your actual office/VPN IP addresses or networks diff --git a/tests_e2e/kubernetes/jwt-validator.yml b/tests_e2e/kubernetes/jwt-validator.yml index e51258d..de66fa5 100644 --- a/tests_e2e/kubernetes/jwt-validator.yml +++ b/tests_e2e/kubernetes/jwt-validator.yml @@ -30,7 +30,7 @@ # ```bash # # 1. Ensure EasyHAProxy is installed in your cluster # kubectl create namespace easyhaproxy -# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml # # # 2. Generate RSA key pair (idempotent - skips if exists) # [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 diff --git a/tests_e2e/kubernetes/plugins-combined.yml b/tests_e2e/kubernetes/plugins-combined.yml index 61ed1db..6b6fd3e 100644 --- a/tests_e2e/kubernetes/plugins-combined.yml +++ b/tests_e2e/kubernetes/plugins-combined.yml @@ -15,7 +15,7 @@ # ```bash # # 1. Ensure EasyHAProxy is installed in your cluster # kubectl create namespace easyhaproxy -# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml # # # 2. Generate JWT keys (idempotent - skips if exists) # [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 diff --git a/tests_e2e/kubernetes/service.yml b/tests_e2e/kubernetes/service.yml index 2abfb62..afbfe31 100644 --- a/tests_e2e/kubernetes/service.yml +++ b/tests_e2e/kubernetes/service.yml @@ -12,7 +12,7 @@ # ```bash # # 1. Ensure EasyHAProxy is installed in your cluster # kubectl create namespace easyhaproxy -# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml # # # 2. Label the node where EasyHAProxy will run # kubectl label nodes "easyhaproxy/node=master" diff --git a/tests_e2e/kubernetes/service_tls.yml b/tests_e2e/kubernetes/service_tls.yml index 174272e..4d29a8f 100644 --- a/tests_e2e/kubernetes/service_tls.yml +++ b/tests_e2e/kubernetes/service_tls.yml @@ -12,7 +12,7 @@ # ```bash # # 1. Ensure EasyHAProxy is installed in your cluster # kubectl create namespace easyhaproxy -# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.1.1/deploy/kubernetes/easyhaproxy-daemonset.yml # # # 2. Label the node where EasyHAProxy will run # kubectl label nodes "easyhaproxy/node=master" diff --git a/tests_e2e/test_docker_compose.py b/tests_e2e/test_docker_compose.py index 5e9b419..87fb884 100644 --- a/tests_e2e/test_docker_compose.py +++ b/tests_e2e/test_docker_compose.py @@ -920,7 +920,7 @@ def test_certificate_issuance(self, docker_compose_acme): """Test that Pebble successfully issues a certificate""" # Wait for certificate issuance (Certbot runs in background loop) # Typical time: 10-15 seconds from container start - max_retries = 30 + max_retries = 60 check_interval = 1 has_success = False diff --git a/uv.lock b/uv.lock index 392de8f..e6fbd96 100644 --- a/uv.lock +++ b/uv.lock @@ -616,7 +616,7 @@ wheels = [ [[package]] name = "easyhaproxy" -version = "6.1.0" +version = "6.1.1" source = { editable = "." } dependencies = [ { name = "deepdiff" },