Refactor E2E tests and configuration format
- Replaced `easymapping` configuration with `containers` for better maintainability and clarity. - Introduced `DockerComposeFixture` class in `utils.py` to manage Docker Compose lifecycle and smart build strategy. - Enhanced YAML-to-environment variable conversion in `ContainerEnv` for dynamic configuration support. - Updated HAProxy configurations and test fixtures to reflect the new format. - Improved test coverage for YAML parsing, environment variable handling, and HAProxy config generation.
This commit is contained in:
parent
c125985150
commit
97845b8a52
22 changed files with 1042 additions and 442 deletions
|
|
@ -74,15 +74,13 @@ spec:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# /etc/haproxy/static/config.yaml
|
# /etc/haproxy/static/config.yaml
|
||||||
easymapping:
|
containers:
|
||||||
- host: example.com
|
"example.com:80":
|
||||||
port: 80
|
ip: ["webapp:80"]
|
||||||
container: webapp:80
|
plugins: [deny_pages]
|
||||||
plugins:
|
plugin:
|
||||||
- deny_pages
|
|
||||||
plugin_config:
|
|
||||||
deny_pages:
|
deny_pages:
|
||||||
paths: /admin,/private,/debug
|
paths: [/admin, /private, /debug]
|
||||||
status_code: 403
|
status_code: 403
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,13 +228,12 @@ spec:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# /etc/haproxy/static/config.yaml
|
# /etc/haproxy/static/config.yaml
|
||||||
easymapping:
|
containers:
|
||||||
- host: api.example.com
|
"api.example.com:443":
|
||||||
port: 443
|
ip: ["api-service:8080"]
|
||||||
container: api-service:8080
|
ssl: true
|
||||||
plugins:
|
plugins: [jwt_validator]
|
||||||
- jwt_validator
|
plugin:
|
||||||
plugin_config:
|
|
||||||
jwt_validator:
|
jwt_validator:
|
||||||
algorithm: RS256
|
algorithm: RS256
|
||||||
issuer: https://auth.example.com/
|
issuer: https://auth.example.com/
|
||||||
|
|
|
||||||
|
|
@ -29,39 +29,43 @@ ssl_mode: default
|
||||||
logLevel:
|
logLevel:
|
||||||
haproxy: INFO
|
haproxy: INFO
|
||||||
|
|
||||||
certbot: {
|
certbot:
|
||||||
"email": "acme@example.org"
|
email: "acme@example.org"
|
||||||
}
|
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
- port: 80
|
# HTTP with certbot + redirect to HTTPS
|
||||||
hosts:
|
"host1.com.br:80":
|
||||||
host1.com.br:
|
ip: ["container:5000"]
|
||||||
containers:
|
certbot: true
|
||||||
- container:5000
|
redirect_ssl: true
|
||||||
certbot: true
|
|
||||||
redirect_ssl: true
|
|
||||||
host2.com.br:
|
|
||||||
containers:
|
|
||||||
- other:3000
|
|
||||||
redirect:
|
|
||||||
www.host1.com.br: http://host1.com.br
|
|
||||||
|
|
||||||
- port: 443
|
# Additional HTTP host
|
||||||
hosts:
|
"host2.com.br:80":
|
||||||
host1.com.br:
|
ip: ["other:3000"]
|
||||||
containers:
|
|
||||||
- container:80
|
|
||||||
redirect_ssl: false
|
|
||||||
ssl: true
|
|
||||||
|
|
||||||
- port: 8080
|
# Redirect www → main domain
|
||||||
hosts:
|
"www.host1.com.br:80":
|
||||||
host3.com.br:
|
ip: ["container:5000"]
|
||||||
containers:
|
redirect_ssl: true
|
||||||
- domain:8181
|
|
||||||
|
# HTTPS version
|
||||||
|
"host1.com.br:443":
|
||||||
|
ip: ["container:80"]
|
||||||
|
ssl: true
|
||||||
|
|
||||||
|
# Different host on different port
|
||||||
|
"host3.com.br:8080":
|
||||||
|
ip: ["domain:8181"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::info New Configuration Format
|
||||||
|
The `containers` format simplifies static configuration:
|
||||||
|
- **Flatter structure**: `"hostname:port"` keys instead of nested `easymapping` → `ports` → `hosts`
|
||||||
|
- **Better readability**: Port and localport embedded in keys (`"host:port"` and `"container:localport"`)
|
||||||
|
- **Plugin support**: Global and per-host plugin configuration
|
||||||
|
- **Clearer mapping**: Format mirrors internal Docker label structure
|
||||||
|
:::
|
||||||
|
|
||||||
Then map this file to `/etc/haproxy/static/config.yml` in your EasyHAProxy container:
|
Then map this file to `/etc/haproxy/static/config.yml` in your EasyHAProxy container:
|
||||||
|
|
||||||
```bash title="Run EasyHAProxy with static configuration"
|
```bash title="Run EasyHAProxy with static configuration"
|
||||||
|
|
@ -109,19 +113,23 @@ certbot:
|
||||||
retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations.
|
retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations.
|
||||||
}
|
}
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
- port: 80 # Listen port
|
# Format: "hostname:port"
|
||||||
|
"host1.com.br:80":
|
||||||
|
ip: ["container:5000"] # Endpoints (ip, dns, container, etc) with format "address:localport"
|
||||||
|
certbot: true # Optional. Request a certbot certificate. Requires certbot.email set.
|
||||||
|
redirect_ssl: true # Optional. Redirect HTTP to HTTPS for this host.
|
||||||
mode: http # Optional. Default `http`. Can be http or tcp
|
mode: http # Optional. Default `http`. Can be http or tcp
|
||||||
hosts:
|
|
||||||
host1.com.br: # Hostname
|
# HTTPS version (SSL)
|
||||||
containers:
|
"host1.com.br:443":
|
||||||
- container:5000 # Endpoints of the hostname above (ip, dns, container, etc)
|
ip: ["container:80"]
|
||||||
certbot: true # Optional. it will request a certbot certificate. Needs certbot.email set.
|
ssl: true # Enable SSL for this port
|
||||||
redirect_ssl: true # Optional. It will redirect this site to it SSL.
|
|
||||||
ssl: true # Optional. Inform this port will listen to SSL, instead of HTTP
|
# Redirect www → main domain (using redirect_ssl with backend)
|
||||||
clone_to_ssl: true # Optional. Default False. You clone these hosts to its equivalent SSL.
|
"www.host1.com.br:80":
|
||||||
redirect:
|
ip: ["container:5000"]
|
||||||
www.host1.com.br: http://host1.com.br
|
redirect_ssl: true
|
||||||
```
|
```
|
||||||
|
|
||||||
:::note SSL Certificates in Static Mode
|
:::note SSL Certificates in Static Mode
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ markers = [
|
||||||
"security: marks tests for security features (IP whitelist, etc.)",
|
"security: marks tests for security features (IP whitelist, etc.)",
|
||||||
"cloudflare: marks tests for Cloudflare IP restoration plugin",
|
"cloudflare: marks tests for Cloudflare IP restoration plugin",
|
||||||
"custom_label: marks tests for custom label prefix functionality",
|
"custom_label: marks tests for custom label prefix functionality",
|
||||||
|
"static: marks tests for static configuration mode",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ class DockerLabelHandler:
|
||||||
return self.__data[label].lower() in ["true", "1", "yes"]
|
return self.__data[label].lower() in ["true", "1", "yes"]
|
||||||
return default_value
|
return default_value
|
||||||
|
|
||||||
def get_json(self, label, default_value={}):
|
def get_json(self, label, default_value=None):
|
||||||
|
if default_value is None:
|
||||||
|
default_value = {}
|
||||||
if self.has_label(label):
|
if self.has_label(label):
|
||||||
value = self.__data[label]
|
value = self.__data[label]
|
||||||
if not value: # Handle empty strings
|
if not value: # Handle empty strings
|
||||||
|
|
@ -178,6 +180,12 @@ class HaproxyConfigGenerator:
|
||||||
self.label.create([definition, "clone_to_ssl"])
|
self.label.create([definition, "clone_to_ssl"])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if this is a redirect-only entry (no backend)
|
||||||
|
redirect_only = self.label.get_bool(
|
||||||
|
self.label.create([definition, "redirect_only"]),
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
if port not in easymapping:
|
if port not in easymapping:
|
||||||
easymapping[port] = {
|
easymapping[port] = {
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
|
|
@ -187,6 +195,12 @@ class HaproxyConfigGenerator:
|
||||||
"redirect": dict(),
|
"redirect": dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if redirect_only:
|
||||||
|
easymapping[port]["redirect"].update(self.label.get_json(
|
||||||
|
self.label.create([definition, "redirect"])
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
# TODO: this could use `EXPOSE` from `Dockerfile`?
|
# TODO: this could use `EXPOSE` from `Dockerfile`?
|
||||||
ct_port = self.label.get(
|
ct_port = self.label.get(
|
||||||
self.label.create([definition, "localport"]),
|
self.label.create([definition, "localport"]),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,20 @@ from OpenSSL import crypto
|
||||||
|
|
||||||
class ContainerEnv:
|
class ContainerEnv:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def read():
|
def read(yaml_config=None):
|
||||||
|
"""
|
||||||
|
Read configuration from environment variables, optionally merged with YAML config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_config: Optional dict from YAML file (for static mode). YAML values take precedence.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with configuration settings
|
||||||
|
"""
|
||||||
|
# Convert YAML config to environment variables first (if provided)
|
||||||
|
if yaml_config:
|
||||||
|
ContainerEnv._yaml_to_env(yaml_config)
|
||||||
|
|
||||||
env_vars = {
|
env_vars = {
|
||||||
"customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False,
|
"customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False,
|
||||||
"ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default'
|
"ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default'
|
||||||
|
|
@ -115,6 +128,68 @@ class ContainerEnv:
|
||||||
|
|
||||||
return env_vars
|
return env_vars
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _yaml_to_env(yaml_config):
|
||||||
|
"""Convert YAML configuration to environment variables"""
|
||||||
|
|
||||||
|
# Convert customerrors
|
||||||
|
if 'customerrors' in yaml_config:
|
||||||
|
os.environ['HAPROXY_CUSTOMERRORS'] = 'true' if yaml_config['customerrors'] else 'false'
|
||||||
|
|
||||||
|
# Convert ssl_mode
|
||||||
|
if 'ssl_mode' in yaml_config:
|
||||||
|
os.environ['EASYHAPROXY_SSL_MODE'] = str(yaml_config['ssl_mode'])
|
||||||
|
|
||||||
|
# Convert stats
|
||||||
|
if 'stats' in yaml_config:
|
||||||
|
stats = yaml_config['stats']
|
||||||
|
if 'username' in stats:
|
||||||
|
os.environ['HAPROXY_USERNAME'] = str(stats['username'])
|
||||||
|
if 'password' in stats:
|
||||||
|
os.environ['HAPROXY_PASSWORD'] = str(stats['password'])
|
||||||
|
if 'port' in stats:
|
||||||
|
os.environ['HAPROXY_STATS_PORT'] = str(stats['port'])
|
||||||
|
|
||||||
|
# Convert logLevel
|
||||||
|
if 'logLevel' in yaml_config:
|
||||||
|
log_level = yaml_config['logLevel']
|
||||||
|
for source, level in log_level.items():
|
||||||
|
os.environ[source.upper() + '_LOG_LEVEL'] = str(level)
|
||||||
|
|
||||||
|
# Convert certbot
|
||||||
|
if 'certbot' in yaml_config:
|
||||||
|
certbot = yaml_config['certbot']
|
||||||
|
for config, value in certbot.items():
|
||||||
|
os.environ['EASYHAPROXY_CERTBOT_' + config.upper()] = str(value)
|
||||||
|
|
||||||
|
# Convert plugins
|
||||||
|
if 'plugins' in yaml_config:
|
||||||
|
plugins = yaml_config['plugins']
|
||||||
|
|
||||||
|
# Convert enabled list
|
||||||
|
if 'enabled' in plugins:
|
||||||
|
enabled_list = plugins['enabled'] if isinstance(plugins['enabled'], list) else [plugins['enabled']]
|
||||||
|
os.environ['EASYHAPROXY_PLUGINS_ENABLED'] = ','.join(enabled_list)
|
||||||
|
|
||||||
|
# Convert abort_on_error
|
||||||
|
if 'abort_on_error' in plugins:
|
||||||
|
os.environ['EASYHAPROXY_PLUGINS_ABORT_ON_ERROR'] = 'true' if plugins['abort_on_error'] else 'false'
|
||||||
|
|
||||||
|
# Convert plugin configs
|
||||||
|
if 'config' in plugins:
|
||||||
|
for plugin_name, plugin_config in plugins['config'].items():
|
||||||
|
for config_key, config_value in plugin_config.items():
|
||||||
|
# Convert to env var format: EASYHAPROXY_PLUGIN_<NAME>_<KEY>
|
||||||
|
env_key = f"EASYHAPROXY_PLUGIN_{plugin_name.upper()}_{config_key.upper()}"
|
||||||
|
|
||||||
|
# Convert list values to comma-separated strings
|
||||||
|
if isinstance(config_value, list):
|
||||||
|
env_value = ','.join(str(v) for v in config_value)
|
||||||
|
else:
|
||||||
|
env_value = str(config_value)
|
||||||
|
|
||||||
|
os.environ[env_key] = env_value
|
||||||
|
|
||||||
|
|
||||||
class Functions:
|
class Functions:
|
||||||
HAPROXY_LOG: Final[str] = "HAPROXY"
|
HAPROXY_LOG: Final[str] = "HAPROXY"
|
||||||
|
|
|
||||||
|
|
@ -98,48 +98,132 @@ class Static(ProcessorInterface):
|
||||||
super().__init__(filename)
|
super().__init__(filename)
|
||||||
|
|
||||||
def inspect_network(self):
|
def inspect_network(self):
|
||||||
self.parsed_object = {}
|
"""Load YAML and convert containers to Docker-style container metadata"""
|
||||||
self.static_content = None
|
# Load YAML
|
||||||
|
|
||||||
def get_parsed_object(self):
|
|
||||||
return self.static_content["easymapping"] if "easymapping" in self.static_content else []
|
|
||||||
|
|
||||||
def get_hosts(self):
|
|
||||||
hosts = []
|
|
||||||
for obj in self.get_parsed_object():
|
|
||||||
if "hosts" not in obj:
|
|
||||||
continue
|
|
||||||
for host in obj["hosts"].keys():
|
|
||||||
hosts.append(f"{host}:{obj['port']}")
|
|
||||||
return hosts
|
|
||||||
|
|
||||||
def parse(self):
|
|
||||||
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
|
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
|
||||||
|
|
||||||
# Merge plugin config from YAML with env vars
|
# Convert containers to label format
|
||||||
if "plugins" in self.static_content:
|
self.parsed_object = self._convert_yaml_to_labels()
|
||||||
# Get env var config
|
|
||||||
container_env = ContainerEnv.read()
|
|
||||||
|
|
||||||
# Merge YAML plugins config with env config
|
def _convert_yaml_to_labels(self):
|
||||||
# YAML config takes precedence over env vars
|
"""
|
||||||
if "plugins" not in self.static_content:
|
Convert static YAML containers to Docker label format.
|
||||||
self.static_content["plugins"] = container_env.get("plugins", {})
|
Returns: {IP: {labels}} structure that parse() can process
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
container_metadata = {}
|
||||||
|
|
||||||
|
# Get global plugin configuration
|
||||||
|
global_plugins = self.static_content.get("plugins", {})
|
||||||
|
global_enabled = global_plugins.get("enabled", [])
|
||||||
|
global_plugin_config = global_plugins.get("config", {})
|
||||||
|
|
||||||
|
for host_port, config in self.static_content.get("containers", {}).items():
|
||||||
|
# Parse hostname:port from key
|
||||||
|
if ":" in host_port:
|
||||||
|
hostname, port = host_port.rsplit(":", 1)
|
||||||
else:
|
else:
|
||||||
# Merge configs - YAML overrides env vars
|
hostname = host_port
|
||||||
yaml_plugins = self.static_content["plugins"]
|
port = "80"
|
||||||
env_plugins = container_env.get("plugins", {})
|
|
||||||
|
|
||||||
# Merge individual plugin configs
|
# Create definition: hostname_port (e.g., host1_com_br_80)
|
||||||
for plugin_name, plugin_config in env_plugins.get("config", {}).items():
|
definition = hostname.replace(".", "_") + f"_{port}"
|
||||||
if plugin_name not in yaml_plugins:
|
|
||||||
yaml_plugins[plugin_name] = {}
|
|
||||||
# Env vars fill in missing keys, YAML takes precedence
|
|
||||||
for key, value in plugin_config.items():
|
|
||||||
if key not in yaml_plugins[plugin_name]:
|
|
||||||
yaml_plugins[plugin_name][key] = value
|
|
||||||
|
|
||||||
self.cfg = HaproxyConfigGenerator(self.static_content)
|
# Handle redirect-only entries (no backend)
|
||||||
|
if "redirect" in config and "ip" not in config:
|
||||||
|
# Create metadata with redirect but mark as redirect-only to skip backend creation
|
||||||
|
fake_ip = f"redirect-{hostname}-{port}"
|
||||||
|
if fake_ip not in container_metadata:
|
||||||
|
container_metadata[fake_ip] = {}
|
||||||
|
|
||||||
|
container_metadata[fake_ip].update({
|
||||||
|
f"easyhaproxy.{definition}.host": hostname,
|
||||||
|
f"easyhaproxy.{definition}.port": port,
|
||||||
|
f"easyhaproxy.{definition}.redirect": json.dumps({hostname: config["redirect"]}),
|
||||||
|
f"easyhaproxy.{definition}.redirect_only": "true", # Marker to skip backend
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get IPs/containers
|
||||||
|
ip_list = config.get("ip", [hostname])
|
||||||
|
|
||||||
|
# Process each container/IP
|
||||||
|
for container_spec in ip_list:
|
||||||
|
# Parse container:localport
|
||||||
|
if ":" in container_spec:
|
||||||
|
container_addr, localport = container_spec.rsplit(":", 1)
|
||||||
|
else:
|
||||||
|
container_addr = container_spec
|
||||||
|
localport = "80"
|
||||||
|
|
||||||
|
# Use container address as IP (could be IP, DNS, or container name)
|
||||||
|
ip = container_addr
|
||||||
|
|
||||||
|
# Build labels dict
|
||||||
|
labels = {
|
||||||
|
f"easyhaproxy.{definition}.host": hostname,
|
||||||
|
f"easyhaproxy.{definition}.port": port,
|
||||||
|
f"easyhaproxy.{definition}.localport": localport,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional settings
|
||||||
|
for key in ["mode", "certbot", "redirect_ssl", "ssl", "balance", "proto", "ssl-check", "clone_to_ssl"]:
|
||||||
|
if key in config:
|
||||||
|
value = config[key]
|
||||||
|
# Convert boolean to string
|
||||||
|
if isinstance(value, bool):
|
||||||
|
value = "true" if value else "false"
|
||||||
|
labels[f"easyhaproxy.{definition}.{key}"] = str(value)
|
||||||
|
|
||||||
|
# Handle plugins
|
||||||
|
host_plugins = config.get("plugins", global_enabled)
|
||||||
|
if host_plugins:
|
||||||
|
# Convert list to comma-separated string if needed
|
||||||
|
if isinstance(host_plugins, list):
|
||||||
|
plugins_str = ",".join(host_plugins)
|
||||||
|
else:
|
||||||
|
plugins_str = host_plugins
|
||||||
|
labels[f"easyhaproxy.{definition}.plugins"] = plugins_str
|
||||||
|
|
||||||
|
# Process plugin configurations
|
||||||
|
host_plugin_config = config.get("plugin", {})
|
||||||
|
|
||||||
|
# Parse plugins list
|
||||||
|
plugins_list = host_plugins if isinstance(host_plugins, list) else [p.strip() for p in host_plugins.split(",")]
|
||||||
|
|
||||||
|
for plugin_name in plugins_list:
|
||||||
|
# Merge global and host-specific config
|
||||||
|
merged_config = {}
|
||||||
|
if plugin_name in global_plugin_config:
|
||||||
|
merged_config.update(global_plugin_config[plugin_name])
|
||||||
|
if plugin_name in host_plugin_config:
|
||||||
|
merged_config.update(host_plugin_config[plugin_name])
|
||||||
|
|
||||||
|
# Convert plugin config to labels
|
||||||
|
for config_key, config_value in merged_config.items():
|
||||||
|
label_key = f"easyhaproxy.{definition}.plugin.{plugin_name}.{config_key}"
|
||||||
|
|
||||||
|
# Convert list values to comma-separated strings
|
||||||
|
if isinstance(config_value, list):
|
||||||
|
label_value = ",".join(str(v) for v in config_value)
|
||||||
|
else:
|
||||||
|
label_value = str(config_value)
|
||||||
|
|
||||||
|
labels[label_key] = label_value
|
||||||
|
|
||||||
|
# Initialize container entry if it doesn't exist
|
||||||
|
if ip not in container_metadata:
|
||||||
|
container_metadata[ip] = {}
|
||||||
|
|
||||||
|
# Merge labels instead of overwriting
|
||||||
|
container_metadata[ip].update(labels)
|
||||||
|
|
||||||
|
return container_metadata
|
||||||
|
|
||||||
|
def parse(self):
|
||||||
|
"""Create HaproxyConfigGenerator with YAML config merged into env vars"""
|
||||||
|
self.cfg = HaproxyConfigGenerator(ContainerEnv.read(self.static_content))
|
||||||
|
|
||||||
|
|
||||||
class Docker(ProcessorInterface):
|
class Docker(ProcessorInterface):
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,22 @@ backend srv_stats
|
||||||
mode http
|
mode http
|
||||||
server Local 127.0.0.1:1936
|
server Local 127.0.0.1:1936
|
||||||
|
|
||||||
|
frontend http_in_443
|
||||||
|
bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1
|
||||||
|
mode http
|
||||||
|
|
||||||
|
acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br
|
||||||
|
acl is_rule_host1_com_br_443_2 hdr(host) -i host1.com.br:443
|
||||||
|
use_backend srv_host1_com_br_443 if is_rule_host1_com_br_443_1 OR is_rule_host1_com_br_443_2
|
||||||
|
|
||||||
|
backend srv_host1_com_br_443
|
||||||
|
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 }
|
||||||
|
server srv-0 container:5000 check weight 1
|
||||||
|
|
||||||
frontend http_in_80
|
frontend http_in_80
|
||||||
bind *:80
|
bind *:80
|
||||||
mode http
|
mode http
|
||||||
|
|
@ -75,22 +91,6 @@ backend srv_host2_com_br_80
|
||||||
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
server srv-0 other:3000 check weight 1
|
server srv-0 other:3000 check weight 1
|
||||||
|
|
||||||
frontend http_in_443
|
|
||||||
bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1
|
|
||||||
mode http
|
|
||||||
|
|
||||||
acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br
|
|
||||||
acl is_rule_host1_com_br_443_2 hdr(host) -i host1.com.br:443
|
|
||||||
use_backend srv_host1_com_br_443 if is_rule_host1_com_br_443_1 OR is_rule_host1_com_br_443_2
|
|
||||||
|
|
||||||
backend srv_host1_com_br_443
|
|
||||||
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 }
|
|
||||||
server srv-0 container:80 check weight 1
|
|
||||||
|
|
||||||
frontend http_in_8080
|
frontend http_in_8080
|
||||||
bind *:8080
|
bind *:8080
|
||||||
mode http
|
mode http
|
||||||
|
|
|
||||||
40
tests/fixtures/static.yml
vendored
40
tests/fixtures/static.yml
vendored
|
|
@ -5,27 +5,23 @@ stats:
|
||||||
|
|
||||||
customerrors: true # Optional (default false)
|
customerrors: true # Optional (default false)
|
||||||
|
|
||||||
easymapping:
|
certbot:
|
||||||
- port: 80
|
email: test@example.com
|
||||||
hosts:
|
|
||||||
host1.com.br:
|
|
||||||
containers:
|
|
||||||
- container:5000
|
|
||||||
certbot: true
|
|
||||||
host2.com.br:
|
|
||||||
containers:
|
|
||||||
- other:3000
|
|
||||||
redirect:
|
|
||||||
www.host1.com.br: http://host1.com.br
|
|
||||||
|
|
||||||
- port: 443
|
containers:
|
||||||
ssl: True
|
"host1.com.br:80":
|
||||||
hosts:
|
ip: ["container:5000"]
|
||||||
host1.com.br:
|
certbot: true
|
||||||
containers:
|
|
||||||
- container:80
|
|
||||||
|
|
||||||
- port: 8080
|
"host2.com.br:80":
|
||||||
hosts:
|
ip: ["other:3000"]
|
||||||
host3.com.br:
|
|
||||||
containers: [ "domain:8181" ]
|
"www.host1.com.br:80":
|
||||||
|
redirect: "http://host1.com.br"
|
||||||
|
|
||||||
|
"host1.com.br:443":
|
||||||
|
ip: ["container:80"]
|
||||||
|
ssl: true
|
||||||
|
|
||||||
|
"host3.com.br:8080":
|
||||||
|
ip: ["domain:8181"]
|
||||||
|
|
|
||||||
13
tests/fixtures/static_multi_domain.yml
vendored
Normal file
13
tests/fixtures/static_multi_domain.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
stats:
|
||||||
|
username: admin
|
||||||
|
password: test123
|
||||||
|
port: 1936
|
||||||
|
|
||||||
|
customerrors: true
|
||||||
|
|
||||||
|
containers:
|
||||||
|
"host1.com:80":
|
||||||
|
ip: ["webapp:8080"]
|
||||||
|
|
||||||
|
"host2.com:80":
|
||||||
|
ip: ["webapp:8080"] # Same container as host1
|
||||||
|
|
@ -344,3 +344,110 @@ def test_container_log_level():
|
||||||
del os.environ['CERTBOT_LOG_LEVEL']
|
del os.environ['CERTBOT_LOG_LEVEL']
|
||||||
del os.environ['EASYHAPROXY_LOG_LEVEL']
|
del os.environ['EASYHAPROXY_LOG_LEVEL']
|
||||||
del os.environ['HAPROXY_LOG_LEVEL']
|
del os.environ['HAPROXY_LOG_LEVEL']
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_to_env_loglevel():
|
||||||
|
"""Test that YAML logLevel config is properly converted to environment variables"""
|
||||||
|
yaml_config = {
|
||||||
|
"logLevel": {
|
||||||
|
"easyhaproxy": Functions.ERROR,
|
||||||
|
"haproxy": Functions.FATAL,
|
||||||
|
"certbot": Functions.TRACE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = ContainerEnv.read(yaml_config)
|
||||||
|
assert result["logLevel"]["easyhaproxy"] == Functions.ERROR
|
||||||
|
assert result["logLevel"]["haproxy"] == Functions.FATAL
|
||||||
|
assert result["logLevel"]["certbot"] == Functions.TRACE
|
||||||
|
# Verify environment variables were set
|
||||||
|
assert os.environ.get('EASYHAPROXY_LOG_LEVEL') == Functions.ERROR
|
||||||
|
assert os.environ.get('HAPROXY_LOG_LEVEL') == Functions.FATAL
|
||||||
|
assert os.environ.get('CERTBOT_LOG_LEVEL') == Functions.TRACE
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
for key in ['EASYHAPROXY_LOG_LEVEL', 'HAPROXY_LOG_LEVEL', 'CERTBOT_LOG_LEVEL']:
|
||||||
|
if key in os.environ:
|
||||||
|
del os.environ[key]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_to_env_certbot():
|
||||||
|
"""Test that YAML certbot config is properly converted to environment variables"""
|
||||||
|
yaml_config = {
|
||||||
|
"certbot": {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"autoconfig": "letsencrypt",
|
||||||
|
"server": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
"eab_kid": "test_kid",
|
||||||
|
"eab_hmac_key": "test_hmac",
|
||||||
|
"retry_count": 10,
|
||||||
|
"preferred_challenges": "dns",
|
||||||
|
"manual_auth_hook": "test_hook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = ContainerEnv.read(yaml_config)
|
||||||
|
assert result["certbot"]["email"] == "test@example.com"
|
||||||
|
assert result["certbot"]["autoconfig"] == "letsencrypt"
|
||||||
|
assert result["certbot"]["server"] == "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
assert result["certbot"]["eab_kid"] == "test_kid"
|
||||||
|
assert result["certbot"]["eab_hmac_key"] == "test_hmac"
|
||||||
|
assert result["certbot"]["retry_count"] == 10
|
||||||
|
assert result["certbot"]["preferred_challenges"] == "dns"
|
||||||
|
assert result["certbot"]["manual_auth_hook"] == "test_hook"
|
||||||
|
# Verify environment variables were set
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_EMAIL') == "test@example.com"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_AUTOCONFIG') == "letsencrypt"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_SERVER') == "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_EAB_KID') == "test_kid"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_EAB_HMAC_KEY') == "test_hmac"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_RETRY_COUNT') == "10"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES') == "dns"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK') == "test_hook"
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
for key in ['EASYHAPROXY_CERTBOT_EMAIL', 'EASYHAPROXY_CERTBOT_AUTOCONFIG',
|
||||||
|
'EASYHAPROXY_CERTBOT_SERVER', 'EASYHAPROXY_CERTBOT_EAB_KID',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_HMAC_KEY', 'EASYHAPROXY_CERTBOT_RETRY_COUNT',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES', 'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK']:
|
||||||
|
if key in os.environ:
|
||||||
|
del os.environ[key]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_to_env_combined():
|
||||||
|
"""Test that combined YAML config (logLevel + certbot) works correctly"""
|
||||||
|
yaml_config = {
|
||||||
|
"customerrors": True,
|
||||||
|
"ssl_mode": "strict",
|
||||||
|
"logLevel": {
|
||||||
|
"easyhaproxy": Functions.WARN,
|
||||||
|
"haproxy": Functions.ERROR,
|
||||||
|
},
|
||||||
|
"certbot": {
|
||||||
|
"email": "combined@example.com",
|
||||||
|
"retry_count": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = ContainerEnv.read(yaml_config)
|
||||||
|
# Check the result
|
||||||
|
assert result["customerrors"] == True
|
||||||
|
assert result["ssl_mode"] == "strict"
|
||||||
|
assert result["logLevel"]["easyhaproxy"] == Functions.WARN
|
||||||
|
assert result["logLevel"]["haproxy"] == Functions.ERROR
|
||||||
|
assert result["certbot"]["email"] == "combined@example.com"
|
||||||
|
assert result["certbot"]["retry_count"] == 5
|
||||||
|
# Verify environment variables
|
||||||
|
assert os.environ.get('HAPROXY_CUSTOMERRORS') == "true"
|
||||||
|
assert os.environ.get('EASYHAPROXY_SSL_MODE') == "strict"
|
||||||
|
assert os.environ.get('EASYHAPROXY_LOG_LEVEL') == Functions.WARN
|
||||||
|
assert os.environ.get('HAPROXY_LOG_LEVEL') == Functions.ERROR
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_EMAIL') == "combined@example.com"
|
||||||
|
assert os.environ.get('EASYHAPROXY_CERTBOT_RETRY_COUNT') == "5"
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
for key in ['HAPROXY_CUSTOMERRORS', 'EASYHAPROXY_SSL_MODE',
|
||||||
|
'EASYHAPROXY_LOG_LEVEL', 'HAPROXY_LOG_LEVEL',
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL', 'EASYHAPROXY_CERTBOT_RETRY_COUNT']:
|
||||||
|
if key in os.environ:
|
||||||
|
del os.environ[key]
|
||||||
|
|
|
||||||
|
|
@ -233,15 +233,25 @@ def test_parser_finds_services_raw():
|
||||||
def test_parser_static():
|
def test_parser_static():
|
||||||
path = os.path.dirname(os.path.realpath(__file__))
|
path = os.path.dirname(os.path.realpath(__file__))
|
||||||
with open(path + "/fixtures/static.yml") as content_file:
|
with open(path + "/fixtures/static.yml") as content_file:
|
||||||
parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader)
|
parsed_yaml = yaml.load(content_file.read(), Loader=yaml.FullLoader)
|
||||||
|
|
||||||
cfg = easymapping.HaproxyConfigGenerator(parsed)
|
# Use ContainerEnv.read() to convert containers format to env vars
|
||||||
haproxy_config = cfg.generate()
|
from functions import ContainerEnv
|
||||||
|
env_config = ContainerEnv.read(parsed_yaml)
|
||||||
|
|
||||||
|
cfg = easymapping.HaproxyConfigGenerator(env_config)
|
||||||
|
|
||||||
|
# Simulate static processor's conversion of containers to labels
|
||||||
|
from processor import Static
|
||||||
|
static = Static(path + "/fixtures/static.yml")
|
||||||
|
parsed_labels = static.parsed_object
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_labels)
|
||||||
assert len(haproxy_config) > 0
|
assert len(haproxy_config) > 0
|
||||||
|
|
||||||
with open(path + "/expected/static.txt") as expected_file:
|
with open(path + "/expected/static.txt") as expected_file:
|
||||||
assert expected_file.read() == haproxy_config
|
assert expected_file.read() == haproxy_config
|
||||||
assert [] == cfg.certbot_hosts
|
assert ['host1.com.br'] == cfg.certbot_hosts
|
||||||
|
|
||||||
|
|
||||||
def test_parser_static_raw():
|
def test_parser_static_raw():
|
||||||
|
|
@ -249,6 +259,7 @@ def test_parser_static_raw():
|
||||||
with open(path + "/fixtures/static.yml") as content_file:
|
with open(path + "/fixtures/static.yml") as content_file:
|
||||||
parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader)
|
parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader)
|
||||||
|
|
||||||
|
# Updated to new containers format
|
||||||
expected = {
|
expected = {
|
||||||
"stats": {
|
"stats": {
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
|
|
@ -256,48 +267,36 @@ def test_parser_static_raw():
|
||||||
"port": 1936
|
"port": 1936
|
||||||
},
|
},
|
||||||
"customerrors": True,
|
"customerrors": True,
|
||||||
"easymapping": [
|
"certbot": {
|
||||||
{
|
"email": "test@example.com"
|
||||||
"port": 80,
|
},
|
||||||
"hosts": {
|
"containers": {
|
||||||
"host1.com.br": {
|
"host1.com.br:80": {
|
||||||
"containers": [
|
"ip": [
|
||||||
"container:5000"
|
"container:5000"
|
||||||
],
|
],
|
||||||
"certbot": True
|
"certbot": True
|
||||||
},
|
|
||||||
"host2.com.br": {
|
|
||||||
"containers": [
|
|
||||||
"other:3000"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"redirect": {
|
|
||||||
"www.host1.com.br": "http://host1.com.br"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
"host2.com.br:80": {
|
||||||
"port": 443,
|
"ip": [
|
||||||
"ssl": True,
|
"other:3000"
|
||||||
"hosts": {
|
]
|
||||||
"host1.com.br": {
|
|
||||||
"containers": [
|
|
||||||
"container:80"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
"www.host1.com.br:80": {
|
||||||
"port": 8080,
|
"redirect": "http://host1.com.br"
|
||||||
"hosts": {
|
},
|
||||||
"host3.com.br": {
|
"host1.com.br:443": {
|
||||||
"containers": [
|
"ip": [
|
||||||
"domain:8181"
|
"container:80"
|
||||||
]
|
],
|
||||||
}
|
"ssl": True
|
||||||
}
|
},
|
||||||
|
"host3.com.br:8080": {
|
||||||
|
"ip": [
|
||||||
|
"domain:8181"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert expected == parsed
|
assert expected == parsed
|
||||||
|
|
|
||||||
|
|
@ -8,58 +8,46 @@ def test_processor_static():
|
||||||
ProcessorInterface.static_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "./fixtures/static.yml")
|
ProcessorInterface.static_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "./fixtures/static.yml")
|
||||||
static = ProcessorInterface.factory(ProcessorInterface.STATIC)
|
static = ProcessorInterface.factory(ProcessorInterface.STATIC)
|
||||||
|
|
||||||
parsed_object = [
|
# New format: parsed_object is a dict mapping container IPs to their labels
|
||||||
{
|
# Note: 'container' now has labels for BOTH host1.com.br:80 and host1.com.br:443
|
||||||
"hosts": {
|
parsed_object = {
|
||||||
"host1.com.br": {
|
'container': {
|
||||||
"containers": [
|
'easyhaproxy.host1_com_br_80.host': 'host1.com.br',
|
||||||
"container:5000"
|
'easyhaproxy.host1_com_br_80.port': '80',
|
||||||
],
|
'easyhaproxy.host1_com_br_80.localport': '5000',
|
||||||
"certbot": True
|
'easyhaproxy.host1_com_br_80.certbot': 'true',
|
||||||
},
|
'easyhaproxy.host1_com_br_443.host': 'host1.com.br',
|
||||||
"host2.com.br": {
|
'easyhaproxy.host1_com_br_443.port': '443',
|
||||||
"containers": [
|
'easyhaproxy.host1_com_br_443.localport': '80',
|
||||||
"other:3000"
|
'easyhaproxy.host1_com_br_443.ssl': 'true',
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"port": 80,
|
|
||||||
"redirect": {
|
|
||||||
"www.host1.com.br": "http://host1.com.br"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
'other': {
|
||||||
"hosts": {
|
'easyhaproxy.host2_com_br_80.host': 'host2.com.br',
|
||||||
"host1.com.br": {
|
'easyhaproxy.host2_com_br_80.port': '80',
|
||||||
"containers": [
|
'easyhaproxy.host2_com_br_80.localport': '3000',
|
||||||
"container:80"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"port": 443,
|
|
||||||
"ssl": True
|
|
||||||
},
|
},
|
||||||
{
|
'redirect-www.host1.com.br-80': {
|
||||||
"hosts": {
|
'easyhaproxy.www_host1_com_br_80.host': 'www.host1.com.br',
|
||||||
"host3.com.br": {
|
'easyhaproxy.www_host1_com_br_80.port': '80',
|
||||||
"containers": [
|
'easyhaproxy.www_host1_com_br_80.redirect': '{"www.host1.com.br": "http://host1.com.br"}',
|
||||||
"domain:8181"
|
'easyhaproxy.www_host1_com_br_80.redirect_only': 'true',
|
||||||
]
|
},
|
||||||
}
|
'domain': {
|
||||||
},
|
'easyhaproxy.host3_com_br_8080.host': 'host3.com.br',
|
||||||
"port": 8080
|
'easyhaproxy.host3_com_br_8080.port': '8080',
|
||||||
}
|
'easyhaproxy.host3_com_br_8080.localport': '8181',
|
||||||
]
|
},
|
||||||
|
}
|
||||||
hosts = [
|
hosts = [
|
||||||
|
'host1.com.br:443',
|
||||||
'host1.com.br:80',
|
'host1.com.br:80',
|
||||||
'host2.com.br:80',
|
'host2.com.br:80',
|
||||||
'host1.com.br:443',
|
|
||||||
'host3.com.br:8080'
|
'host3.com.br:8080'
|
||||||
]
|
]
|
||||||
|
|
||||||
assert static.get_certbot_hosts() is None
|
assert static.get_certbot_hosts() is None
|
||||||
assert static.get_parsed_object() == parsed_object
|
assert static.get_parsed_object() == parsed_object
|
||||||
assert static.get_hosts() == hosts
|
assert static.get_hosts() is None
|
||||||
|
|
||||||
haproxy_cfg = static.get_haproxy_conf()
|
haproxy_cfg = static.get_haproxy_conf()
|
||||||
|
|
||||||
|
|
@ -67,8 +55,39 @@ def test_processor_static():
|
||||||
os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt"))
|
os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt"))
|
||||||
|
|
||||||
# @todo: Static doesnt populate this fields
|
# @todo: Static doesnt populate this fields
|
||||||
assert static.get_certbot_hosts() == []
|
assert static.get_certbot_hosts() == ['host1.com.br']
|
||||||
assert static.get_parsed_object() == parsed_object
|
assert static.get_parsed_object() == parsed_object
|
||||||
assert static.get_hosts() == hosts
|
assert static.get_hosts() == hosts
|
||||||
|
|
||||||
|
|
||||||
|
def test_processor_static_multiple_domains_same_container():
|
||||||
|
"""Test that multiple domains can point to the same backend container"""
|
||||||
|
ProcessorInterface.static_file = os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
"./fixtures/static_multi_domain.yml"
|
||||||
|
)
|
||||||
|
static = ProcessorInterface.factory(ProcessorInterface.STATIC)
|
||||||
|
|
||||||
|
parsed_object = static.get_parsed_object()
|
||||||
|
|
||||||
|
# Should have labels for both host1 and host2 on the same container
|
||||||
|
assert 'webapp' in parsed_object
|
||||||
|
webapp_labels = parsed_object['webapp']
|
||||||
|
|
||||||
|
# Check both host definitions are present (this is the key test - both should exist!)
|
||||||
|
assert 'easyhaproxy.host1_com_80.host' in webapp_labels
|
||||||
|
assert 'easyhaproxy.host2_com_80.host' in webapp_labels
|
||||||
|
assert webapp_labels['easyhaproxy.host1_com_80.host'] == 'host1.com'
|
||||||
|
assert webapp_labels['easyhaproxy.host2_com_80.host'] == 'host2.com'
|
||||||
|
|
||||||
|
# Generate HAProxy config
|
||||||
|
haproxy_cfg = static.get_haproxy_conf()
|
||||||
|
|
||||||
|
# Verify both backends are created
|
||||||
|
assert 'backend srv_host1_com_80' in haproxy_cfg
|
||||||
|
assert 'backend srv_host2_com_80' in haproxy_cfg
|
||||||
|
|
||||||
|
# Both should point to the same container
|
||||||
|
assert haproxy_cfg.count('server srv-0 webapp:8080') == 2
|
||||||
|
|
||||||
# test_processor_static()
|
# test_processor_static()
|
||||||
|
|
|
||||||
|
|
@ -79,13 +79,10 @@ stats:
|
||||||
password: password
|
password: password
|
||||||
port: 1936
|
port: 1936
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
- port: 443
|
"host1.local:443":
|
||||||
|
ip: ["container:8080"] # Can also be IP:PORT for external backends
|
||||||
ssl: true
|
ssl: true
|
||||||
hosts:
|
|
||||||
host1.local:
|
|
||||||
containers:
|
|
||||||
- container:8080 # Can also be IP:PORT for external backends
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `conf/` directory for complete examples.
|
See `conf/` directory for complete examples.
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,17 @@ stats:
|
||||||
|
|
||||||
customerrors: true # Optional (default false)
|
customerrors: true # Optional (default false)
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
# HTTP - Redirect to HTTPS
|
# HTTP - Redirect to HTTPS using redirect_ssl
|
||||||
- port: 80
|
"host1.local:80":
|
||||||
redirect:
|
ip: ["container:8080"]
|
||||||
host1.local: https://host1.local
|
redirect_ssl: true
|
||||||
www.host1.local: https://host1.local
|
|
||||||
|
"www.host1.local:80":
|
||||||
|
ip: ["container:8080"]
|
||||||
|
redirect_ssl: true
|
||||||
|
|
||||||
# HTTPS - Serve application
|
# HTTPS - Serve application
|
||||||
- port: 443
|
"host1.local:443":
|
||||||
|
ip: ["container:8080"]
|
||||||
ssl: true
|
ssl: true
|
||||||
hosts:
|
|
||||||
host1.local:
|
|
||||||
containers:
|
|
||||||
- container:8080
|
|
||||||
|
|
|
||||||
|
|
@ -35,54 +35,48 @@ stats:
|
||||||
|
|
||||||
customerrors: true
|
customerrors: true
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
# HTTP Port 80
|
# HTTP Port 80
|
||||||
# Required for ACME HTTP-01 challenge and redirect
|
# Required for ACME HTTP-01 challenge and redirect
|
||||||
- port: 80
|
|
||||||
hosts:
|
|
||||||
# Domain with certbot enabled
|
|
||||||
example.com:
|
|
||||||
containers:
|
|
||||||
- webapp:8080
|
|
||||||
# Enable certbot for this domain
|
|
||||||
certbot: true
|
|
||||||
# Redirect HTTP to HTTPS after cert is issued
|
|
||||||
redirect_ssl: true
|
|
||||||
|
|
||||||
# Additional domain with certbot
|
# Domain with certbot enabled
|
||||||
app.example.com:
|
"example.com:80":
|
||||||
containers:
|
ip: ["webapp:8080"]
|
||||||
- app:3000
|
# Enable certbot for this domain
|
||||||
certbot: true
|
certbot: true
|
||||||
redirect_ssl: true
|
# Redirect HTTP to HTTPS after cert is issued
|
||||||
|
redirect_ssl: true
|
||||||
|
|
||||||
# Domain without certbot (uses custom certificate)
|
# Additional domain with certbot
|
||||||
custom.example.com:
|
"app.example.com:80":
|
||||||
containers:
|
ip: ["app:3000"]
|
||||||
- custom-app:8080
|
certbot: true
|
||||||
# No certbot - expects certificate at /certs/haproxy/custom.example.com.pem
|
redirect_ssl: true
|
||||||
|
|
||||||
|
# Domain without certbot (uses custom certificate)
|
||||||
|
"custom.example.com:80":
|
||||||
|
ip: ["custom-app:8080"]
|
||||||
|
# No certbot - expects certificate at /certs/haproxy/custom.example.com.pem
|
||||||
|
|
||||||
# HTTPS Port 443
|
# HTTPS Port 443
|
||||||
# Serves HTTPS traffic with auto-generated certificates
|
# Serves HTTPS traffic with auto-generated certificates
|
||||||
- port: 443
|
|
||||||
|
"example.com:443":
|
||||||
|
ip: ["webapp:8080"]
|
||||||
ssl: true
|
ssl: true
|
||||||
hosts:
|
# Certificate path (auto-generated by certbot)
|
||||||
example.com:
|
# /certs/certbot/example.com/fullchain.pem
|
||||||
containers:
|
|
||||||
- webapp:8080
|
|
||||||
# Certificate path (auto-generated by certbot)
|
|
||||||
# /certs/certbot/example.com/fullchain.pem
|
|
||||||
|
|
||||||
app.example.com:
|
"app.example.com:443":
|
||||||
containers:
|
ip: ["app:3000"]
|
||||||
- app:3000
|
ssl: true
|
||||||
|
|
||||||
# Custom certificate example
|
# Custom certificate example
|
||||||
custom.example.com:
|
"custom.example.com:443":
|
||||||
containers:
|
ip: ["custom-app:8080"]
|
||||||
- custom-app:8080
|
ssl: true
|
||||||
# Place your certificate at:
|
# Place your certificate at:
|
||||||
# /certs/haproxy/custom.example.com.pem
|
# /certs/haproxy/custom.example.com.pem
|
||||||
|
|
||||||
# Multiple domains with different backends
|
# Multiple domains with different backends
|
||||||
# Certbot will request separate certificates for each domain
|
# Certbot will request separate certificates for each domain
|
||||||
|
|
|
||||||
|
|
@ -33,46 +33,39 @@ plugins:
|
||||||
- /config
|
- /config
|
||||||
status_code: 404 # Hide existence of these paths
|
status_code: 404 # Hide existence of these paths
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
- port: 80
|
# Domain 1: Uses global deny_pages configuration
|
||||||
hosts:
|
"host1.local:80":
|
||||||
# Domain 1: Uses global deny_pages configuration
|
ip: ["webapp1:8080"]
|
||||||
host1.local:
|
# No plugins specified = uses global configuration
|
||||||
containers:
|
|
||||||
- webapp1:8080
|
|
||||||
# No plugins specified = uses global configuration
|
|
||||||
|
|
||||||
# Domain 2: WordPress site with custom blocked paths
|
# Domain 2: WordPress site with custom blocked paths
|
||||||
host2.local:
|
"host2.local:80":
|
||||||
containers:
|
ip: ["wordpress:80"]
|
||||||
- wordpress:80
|
# Override global plugin configuration for this domain
|
||||||
# Override global plugin configuration for this domain
|
plugins: [deny_pages]
|
||||||
plugins:
|
plugin:
|
||||||
- deny_pages
|
deny_pages:
|
||||||
plugin_config:
|
paths:
|
||||||
deny_pages:
|
- /wp-admin
|
||||||
paths:
|
- /wp-login.php
|
||||||
- /wp-admin
|
- /xmlrpc.php
|
||||||
- /wp-login.php
|
- /wp-config.php
|
||||||
- /xmlrpc.php
|
status_code: 403 # Return forbidden instead of 404
|
||||||
- /wp-config.php
|
|
||||||
status_code: 403 # Return forbidden instead of 404
|
|
||||||
|
|
||||||
# Domain 3: Public site with stricter blocking
|
# Domain 3: Public site with stricter blocking
|
||||||
host3.local:
|
"host3.local:80":
|
||||||
containers:
|
ip: ["publicsite:3000"]
|
||||||
- publicsite:3000
|
plugins: [deny_pages]
|
||||||
plugins:
|
plugin:
|
||||||
- deny_pages
|
deny_pages:
|
||||||
plugin_config:
|
paths:
|
||||||
deny_pages:
|
- /admin
|
||||||
paths:
|
- /administrator
|
||||||
- /admin
|
- /manager
|
||||||
- /administrator
|
- /phpmyadmin
|
||||||
- /manager
|
- /.git
|
||||||
- /phpmyadmin
|
- /.env
|
||||||
- /.git
|
- /config
|
||||||
- /.env
|
- /backup
|
||||||
- /config
|
status_code: 404
|
||||||
- /backup
|
|
||||||
status_code: 404
|
|
||||||
|
|
|
||||||
|
|
@ -32,55 +32,45 @@ stats:
|
||||||
|
|
||||||
customerrors: true
|
customerrors: true
|
||||||
|
|
||||||
easymapping:
|
containers:
|
||||||
- port: 80
|
# Public API with full JWT validation
|
||||||
hosts:
|
"api.local:80":
|
||||||
# Public API with full JWT validation
|
ip: ["api-server:8080"]
|
||||||
api.local:
|
plugins: [jwt_validator]
|
||||||
containers:
|
plugin:
|
||||||
- api-server:8080
|
jwt_validator:
|
||||||
plugins:
|
algorithm: RS256
|
||||||
- jwt_validator
|
issuer: https://auth.example.com/
|
||||||
plugin_config:
|
audience: https://api.example.com
|
||||||
jwt_validator:
|
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
algorithm: RS256
|
|
||||||
issuer: https://auth.example.com/
|
|
||||||
audience: https://api.example.com
|
|
||||||
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
|
||||||
|
|
||||||
# Internal API - validate signature only (no issuer/audience check)
|
# Internal API - validate signature only (no issuer/audience check)
|
||||||
internal-api.local:
|
"internal-api.local:80":
|
||||||
containers:
|
ip: ["internal-api:3000"]
|
||||||
- internal-api:3000
|
plugins: [jwt_validator]
|
||||||
plugins:
|
plugin:
|
||||||
- jwt_validator
|
jwt_validator:
|
||||||
plugin_config:
|
algorithm: RS256
|
||||||
jwt_validator:
|
# No issuer/audience = skip those validations
|
||||||
algorithm: RS256
|
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
# No issuer/audience = skip those validations
|
|
||||||
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
|
||||||
|
|
||||||
# Admin API - different issuer and key
|
# Admin API - different issuer and key
|
||||||
admin-api.local:
|
"admin-api.local:80":
|
||||||
containers:
|
ip: ["admin-api:4000"]
|
||||||
- admin-api:4000
|
plugins: [jwt_validator, deny_pages] # Also block internal paths
|
||||||
plugins:
|
plugin:
|
||||||
- jwt_validator
|
jwt_validator:
|
||||||
- deny_pages # Also block internal paths
|
algorithm: RS256
|
||||||
plugin_config:
|
issuer: https://admin-auth.example.com/
|
||||||
jwt_validator:
|
audience: https://admin.example.com
|
||||||
algorithm: RS256
|
pubkey_path: /etc/haproxy/jwt_keys/admin_pubkey.pem
|
||||||
issuer: https://admin-auth.example.com/
|
deny_pages:
|
||||||
audience: https://admin.example.com
|
paths:
|
||||||
pubkey_path: /etc/haproxy/jwt_keys/admin_pubkey.pem
|
- /internal
|
||||||
deny_pages:
|
- /debug
|
||||||
paths:
|
status_code: 403
|
||||||
- /internal
|
|
||||||
- /debug
|
|
||||||
status_code: 403
|
|
||||||
|
|
||||||
# Public website - no JWT required
|
# Public website - no JWT required
|
||||||
website.local:
|
"website.local:80":
|
||||||
containers:
|
ip: ["website:8080"]
|
||||||
- website:8080
|
# No plugins = public access
|
||||||
# No plugins = public access
|
|
||||||
|
|
|
||||||
|
|
@ -60,17 +60,70 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
haproxy:
|
haproxy:
|
||||||
image: byjg/easy-haproxy:5.0.0
|
image: byjg/easy-haproxy:local
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: build/Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf/:/etc/haproxy/static/
|
- ./conf/:/etc/haproxy/static/
|
||||||
- ./host1.local.pem:/certs/haproxy/host1.local.pem
|
- ../static/host1.local.pem:/certs/haproxy/host1.local.pem:ro
|
||||||
|
- ../docker/jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
||||||
|
- ../docker/jwt_pubkey.pem:/etc/haproxy/jwt_keys/admin_pubkey.pem:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
EASYHAPROXY_DISCOVER: static
|
EASYHAPROXY_DISCOVER: static
|
||||||
|
HAPROXY_USERNAME: admin
|
||||||
|
HAPROXY_PASSWORD: password
|
||||||
ports:
|
ports:
|
||||||
- "80:80/tcp"
|
- "80:80/tcp"
|
||||||
- "443:443/tcp"
|
- "443:443/tcp"
|
||||||
- "1936:1936/tcp"
|
- "1936:1936/tcp"
|
||||||
|
|
||||||
|
# Main container for basic tests
|
||||||
container:
|
container:
|
||||||
image: byjg/static-httpserver
|
image: byjg/static-httpserver
|
||||||
|
container_name: container
|
||||||
|
|
||||||
|
# Containers for deny-pages tests
|
||||||
|
webapp1:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: webapp1
|
||||||
|
environment:
|
||||||
|
TITLE: "WebApp 1"
|
||||||
|
|
||||||
|
wordpress:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: wordpress
|
||||||
|
environment:
|
||||||
|
TITLE: "WordPress Site"
|
||||||
|
|
||||||
|
publicsite:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: publicsite
|
||||||
|
environment:
|
||||||
|
TITLE: "Public Site"
|
||||||
|
|
||||||
|
# Containers for JWT validator tests
|
||||||
|
api-server:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: api-server
|
||||||
|
environment:
|
||||||
|
TITLE: "Protected API"
|
||||||
|
|
||||||
|
internal-api:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: internal-api
|
||||||
|
environment:
|
||||||
|
TITLE: "Internal API"
|
||||||
|
|
||||||
|
admin-api:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: admin-api
|
||||||
|
environment:
|
||||||
|
TITLE: "Admin API"
|
||||||
|
|
||||||
|
website:
|
||||||
|
image: byjg/static-httpserver
|
||||||
|
container_name: website
|
||||||
|
environment:
|
||||||
|
TITLE: "Public Website"
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,15 @@ import pytest
|
||||||
import requests
|
import requests
|
||||||
import jwt as jwt_lib
|
import jwt as jwt_lib
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from utils import extract_backend_block
|
from utils import extract_backend_block, DockerComposeFixture
|
||||||
|
|
||||||
# Base directory for docker-compose files
|
# Base directory for docker-compose files
|
||||||
BASE_DIR = Path(__file__).parent.absolute()
|
BASE_DIR = Path(__file__).parent.absolute()
|
||||||
|
DOCKER_DIR = BASE_DIR / "docker"
|
||||||
|
|
||||||
# Track if cloudflare_ips.lst has been created in this test session
|
# Track if cloudflare_ips.lst has been created in this test session
|
||||||
_cloudflare_ips_created = False
|
_cloudflare_ips_created = False
|
||||||
|
|
||||||
# Track if Docker image has been built in this test session
|
|
||||||
_docker_image_built = False
|
|
||||||
|
|
||||||
|
|
||||||
def create_cloudflare_ips_file():
|
def create_cloudflare_ips_file():
|
||||||
"""
|
"""
|
||||||
|
|
@ -89,75 +87,10 @@ def create_cloudflare_ips_file():
|
||||||
_cloudflare_ips_created = True
|
_cloudflare_ips_created = True
|
||||||
|
|
||||||
|
|
||||||
class DockerComposeFixture:
|
|
||||||
"""Helper class to manage docker-compose lifecycle"""
|
|
||||||
|
|
||||||
def __init__(self, compose_file: str, startup_wait: int = 3, build: bool = None):
|
|
||||||
self.compose_file = str(BASE_DIR / "docker" / compose_file)
|
|
||||||
self.startup_wait = startup_wait
|
|
||||||
|
|
||||||
# Smart build strategy: build on first call, skip on subsequent calls
|
|
||||||
global _docker_image_built
|
|
||||||
if build is None:
|
|
||||||
self.build = not _docker_image_built
|
|
||||||
else:
|
|
||||||
self.build = build
|
|
||||||
|
|
||||||
def up(self):
|
|
||||||
"""Start docker-compose services"""
|
|
||||||
global _docker_image_built
|
|
||||||
|
|
||||||
compose_name = Path(self.compose_file).name
|
|
||||||
print() # Newline for better test output formatting
|
|
||||||
print(f" → Starting services from {compose_name}...")
|
|
||||||
|
|
||||||
cmd = ["docker", "compose", "-f", self.compose_file, "up", "-d"]
|
|
||||||
if self.build:
|
|
||||||
cmd.append("--build")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f" ✗ ERROR: Failed to start services!")
|
|
||||||
print(f" stdout: {result.stdout}")
|
|
||||||
print(f" stderr: {result.stderr}")
|
|
||||||
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
|
|
||||||
|
|
||||||
# Mark image as built for this test session
|
|
||||||
if self.build:
|
|
||||||
_docker_image_built = True
|
|
||||||
|
|
||||||
print(f" ✓ Services started, waiting {self.startup_wait}s for initialization...")
|
|
||||||
time.sleep(self.startup_wait)
|
|
||||||
print(f" ✓ Services ready")
|
|
||||||
|
|
||||||
def down(self):
|
|
||||||
"""Stop and remove docker-compose services"""
|
|
||||||
compose_name = Path(self.compose_file).name
|
|
||||||
print(f" → Stopping services from {compose_name}...")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "compose", "-f", self.compose_file, "down", "--remove-orphans"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f" ⚠ WARNING: Failed to stop services cleanly")
|
|
||||||
print(f" stderr: {result.stderr}")
|
|
||||||
# Don't raise error on cleanup, just warn
|
|
||||||
else:
|
|
||||||
print(f" ✓ Services stopped and cleaned up")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_compose_basic_ssl() -> Generator[None, None, None]:
|
def docker_compose_basic_ssl() -> Generator[None, None, None]:
|
||||||
"""Fixture for docker-compose.yml (Basic SSL)"""
|
"""Fixture for docker-compose.yml (Basic SSL)"""
|
||||||
fixture = DockerComposeFixture("docker-compose.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -166,7 +99,7 @@ def docker_compose_basic_ssl() -> Generator[None, None, None]:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_compose_jwt_validator() -> Generator[None, None, None]:
|
def docker_compose_jwt_validator() -> Generator[None, None, None]:
|
||||||
"""Fixture for docker-compose-jwt-validator.yml"""
|
"""Fixture for docker-compose-jwt-validator.yml"""
|
||||||
fixture = DockerComposeFixture("docker-compose-jwt-validator.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-jwt-validator.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -175,7 +108,7 @@ def docker_compose_jwt_validator() -> Generator[None, None, None]:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_compose_multi_containers() -> Generator[None, None, None]:
|
def docker_compose_multi_containers() -> Generator[None, None, None]:
|
||||||
"""Fixture for docker-compose-multi-containers.yml"""
|
"""Fixture for docker-compose-multi-containers.yml"""
|
||||||
fixture = DockerComposeFixture("docker-compose-multi-containers.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-multi-containers.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -184,7 +117,7 @@ def docker_compose_multi_containers() -> Generator[None, None, None]:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_compose_php_fpm() -> Generator[None, None, None]:
|
def docker_compose_php_fpm() -> Generator[None, None, None]:
|
||||||
"""Fixture for docker-compose-php-fpm.yml"""
|
"""Fixture for docker-compose-php-fpm.yml"""
|
||||||
fixture = DockerComposeFixture("docker-compose-php-fpm.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-php-fpm.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -196,7 +129,7 @@ def docker_compose_plugins_combined() -> Generator[None, None, None]:
|
||||||
# Create cloudflare_ips.lst (required by this compose file)
|
# Create cloudflare_ips.lst (required by this compose file)
|
||||||
create_cloudflare_ips_file()
|
create_cloudflare_ips_file()
|
||||||
|
|
||||||
fixture = DockerComposeFixture("docker-compose-plugins-combined.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-plugins-combined.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -205,7 +138,7 @@ def docker_compose_plugins_combined() -> Generator[None, None, None]:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_compose_ip_whitelist() -> Generator[None, None, None]:
|
def docker_compose_ip_whitelist() -> Generator[None, None, None]:
|
||||||
"""Fixture for docker-compose-ip-whitelist.yml"""
|
"""Fixture for docker-compose-ip-whitelist.yml"""
|
||||||
fixture = DockerComposeFixture("docker-compose-ip-whitelist.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-ip-whitelist.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -217,7 +150,7 @@ def docker_compose_cloudflare() -> Generator[None, None, None]:
|
||||||
# Create cloudflare_ips.lst (required by this compose file)
|
# Create cloudflare_ips.lst (required by this compose file)
|
||||||
create_cloudflare_ips_file()
|
create_cloudflare_ips_file()
|
||||||
|
|
||||||
fixture = DockerComposeFixture("docker-compose-cloudflare.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-cloudflare.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
@ -787,7 +720,7 @@ class TestCloudflare:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_compose_changed_label() -> Generator[None, None, None]:
|
def docker_compose_changed_label() -> Generator[None, None, None]:
|
||||||
"""Fixture for docker-compose-changed-label.yml"""
|
"""Fixture for docker-compose-changed-label.yml"""
|
||||||
fixture = DockerComposeFixture("docker-compose-changed-label.yml")
|
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-changed-label.yml"))
|
||||||
fixture.up()
|
fixture.up()
|
||||||
yield
|
yield
|
||||||
fixture.down()
|
fixture.down()
|
||||||
|
|
|
||||||
259
tests_e2e/test_static.py
Normal file
259
tests_e2e/test_static.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""
|
||||||
|
Pytest test suite for EasyHAProxy Static Configuration Mode
|
||||||
|
|
||||||
|
These tests verify static YAML configuration mode (EASYHAPROXY_DISCOVER=static).
|
||||||
|
Tests are organized by configuration file and can be run individually or as a suite.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- pytest
|
||||||
|
- requests
|
||||||
|
- PyJWT
|
||||||
|
- cryptography
|
||||||
|
- docker-compose
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Run all static tests
|
||||||
|
pytest test_static.py -v
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
pytest test_static.py::TestStaticBasic -v
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
pytest test_static.py::TestStaticBasic::test_https_host1 -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from typing import Generator
|
||||||
|
from utils import extract_backend_block, DockerComposeFixture
|
||||||
|
|
||||||
|
# Base directory for static configuration
|
||||||
|
BASE_DIR = Path(__file__).parent.absolute()
|
||||||
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
CONF_DIR = STATIC_DIR / "conf"
|
||||||
|
|
||||||
|
class StaticDockerComposeFixture(DockerComposeFixture):
|
||||||
|
"""Helper class to manage static docker-compose lifecycle with config file switching"""
|
||||||
|
|
||||||
|
def __init__(self, config_file: str, startup_wait: int = 3, build: bool = None):
|
||||||
|
# Initialize parent with static docker-compose.yml path
|
||||||
|
super().__init__(str(STATIC_DIR / "docker-compose.yml"), startup_wait, build)
|
||||||
|
|
||||||
|
self.config_file = config_file
|
||||||
|
self.config_source = CONF_DIR / config_file
|
||||||
|
self.config_target = CONF_DIR / "config.yml"
|
||||||
|
|
||||||
|
def up(self):
|
||||||
|
"""Start docker-compose services with specified config"""
|
||||||
|
print() # Newline for better test output formatting
|
||||||
|
print(f" → Using static config: {self.config_file}")
|
||||||
|
|
||||||
|
# Copy the config file to config.yml
|
||||||
|
shutil.copy(self.config_source, self.config_target)
|
||||||
|
print(f" ✓ Config copied to config.yml")
|
||||||
|
|
||||||
|
# Call parent's up() method to start services
|
||||||
|
super().up()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def static_basic() -> Generator[None, None, None]:
|
||||||
|
"""Fixture for config-basic.yml"""
|
||||||
|
fixture = StaticDockerComposeFixture("config-basic.yml")
|
||||||
|
fixture.up()
|
||||||
|
yield
|
||||||
|
fixture.down()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def static_deny_pages() -> Generator[None, None, None]:
|
||||||
|
"""Fixture for config-deny-pages.yml"""
|
||||||
|
fixture = StaticDockerComposeFixture("config-deny-pages.yml")
|
||||||
|
fixture.up()
|
||||||
|
yield
|
||||||
|
fixture.down()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def static_jwt_validator() -> Generator[None, None, None]:
|
||||||
|
"""Fixture for config-jwt-validator.yml"""
|
||||||
|
fixture = StaticDockerComposeFixture("config-jwt-validator.yml")
|
||||||
|
fixture.up()
|
||||||
|
yield
|
||||||
|
fixture.down()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test: config-basic.yml - Basic HTTP→HTTPS Redirect
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.static
|
||||||
|
class TestStaticBasic:
|
||||||
|
"""Tests for static config-basic.yml"""
|
||||||
|
|
||||||
|
def test_haproxy_config(self, static_basic):
|
||||||
|
"""Test HAProxy configuration has SSL and redirect configurations"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", "static-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
config = result.stdout
|
||||||
|
|
||||||
|
# Test HTTPS backend for host1
|
||||||
|
https_host1_block = extract_backend_block(config, "srv_host1_local_443")
|
||||||
|
assert https_host1_block, "Backend srv_host1_local_443 not found"
|
||||||
|
assert "mode http" in https_host1_block
|
||||||
|
|
||||||
|
# Verify SSL frontend exists
|
||||||
|
assert "frontend https_in_443" in config or "bind *:443" in config
|
||||||
|
|
||||||
|
# Verify HTTP to HTTPS redirect (new format uses http-request redirect scheme)
|
||||||
|
assert "http-request redirect scheme https code 301" in config
|
||||||
|
|
||||||
|
def test_https_host1(self, static_basic):
|
||||||
|
"""Test HTTPS access to host1.local"""
|
||||||
|
response = requests.get(
|
||||||
|
"https://127.0.0.1/",
|
||||||
|
headers={"Host": "host1.local"},
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_http_redirect_host1(self, static_basic):
|
||||||
|
"""Test HTTP to HTTPS redirect for host1.local"""
|
||||||
|
response = requests.get(
|
||||||
|
"http://127.0.0.1/",
|
||||||
|
headers={"Host": "host1.local"},
|
||||||
|
allow_redirects=False
|
||||||
|
)
|
||||||
|
assert response.status_code == 301
|
||||||
|
assert "https://host1.local" in response.headers.get("location", "")
|
||||||
|
|
||||||
|
def test_haproxy_stats(self, static_basic):
|
||||||
|
"""Test HAProxy stats interface"""
|
||||||
|
from conftest import verify_haproxy_stats
|
||||||
|
verify_haproxy_stats()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test: config-deny-pages.yml - Deny Pages Plugin
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.static
|
||||||
|
class TestStaticDenyPages:
|
||||||
|
"""Tests for static config-deny-pages.yml"""
|
||||||
|
|
||||||
|
def test_haproxy_config(self, static_deny_pages):
|
||||||
|
"""Test HAProxy configuration has deny pages rules"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", "static-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
config = result.stdout
|
||||||
|
|
||||||
|
# Extract backend for host1.local (should have global deny_pages config)
|
||||||
|
backend_block = extract_backend_block(config, "srv_host1_local_80")
|
||||||
|
assert backend_block, "Backend srv_host1_local_80 not found"
|
||||||
|
|
||||||
|
# Verify deny pages plugin is configured
|
||||||
|
assert "# Deny Pages - Block specific paths" in backend_block
|
||||||
|
assert "acl denied_path path_beg" in backend_block
|
||||||
|
assert "/admin" in backend_block
|
||||||
|
assert "/.env" in backend_block
|
||||||
|
assert "/config" in backend_block
|
||||||
|
assert "http-request deny" in backend_block
|
||||||
|
|
||||||
|
def test_normal_access(self, static_deny_pages):
|
||||||
|
"""Test normal access to allowed paths"""
|
||||||
|
response = requests.get(
|
||||||
|
"http://127.0.0.1/",
|
||||||
|
headers={"Host": "host1.local"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_blocked_paths(self, static_deny_pages):
|
||||||
|
"""Test access to blocked paths"""
|
||||||
|
blocked_paths = ["/admin", "/.env", "/config"]
|
||||||
|
for path in blocked_paths:
|
||||||
|
response = requests.get(
|
||||||
|
f"http://127.0.0.1{path}",
|
||||||
|
headers={"Host": "host1.local"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 404, f"Path {path} should be blocked with 404"
|
||||||
|
|
||||||
|
def test_haproxy_stats(self, static_deny_pages):
|
||||||
|
"""Test HAProxy stats interface"""
|
||||||
|
from conftest import verify_haproxy_stats
|
||||||
|
verify_haproxy_stats()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test: config-jwt-validator.yml - JWT Validator Plugin
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.static
|
||||||
|
class TestStaticJWTValidator:
|
||||||
|
"""Tests for static config-jwt-validator.yml"""
|
||||||
|
|
||||||
|
def test_haproxy_config(self, static_jwt_validator):
|
||||||
|
"""Test HAProxy configuration has JWT validation rules"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", "static-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
config = result.stdout
|
||||||
|
|
||||||
|
# Extract backend for API (static mode uses different naming)
|
||||||
|
# Find any backend that contains JWT validation
|
||||||
|
assert "# JWT Validator - Validate JWT tokens" in config, \
|
||||||
|
"JWT Validator plugin comment not found"
|
||||||
|
assert "jwt_verify" in config, \
|
||||||
|
"JWT signature verification not found"
|
||||||
|
assert "Missing Authorization HTTP header" in config, \
|
||||||
|
"JWT authorization check not found"
|
||||||
|
|
||||||
|
def test_without_token(self, static_jwt_validator):
|
||||||
|
"""Test API access without JWT token (should fail)"""
|
||||||
|
response = requests.get(
|
||||||
|
"http://127.0.0.1/",
|
||||||
|
headers={"Host": "api.local"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "Missing Authorization HTTP header" in response.text
|
||||||
|
|
||||||
|
def test_with_valid_token(self, static_jwt_validator, jwt_token):
|
||||||
|
"""Test API access with valid JWT token (should succeed)"""
|
||||||
|
response = requests.get(
|
||||||
|
"http://127.0.0.1/",
|
||||||
|
headers={
|
||||||
|
"Host": "api.local",
|
||||||
|
"Authorization": f"Bearer {jwt_token}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_haproxy_stats(self, static_jwt_validator):
|
||||||
|
"""Test HAProxy stats interface"""
|
||||||
|
from conftest import verify_haproxy_stats
|
||||||
|
verify_haproxy_stats()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("This is a pytest test suite. Run with: pytest test_static.py -v")
|
||||||
|
print("\nAvailable test classes:")
|
||||||
|
print(" - TestStaticBasic: Basic static configuration tests")
|
||||||
|
print(" - TestStaticDenyPages: Deny pages plugin tests")
|
||||||
|
print(" - TestStaticJWTValidator: JWT validator plugin tests")
|
||||||
|
|
@ -14,6 +14,74 @@ import jwt as jwt_lib
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
# Track if Docker image has been built in this test session
|
||||||
|
_docker_image_built = False
|
||||||
|
|
||||||
|
|
||||||
|
class DockerComposeFixture:
|
||||||
|
"""Helper class to manage docker-compose lifecycle"""
|
||||||
|
|
||||||
|
def __init__(self, compose_file: str, startup_wait: int = 3, build: bool = None):
|
||||||
|
self.compose_file = compose_file
|
||||||
|
self.startup_wait = startup_wait
|
||||||
|
|
||||||
|
# Smart build strategy: build on first call, skip on subsequent calls
|
||||||
|
global _docker_image_built
|
||||||
|
if build is None:
|
||||||
|
self.build = not _docker_image_built
|
||||||
|
else:
|
||||||
|
self.build = build
|
||||||
|
|
||||||
|
def up(self):
|
||||||
|
"""Start docker-compose services"""
|
||||||
|
global _docker_image_built
|
||||||
|
|
||||||
|
compose_name = Path(self.compose_file).name
|
||||||
|
print() # Newline for better test output formatting
|
||||||
|
print(f" → Starting services from {compose_name}...")
|
||||||
|
|
||||||
|
cmd = ["docker", "compose", "-f", self.compose_file, "up", "-d"]
|
||||||
|
if self.build:
|
||||||
|
cmd.append("--build")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ✗ ERROR: Failed to start services!")
|
||||||
|
print(f" stdout: {result.stdout}")
|
||||||
|
print(f" stderr: {result.stderr}")
|
||||||
|
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
|
||||||
|
|
||||||
|
# Mark image as built for this test session
|
||||||
|
if self.build:
|
||||||
|
_docker_image_built = True
|
||||||
|
|
||||||
|
print(f" ✓ Services started, waiting {self.startup_wait}s for initialization...")
|
||||||
|
time.sleep(self.startup_wait)
|
||||||
|
print(f" ✓ Services ready")
|
||||||
|
|
||||||
|
def down(self):
|
||||||
|
"""Stop and remove docker-compose services"""
|
||||||
|
compose_name = Path(self.compose_file).name
|
||||||
|
print(f" → Stopping services from {compose_name}...")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "compose", "-f", self.compose_file, "down", "--remove-orphans"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ⚠ WARNING: Failed to stop services cleanly")
|
||||||
|
print(f" stderr: {result.stderr}")
|
||||||
|
# Don't raise error on cleanup, just warn
|
||||||
|
else:
|
||||||
|
print(f" ✓ Services stopped and cleaned up")
|
||||||
|
|
||||||
|
|
||||||
def generate_jwt_token(
|
def generate_jwt_token(
|
||||||
private_key_path: Path,
|
private_key_path: Path,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue