1
0
Fork 0

Add support for CORS in HAProxy stats dashboard

- Introduced `HAPROXY_STATS_CORS_ORIGIN` environment variable for enabling CORS in HAProxy stats.
- Updated HAProxy configuration template to handle CORS preflight and response headers.
- Added tests for static configuration mode with CORS enabled.
- Updated documentation to include new CORS configuration details and examples.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-16 20:41:49 -05:00
parent 353fd392f6
commit 491f4a8c09
8 changed files with 203 additions and 23 deletions

View file

@ -4,21 +4,22 @@ sidebar_position: 12
# Docker environment variables
| Environment Variable | Description | Default |
|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|
| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** |
| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` |
| EASYHAPROXY_BASE_PATH | (Optional) Base directory for all EasyHAProxy files. All paths (config, certs, plugins, www) are constructed relative to this base. | `/etc/easyhaproxy` |
| EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* |
| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO |
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` |
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` |
| Environment Variable | Description | Default |
|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|
| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** |
| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` |
| EASYHAPROXY_BASE_PATH | (Optional) Base directory for all EasyHAProxy files. All paths (config, certs, plugins, www) are constructed relative to this base. | `/etc/easyhaproxy` |
| EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* |
| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO |
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` |
| HAPROXY_STATS_CORS_ORIGIN | (Optional) Enable CORS for the HAProxy stats dashboard by specifying the allowed origin (e.g., `http://localhost:3000`). Only applies when `HAPROXY_PASSWORD` is defined. | *empty* |
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` |
:::tip HAProxy Stats
Statistics are only configured when `HAPROXY_PASSWORD` is set. Without a password, the stats section is not generated.

View file

@ -68,6 +68,7 @@ markers = [
"custom_label: marks tests for custom label prefix functionality",
"static: marks tests for static configuration mode",
"acme: marks tests for certbot/acme",
"proxy_headers: marks tests for proxy headers",
]
[tool.ruff]

View file

@ -39,6 +39,7 @@ class ContainerEnv:
"username": os.getenv("HAPROXY_USERNAME") if os.getenv("HAPROXY_USERNAME") else "admin",
"password": os.getenv("HAPROXY_PASSWORD"),
"port": os.getenv("HAPROXY_STATS_PORT") if os.getenv("HAPROXY_STATS_PORT") else "1936",
"cors_origin": os.getenv("HAPROXY_STATS_CORS_ORIGIN", ""),
}
env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv(
@ -149,6 +150,8 @@ class ContainerEnv:
os.environ['HAPROXY_PASSWORD'] = str(stats['password'])
if 'port' in stats:
os.environ['HAPROXY_STATS_PORT'] = str(stats['port'])
if 'cors_origin' in stats:
os.environ['HAPROXY_STATS_CORS_ORIGIN'] = str(stats['cors_origin'])
# Convert logLevel
if 'logLevel' in yaml_config:
@ -333,10 +336,12 @@ class DaemonizeHAProxy:
self.custom_config_folder = custom_config_folder if custom_config_folder is not None else Consts.custom_config_folder
def haproxy(self, action):
self.__prepare(self.get_haproxy_command(action))
error = self.__prepare(self.get_haproxy_command(action), action)
if self.process is None:
return
if error or self.process is None:
logger_haproxy.fatal(f"Failed to start HAProxy ({action}). Exiting.")
import sys
sys.exit(1)
self.thread = Process(target=self.__start, args=())
self.thread.start()
@ -360,10 +365,42 @@ class DaemonizeHAProxy:
)
return self.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START, pid_file)
def __prepare(self, command):
def __validate_config(self):
"""Validate HAProxy configuration before starting."""
validation_cmd = ["haproxy", "-c", "-f", Consts.haproxy_config]
# Add custom config files if they exist
for config_file in self.get_custom_config_files().keys():
validation_cmd.extend(["-f", config_file])
try:
result = subprocess.run(
validation_cmd,
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return result.stderr if result.stderr else result.stdout
return None
except subprocess.TimeoutExpired:
return "HAProxy configuration validation timed out"
except Exception as e:
return f"Error validating configuration: {e}"
def __prepare(self, command, action=None):
if not isinstance(command, (list, tuple)):
command = shlex.split(command)
# Validate HAProxy config before starting (but not on reload - HAProxy validates itself during reload)
if action == DaemonizeHAProxy.HAPROXY_START:
validation_error = self.__validate_config()
if validation_error:
logger_haproxy.fatal(f"HAProxy configuration validation failed:\n{validation_error}")
return validation_error
try:
logger_haproxy.debug(f"HAPROXY command: {command}")
self.process = subprocess.Popen(command,
@ -372,9 +409,12 @@ class DaemonizeHAProxy:
stderr=subprocess.PIPE,
bufsize=-1,
universal_newlines=True)
return None
except Exception as e:
logger_haproxy.error(f"{e}")
error_msg = f"Failed to start HAProxy process: {e}"
logger_haproxy.error(error_msg)
return error_msg
def __start(self):
try:

View file

@ -62,6 +62,23 @@ frontend stats
bind *:{{ data_stats["port"] | default(1936) }}
mode http
http-request use-service prometheus-exporter if { path /metrics }
{%- if data_stats["cors_origin"] | default("") != "" %}
# CORS for stats dashboard (only for configured origin)
acl from_ui hdr(Origin) -i {{ data_stats["cors_origin"] }}
acl preflight method OPTIONS
# Preflight response
http-request return status 204 hdr "Access-Control-Allow-Origin" "%[req.hdr(Origin)]" hdr "Access-Control-Allow-Methods" "GET, OPTIONS" hdr "Access-Control-Allow-Headers" "Authorization, Content-Type" hdr "Access-Control-Max-Age" "86400" hdr "Vary" "Origin" if from_ui preflight
# Actual response headers
http-after-response set-header Access-Control-Allow-Origin "{{ data_stats["cors_origin"] }}"
http-after-response set-header Access-Control-Allow-Methods "GET, OPTIONS"
http-after-response set-header Access-Control-Allow-Headers "Authorization, Content-Type"
http-after-response set-header Access-Control-Expose-Headers "X-Request-ID"
http-after-response set-header Vary "Origin"
{% endif %}
stats enable
stats hide-version
stats realm Haproxy\ Statistics

View file

@ -0,0 +1,77 @@
global
log stdout format raw local0 info
maxconn 2000
tune.ssl.default-dh-param 2048
# intermediate configuration
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
ssl-dh-param-file /etc/easyhaproxy/haproxy/dhparam
defaults
log global
unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
unique-id-header X-Edge-Request-ID
option httplog
timeout connect 3s
timeout client 10s
timeout server 10m
frontend stats
bind *:1936
mode http
http-request use-service prometheus-exporter if { path /metrics }
# CORS for stats dashboard (only for configured origin)
acl from_ui hdr(Origin) -i http://localhost:3000
acl preflight method OPTIONS
# Preflight response
http-request return status 204 hdr "Access-Control-Allow-Origin" "%[req.hdr(Origin)]" hdr "Access-Control-Allow-Methods" "GET, OPTIONS" hdr "Access-Control-Allow-Headers" "Authorization, Content-Type" hdr "Access-Control-Max-Age" "86400" hdr "Vary" "Origin" if from_ui preflight
# Actual response headers
http-after-response set-header Access-Control-Allow-Origin "http://localhost:3000"
http-after-response set-header Access-Control-Allow-Methods "GET, OPTIONS"
http-after-response set-header Access-Control-Allow-Headers "Authorization, Content-Type"
http-after-response set-header Access-Control-Expose-Headers "X-Request-ID"
http-after-response set-header Vary "Origin"
stats enable
stats hide-version
stats realm Haproxy\ Statistics
stats uri /
stats auth admin:test123
default_backend srv_stats
backend srv_stats
mode http
server Local 127.0.0.1:1936
frontend http_in_80
bind *:80
mode http
acl is_rule_host1_com_br_80_1 hdr(host) -i host1.com.br
acl is_rule_host1_com_br_80_2 hdr(host) -i host1.com.br:80
use_backend srv_host1_com_br_80 if is_rule_host1_com_br_80_1 OR is_rule_host1_com_br_80_2
backend srv_host1_com_br_80
balance roundrobin
mode http
option forwardfor
http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
http-request set-header X-Request-ID %[uuid()]
server srv-0 container:5000 check weight 1
backend certbot_backend
mode http
server certbot 127.0.0.1:2080

11
tests/fixtures/static_cors.yml vendored Normal file
View file

@ -0,0 +1,11 @@
stats:
username: admin
password: test123
port: 1936
cors_origin: http://localhost:3000
customerrors: false
containers:
"host1.com.br:80":
ip: ["container:5000"]

View file

@ -149,8 +149,8 @@ def test_container_env_stats_password():
"stats": {
"username": "admin",
"password": "xyz",
"port": "1936"
"port": "1936",
"cors_origin": ""
},
"logLevel": {
"easyhaproxy": Functions.DEBUG,
@ -191,7 +191,8 @@ def test_container_env_stats_password_2():
"stats": {
"username": "abc",
"password": "xyz",
"port": "2101"
"port": "2101",
"cors_origin": ""
},
"logLevel": {
"easyhaproxy": Functions.DEBUG,

View file

@ -90,4 +90,36 @@ def test_processor_static_multiple_domains_same_container():
# Both should point to the same container
assert haproxy_cfg.count('server srv-0 webapp:8080') == 2
def test_processor_static_with_cors():
"""Test that CORS configuration is properly generated when cors_origin is set"""
ProcessorInterface.static_file = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"./fixtures/static_cors.yml"
)
static = ProcessorInterface.factory(ProcessorInterface.STATIC)
haproxy_cfg = static.get_haproxy_conf()
# Verify CORS configuration is present in stats frontend
assert '# CORS for stats dashboard (only for configured origin)' in haproxy_cfg
assert 'acl from_ui hdr(Origin) -i http://localhost:3000' in haproxy_cfg
assert 'acl preflight method OPTIONS' in haproxy_cfg
# Verify preflight response
assert 'http-request return status 204' in haproxy_cfg
assert 'hdr "Access-Control-Allow-Origin"' in haproxy_cfg
assert 'hdr "Access-Control-Allow-Methods" "GET, OPTIONS"' in haproxy_cfg
assert 'hdr "Access-Control-Allow-Headers" "Authorization, Content-Type"' in haproxy_cfg
assert 'if from_ui preflight' in haproxy_cfg
# Verify actual response headers (no ACL condition in response phase)
assert 'http-after-response set-header Access-Control-Allow-Origin "http://localhost:3000"' in haproxy_cfg
assert 'http-after-response set-header Access-Control-Expose-Headers "X-Request-ID"' in haproxy_cfg
assert 'http-after-response set-header Vary "Origin"' in haproxy_cfg
# Verify the full config matches expected
assert haproxy_cfg == Functions.load(
os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static-cors.txt"))
# test_processor_static()