From 90e3df2b75de19d728319b8126ceb7be57d25129 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 17:43:59 -0500 Subject: [PATCH 01/27] Add built-in plugin framework and initial plugins - Introduced a plugin system enabling EasyHAProxy extensions. - Added `CleanupPlugin` (GLOBAL): Handles temporary file cleanup. - Added `CloudflarePlugin` (DOMAIN): Restores visitor IP from Cloudflare headers. - Added `DenyPagesPlugin` (DOMAIN): Blocks access to specified paths. - Documented the plugin architecture, configuration options, and built-in plugins in `docs/plugin-development.md` and `docs/plugins.md`. --- README.md | 1 + docs/plugin-development.md | 294 ++++++++++++++++++++++++++ docs/plugins.md | 341 ++++++++++++++++++++++++++++++ src/easymapping/__init__.py | 82 ++++++- src/functions/__init__.py | 17 ++ src/plugins/__init__.py | 250 ++++++++++++++++++++++ src/plugins/builtin/__init__.py | 1 + src/plugins/builtin/cleanup.py | 124 +++++++++++ src/plugins/builtin/cloudflare.py | 89 ++++++++ src/plugins/builtin/deny_pages.py | 106 ++++++++++ src/processor/__init__.py | 24 +++ src/templates/haproxy.cfg.j2 | 13 ++ 12 files changed, 1341 insertions(+), 1 deletion(-) create mode 100644 docs/plugin-development.md create mode 100644 docs/plugins.md create mode 100644 src/plugins/__init__.py create mode 100644 src/plugins/builtin/__init__.py create mode 100644 src/plugins/builtin/cleanup.py create mode 100644 src/plugins/builtin/cloudflare.py create mode 100644 src/plugins/builtin/deny_pages.py diff --git a/README.md b/README.md index e5b916a..5e8bff9 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Detailed configuration guides for advanced setups: - [Container Labels](docs/container-labels.md) - Configure Docker/Swarm containers with labels - [Environment Variables](docs/environment-variable.md) - Configure EasyHAProxy behavior - [Volumes](docs/volumes.md) - Map volumes for certificates, config, and custom files +- [Plugins](docs/plugins.md) - Extend HAProxy with custom plugins - [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.) - [Limitations](docs/limitations.md) - Important limitations and considerations diff --git a/docs/plugin-development.md b/docs/plugin-development.md new file mode 100644 index 0000000..f9f3a67 --- /dev/null +++ b/docs/plugin-development.md @@ -0,0 +1,294 @@ +--- +sidebar_position: 17 +--- + +# EasyHAProxy Plugin System - Developer Guide + +## Overview + +The EasyHAProxy plugin system allows you to extend HAProxy configuration generation with custom functionality. Plugins are invoked during the discovery cycle and can inject HAProxy configuration snippets or modify the discovery data. + +## Plugin Types + +### GLOBAL Plugins +- **Execution**: Once per discovery cycle +- **Use Cases**: Cleanup tasks, global configuration, monitoring, DNS updates +- **Example**: `CleanupPlugin` + +### DOMAIN Plugins +- **Execution**: Once for each discovered domain/host +- **Use Cases**: Domain-specific configuration, IP restoration, path blocking, custom headers +- **Example**: `CloudflarePlugin`, `DenyPagesPlugin` + +## Creating a Custom Plugin + +### 1. Plugin Structure + +Create a Python file in `/etc/haproxy/plugins/` or `/scripts/plugins/builtin/`: + +```python +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class MyCustomPlugin(PluginInterface): + """Your plugin description""" + + def __init__(self): + # Initialize plugin state + self.enabled = True + self.my_config_value = "default" + + @property + def name(self) -> str: + """Return unique plugin name""" + return "my_custom_plugin" + + @property + def plugin_type(self) -> PluginType: + """Return GLOBAL or DOMAIN""" + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure plugin from YAML/env/labels + + Args: + config: Configuration dictionary + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "my_config_value" in config: + self.my_config_value = config["my_config_value"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Process plugin logic + + Args: + context: PluginContext with execution data + + Returns: + PluginResult with HAProxy config and/or metadata + """ + if not self.enabled: + return PluginResult() + + # Generate HAProxy configuration snippet + haproxy_config = """# My Custom Plugin + # Add your HAProxy directives here + http-request set-header X-Custom-Header "value" + """ + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, # Optional: modify discovery data + metadata={"info": "my metadata"} # Optional: logging info + ) +``` + +### 2. Plugin Context Data + +The `PluginContext` object provides: + +```python +@dataclass +class PluginContext: + parsed_object: dict # {IP: labels} from container discovery + easymapping: list # Current HAProxy mapping structure + container_env: dict # Environment configuration + domain: Optional[str] # Domain name (DOMAIN plugins only) + port: Optional[str] # Port (DOMAIN plugins only) + host_config: Optional[dict] # Domain config (DOMAIN plugins only) +``` + +**Example parsed_object:** +```python +{ + "192.168.1.10": { + "easyhaproxy.http.host": "example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080" + } +} +``` + +**Example easymapping:** +```python +[ + { + "port": "80", + "mode": "http", + "hosts": { + "example.com": { + "containers": ["192.168.1.10:8080"], + "certbot": False, + "balance": "roundrobin" + } + } + } +] +``` + +### 3. Plugin Configuration + +Plugins can be configured via: + +#### YAML (`/etc/haproxy/static/config.yaml`): +```yaml +plugins: + my_custom_plugin: + enabled: true + my_config_value: "custom" +``` + +#### Environment Variables: +```bash +EASYHAPROXY_PLUGINS_ENABLED=my_custom_plugin +EASYHAPROXY_PLUGIN_MY_CUSTOM_PLUGIN_MY_CONFIG_VALUE=custom +``` + +#### Container Labels: +```yaml +labels: + easyhaproxy.http.plugins: "my_custom_plugin" + easyhaproxy.http.plugin.my_custom_plugin.my_config_value: "custom" +``` + +## Built-in Plugins + +### CloudflarePlugin (DOMAIN) +- **Purpose**: Restore original visitor IP from Cloudflare headers +- **Config**: `ip_list_path` - Path to Cloudflare IP list +- **Location**: `/scripts/plugins/builtin/cloudflare.py` + +### CleanupPlugin (GLOBAL) +- **Purpose**: Cleanup temporary files during discovery +- **Config**: `max_idle_time`, `cleanup_temp_files` +- **Location**: `/scripts/plugins/builtin/cleanup.py` + +### DenyPagesPlugin (DOMAIN) +- **Purpose**: Block access to specific paths +- **Config**: `paths`, `status_code` +- **Location**: `/scripts/plugins/builtin/deny_pages.py` + +## Plugin Lifecycle + +``` +Discovery Cycle + ├─ Container/Service Discovery + ├─ Parse Metadata → easymapping + ├─ Execute GLOBAL plugins (once) + ├─ For each domain: + │ ├─ Execute DOMAIN plugins + │ └─ Store plugin configs + ├─ Render Jinja2 template with plugin snippets + └─ Generate final HAProxy config +``` + +## Error Handling + +### Default Behavior (Log and Continue) +```yaml +plugins: + abort_on_error: false # Default +``` +- Plugin errors are logged as warnings +- Discovery cycle continues +- HAProxy config generation proceeds + +### Abort on Error +```yaml +plugins: + abort_on_error: true +``` +- Plugin errors stop the discovery cycle +- Previous HAProxy config remains active +- Useful for critical plugins + +## Best Practices + +1. **Keep It Simple**: Plugins should do one thing well +2. **Error Handling**: Use try/except and return empty PluginResult on errors +3. **Logging**: Use `from functions import loggerEasyHaproxy` for logging +4. **Configuration**: Provide sensible defaults +5. **Documentation**: Add docstrings explaining configuration options +6. **Testing**: Test your plugin with various configurations + +## Debugging + +Enable debug logging: +```bash +EASYHAPROXY_LOG_LEVEL=DEBUG +``` + +Check plugin loading: +``` +# Look for log messages: +Loaded builtin plugin: cloudflare (domain) +Loaded external plugin: my_plugin (global) +``` + +## Examples + +### Example 1: Add Custom Header (DOMAIN) + +```python +class CustomHeaderPlugin(PluginInterface): + def __init__(self): + self.header_name = "X-Custom" + self.header_value = "value" + + @property + def name(self) -> str: + return "custom_header" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + self.header_name = config.get("header_name", self.header_name) + self.header_value = config.get("header_value", self.header_value) + + def process(self, context: PluginContext) -> PluginResult: + return PluginResult( + haproxy_config=f'http-request set-header {self.header_name} "{self.header_value}"' + ) +``` + +### Example 2: DNS Update (GLOBAL) + +```python +class DNSUpdatePlugin(PluginInterface): + @property + def name(self) -> str: + return "dns_update" + + @property + def plugin_type(self) -> PluginType: + return PluginType.GLOBAL + + def configure(self, config: dict) -> None: + self.dns_server = config.get("dns_server", "8.8.8.8") + + def process(self, context: PluginContext) -> PluginResult: + # Update DNS records based on discovered hosts + for ip, labels in context.parsed_object.items(): + # Your DNS update logic here + pass + + return PluginResult(metadata={"dns_updates": "completed"}) +``` + +## Support + +For issues or questions: +- GitHub: https://github.com/byjg/docker-easy-haproxy +- Documentation: https://byjg.github.io/docker-easy-haproxy diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..d1d0ec2 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,341 @@ +--- +sidebar_position: 16 +--- + +# Plugins + +EasyHAProxy supports a flexible plugin system that allows you to extend HAProxy configuration with custom functionality. Plugins are automatically invoked during the discovery cycle and can inject HAProxy configuration directives or modify discovery data. + +## Plugin Types + +### Global Plugins +Global plugins execute **once per discovery cycle**, regardless of how many domains are discovered. They're ideal for: +- Cleanup tasks +- Global monitoring +- DNS updates +- Log management + +### Domain Plugins +Domain plugins execute **once for each discovered domain/host**. They're ideal for: +- Domain-specific configuration +- IP restoration (e.g., Cloudflare) +- Path blocking +- Custom headers per domain + +## Built-in Plugins + +### Cloudflare (Domain Plugin) + +Restores the original visitor IP address from Cloudflare's `CF-Connecting-IP` header when requests come through Cloudflare's CDN. + +**Configuration:** +```yaml +# /etc/haproxy/static/config.yaml +plugins: + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst +``` + +**Container Label:** +```yaml +services: + myapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: cloudflare +``` + +**Environment Variable:** +```bash +EASYHAPROXY_PLUGINS_ENABLED=cloudflare +EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst +``` + +### Cleanup (Global Plugin) + +Performs cleanup tasks during each discovery cycle, such as removing old temporary files. + +**Configuration:** +```yaml +# /etc/haproxy/static/config.yaml +plugins: + cleanup: + enabled: true + max_idle_time: 300 # seconds + cleanup_temp_files: true +``` + +**Environment Variable:** +```bash +EASYHAPROXY_PLUGINS_ENABLED=cleanup +EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +``` + +### Deny Pages (Domain Plugin) + +Blocks access to specific paths for a domain. + +**Configuration:** +```yaml +# /etc/haproxy/static/config.yaml +plugins: + deny_pages: + enabled: true + paths: /admin,/private,/internal + status_code: 403 +``` + +**Container Label:** +```yaml +services: + myapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/private + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +## Configuration Methods + +Plugins can be configured using three methods. Configuration from YAML takes precedence over environment variables. + +### 1. Static YAML Configuration + +Configure plugins in `/etc/haproxy/static/config.yaml`: + +```yaml +plugins: + # Global settings + abort_on_error: false # Log and continue on plugin errors (default) + + # Plugin-specific configuration + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst + + cleanup: + enabled: true + max_idle_time: 300 + + deny_pages: + enabled: false # Disable globally, can still be enabled per domain +``` + +### 2. Environment Variables + +Configure plugins via environment variables: + +```bash +# Global settings +EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false +EASYHAPROXY_PLUGINS_ENABLED=cloudflare,cleanup + +# Plugin-specific configuration +EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED=true +EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst +EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +``` + +### 3. Container Labels (Domain Plugins Only) + +Enable domain plugins for specific containers: + +```yaml +services: + webapp: + image: myapp:latest + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: cloudflare,deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/api/internal +``` + +## Error Handling + +### Log and Continue (Default) + +By default, plugin errors are logged as warnings and the discovery cycle continues: + +```yaml +plugins: + abort_on_error: false # Default +``` + +This ensures that a failing plugin doesn't prevent HAProxy configuration updates. + +### Abort on Error + +For critical plugins, you can stop the discovery cycle on errors: + +```yaml +plugins: + abort_on_error: true +``` + +With this setting, any plugin error will halt configuration generation and the previous HAProxy config remains active until the issue is resolved. + +## Custom Plugins + +You can create custom plugins by placing Python files in `/etc/haproxy/plugins/`. + +### Simple Custom Plugin Example + +Create `/etc/haproxy/plugins/custom_header.py`: + +```python +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class CustomHeaderPlugin(PluginInterface): + def __init__(self): + self.enabled = True + self.header_name = "X-Custom-App" + self.header_value = "EasyHAProxy" + + @property + def name(self) -> str: + return "custom_header" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + if "header_name" in config: + self.header_name = config["header_name"] + if "header_value" in config: + self.header_value = config["header_value"] + + def process(self, context: PluginContext) -> PluginResult: + if not self.enabled: + return PluginResult() + + haproxy_config = f'http-request set-header {self.header_name} "{self.header_value}"' + + return PluginResult( + haproxy_config=haproxy_config, + metadata={"domain": context.domain} + ) +``` + +### Enable Your Custom Plugin + +**YAML:** +```yaml +plugins: + custom_header: + enabled: true + header_name: X-My-Header + header_value: MyValue +``` + +**Container Label:** +```yaml +labels: + easyhaproxy.http.plugins: custom_header + easyhaproxy.http.plugin.custom_header.header_value: CustomValue +``` + +## Plugin Development + +For detailed information on developing plugins, see the [Plugin Developer Guide](plugin-development.md). + +Key points: +- Plugins must inherit from `PluginInterface` +- Implement required methods: `name`, `plugin_type`, `configure`, `process` +- Return `PluginResult` with HAProxy config snippets +- Use `loggerEasyHaproxy` for logging +- Handle errors gracefully + +## Use Cases + +### 1. Cloudflare IP Restoration + +Restore original visitor IPs when using Cloudflare: + +```yaml +plugins: + cloudflare: + enabled: true +``` + +Requires Cloudflare IP list at `/etc/haproxy/cloudflare_ips.lst`. See [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786-Restoring-original-visitor-IPs). + +### 2. Path Protection + +Block sensitive paths from public access: + +```yaml +labels: + easyhaproxy.http.host: myapp.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/config,/debug +``` + +### 3. Automatic Cleanup + +Keep your system clean: + +```yaml +plugins: + cleanup: + enabled: true + max_idle_time: 3600 # 1 hour +``` + +### 4. Custom Authentication + +Create a plugin to add basic auth: + +```python +def process(self, context: PluginContext) -> PluginResult: + return PluginResult( + haproxy_config="""http-request auth realm MyApp unless { http_auth(user_list) }""" + ) +``` + +## Debugging + +Enable debug logging to see plugin execution: + +```bash +EASYHAPROXY_LOG_LEVEL=DEBUG +``` + +Look for log messages: +``` +INFO: Loaded builtin plugin: cloudflare (domain) +DEBUG: Executing domain plugin: cloudflare for domain: example.com +DEBUG: Plugin cloudflare metadata: {'domain': 'example.com'} +``` + +## Best Practices + +1. **Test plugins independently** before deploying to production +2. **Use log-and-continue mode** unless a plugin is critical +3. **Keep plugin logic simple** - one plugin should do one thing well +4. **Document configuration options** in plugin docstrings +5. **Handle errors gracefully** - return empty PluginResult on errors +6. **Use sensible defaults** so plugins work out of the box + +## Limitations + +- Plugins are Python-only (no shell scripts) +- Plugins cannot modify the HAProxy template structure +- Domain plugins are executed for each domain, so keep them lightweight +- Plugin errors in abort mode will halt configuration updates + +## Further Reading + +- [Plugin Developer Guide](plugin-development.md) +- [Container Labels](container-labels.md) +- [Environment Variables](environment-variable.md) +- [Static Configuration](static.md) diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index bb1c833..79f3700 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -54,19 +54,60 @@ class HaproxyConfigGenerator: self.serving_hosts = [] self.certs = {} + # Initialize plugin system + try: + from plugins import PluginManager + self.plugin_manager = PluginManager( + plugins_dir="/etc/haproxy/plugins", + abort_on_error=self.mapping.get("plugins", {}).get("abort_on_error", False) + ) + self.plugin_manager.load_plugins() + self.plugin_manager.configure_plugins(self.mapping.get("plugins", {})) + self.global_plugin_configs = [] + except Exception as e: + # If plugin system fails to initialize, log but continue + import logging + logging.warning(f"Failed to initialize plugin system: {e}") + self.plugin_manager = None + self.global_plugin_configs = [] + def generate(self, container_metadata={}): self.mapping.setdefault("easymapping", []) if container_metadata != {}: self.mapping["easymapping"] = self.parse(container_metadata) + # Execute global plugins + if self.plugin_manager: + try: + from plugins import PluginContext + global_context = PluginContext( + parsed_object=container_metadata, + easymapping=self.mapping.get("easymapping", []), + container_env=self.mapping, + domain=None, + port=None, + host_config=None + ) + + # Get enabled plugins from config + enabled_list = self.mapping.get("plugins", {}).get("enabled") + if enabled_list and len(enabled_list) > 0 and enabled_list[0] == "": + enabled_list = None + + global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list) + self.global_plugin_configs = [r.haproxy_config for r in global_results if r.haproxy_config] + except Exception as e: + import logging + logging.warning(f"Failed to execute global plugins: {e}") + file_loader = FileSystemLoader('templates') env = Environment(loader=file_loader) env.trim_blocks = True env.lstrip_blocks = True env.rstrip_blocks = True template = env.get_template('haproxy.cfg.j2') - return template.render(data=self.mapping) + return template.render(data=self.mapping, global_plugin_configs=self.global_plugin_configs) def parse(self, container_metadata): easymapping = dict() @@ -151,6 +192,45 @@ class HaproxyConfigGenerator: self.label.create([definition, "redirect"]) ) + # Execute domain plugins for this host + if self.plugin_manager: + try: + from plugins import PluginContext + + domain_context = PluginContext( + parsed_object=container_metadata, + easymapping=easymapping, + container_env=self.mapping, + domain=hostname, + port=port, + host_config=easymapping[port]["hosts"][hostname] + ) + + # Check if plugins are enabled for this domain (from labels) + enabled_plugins = None + if self.label.has_label(self.label.create([definition, "plugins"])): + enabled_plugins = self.label.get( + self.label.create([definition, "plugins"]), + "" + ).split(",") + enabled_plugins = [p.strip() for p in enabled_plugins if p.strip()] + + domain_results = self.plugin_manager.execute_domain_plugins( + domain_context, + enabled_list=enabled_plugins + ) + + # Store domain plugin configs for this host + easymapping[port]["hosts"][hostname]["plugin_configs"] = [ + r.haproxy_config for r in domain_results if r.haproxy_config + ] + except Exception as e: + import logging + logging.warning(f"Failed to execute domain plugins for {hostname}: {e}") + easymapping[port]["hosts"][hostname]["plugin_configs"] = [] + else: + easymapping[port]["hosts"][hostname]["plugin_configs"] = [] + if certbot or clone_to_ssl: if "443" not in easymapping: easymapping["443"] = { diff --git a/src/functions/__init__.py b/src/functions/__init__.py index c6e8bcf..979eb42 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -88,6 +88,23 @@ class ContainerEnv: os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"] + # Plugin configuration + env_vars["plugins"] = { + "abort_on_error": os.getenv("EASYHAPROXY_PLUGINS_ABORT_ON_ERROR", "false").lower() == "true", + "enabled": os.getenv("EASYHAPROXY_PLUGINS_ENABLED", "").split(",") if os.getenv("EASYHAPROXY_PLUGINS_ENABLED") else [], + "config": {} # Individual plugin configs from env vars + } + + # Parse individual plugin configs (e.g., EASYHAPROXY_PLUGIN_CLOUDFLARE_*) + for key, value in os.environ.items(): + if key.startswith("EASYHAPROXY_PLUGIN_"): + parts = key.split("_", 3) # ['EASYHAPROXY', 'PLUGIN', 'NAME', 'KEY'] + if len(parts) >= 4: + plugin_name = parts[2].lower() + config_key = "_".join(parts[3:]).lower() + env_vars["plugins"]["config"].setdefault(plugin_name, {}) + env_vars["plugins"]["config"][plugin_name][config_key] = value + return env_vars diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py new file mode 100644 index 0000000..ac7d1c4 --- /dev/null +++ b/src/plugins/__init__.py @@ -0,0 +1,250 @@ +import os +import importlib.util +import sys +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Dict, Any, List +from functions import loggerEasyHaproxy + + +class PluginType(Enum): + """Plugin execution types""" + GLOBAL = "global" # Execute once per discovery cycle + DOMAIN = "domain" # Execute per domain/host + + +@dataclass +class PluginContext: + """Container for all plugin execution data""" + parsed_object: dict # {IP: labels} from discovery + easymapping: list # Current HAProxy mapping structure + container_env: dict # Environment configuration + domain: Optional[str] = None # Domain name (for DOMAIN plugins) + port: Optional[str] = None # Port (for DOMAIN plugins) + host_config: Optional[dict] = None # Domain-specific config + + +@dataclass +class PluginResult: + """Plugin execution result""" + haproxy_config: str = "" # HAProxy config snippet to inject + modified_easymapping: Optional[list] = None # Modified easymapping structure + metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging + + +class PluginInterface(ABC): + """Base class all plugins must inherit""" + + @property + @abstractmethod + def name(self) -> str: + """Return the unique plugin name""" + pass + + @property + @abstractmethod + def plugin_type(self) -> PluginType: + """Return the plugin type (GLOBAL or DOMAIN)""" + pass + + @abstractmethod + def configure(self, config: dict) -> None: + """ + Configure the plugin with settings from YAML/env/labels + + Args: + config: Dictionary with plugin-specific configuration + """ + pass + + @abstractmethod + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin logic and return result + + Args: + context: PluginContext with all necessary data + + Returns: + PluginResult with HAProxy config snippets and/or modified data + """ + pass + + +class PluginManager: + """Manages plugin loading, configuration, and execution""" + + def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False): + """ + Initialize the plugin manager + + Args: + plugins_dir: Directory containing plugin files + abort_on_error: If True, abort on plugin errors; if False, log and continue + """ + self.plugins_dir = plugins_dir + self.abort_on_error = abort_on_error + self.plugins: Dict[str, PluginInterface] = {} + self.global_plugins: List[PluginInterface] = [] + self.domain_plugins: List[PluginInterface] = [] + self.logger = loggerEasyHaproxy + + def load_plugins(self) -> None: + """ + Discover and load plugins from the plugins directory + Loads both builtin plugins and external plugins + """ + # Load builtin plugins first + builtin_dir = os.path.join(os.path.dirname(__file__), "builtin") + self._load_plugins_from_directory(builtin_dir, "builtin") + + # Load external plugins from /etc/haproxy/plugins + if os.path.exists(self.plugins_dir): + self._load_plugins_from_directory(self.plugins_dir, "external") + else: + self.logger.info(f"Plugin directory {self.plugins_dir} does not exist, skipping external plugins") + + def _load_plugins_from_directory(self, directory: str, source: str) -> None: + """ + Load plugins from a specific directory + + Args: + directory: Path to directory containing plugins + source: Source identifier ("builtin" or "external") + """ + if not os.path.exists(directory): + return + + for filename in os.listdir(directory): + if filename.endswith(".py") and not filename.startswith("__"): + filepath = os.path.join(directory, filename) + module_name = f"plugins.{source}.{filename[:-3]}" + + try: + # Load module from file + spec = importlib.util.spec_from_file_location(module_name, filepath) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + # Find plugin classes in module + for item_name in dir(module): + item = getattr(module, item_name) + if (isinstance(item, type) and + issubclass(item, PluginInterface) and + item is not PluginInterface): + # Instantiate plugin + plugin = item() + self.plugins[plugin.name] = plugin + + # Categorize by type + if plugin.plugin_type == PluginType.GLOBAL: + self.global_plugins.append(plugin) + elif plugin.plugin_type == PluginType.DOMAIN: + self.domain_plugins.append(plugin) + + self.logger.info(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})") + + except Exception as e: + self._handle_error(f"Failed to load plugin from {filepath}: {str(e)}") + + def configure_plugins(self, plugins_config: dict) -> None: + """ + Configure all loaded plugins with their settings + + Args: + plugins_config: Plugin configuration from YAML/env + Format: {"plugin_name": {"key": "value"}, ...} + """ + for plugin_name, plugin in self.plugins.items(): + try: + # Get plugin-specific config + plugin_cfg = plugins_config.get(plugin_name, {}) + + # Also check "config" sub-key for env var configs + if "config" in plugins_config and plugin_name in plugins_config["config"]: + plugin_cfg.update(plugins_config["config"][plugin_name]) + + # Configure plugin + plugin.configure(plugin_cfg) + self.logger.debug(f"Configured plugin: {plugin_name} with config: {plugin_cfg}") + + except Exception as e: + self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(e)}") + + def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """ + Execute all global plugins + + Args: + context: PluginContext with execution data + enabled_list: Optional list of plugin names to execute. If None, execute all. + + Returns: + List of PluginResult from each plugin + """ + results = [] + + for plugin in self.global_plugins: + # Check if plugin is in enabled list (if provided) + if enabled_list is not None and plugin.name not in enabled_list: + continue + + try: + self.logger.debug(f"Executing global plugin: {plugin.name}") + result = plugin.process(context) + results.append(result) + + if result.metadata: + self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}") + + except Exception as e: + self._handle_error(f"Global plugin '{plugin.name}' failed: {str(e)}") + + return results + + def execute_domain_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """ + Execute all domain plugins for a specific domain + + Args: + context: PluginContext with domain-specific data + enabled_list: Optional list of plugin names to execute. If None, execute all. + + Returns: + List of PluginResult from each plugin + """ + results = [] + + for plugin in self.domain_plugins: + # Check if plugin is in enabled list (if provided) + if enabled_list is not None and plugin.name not in enabled_list: + continue + + try: + self.logger.debug(f"Executing domain plugin: {plugin.name} for domain: {context.domain}") + result = plugin.process(context) + results.append(result) + + if result.metadata: + self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}") + + except Exception as e: + self._handle_error(f"Domain plugin '{plugin.name}' failed for domain '{context.domain}': {str(e)}") + + return results + + def _handle_error(self, message: str) -> None: + """ + Handle plugin errors according to abort_on_error setting + + Args: + message: Error message to log + """ + if self.abort_on_error: + self.logger.error(message) + raise RuntimeError(message) + else: + self.logger.warning(message) diff --git a/src/plugins/builtin/__init__.py b/src/plugins/builtin/__init__.py new file mode 100644 index 0000000..3afacbe --- /dev/null +++ b/src/plugins/builtin/__init__.py @@ -0,0 +1 @@ +# Built-in plugins for EasyHAProxy diff --git a/src/plugins/builtin/cleanup.py b/src/plugins/builtin/cleanup.py new file mode 100644 index 0000000..45ef13d --- /dev/null +++ b/src/plugins/builtin/cleanup.py @@ -0,0 +1,124 @@ +""" +Cleanup Plugin for EasyHAProxy + +This plugin performs cleanup tasks during each discovery cycle. +It runs as a GLOBAL plugin (once per cycle). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - max_idle_time: Maximum idle time before cleanup in seconds (default: 300) + - cleanup_temp_files: Clean up temporary files (default: true) + +Example YAML config: + plugins: + cleanup: + enabled: true + max_idle_time: 300 + cleanup_temp_files: true + +Example Environment Variable: + EASYHAPROXY_PLUGINS_ENABLED=cleanup + EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +""" + +import os +import sys +import glob +import time + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class CleanupPlugin(PluginInterface): + """Plugin to perform cleanup tasks during discovery cycle""" + + def __init__(self): + self.enabled = True + self.max_idle_time = 300 # 5 minutes + self.cleanup_temp_files = True + + @property + def name(self) -> str: + return "cleanup" + + @property + def plugin_type(self) -> PluginType: + return PluginType.GLOBAL + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - max_idle_time: Maximum idle time in seconds + - cleanup_temp_files: Whether to clean up temp files + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "max_idle_time" in config: + try: + self.max_idle_time = int(config["max_idle_time"]) + except ValueError: + loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") + + if "cleanup_temp_files" in config: + self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Perform cleanup tasks + + Args: + context: Plugin execution context + + Returns: + PluginResult with metadata about cleanup actions + """ + if not self.enabled: + return PluginResult() + + cleanup_actions = [] + + # Cleanup temporary files + if self.cleanup_temp_files: + temp_dirs = ["/tmp", "/var/tmp"] + current_time = time.time() + + for temp_dir in temp_dirs: + if not os.path.exists(temp_dir): + continue + + try: + # Find old EasyHAProxy temp files + pattern = os.path.join(temp_dir, "easyhaproxy_*") + for filepath in glob.glob(pattern): + try: + file_age = current_time - os.path.getmtime(filepath) + if file_age > self.max_idle_time: + os.remove(filepath) + cleanup_actions.append(f"Removed old temp file: {filepath}") + loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") + + # Log cleanup summary + if cleanup_actions: + loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") + + return PluginResult( + haproxy_config="", # No HAProxy config needed for cleanup + modified_easymapping=None, + metadata={ + "actions_performed": len(cleanup_actions), + "actions": cleanup_actions + } + ) diff --git a/src/plugins/builtin/cloudflare.py b/src/plugins/builtin/cloudflare.py new file mode 100644 index 0000000..3a51a52 --- /dev/null +++ b/src/plugins/builtin/cloudflare.py @@ -0,0 +1,89 @@ +""" +Cloudflare Plugin for EasyHAProxy + +This plugin restores the original visitor IP address from Cloudflare's +CF-Connecting-IP header when requests come through Cloudflare's CDN. + +Configuration: + - ip_list_path: Path to file containing Cloudflare IP ranges (default: /etc/haproxy/cloudflare_ips.lst) + +Example YAML config: + plugins: + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst + +Example Container Label: + easyhaproxy.http.plugins: "cloudflare" + +HAProxy Config Generated: + # Cloudflare - Restore original visitor IP + acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst + http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class CloudflarePlugin(PluginInterface): + """Plugin to restore original visitor IP from Cloudflare""" + + def __init__(self): + self.ip_list_path = "/etc/haproxy/cloudflare_ips.lst" + self.enabled = True + + @property + def name(self) -> str: + return "cloudflare" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - ip_list_path: Path to Cloudflare IP list file + - enabled: Whether plugin is enabled + """ + if "ip_list_path" in config: + self.ip_list_path = config["ip_list_path"] + + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to restore original IP from Cloudflare + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled: + return PluginResult() + + # Generate HAProxy config snippet + haproxy_config = f"""# Cloudflare - Restore original visitor IP + acl from_cloudflare src -f {self.ip_list_path} + http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "ip_list_path": self.ip_list_path + } + ) diff --git a/src/plugins/builtin/deny_pages.py b/src/plugins/builtin/deny_pages.py new file mode 100644 index 0000000..f1d3870 --- /dev/null +++ b/src/plugins/builtin/deny_pages.py @@ -0,0 +1,106 @@ +""" +Deny Pages Plugin for EasyHAProxy + +This plugin blocks access to specific paths for a domain. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - paths: Comma-separated list of paths to deny (e.g., "/admin,/private") + - status_code: HTTP status code to return (default: 403) + +Example YAML config: + plugins: + deny_pages: + enabled: true + paths: "/admin,/private,/internal" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "deny_pages" + easyhaproxy.http.plugin.deny_pages.paths: "/admin,/private" + +HAProxy Config Generated: + # Deny Pages - Block specific paths + acl denied_path path_beg /admin /private + http-request deny deny_status 403 if denied_path +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class DenyPagesPlugin(PluginInterface): + """Plugin to deny access to specific paths""" + + def __init__(self): + self.enabled = True + self.paths = [] + self.status_code = 403 + + @property + def name(self) -> str: + return "deny_pages" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - paths: Comma-separated list of paths to deny + - status_code: HTTP status code to return + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "paths" in config: + paths_str = str(config["paths"]) + self.paths = [p.strip() for p in paths_str.split(",") if p.strip()] + + if "status_code" in config: + try: + self.status_code = int(config["status_code"]) + except ValueError: + self.status_code = 403 + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to deny specific paths + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.paths: + return PluginResult() + + # Create path list for ACL + paths_str = " ".join(self.paths) + + # Generate HAProxy config snippet + haproxy_config = f"""# Deny Pages - Block specific paths + acl denied_path path_beg {paths_str} + http-request deny deny_status {self.status_code} if denied_path""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "blocked_paths": self.paths, + "status_code": self.status_code + } + ) diff --git a/src/processor/__init__.py b/src/processor/__init__.py index ec038bf..8daf5a2 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -116,6 +116,30 @@ class Static(ProcessorInterface): def parse(self): self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader) + + # Merge plugin config from YAML with env vars + if "plugins" in self.static_content: + # Get env var config + container_env = ContainerEnv.read() + + # Merge YAML plugins config with env config + # YAML config takes precedence over env vars + if "plugins" not in self.static_content: + self.static_content["plugins"] = container_env.get("plugins", {}) + else: + # Merge configs - YAML overrides env vars + yaml_plugins = self.static_content["plugins"] + env_plugins = container_env.get("plugins", {}) + + # Merge individual plugin configs + for plugin_name, plugin_config in env_plugins.get("config", {}).items(): + 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) diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index d2df424..f830e0d 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -40,6 +40,13 @@ defaults errorfile 504 /etc/haproxy/errors-custom/504.http {% endif %} +{% if global_plugin_configs %} +# Global Plugin Configurations +{% for config in global_plugin_configs %} +{{ config }} +{% endfor %} +{% endif %} + {% set data_stats = data["stats"] | default({}) %} {% if data_stats["port"] | default(1936) | int > 0 %} frontend stats @@ -75,6 +82,12 @@ frontend {{ mode }}_in_{{ o["port"] }} backend srv_{{ host }} balance {{ o["balance"] | default("roundrobin") }} mode {{ mode }} + {% if o["hosts"][k]["plugin_configs"] is defined and o["hosts"][k]["plugin_configs"] | length > 0 %} + # Domain Plugin Configurations for {{ k }} + {% for config in o["hosts"][k]["plugin_configs"] %} +{{ config | indent(4, first=True) }} + {% endfor %} + {% endif %} {% if mode == "http" %} option forwardfor http-request set-header X-Forwarded-Port %[dst_port] From 113b90a697da1abe27f6e50bab6abb20b9ebfafa Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:08:05 -0500 Subject: [PATCH 02/27] Add support for plugin configurations and minor refactoring - Added `plugin_configs` attribute to test cases. - Improved handling of enabled plugins in `easymapping/__init__.py`. - Updated templates directory path resolution for robustness. - Refactored example files to enhance formatting consistency. --- src/easymapping/__init__.py | 11 ++-- src/tests/expected/no-services.txt | 1 + src/tests/expected/services-letsencrypt.txt | 1 + .../expected/services-multi-containers.txt | 1 + .../expected/services-multiple-hosts.txt | 1 + src/tests/expected/services-redirect-ssl.txt | 1 + src/tests/expected/services-tcp.txt | 1 + src/tests/expected/services.txt | 1 + src/tests/expected/ssl-loose.txt | 1 + src/tests/expected/ssl-strict.txt | 1 + src/tests/expected/static.txt | 1 + src/tests/test_containerenv.py | 57 +++++++++++++++++-- src/tests/test_parser.py | 30 ++++++---- 13 files changed, 88 insertions(+), 20 deletions(-) diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index 79f3700..2c1d232 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -1,5 +1,6 @@ import base64 import json +import os import re from jinja2 import Environment, FileSystemLoader @@ -91,9 +92,10 @@ class HaproxyConfigGenerator: ) # Get enabled plugins from config - enabled_list = self.mapping.get("plugins", {}).get("enabled") + enabled_list = self.mapping.get("plugins", {}).get("enabled", []) + # If enabled list contains only empty string, treat as no plugins enabled if enabled_list and len(enabled_list) > 0 and enabled_list[0] == "": - enabled_list = None + enabled_list = [] global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list) self.global_plugin_configs = [r.haproxy_config for r in global_results if r.haproxy_config] @@ -101,7 +103,8 @@ class HaproxyConfigGenerator: import logging logging.warning(f"Failed to execute global plugins: {e}") - file_loader = FileSystemLoader('templates') + templates_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'templates') + file_loader = FileSystemLoader(templates_dir) env = Environment(loader=file_loader) env.trim_blocks = True env.lstrip_blocks = True @@ -207,7 +210,7 @@ class HaproxyConfigGenerator: ) # Check if plugins are enabled for this domain (from labels) - enabled_plugins = None + enabled_plugins = [] if self.label.has_label(self.label.create([definition, "plugins"])): enabled_plugins = self.label.get( self.label.create([definition, "plugins"]), diff --git a/src/tests/expected/no-services.txt b/src/tests/expected/no-services.txt index 7624237..a898508 100644 --- a/src/tests/expected/no-services.txt +++ b/src/tests/expected/no-services.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-letsencrypt.txt b/src/tests/expected/services-letsencrypt.txt index d95732d..ffb28c6 100644 --- a/src/tests/expected/services-letsencrypt.txt +++ b/src/tests/expected/services-letsencrypt.txt @@ -29,6 +29,7 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http + frontend stats bind *:1936 mode http diff --git a/src/tests/expected/services-multi-containers.txt b/src/tests/expected/services-multi-containers.txt index 3b1644d..0cfedd8 100644 --- a/src/tests/expected/services-multi-containers.txt +++ b/src/tests/expected/services-multi-containers.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + frontend http_in_19901 bind *:19901 mode http diff --git a/src/tests/expected/services-multiple-hosts.txt b/src/tests/expected/services-multiple-hosts.txt index 66810d5..0b3a64d 100644 --- a/src/tests/expected/services-multiple-hosts.txt +++ b/src/tests/expected/services-multiple-hosts.txt @@ -29,6 +29,7 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http + frontend stats bind *:1937 mode http diff --git a/src/tests/expected/services-redirect-ssl.txt b/src/tests/expected/services-redirect-ssl.txt index ad841cc..8c51afc 100644 --- a/src/tests/expected/services-redirect-ssl.txt +++ b/src/tests/expected/services-redirect-ssl.txt @@ -21,6 +21,7 @@ defaults timeout server 10m + frontend http_in_80 bind *:80 mode http diff --git a/src/tests/expected/services-tcp.txt b/src/tests/expected/services-tcp.txt index 2c19128..f9a41bd 100644 --- a/src/tests/expected/services-tcp.txt +++ b/src/tests/expected/services-tcp.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + frontend tcp_in_31339 bind *:31339 mode tcp diff --git a/src/tests/expected/services.txt b/src/tests/expected/services.txt index 82f8a6c..2c876f9 100644 --- a/src/tests/expected/services.txt +++ b/src/tests/expected/services.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + frontend tcp_in_31339 bind *:31339 mode tcp diff --git a/src/tests/expected/ssl-loose.txt b/src/tests/expected/ssl-loose.txt index 239e566..e9cb61f 100644 --- a/src/tests/expected/ssl-loose.txt +++ b/src/tests/expected/ssl-loose.txt @@ -20,6 +20,7 @@ defaults timeout client 10s timeout server 10m + frontend stats bind *:1936 mode http diff --git a/src/tests/expected/ssl-strict.txt b/src/tests/expected/ssl-strict.txt index 7c3306f..0f26d3c 100644 --- a/src/tests/expected/ssl-strict.txt +++ b/src/tests/expected/ssl-strict.txt @@ -18,6 +18,7 @@ defaults timeout server 10m + backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/static.txt b/src/tests/expected/static.txt index 1d0d9a2..2002c62 100644 --- a/src/tests/expected/static.txt +++ b/src/tests/expected/static.txt @@ -29,6 +29,7 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http + frontend stats bind *:1936 mode http diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 27d672e..f5837d7 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -20,7 +20,12 @@ def test_container_env_empty(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() # os.environ['CERTBOT_LOG_LEVEL'] = 'warn' @@ -45,7 +50,12 @@ def test_container_env_customerrors(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_CUSTOMERRORS'] @@ -70,7 +80,12 @@ def test_container_env_sslmode(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['EASYHAPROXY_SSL_MODE'] @@ -96,7 +111,12 @@ def test_container_env_stats(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_USERNAME'] @@ -128,7 +148,12 @@ def test_container_env_stats_password(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_PASSWORD'] @@ -160,7 +185,12 @@ def test_container_env_stats_password_2(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_USERNAME'] @@ -189,6 +219,11 @@ def test_container_env_certbot_email(): "retry_count": 60, "preferred_challenges": "http", "manual_auth_hook": False + }, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] } } == ContainerEnv.read() finally: @@ -222,6 +257,11 @@ def test_container_env_certbot_full(): 'retry_count': 10, "preferred_challenges": "dns", "manual_auth_hook": "something_manual_auth_hook" + }, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] } } == ContainerEnv.read() finally: @@ -257,6 +297,11 @@ def test_container_log_level(): "retry_count": 60, "preferred_challenges": "http", "manual_auth_hook": False + }, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] } } == ContainerEnv.read() finally: diff --git a/src/tests/test_parser.py b/src/tests/test_parser.py index 3f6f1fa..1aa0f4b 100644 --- a/src/tests/test_parser.py +++ b/src/tests/test_parser.py @@ -124,7 +124,8 @@ def test_parser_finds_services_raw(): "my-stack_agent:9001" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -142,7 +143,8 @@ def test_parser_finds_services_raw(): "my-stack_cadvisor:8080" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] }, "node-exporter.quantum.example.org":{ "balance": "roundrobin", @@ -150,7 +152,8 @@ def test_parser_finds_services_raw(): "my-stack_node-exporter:9100" ], "certbot": True, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -168,7 +171,8 @@ def test_parser_finds_services_raw(): "my-stack_node-exporter:9100" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] }, "www.somehost.com.br":{ "balance": "roundrobin", @@ -176,7 +180,8 @@ def test_parser_finds_services_raw(): "some-service:80" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -199,7 +204,8 @@ def test_parser_finds_services_raw(): "some-service:80" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -465,7 +471,8 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.215:8080" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] }, "valida.me":{ "balance":"roundrobin", @@ -473,7 +480,8 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.62:8080" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] }, "www.valida.me":{ "balance":"roundrobin", @@ -481,7 +489,8 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.62:8080" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] } }, "mode": "http", @@ -499,7 +508,8 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.215:8080" ], "certbot": False, - "redirect_ssl": False + "redirect_ssl": False, + "plugin_configs": [] } }, "mode": "http", From 91f8ff5d085231d7e09ecb63f23596b3526ba659 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:17:58 -0500 Subject: [PATCH 03/27] Add tests for plugin integration and improve plugin configuration handling - Created fixtures for `services-with-cloudflare`, `services-with-deny-pages`, and `services-with-multiple-plugins`. - Added comprehensive test cases for `CloudflarePlugin`, `DenyPagesPlugin`, and `CleanupPlugin`. - Ensured proper configuration propagation to plugins using labels in `easymapping/__init__.py`. - Validated multi-plugin integration and consistent output in HAProxy configuration generation. --- src/easymapping/__init__.py | 18 + src/tests/fixtures/services-with-cloudflare | 8 + src/tests/fixtures/services-with-deny-pages | 10 + .../fixtures/services-with-multiple-plugins | 11 + src/tests/test_plugins.py | 477 ++++++++++++++++++ 5 files changed, 524 insertions(+) create mode 100644 src/tests/fixtures/services-with-cloudflare create mode 100644 src/tests/fixtures/services-with-deny-pages create mode 100644 src/tests/fixtures/services-with-multiple-plugins create mode 100644 src/tests/test_plugins.py diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index 2c1d232..24b5277 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -218,6 +218,24 @@ class HaproxyConfigGenerator: ).split(",") enabled_plugins = [p.strip() for p in enabled_plugins if p.strip()] + # Extract plugin configurations from labels + # Format: easyhaproxy.http.plugin.PLUGIN_NAME.CONFIG_KEY + plugin_configs = {} + for plugin_name in enabled_plugins: + plugin_configs[plugin_name] = {} + # Look for all labels matching easyhaproxy.{definition}.plugin.{plugin_name}.* + plugin_label_prefix = self.label.create([definition, "plugin", plugin_name]) + for label_key in d.keys(): + if label_key.startswith(plugin_label_prefix + "."): + # Extract config key (everything after plugin_label_prefix + ".") + config_key = label_key[len(plugin_label_prefix) + 1:] + plugin_configs[plugin_name][config_key] = d[label_key] + + # Configure plugins with label-specific configs before execution + for plugin_name, config in plugin_configs.items(): + if plugin_name in self.plugin_manager.plugins: + self.plugin_manager.plugins[plugin_name].configure(config) + domain_results = self.plugin_manager.execute_domain_plugins( domain_context, enabled_list=enabled_plugins diff --git a/src/tests/fixtures/services-with-cloudflare b/src/tests/fixtures/services-with-cloudflare new file mode 100644 index 0000000..902c62d --- /dev/null +++ b/src/tests/fixtures/services-with-cloudflare @@ -0,0 +1,8 @@ +{ + "192.168.1.10": { + "easyhaproxy.http.host": "example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "cloudflare" + } +} diff --git a/src/tests/fixtures/services-with-deny-pages b/src/tests/fixtures/services-with-deny-pages new file mode 100644 index 0000000..b592182 --- /dev/null +++ b/src/tests/fixtures/services-with-deny-pages @@ -0,0 +1,10 @@ +{ + "192.168.1.20": { + "easyhaproxy.http.host": "secure.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "deny_pages", + "easyhaproxy.http.plugin.deny_pages.paths": "/admin,/wp-admin", + "easyhaproxy.http.plugin.deny_pages.status_code": "404" + } +} diff --git a/src/tests/fixtures/services-with-multiple-plugins b/src/tests/fixtures/services-with-multiple-plugins new file mode 100644 index 0000000..1b13766 --- /dev/null +++ b/src/tests/fixtures/services-with-multiple-plugins @@ -0,0 +1,11 @@ +{ + "192.168.1.30": { + "easyhaproxy.http.host": "multi.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "cloudflare,deny_pages", + "easyhaproxy.http.plugin.cloudflare.ip_list_path": "/etc/haproxy/cloudflare_ips.lst", + "easyhaproxy.http.plugin.deny_pages.paths": "/admin,/private", + "easyhaproxy.http.plugin.deny_pages.status_code": "403" + } +} diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py new file mode 100644 index 0000000..8defa6e --- /dev/null +++ b/src/tests/test_plugins.py @@ -0,0 +1,477 @@ +""" +Tests for EasyHAProxy Plugin System + +Tests all builtin plugins: +- CloudflarePlugin (domain) +- CleanupPlugin (global) +- DenyPagesPlugin (domain) +""" + +import os +import sys +import json +import tempfile +import time + +# Add src to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginManager, PluginContext +from plugins.builtin.cloudflare import CloudflarePlugin +from plugins.builtin.cleanup import CleanupPlugin +from plugins.builtin.deny_pages import DenyPagesPlugin +import easymapping + + +def load_fixture(file): + """Load a test fixture""" + fixture_path = os.path.join(os.path.dirname(__file__), "fixtures", file) + with open(fixture_path, 'r') as content_file: + line_list = json.loads("".join(content_file.readlines())) + return line_list + + +class TestCloudflarePlugin: + """Test cases for CloudflarePlugin (DOMAIN plugin)""" + + def test_cloudflare_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = CloudflarePlugin() + assert plugin.name == "cloudflare" + assert plugin.enabled is True + assert plugin.ip_list_path == "/etc/haproxy/cloudflare_ips.lst" + + def test_cloudflare_plugin_configuration(self): + """Test plugin configuration""" + plugin = CloudflarePlugin() + + # Test custom IP list path + plugin.configure({"ip_list_path": "/custom/path/cf_ips.txt"}) + assert plugin.ip_list_path == "/custom/path/cf_ips.txt" + + # Test disabling + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + # Test enabling with various values + plugin.configure({"enabled": "true"}) + assert plugin.enabled is True + + plugin.configure({"enabled": "1"}) + assert plugin.enabled is True + + plugin.configure({"enabled": "yes"}) + assert plugin.enabled is True + + def test_cloudflare_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = CloudflarePlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "Cloudflare" in result.haproxy_config + assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in result.haproxy_config + assert "http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["ip_list_path"] == "/etc/haproxy/cloudflare_ips.lst" + + def test_cloudflare_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = CloudflarePlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_cloudflare_plugin_in_haproxy_config(self): + """Test Cloudflare plugin integration in full HAProxy config generation""" + # Use fixture with cloudflare plugin enabled via labels + line_list = load_fixture("services-with-cloudflare") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify Cloudflare config is in the output + assert "Cloudflare - Restore original visitor IP" in haproxy_config + assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in haproxy_config + assert "http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]" in haproxy_config + + +class TestCleanupPlugin: + """Test cases for CleanupPlugin (GLOBAL plugin)""" + + def test_cleanup_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = CleanupPlugin() + assert plugin.name == "cleanup" + assert plugin.enabled is True + assert plugin.max_idle_time == 300 + assert plugin.cleanup_temp_files is True + + def test_cleanup_plugin_configuration(self): + """Test plugin configuration""" + plugin = CleanupPlugin() + + # Test max_idle_time + plugin.configure({"max_idle_time": "600"}) + assert plugin.max_idle_time == 600 + + # Test cleanup_temp_files + plugin.configure({"cleanup_temp_files": "false"}) + assert plugin.cleanup_temp_files is False + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_cleanup_plugin_processes_files(self): + """Test plugin cleans up old temp files""" + plugin = CleanupPlugin() + plugin.configure({"max_idle_time": "1"}) # 1 second + + # Create a temp file + with tempfile.NamedTemporaryFile(prefix="easyhaproxy_", delete=False) as tmp: + temp_file = tmp.name + + # Wait for file to age + time.sleep(2) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={} + ) + + # Run cleanup + result = plugin.process(context) + + # Verify file was removed + assert not os.path.exists(temp_file) + assert result.haproxy_config == "" + assert result.metadata["actions_performed"] >= 0 + + def test_cleanup_plugin_disabled(self): + """Test plugin does nothing when disabled""" + plugin = CleanupPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={} + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_cleanup_plugin_in_haproxy_config(self): + """Test Cleanup plugin integration (should not affect config output)""" + line_list = load_fixture("services") + + # Enable cleanup plugin + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0}, + "plugins": { + "enabled": ["cleanup"], + "config": { + "cleanup": { + "max_idle_time": "300" + } + } + } + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Cleanup plugin should not add any HAProxy config + assert "cleanup" not in haproxy_config.lower() + # But the config should still be valid + assert "backend certbot_backend" in haproxy_config + + +class TestDenyPagesPlugin: + """Test cases for DenyPagesPlugin (DOMAIN plugin)""" + + def test_deny_pages_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = DenyPagesPlugin() + assert plugin.name == "deny_pages" + assert plugin.enabled is True + assert plugin.paths == [] + assert plugin.status_code == 403 + + def test_deny_pages_plugin_configuration(self): + """Test plugin configuration""" + plugin = DenyPagesPlugin() + + # Test paths + plugin.configure({"paths": "/admin,/private,/internal"}) + assert plugin.paths == ["/admin", "/private", "/internal"] + + # Test status code + plugin.configure({"status_code": "404"}) + assert plugin.status_code == 404 + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_deny_pages_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = DenyPagesPlugin() + plugin.configure({ + "paths": "/admin,/private", + "status_code": "403" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "Deny Pages" in result.haproxy_config + assert "acl denied_path path_beg /admin /private" in result.haproxy_config + assert "http-request deny deny_status 403 if denied_path" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["blocked_paths"] == ["/admin", "/private"] + assert result.metadata["status_code"] == 403 + + def test_deny_pages_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = DenyPagesPlugin() + plugin.configure({"enabled": "false", "paths": "/admin"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_deny_pages_plugin_no_paths(self): + """Test plugin returns empty config when no paths configured""" + plugin = DenyPagesPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_deny_pages_plugin_in_haproxy_config(self): + """Test Deny Pages plugin integration in full HAProxy config generation""" + # Use fixture with deny_pages plugin enabled via labels + line_list = load_fixture("services-with-deny-pages") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify Deny Pages config is in the output + assert "Deny Pages - Block specific paths" in haproxy_config + assert "acl denied_path path_beg /admin /wp-admin" in haproxy_config + assert "http-request deny deny_status 404 if denied_path" in haproxy_config + + +class TestPluginManager: + """Test cases for PluginManager""" + + def test_plugin_manager_loads_builtin_plugins(self): + """Test that plugin manager loads all builtin plugins""" + manager = PluginManager() + manager.load_plugins() + + # Verify all builtin plugins are loaded + assert "cloudflare" in manager.plugins + assert "cleanup" in manager.plugins + assert "deny_pages" in manager.plugins + + # Verify plugin types + assert len(manager.global_plugins) == 1 # cleanup + assert len(manager.domain_plugins) == 2 # cloudflare, deny_pages + + # Verify plugin instances + assert manager.plugins["cloudflare"].name == "cloudflare" + assert manager.plugins["cleanup"].name == "cleanup" + assert manager.plugins["deny_pages"].name == "deny_pages" + + def test_plugin_manager_executes_global_plugins(self): + """Test plugin manager executes global plugins correctly""" + manager = PluginManager() + manager.load_plugins() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={} + ) + + # Execute only cleanup plugin + results = manager.execute_global_plugins(context, enabled_list=["cleanup"]) + + assert len(results) == 1 + assert results[0].haproxy_config == "" # Cleanup doesn't generate config + + def test_plugin_manager_executes_domain_plugins(self): + """Test plugin manager executes domain plugins correctly""" + manager = PluginManager() + manager.load_plugins() + + # Configure deny_pages + manager.configure_plugins({ + "deny_pages": { + "paths": "/admin,/private", + "status_code": "403" + } + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + # Execute both domain plugins + results = manager.execute_domain_plugins(context, enabled_list=["cloudflare", "deny_pages"]) + + assert len(results) == 2 + + # Verify configs were generated + configs = [r.haproxy_config for r in results if r.haproxy_config] + assert len(configs) == 2 + + # Verify both plugin outputs + all_config = "\n".join(configs) + assert "Cloudflare" in all_config + assert "Deny Pages" in all_config + + def test_plugin_manager_empty_enabled_list(self): + """Test that empty enabled list means no plugins execute""" + manager = PluginManager() + manager.load_plugins() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + # Execute with empty list - should execute nothing + results = manager.execute_domain_plugins(context, enabled_list=[]) + assert len(results) == 0 + + results = manager.execute_global_plugins(context, enabled_list=[]) + assert len(results) == 0 + + +class TestMultiplePluginsCombined: + """Test cases for multiple plugins working together""" + + def test_multiple_plugins_in_haproxy_config(self): + """Test multiple plugins working together in HAProxy config""" + # Use fixture with both plugins enabled via labels + line_list = load_fixture("services-with-multiple-plugins") + + # Enable cleanup plugin globally + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0}, + "plugins": { + "enabled": ["cleanup"], + "config": { + "cleanup": { + "max_idle_time": "600" + } + } + } + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify both domain plugins are in the output + assert "Cloudflare - Restore original visitor IP" in haproxy_config + assert "Deny Pages - Block specific paths" in haproxy_config + assert "acl from_cloudflare" in haproxy_config + assert "acl denied_path path_beg /admin /private" in haproxy_config + + # Cleanup doesn't add to config + assert "cleanup" not in haproxy_config.lower() + + def test_plugins_order_in_output(self): + """Test that plugins maintain consistent order in output""" + # Use fixture with both plugins enabled via labels + line_list = load_fixture("services-with-multiple-plugins") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Find positions of plugin configs + cloudflare_pos = haproxy_config.find("Cloudflare") + deny_pages_pos = haproxy_config.find("Deny Pages") + + # Both should be present + assert cloudflare_pos != -1 + assert deny_pages_pos != -1 + + # They should appear in backend sections (not in global/defaults) + assert cloudflare_pos > haproxy_config.find("backend srv_") + assert deny_pages_pos > haproxy_config.find("backend srv_") From 57387f3e3222a9f1d4dff5d035c1fce43fbbdad3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:35:37 -0500 Subject: [PATCH 04/27] Add IP Whitelist Plugin and corresponding tests - Introduced `IpWhitelistPlugin` (DOMAIN): Restricts access to specific IP addresses or CIDR ranges. - Added configuration options: `enabled`, `allowed_ips`, and `status_code`. - Updated `plugins.md` and `plugin-development.md` to document `IpWhitelistPlugin`. - Created fixtures for `services-with-ip-whitelist`. - Added extensive test cases for `IpWhitelistPlugin` to validate configuration and HAProxy output generation. - Ensured seamless integration into the plugin framework alongside other domain plugins. --- docs/plugin-development.md | 1217 ++++++++++++++--- docs/plugins.md | 534 ++++---- src/plugins/builtin/ip_whitelist.py | 107 ++ src/tests/fixtures/services-with-ip-whitelist | 10 + src/tests/test_plugins.py | 109 +- 5 files changed, 1549 insertions(+), 428 deletions(-) create mode 100644 src/plugins/builtin/ip_whitelist.py create mode 100644 src/tests/fixtures/services-with-ip-whitelist diff --git a/docs/plugin-development.md b/docs/plugin-development.md index f9f3a67..79b0141 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -2,29 +2,583 @@ sidebar_position: 17 --- -# EasyHAProxy Plugin System - Developer Guide +# Plugin Developer Guide -## Overview +This guide explains how to create custom plugins for EasyHAProxy. For information on using existing plugins, see [Using Plugins](plugins.md). -The EasyHAProxy plugin system allows you to extend HAProxy configuration generation with custom functionality. Plugins are invoked during the discovery cycle and can inject HAProxy configuration snippets or modify the discovery data. +## Architecture Overview -## Plugin Types +### Plugin Lifecycle -### GLOBAL Plugins -- **Execution**: Once per discovery cycle -- **Use Cases**: Cleanup tasks, global configuration, monitoring, DNS updates -- **Example**: `CleanupPlugin` +``` +1. Discovery Cycle Starts + │ +2. PluginManager loads plugins + ├─ Load builtin plugins from src/plugins/builtin/ + └─ Load external plugins from /etc/haproxy/plugins/ + │ +3. PluginManager configures plugins + ├─ Read global config (YAML/env vars) + └─ Call plugin.configure(config) + │ +4. Parse container metadata → easymapping + │ +5. Execute GLOBAL plugins (once) + ├─ Create PluginContext (no domain info) + ├─ Call plugin.process(context) + └─ Collect PluginResult + │ +6. For each discovered domain: + ├─ Extract plugin list from labels + ├─ Extract plugin configs from labels + ├─ Call plugin.configure(label_config) + ├─ Execute DOMAIN plugins + │ ├─ Create PluginContext (with domain info) + │ ├─ Call plugin.process(context) + │ └─ Collect PluginResult + └─ Store plugin HAProxy configs + │ +7. Render Jinja2 template + ├─ Inject global plugin configs + └─ Inject domain plugin configs per backend + │ +8. Generate final HAProxy config +``` -### DOMAIN Plugins -- **Execution**: Once for each discovered domain/host -- **Use Cases**: Domain-specific configuration, IP restoration, path blocking, custom headers -- **Example**: `CloudflarePlugin`, `DenyPagesPlugin` +## Built-in Plugins Reference -## Creating a Custom Plugin +EasyHAProxy includes four built-in plugins that serve as both functional tools and reference implementations for plugin development. -### 1. Plugin Structure +### CloudflarePlugin (DOMAIN) -Create a Python file in `/etc/haproxy/plugins/` or `/scripts/plugins/builtin/`: +**Purpose:** Restore original visitor IP addresses when using Cloudflare CDN + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/cloudflare.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `ip_list_path` (str) - Path to Cloudflare IP list file (default: `/etc/haproxy/cloudflare_ips.lst`) + +**What it does:** +- Reads Cloudflare IP ranges from a file +- Checks if request comes from Cloudflare IP +- Restores original visitor IP from `CF-Connecting-IP` header + +**HAProxy config generated:** +``` +# Cloudflare - Restore original visitor IP +acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst +http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare +``` + +**Usage:** See [Cloudflare Plugin documentation](plugins.md#cloudflare-plugin-domain) + +### CleanupPlugin (GLOBAL) + +**Purpose:** Perform cleanup tasks during discovery cycle + +**Type:** GLOBAL - Executes once per discovery cycle + +**Source:** `src/plugins/builtin/cleanup.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `max_idle_time` (int) - Max file age in seconds before deletion (default: `300`) +- `cleanup_temp_files` (bool) - Enable temp file cleanup (default: `true`) + +**What it does:** +- Scans `/tmp` for files prefixed with `easyhaproxy_` +- Removes files older than `max_idle_time` seconds +- Logs cleanup actions to metadata + +**HAProxy config generated:** None (performs cleanup only) + +**Usage:** See [Cleanup Plugin documentation](plugins.md#cleanup-plugin-global) + +### DenyPagesPlugin (DOMAIN) + +**Purpose:** Block access to specific paths for a domain + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/deny_pages.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `paths` (str) - Comma-separated list of paths to block (e.g., `/admin,/private`) +- `status_code` (int) - HTTP status code to return (default: `403`) + +**What it does:** +- Parses comma-separated list of paths +- Creates HAProxy ACL matching those paths +- Returns specified HTTP status code for matching requests + +**HAProxy config generated:** +``` +# Deny Pages - Block specific paths +acl denied_path path_beg /admin /private +http-request deny deny_status 403 if denied_path +``` + +**Usage:** See [Deny Pages Plugin documentation](plugins.md#deny-pages-plugin-domain) + +### IpWhitelistPlugin (DOMAIN) + +**Purpose:** Restrict domain access to specific IP addresses or CIDR ranges + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/ip_whitelist.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `allowed_ips` (str) - Comma-separated list of IPs/CIDR ranges to allow (e.g., `192.168.1.0/24,10.0.0.1`) +- `status_code` (int) - HTTP status code to return for blocked IPs (default: `403`) + +**What it does:** +- Parses comma-separated list of IPs and CIDR ranges +- Creates HAProxy ACL matching whitelisted IPs +- Denies all requests NOT from whitelisted IPs + +**HAProxy config generated:** +``` +# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 +http-request deny deny_status 403 if !whitelisted_ip +``` + +**Usage:** See [IP Whitelist Plugin documentation](plugins.md#ip-whitelist-plugin-domain) + +### Summary Table + +| Plugin | Type | Purpose | Config Generated | +|----------------|--------|------------------------------|------------------| +| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | +| `cleanup` | GLOBAL | Clean up temp files | ❌ No | +| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | +| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | + +### Learning from Built-in Plugins + +**Best practices demonstrated:** + +1. **CloudflarePlugin** shows: + - Reading external files (IP list) + - Conditional HAProxy ACLs + - Header manipulation + +2. **CleanupPlugin** shows: + - GLOBAL plugin pattern + - File system operations + - Metadata-only results (no HAProxy config) + +3. **DenyPagesPlugin** shows: + - Parsing comma-separated config values + - Configurable status codes + - Path-based ACLs + +4. **IpWhitelistPlugin** shows: + - IP-based access control + - Negated ACLs (`if !whitelisted_ip`) + - CIDR range support + +**View the source code:** +- [cloudflare.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cloudflare.py) +- [cleanup.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cleanup.py) +- [deny_pages.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/deny_pages.py) +- [ip_whitelist.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/ip_whitelist.py) + +## Plugin API Reference + +### PluginInterface (Abstract Base Class) + +All plugins must inherit from `PluginInterface` and implement these abstract methods: + +```python +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + +class MyPlugin(PluginInterface): + @property + @abstractmethod + def name(self) -> str: + """ + Return unique plugin identifier. + + Used for: + - Configuration lookups + - Enable/disable via labels + - Logging + + Must be: + - Unique across all plugins + - Lowercase with underscores + - Match filename (e.g., my_plugin.py → "my_plugin") + """ + pass + + @property + @abstractmethod + def plugin_type(self) -> PluginType: + """ + Return plugin execution type. + + Returns: + PluginType.GLOBAL - Execute once per discovery cycle + PluginType.DOMAIN - Execute once per domain/host + """ + pass + + @abstractmethod + def configure(self, config: dict) -> None: + """ + Configure plugin with settings from YAML/env/labels. + + Called: + - Once at startup with global config + - Before each execution with label-specific config (domain plugins) + + Args: + config: Dictionary with plugin configuration + Keys are configuration option names + Values are strings from YAML/env/labels + + Common pattern: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + if "my_option" in config: + self.my_option = config["my_option"] + """ + pass + + @abstractmethod + def process(self, context: PluginContext) -> PluginResult: + """ + Execute plugin logic. + + Args: + context: PluginContext with all execution data + + Returns: + PluginResult with HAProxy config snippets and/or metadata + + Common pattern: + if not self.enabled: + return PluginResult() # Empty result when disabled + + # Generate config + haproxy_config = "..." + + return PluginResult( + haproxy_config=haproxy_config, + metadata={"info": "value"} + ) + """ + pass +``` + +### PluginType (Enum) + +Defines when plugins execute: + +```python +class PluginType(Enum): + GLOBAL = "global" # Execute once per discovery cycle + DOMAIN = "domain" # Execute once per domain/host +``` + +**GLOBAL plugins:** +- Execute once regardless of how many domains exist +- Receive empty domain/port/host_config in context +- Use cases: cleanup, monitoring, DNS updates + +**DOMAIN plugins:** +- Execute for each discovered domain +- Receive domain-specific data in context +- Use cases: per-domain config, headers, path blocking + +### PluginContext (Data Class) + +Contains all data available to plugins during execution: + +```python +@dataclass +class PluginContext: + parsed_object: dict # Discovery data: {IP: labels} + easymapping: list # HAProxy mapping structure + container_env: dict # Global environment config + domain: Optional[str] = None # Domain name (DOMAIN plugins only) + port: Optional[str] = None # Port (DOMAIN plugins only) + host_config: Optional[dict] = None # Host config (DOMAIN plugins only) +``` + +**Fields explained:** + +#### `parsed_object: dict` + +Container discovery data mapping IP addresses to labels. + +**Example:** +```python +{ + "192.168.1.10": { + "easyhaproxy.http.host": "example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "cloudflare,deny_pages", + "easyhaproxy.http.plugin.deny_pages.paths": "/admin" + }, + "192.168.1.20": { + "easyhaproxy.http.host": "other.com", + "easyhaproxy.http.port": "80" + } +} +``` + +**Use cases:** +- Iterate over all discovered containers +- Access container labels directly +- Global plugins analyzing all containers + +#### `easymapping: list` + +Parsed HAProxy configuration structure before template rendering. + +**Example:** +```python +[ + { + "port": "80", + "mode": "http", + "ssl-check": "", + "hosts": { + "example.com": { + "containers": ["192.168.1.10:8080"], + "balance": "roundrobin", + "certbot": False, + "redirect_ssl": False, + "plugin_configs": [] + } + }, + "redirect": {} + } +] +``` + +**Use cases:** +- Analyze discovered hosts +- Modify discovery structure (advanced) + +#### `container_env: dict` + +Global configuration from YAML and environment variables. + +**Example:** +```python +{ + "customerrors": False, + "ssl_mode": "default", + "certbot": { + "email": "admin@example.com" + }, + "stats": { + "port": 1936, + "username": "admin" + }, + "plugins": { + "enabled": ["cleanup"], + "abort_on_error": False, + "config": { + "cleanup": { + "max_idle_time": "300" + } + } + } +} +``` + +**Use cases:** +- Access global settings +- Check certbot configuration +- Read global plugin config + +#### `domain: Optional[str]` + +Domain name for DOMAIN plugins. `None` for GLOBAL plugins. + +**Example:** `"example.com"` + +#### `port: Optional[str]` + +Port for DOMAIN plugins. `None` for GLOBAL plugins. + +**Example:** `"80"`, `"443"` + +#### `host_config: Optional[dict]` + +Host-specific configuration for DOMAIN plugins. `None` for GLOBAL plugins. + +**Example:** +```python +{ + "containers": ["192.168.1.10:8080"], + "balance": "roundrobin", + "certbot": False, + "redirect_ssl": False, + "plugin_configs": [] +} +``` + +### PluginResult (Data Class) + +``` +Plugin.process(context) → PluginResult + ├─ haproxy_config (what to add to HAProxy config) + ├─ modified_easymapping (optional: modify discovery data) + └─ metadata (optional: debug/logging info) +``` + +**What is PluginResult?** + +`PluginResult` is a container object that your plugin returns from its `process()` method. It holds everything the plugin produced during execution. + +**Why does it exist?** + +Plugins need to return multiple pieces of information: +- HAProxy configuration snippets to inject +- Optional modifications to the discovery data +- Metadata for logging and debugging + +Instead of returning multiple values, plugins return one `PluginResult` object containing all this information. + +**How do you use it?** + +Every `process()` method must return a `PluginResult`: + +```python +def process(self, context: PluginContext) -> PluginResult: + # Plugin is disabled - return empty result + if not self.enabled: + return PluginResult() + + # Plugin is enabled - return config + return PluginResult( + haproxy_config="http-request set-header X-Custom-Header 'value'", + metadata={"info": "some debug info"} + ) +``` + +**PluginResult structure:** + +```python +@dataclass +class PluginResult: + haproxy_config: str = "" # HAProxy config to inject + modified_easymapping: Optional[list] = None # Modified discovery data (advanced) + metadata: Dict[str, Any] = field(default_factory=dict) # Debug/logging info +``` + +**Common usage patterns:** + +```python +# Empty result (plugin disabled or nothing to do) +return PluginResult() + +# Config only (most common) +return PluginResult( + haproxy_config="# My config\n directive value" +) + +# Config + metadata (recommended) +return PluginResult( + haproxy_config="# My config\n directive value", + metadata={"domain": context.domain, "option": self.my_option} +) + +# All fields (advanced) +return PluginResult( + haproxy_config="# My config\n directive value", + modified_easymapping=modified_data, + metadata={"info": "value"} +) +``` + +**Fields explained:** + +#### `haproxy_config: str` + +HAProxy configuration snippet to inject into the generated config. + +**For DOMAIN plugins:** Injected into the backend section for that domain. + +**Example:** +```python +haproxy_config = """# Cloudflare IP Restoration + acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst + http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" +``` + +**Important:** +- Include leading spaces/tabs for proper indentation +- Add comment describing what the config does +- Use HAProxy directives that make sense for backend context (domain plugins) +- Return empty string `""` when plugin is disabled or has nothing to add + +#### `modified_easymapping: Optional[list]` + +Modified easymapping structure. **Advanced feature, rarely used.** + +**When to use:** +- Modify discovery data before template rendering +- Add/remove hosts dynamically +- Change port mappings + +**Default:** `None` (don't modify easymapping) + +**Example:** +```python +# Add a new host to port 80 +modified = context.easymapping.copy() +modified[0]["hosts"]["new-host.com"] = { + "containers": ["192.168.1.30:8080"], + "balance": "roundrobin", + "certbot": False, + "redirect_ssl": False, + "plugin_configs": [] +} +return PluginResult(modified_easymapping=modified) +``` + +#### `metadata: Dict[str, Any]` + +Metadata for logging and debugging. Not used in HAProxy config. + +**Example:** +```python +metadata = { + "domain": context.domain, + "blocked_paths": ["/admin", "/private"], + "status_code": 403, + "files_cleaned": 5 +} +``` + +**Use cases:** +- Debug information +- Statistics +- Audit trail + +**Logged at DEBUG level:** +``` +DEBUG: Plugin deny_pages metadata: {'domain': 'example.com', 'blocked_paths': ['/admin'], 'status_code': 403} +``` + +## Creating a Plugin: Step-by-Step + +### Step 1: Create Plugin File + +Create `/etc/haproxy/plugins/my_plugin.py`: ```python import os @@ -34,216 +588,117 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy # Optional: for logging +``` +### Step 2: Define Plugin Class -class MyCustomPlugin(PluginInterface): - """Your plugin description""" +```python +class MyPlugin(PluginInterface): + """ + Brief description of what your plugin does. + + Configuration: + - enabled: Enable/disable plugin (default: true) + - my_option: Description of option + + Example: + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_option: value + """ def __init__(self): - # Initialize plugin state + """Initialize plugin with default values""" self.enabled = True - self.my_config_value = "default" + self.my_option = "default_value" +``` +### Step 3: Implement Required Methods + +```python @property def name(self) -> str: - """Return unique plugin name""" - return "my_custom_plugin" + return "my_plugin" # Must match filename @property def plugin_type(self) -> PluginType: - """Return GLOBAL or DOMAIN""" - return PluginType.DOMAIN + return PluginType.DOMAIN # or PluginType.GLOBAL def configure(self, config: dict) -> None: - """ - Configure plugin from YAML/env/labels - - Args: - config: Configuration dictionary - """ + """Parse configuration from YAML/env/labels""" if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "my_config_value" in config: - self.my_config_value = config["my_config_value"] + if "my_option" in config: + self.my_option = config["my_option"] + + loggerEasyHaproxy.debug(f"Configured {self.name}: enabled={self.enabled}, my_option={self.my_option}") def process(self, context: PluginContext) -> PluginResult: - """ - Process plugin logic - - Args: - context: PluginContext with execution data - - Returns: - PluginResult with HAProxy config and/or metadata - """ + """Execute plugin logic""" + # Return empty result if disabled if not self.enabled: return PluginResult() - # Generate HAProxy configuration snippet - haproxy_config = """# My Custom Plugin - # Add your HAProxy directives here - http-request set-header X-Custom-Header "value" - """ + # Generate HAProxy config snippet + haproxy_config = f"""# My Plugin - Description + http-request set-header X-My-Header "{self.my_option}\"""" + # Return result return PluginResult( haproxy_config=haproxy_config, - modified_easymapping=None, # Optional: modify discovery data - metadata={"info": "my metadata"} # Optional: logging info + metadata={ + "domain": context.domain, + "my_option": self.my_option + } ) ``` -### 2. Plugin Context Data - -The `PluginContext` object provides: - -```python -@dataclass -class PluginContext: - parsed_object: dict # {IP: labels} from container discovery - easymapping: list # Current HAProxy mapping structure - container_env: dict # Environment configuration - domain: Optional[str] # Domain name (DOMAIN plugins only) - port: Optional[str] # Port (DOMAIN plugins only) - host_config: Optional[dict] # Domain config (DOMAIN plugins only) -``` - -**Example parsed_object:** -```python -{ - "192.168.1.10": { - "easyhaproxy.http.host": "example.com", - "easyhaproxy.http.port": "80", - "easyhaproxy.http.localport": "8080" - } -} -``` - -**Example easymapping:** -```python -[ - { - "port": "80", - "mode": "http", - "hosts": { - "example.com": { - "containers": ["192.168.1.10:8080"], - "certbot": False, - "balance": "roundrobin" - } - } - } -] -``` - -### 3. Plugin Configuration - -Plugins can be configured via: - -#### YAML (`/etc/haproxy/static/config.yaml`): -```yaml -plugins: - my_custom_plugin: - enabled: true - my_config_value: "custom" -``` - -#### Environment Variables: -```bash -EASYHAPROXY_PLUGINS_ENABLED=my_custom_plugin -EASYHAPROXY_PLUGIN_MY_CUSTOM_PLUGIN_MY_CONFIG_VALUE=custom -``` - -#### Container Labels: -```yaml -labels: - easyhaproxy.http.plugins: "my_custom_plugin" - easyhaproxy.http.plugin.my_custom_plugin.my_config_value: "custom" -``` - -## Built-in Plugins - -### CloudflarePlugin (DOMAIN) -- **Purpose**: Restore original visitor IP from Cloudflare headers -- **Config**: `ip_list_path` - Path to Cloudflare IP list -- **Location**: `/scripts/plugins/builtin/cloudflare.py` - -### CleanupPlugin (GLOBAL) -- **Purpose**: Cleanup temporary files during discovery -- **Config**: `max_idle_time`, `cleanup_temp_files` -- **Location**: `/scripts/plugins/builtin/cleanup.py` - -### DenyPagesPlugin (DOMAIN) -- **Purpose**: Block access to specific paths -- **Config**: `paths`, `status_code` -- **Location**: `/scripts/plugins/builtin/deny_pages.py` - -## Plugin Lifecycle - -``` -Discovery Cycle - ├─ Container/Service Discovery - ├─ Parse Metadata → easymapping - ├─ Execute GLOBAL plugins (once) - ├─ For each domain: - │ ├─ Execute DOMAIN plugins - │ └─ Store plugin configs - ├─ Render Jinja2 template with plugin snippets - └─ Generate final HAProxy config -``` - -## Error Handling - -### Default Behavior (Log and Continue) -```yaml -plugins: - abort_on_error: false # Default -``` -- Plugin errors are logged as warnings -- Discovery cycle continues -- HAProxy config generation proceeds - -### Abort on Error -```yaml -plugins: - abort_on_error: true -``` -- Plugin errors stop the discovery cycle -- Previous HAProxy config remains active -- Useful for critical plugins - -## Best Practices - -1. **Keep It Simple**: Plugins should do one thing well -2. **Error Handling**: Use try/except and return empty PluginResult on errors -3. **Logging**: Use `from functions import loggerEasyHaproxy` for logging -4. **Configuration**: Provide sensible defaults -5. **Documentation**: Add docstrings explaining configuration options -6. **Testing**: Test your plugin with various configurations - -## Debugging +### Step 4: Test Your Plugin Enable debug logging: ```bash EASYHAPROXY_LOG_LEVEL=DEBUG ``` -Check plugin loading: -``` -# Look for log messages: -Loaded builtin plugin: cloudflare (domain) -Loaded external plugin: my_plugin (global) +Enable plugin via label: +```yaml +services: + test: + labels: + easyhaproxy.http.host: test.example.com + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_option: custom_value ``` -## Examples +Check logs for: +``` +INFO: Loaded external plugin: my_plugin (domain) +DEBUG: Configured my_plugin: enabled=True, my_option=custom_value +DEBUG: Executing domain plugin: my_plugin for domain: test.example.com +DEBUG: Plugin my_plugin metadata: {'domain': 'test.example.com', 'my_option': 'custom_value'} +``` -### Example 1: Add Custom Header (DOMAIN) +## Complete Examples + +### Example 1: Custom Header Plugin (DOMAIN) + +Add custom headers to specific domains: ```python +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + class CustomHeaderPlugin(PluginInterface): + """Add custom HTTP headers to requests""" + def __init__(self): - self.header_name = "X-Custom" - self.header_value = "value" + self.enabled = True + self.headers = {} # {header_name: header_value} @property def name(self) -> str: @@ -254,41 +709,407 @@ class CustomHeaderPlugin(PluginInterface): return PluginType.DOMAIN def configure(self, config: dict) -> None: - self.header_name = config.get("header_name", self.header_name) - self.header_value = config.get("header_value", self.header_value) + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + # Parse headers from config + # Format: header1:value1,header2:value2 + if "headers" in config: + for header_pair in config["headers"].split(","): + if ":" in header_pair: + name, value = header_pair.split(":", 1) + self.headers[name.strip()] = value.strip() def process(self, context: PluginContext) -> PluginResult: + if not self.enabled or not self.headers: + return PluginResult() + + # Generate HAProxy directives for each header + lines = ["# Custom Headers"] + for name, value in self.headers.items(): + lines.append(f' http-request set-header {name} "{value}"') + return PluginResult( - haproxy_config=f'http-request set-header {self.header_name} "{self.header_value}"' + haproxy_config="\n".join(lines), + metadata={"domain": context.domain, "headers": self.headers} ) ``` -### Example 2: DNS Update (GLOBAL) +**Usage:** +```yaml +labels: + easyhaproxy.http.plugins: custom_header + easyhaproxy.http.plugin.custom_header.headers: X-App-Name:MyApp,X-Environment:Production +``` + +### Example 2: Rate Limiting Plugin (DOMAIN) + +Add HAProxy rate limiting per domain: ```python -class DNSUpdatePlugin(PluginInterface): +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class RateLimitPlugin(PluginInterface): + """Rate limit requests per domain""" + + def __init__(self): + self.enabled = True + self.requests_per_second = 100 + self.burst = 200 + @property def name(self) -> str: - return "dns_update" + return "rate_limit" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "requests_per_second" in config: + try: + self.requests_per_second = int(config["requests_per_second"]) + except ValueError: + pass + + if "burst" in config: + try: + self.burst = int(config["burst"]) + except ValueError: + pass + + def process(self, context: PluginContext) -> PluginResult: + if not self.enabled: + return PluginResult() + + # Use HAProxy stick tables for rate limiting + domain_safe = context.domain.replace(".", "_") + + haproxy_config = f"""# Rate Limiting - {self.requests_per_second} req/s + stick-table type ip size 100k expire 30s store http_req_rate({self.requests_per_second}s) + http-request track-sc0 src + http-request deny deny_status 429 if {{ sc_http_req_rate(0) gt {self.burst} }}""" + + return PluginResult( + haproxy_config=haproxy_config, + metadata={ + "domain": context.domain, + "rate_limit": self.requests_per_second, + "burst": self.burst + } + ) +``` + +**Usage:** +```yaml +labels: + easyhaproxy.http.plugins: rate_limit + easyhaproxy.http.plugin.rate_limit.requests_per_second: 50 + easyhaproxy.http.plugin.rate_limit.burst: 100 +``` + +### Example 3: Maintenance Mode Plugin (GLOBAL) + +Put all sites in maintenance mode: + +```python +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class MaintenanceModePlugin(PluginInterface): + """Enable/disable maintenance mode globally""" + + def __init__(self): + self.enabled = False # Disabled by default + self.message = "Site is under maintenance" + + @property + def name(self) -> str: + return "maintenance_mode" @property def plugin_type(self) -> PluginType: return PluginType.GLOBAL def configure(self, config: dict) -> None: - self.dns_server = config.get("dns_server", "8.8.8.8") + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "message" in config: + self.message = config["message"] def process(self, context: PluginContext) -> PluginResult: - # Update DNS records based on discovered hosts - for ip, labels in context.parsed_object.items(): - # Your DNS update logic here - pass + if not self.enabled: + return PluginResult() - return PluginResult(metadata={"dns_updates": "completed"}) + # When enabled, return maintenance page for all requests + # This would need custom error pages configured in HAProxy + return PluginResult( + metadata={ + "maintenance_mode": True, + "message": self.message + } + ) ``` +## Best Practices + +### 1. Error Handling + +Always handle errors gracefully: + +```python +def process(self, context: PluginContext) -> PluginResult: + try: + # Your plugin logic + result = do_something() + return PluginResult(haproxy_config=result) + except Exception as e: + loggerEasyHaproxy.error(f"Plugin {self.name} failed: {e}") + return PluginResult() # Return empty result on error +``` + +### 2. Logging + +Use structured logging: + +```python +from functions import loggerEasyHaproxy + +# Info level for important events +loggerEasyHaproxy.info(f"Plugin {self.name} executed successfully") + +# Debug level for detailed info +loggerEasyHaproxy.debug(f"Plugin {self.name} config: {self.my_option}") + +# Warning for non-critical issues +loggerEasyHaproxy.warning(f"Plugin {self.name}: config missing, using default") + +# Error for failures +loggerEasyHaproxy.error(f"Plugin {self.name} failed: {error}") +``` + +### 3. Configuration Validation + +Validate configuration values: + +```python +def configure(self, config: dict) -> None: + if "timeout" in config: + try: + timeout = int(config["timeout"]) + if timeout < 0: + loggerEasyHaproxy.warning(f"{self.name}: timeout must be positive, using default") + self.timeout = 30 + else: + self.timeout = timeout + except ValueError: + loggerEasyHaproxy.warning(f"{self.name}: invalid timeout value, using default") + self.timeout = 30 +``` + +### 4. Documentation + +Document your plugin thoroughly: + +```python +class MyPlugin(PluginInterface): + """ + One-line description. + + Detailed description of what the plugin does and why you'd use it. + + Configuration Options: + enabled (bool): Enable/disable plugin (default: true) + option1 (str): Description of option1 (default: "value") + option2 (int): Description of option2 (default: 100) + + Example YAML: + plugins: + my_plugin: + enabled: true + option1: custom_value + option2: 200 + + Example Container Label: + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.option1: custom_value + easyhaproxy.http.plugin.my_plugin.option2: 200 + + HAProxy Config Generated: + # My Plugin - Description + directive1 value + directive2 value + """ +``` + +### 5. Testing + +Test your plugin with various configurations: + +```python +# Test 1: Plugin disabled +config = {"enabled": "false"} +plugin.configure(config) +result = plugin.process(context) +assert result.haproxy_config == "" + +# Test 2: Plugin with default config +plugin = MyPlugin() +result = plugin.process(context) +assert "expected output" in result.haproxy_config + +# Test 3: Plugin with custom config +config = {"my_option": "custom"} +plugin.configure(config) +result = plugin.process(context) +assert "custom" in result.haproxy_config +``` + +### 6. Performance + +Keep plugins lightweight: + +```python +# DON'T: Make external API calls in domain plugins +def process(self, context: PluginContext) -> PluginResult: + # This runs for EVERY domain! + data = requests.get("https://api.example.com/data") # BAD + +# DO: Cache data or use global plugins for external calls +def process(self, context: PluginContext) -> PluginResult: + # Use cached data + data = self.cached_data +``` + +## Testing Plugins + +### Unit Testing + +Create tests in `src/tests/test_my_plugin.py`: + +```python +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginContext +from plugins.my_plugin import MyPlugin + + +def test_plugin_initialization(): + plugin = MyPlugin() + assert plugin.name == "my_plugin" + assert plugin.enabled is True + + +def test_plugin_configuration(): + plugin = MyPlugin() + plugin.configure({"enabled": "false", "my_option": "custom"}) + assert plugin.enabled is False + assert plugin.my_option == "custom" + + +def test_plugin_generates_config(): + plugin = MyPlugin() + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + assert "X-My-Header" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + + +def test_plugin_disabled(): + plugin = MyPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" +``` + +Run tests: +```bash +pytest src/tests/test_my_plugin.py -v +``` + +## Troubleshooting + +### Plugin Not Loading + +**Check logs:** +``` +ERROR: Failed to load plugin from /etc/haproxy/plugins/my_plugin.py: +``` + +**Common causes:** +- Syntax errors in Python code +- Missing imports +- Class doesn't inherit from `PluginInterface` +- `__init__.py` in plugins directory (remove it) + +### Plugin Not Executing + +**Check logs:** +``` +DEBUG: Executing domain plugin: my_plugin for domain: example.com +``` + +**If missing:** +- Plugin not enabled in labels/YAML/env +- Plugin name mismatch +- Plugin returned by `name` property doesn't match + +### Configuration Not Working + +**Enable debug logging:** +```bash +EASYHAPROXY_LOG_LEVEL=DEBUG +``` + +**Check:** +``` +DEBUG: Configured my_plugin: enabled=True, option=value +``` + +**Verify precedence:** +1. Container labels (highest) +2. YAML config +3. Environment variables (lowest) + +## Further Reading + +- [Using Plugins](plugins.md) - How to use existing plugins +- [Built-in Plugin Source Code](https://github.com/byjg/docker-easy-haproxy/tree/master/src/plugins/builtin) - Examples to learn from +- [HAProxy Configuration Manual](http://docs.haproxy.org/2.8/configuration.html) - HAProxy directive reference + ## Support For issues or questions: -- GitHub: https://github.com/byjg/docker-easy-haproxy +- GitHub Issues: https://github.com/byjg/docker-easy-haproxy/issues - Documentation: https://byjg.github.io/docker-easy-haproxy diff --git a/docs/plugins.md b/docs/plugins.md index d1d0ec2..6920ccd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,42 +2,57 @@ sidebar_position: 16 --- -# Plugins +# Using Plugins -EasyHAProxy supports a flexible plugin system that allows you to extend HAProxy configuration with custom functionality. Plugins are automatically invoked during the discovery cycle and can inject HAProxy configuration directives or modify discovery data. +EasyHAProxy supports a plugin system that extends HAProxy configuration with custom functionality. This guide explains how to use and configure plugins. + +## What are Plugins? + +Plugins automatically run during the discovery cycle and can: +- Add HAProxy configuration directives +- Perform maintenance tasks +- Modify discovery data +- Integrate with external services ## Plugin Types ### Global Plugins -Global plugins execute **once per discovery cycle**, regardless of how many domains are discovered. They're ideal for: + +Execute **once per discovery cycle** regardless of how many domains are discovered. + +**Use cases:** - Cleanup tasks - Global monitoring - DNS updates - Log management +**Example:** `cleanup` plugin + ### Domain Plugins -Domain plugins execute **once for each discovered domain/host**. They're ideal for: + +Execute **once for each discovered domain/host**. + +**Use cases:** - Domain-specific configuration -- IP restoration (e.g., Cloudflare) +- IP restoration (Cloudflare) - Path blocking - Custom headers per domain +**Examples:** `cloudflare`, `deny_pages` + ## Built-in Plugins -### Cloudflare (Domain Plugin) +### Cloudflare Plugin (Domain) -Restores the original visitor IP address from Cloudflare's `CF-Connecting-IP` header when requests come through Cloudflare's CDN. +Restores the original visitor IP address when requests come through Cloudflare's CDN. -**Configuration:** -```yaml -# /etc/haproxy/static/config.yaml -plugins: - cloudflare: - enabled: true - ip_list_path: /etc/haproxy/cloudflare_ips.lst -``` +**Why use it:** Cloudflare replaces the visitor's IP with its own. This plugin restores the original IP from the `CF-Connecting-IP` header. -**Container Label:** +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `ip_list_path` - Path to Cloudflare IP list (default: `/etc/haproxy/cloudflare_ips.lst`) + +**Enable via container label:** ```yaml services: myapp: @@ -46,101 +61,117 @@ services: easyhaproxy.http.plugins: cloudflare ``` -**Environment Variable:** -```bash -EASYHAPROXY_PLUGINS_ENABLED=cloudflare -EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst +**Custom IP list path:** +```yaml +labels: + easyhaproxy.http.plugins: cloudflare + easyhaproxy.http.plugin.cloudflare.ip_list_path: /custom/path/cf_ips.lst ``` -### Cleanup (Global Plugin) +**HAProxy config generated:** +``` +# Cloudflare - Restore original visitor IP +acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst +http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare +``` + +**Required:** Download Cloudflare IP list from [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786). + +### Cleanup Plugin (Global) Performs cleanup tasks during each discovery cycle, such as removing old temporary files. -**Configuration:** +**Why use it:** Prevents disk space issues by automatically cleaning up temporary files created by EasyHAProxy. + +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `max_idle_time` - Maximum age in seconds before deleting files (default: `300`) +- `cleanup_temp_files` - Enable temp file cleanup (default: `true`) + +**Enable via YAML:** ```yaml # /etc/haproxy/static/config.yaml plugins: - cleanup: - enabled: true - max_idle_time: 300 # seconds - cleanup_temp_files: true + enabled: [cleanup] + config: + cleanup: + max_idle_time: 600 + cleanup_temp_files: true ``` -**Environment Variable:** +**Enable via environment variable:** ```bash EASYHAPROXY_PLUGINS_ENABLED=cleanup EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 ``` -### Deny Pages (Domain Plugin) +### Deny Pages Plugin (Domain) -Blocks access to specific paths for a domain. +Blocks access to specific paths for a domain, returning a configurable HTTP status code. -**Configuration:** -```yaml -# /etc/haproxy/static/config.yaml -plugins: - deny_pages: - enabled: true - paths: /admin,/private,/internal - status_code: 403 -``` +**Why use it:** Protect admin panels, internal APIs, or debugging endpoints from public access. -**Container Label:** +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `paths` - Comma-separated list of paths to block (e.g., `/admin,/private`) +- `status_code` - HTTP status code to return (default: `403`) + +**Enable via container label:** ```yaml services: - myapp: + webapp: labels: easyhaproxy.http.host: example.com easyhaproxy.http.plugins: deny_pages - easyhaproxy.http.plugin.deny_pages.paths: /admin,/private - easyhaproxy.http.plugin.deny_pages.status_code: 403 + easyhaproxy.http.plugin.deny_pages.paths: /admin,/private,/debug + easyhaproxy.http.plugin.deny_pages.status_code: 404 ``` +**HAProxy config generated:** +``` +# Deny Pages - Block specific paths +acl denied_path path_beg /admin /private /debug +http-request deny deny_status 404 if denied_path +``` + +### IP Whitelist Plugin (Domain) + +Restricts access to a domain to only specific IP addresses or CIDR ranges. + +**Why use it:** Restrict access to internal tools, admin panels, or staging environments to only trusted IP addresses. + +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `allowed_ips` - Comma-separated list of IPs/CIDR ranges to allow (e.g., `192.168.1.0/24,10.0.0.1`) +- `status_code` - HTTP status code to return for blocked IPs (default: `403`) + +**Enable via container label:** +```yaml +services: + admin: + labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 +``` + +**HAProxy config generated:** +``` +# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 +http-request deny deny_status 403 if !whitelisted_ip +``` + +**Important:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! + ## Configuration Methods -Plugins can be configured using three methods. Configuration from YAML takes precedence over environment variables. +Plugins can be configured using three methods, listed in order of precedence (highest to lowest): -### 1. Static YAML Configuration +### 1. Container Labels (Domain Plugins Only) -Configure plugins in `/etc/haproxy/static/config.yaml`: - -```yaml -plugins: - # Global settings - abort_on_error: false # Log and continue on plugin errors (default) - - # Plugin-specific configuration - cloudflare: - enabled: true - ip_list_path: /etc/haproxy/cloudflare_ips.lst - - cleanup: - enabled: true - max_idle_time: 300 - - deny_pages: - enabled: false # Disable globally, can still be enabled per domain -``` - -### 2. Environment Variables - -Configure plugins via environment variables: - -```bash -# Global settings -EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false -EASYHAPROXY_PLUGINS_ENABLED=cloudflare,cleanup - -# Plugin-specific configuration -EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED=true -EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst -EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 -``` - -### 3. Container Labels (Domain Plugins Only) - -Enable domain plugins for specific containers: +Enable and configure domain plugins for specific containers: ```yaml services: @@ -148,194 +179,239 @@ services: image: myapp:latest labels: easyhaproxy.http.host: example.com + easyhaproxy.http.port: 80 + # Enable multiple plugins easyhaproxy.http.plugins: cloudflare,deny_pages + # Configure deny_pages plugin easyhaproxy.http.plugin.deny_pages.paths: /admin,/api/internal + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +**Label format:** +- Enable plugins: `easyhaproxy..plugins: plugin1,plugin2` +- Configure plugin: `easyhaproxy..plugin..: value` + +**Where `` is:** `http`, `https`, `tcp`, etc. + +### 2. Static YAML Configuration + +Configure plugins globally in `/etc/haproxy/static/config.yaml`: + +```yaml +plugins: + # Global settings + abort_on_error: false # Log and continue on errors (recommended) + + # Enable global plugins + enabled: [cleanup] + + # Configure individual plugins + config: + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst + + cleanup: + enabled: true + max_idle_time: 600 + + deny_pages: + enabled: false # Disable globally, enable per-container via labels +``` + +### 3. Environment Variables + +Configure plugins via environment variables: + +```bash +# Global settings +EASYHAPROXY_PLUGINS_ENABLED=cleanup +EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false + +# Plugin-specific configuration +EASYHAPROXY_PLUGIN_CLEANUP_ENABLED=true +EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst +``` + +**Variable format:** +- Enable plugins: `EASYHAPROXY_PLUGINS_ENABLED=plugin1,plugin2` +- Configure plugin: `EASYHAPROXY_PLUGIN__=value` + +## Common Use Cases + +### Restrict Admin Panel to Office IPs + +Protect admin panel by only allowing access from office network: + +```yaml +labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 203.0.113.0/24,198.51.100.42 +``` + +### Protect Admin Paths + +Block access to WordPress admin and other sensitive paths: + +```yaml +labels: + easyhaproxy.http.host: wordpress.example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /wp-admin,/wp-login.php,/.env + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### Cloudflare IP Restoration + +Restore original visitor IPs for applications behind Cloudflare: + +```yaml +labels: + easyhaproxy.http.host: myapp.com + easyhaproxy.http.plugins: cloudflare +``` + +### Multiple Plugins Together + +Combine multiple plugins for one domain: + +```yaml +labels: + easyhaproxy.http.host: secure-app.com + easyhaproxy.http.plugins: cloudflare,deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/config + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +### Automatic Cleanup + +Keep your system clean with automatic temp file removal: + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + enabled: true + max_idle_time: 3600 # 1 hour ``` ## Error Handling -### Log and Continue (Default) +### Log and Continue (Recommended) -By default, plugin errors are logged as warnings and the discovery cycle continues: +By default, plugin errors are logged as warnings and discovery continues: ```yaml plugins: abort_on_error: false # Default ``` -This ensures that a failing plugin doesn't prevent HAProxy configuration updates. +**When to use:** Most situations. Ensures a failing plugin doesn't prevent HAProxy updates. + +**Behavior:** +- Plugin errors logged as warnings +- Discovery cycle continues +- Other plugins still execute +- HAProxy config is generated without the failed plugin ### Abort on Error -For critical plugins, you can stop the discovery cycle on errors: +Stop discovery cycle if any plugin fails: ```yaml plugins: abort_on_error: true ``` -With this setting, any plugin error will halt configuration generation and the previous HAProxy config remains active until the issue is resolved. +**When to use:** Critical plugins where failure should halt deployment. -## Custom Plugins +**Behavior:** +- Plugin error stops discovery +- Previous HAProxy config remains active +- No configuration changes until issue is resolved -You can create custom plugins by placing Python files in `/etc/haproxy/plugins/`. +## Troubleshooting -### Simple Custom Plugin Example +### Enable Debug Logging -Create `/etc/haproxy/plugins/custom_header.py`: - -```python -import os -import sys -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from plugins import PluginInterface, PluginType, PluginContext, PluginResult - - -class CustomHeaderPlugin(PluginInterface): - def __init__(self): - self.enabled = True - self.header_name = "X-Custom-App" - self.header_value = "EasyHAProxy" - - @property - def name(self) -> str: - return "custom_header" - - @property - def plugin_type(self) -> PluginType: - return PluginType.DOMAIN - - def configure(self, config: dict) -> None: - if "enabled" in config: - self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "header_name" in config: - self.header_name = config["header_name"] - if "header_value" in config: - self.header_value = config["header_value"] - - def process(self, context: PluginContext) -> PluginResult: - if not self.enabled: - return PluginResult() - - haproxy_config = f'http-request set-header {self.header_name} "{self.header_value}"' - - return PluginResult( - haproxy_config=haproxy_config, - metadata={"domain": context.domain} - ) -``` - -### Enable Your Custom Plugin - -**YAML:** -```yaml -plugins: - custom_header: - enabled: true - header_name: X-My-Header - header_value: MyValue -``` - -**Container Label:** -```yaml -labels: - easyhaproxy.http.plugins: custom_header - easyhaproxy.http.plugin.custom_header.header_value: CustomValue -``` - -## Plugin Development - -For detailed information on developing plugins, see the [Plugin Developer Guide](plugin-development.md). - -Key points: -- Plugins must inherit from `PluginInterface` -- Implement required methods: `name`, `plugin_type`, `configure`, `process` -- Return `PluginResult` with HAProxy config snippets -- Use `loggerEasyHaproxy` for logging -- Handle errors gracefully - -## Use Cases - -### 1. Cloudflare IP Restoration - -Restore original visitor IPs when using Cloudflare: - -```yaml -plugins: - cloudflare: - enabled: true -``` - -Requires Cloudflare IP list at `/etc/haproxy/cloudflare_ips.lst`. See [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786-Restoring-original-visitor-IPs). - -### 2. Path Protection - -Block sensitive paths from public access: - -```yaml -labels: - easyhaproxy.http.host: myapp.com - easyhaproxy.http.plugins: deny_pages - easyhaproxy.http.plugin.deny_pages.paths: /admin,/config,/debug -``` - -### 3. Automatic Cleanup - -Keep your system clean: - -```yaml -plugins: - cleanup: - enabled: true - max_idle_time: 3600 # 1 hour -``` - -### 4. Custom Authentication - -Create a plugin to add basic auth: - -```python -def process(self, context: PluginContext) -> PluginResult: - return PluginResult( - haproxy_config="""http-request auth realm MyApp unless { http_auth(user_list) }""" - ) -``` - -## Debugging - -Enable debug logging to see plugin execution: +See detailed plugin execution information: ```bash EASYHAPROXY_LOG_LEVEL=DEBUG ``` -Look for log messages: +**Look for:** ``` INFO: Loaded builtin plugin: cloudflare (domain) +INFO: Loaded builtin plugin: cleanup (global) DEBUG: Executing domain plugin: cloudflare for domain: example.com -DEBUG: Plugin cloudflare metadata: {'domain': 'example.com'} +DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/haproxy/cloudflare_ips.lst'} ``` +### Plugin Not Loading + +**Check:** +1. Plugin file exists in `/etc/haproxy/plugins/` or builtin directory +2. Python syntax is valid +3. Plugin class inherits from `PluginInterface` +4. Check logs for load errors + +### Plugin Not Executing + +**For domain plugins:** +1. Check container has label: `easyhaproxy.http.plugins: plugin_name` +2. Verify plugin name is correct (case-sensitive) +3. Enable debug logging + +**For global plugins:** +1. Check YAML config: `plugins.enabled: [plugin_name]` +2. Or env var: `EASYHAPROXY_PLUGINS_ENABLED=plugin_name` +3. Enable debug logging + +### Configuration Not Applied + +**Check precedence order:** +1. Container labels (highest) +2. YAML configuration +3. Environment variables (lowest) + +Container labels override YAML and env vars. + +### Plugin Output Missing + +**Verify:** +1. Plugin is enabled (`enabled: true`) +2. Plugin configuration is correct +3. Plugin's `process()` method returns valid `PluginResult` +4. Check debug logs for plugin execution + ## Best Practices -1. **Test plugins independently** before deploying to production -2. **Use log-and-continue mode** unless a plugin is critical -3. **Keep plugin logic simple** - one plugin should do one thing well -4. **Document configuration options** in plugin docstrings -5. **Handle errors gracefully** - return empty PluginResult on errors -6. **Use sensible defaults** so plugins work out of the box +1. **Start with log-and-continue mode** - Use `abort_on_error: false` until you're confident plugins are stable +2. **Use container labels for domain-specific config** - Easier to manage per-service +3. **Use YAML/env for global config** - Better for global plugins and defaults +4. **Enable debug logging during testing** - Helps identify configuration issues +5. **Test plugin changes in staging first** - Avoid production surprises +6. **Keep plugin configurations simple** - Use defaults when possible ## Limitations -- Plugins are Python-only (no shell scripts) -- Plugins cannot modify the HAProxy template structure -- Domain plugins are executed for each domain, so keep them lightweight -- Plugin errors in abort mode will halt configuration updates +- Plugins must be written in Python +- Domain plugins execute for each domain, so keep them lightweight +- Plugins cannot modify the Jinja2 template structure directly +- Plugin errors in abort mode prevent all configuration updates + +## Creating Custom Plugins + +To create your own plugins, see the [Plugin Developer Guide](plugin-development.md). ## Further Reading -- [Plugin Developer Guide](plugin-development.md) -- [Container Labels](container-labels.md) -- [Environment Variables](environment-variable.md) -- [Static Configuration](static.md) +- [Plugin Developer Guide](plugin-development.md) - Create custom plugins +- [Container Labels](container-labels.md) - Label configuration reference +- [Environment Variables](environment-variable.md) - Environment variable reference +- [Static Configuration](static.md) - YAML configuration reference diff --git a/src/plugins/builtin/ip_whitelist.py b/src/plugins/builtin/ip_whitelist.py new file mode 100644 index 0000000..8a67222 --- /dev/null +++ b/src/plugins/builtin/ip_whitelist.py @@ -0,0 +1,107 @@ +""" +IP Whitelist Plugin for EasyHAProxy + +This plugin restricts access to a domain to only specific IP addresses or CIDR ranges. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - allowed_ips: Comma-separated list of IPs/CIDR ranges to allow + - status_code: HTTP status code to return for blocked IPs (default: 403) + +Example YAML config: + plugins: + ip_whitelist: + enabled: true + allowed_ips: "192.168.1.0/24,10.0.0.1,172.16.0.0/16" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.1" + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 + +HAProxy Config Generated: + # IP Whitelist - Only allow specific IPs + acl whitelisted_ip src 192.168.1.0/24 10.0.0.1 + http-request deny deny_status 403 if !whitelisted_ip +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class IpWhitelistPlugin(PluginInterface): + """Plugin to restrict access to specific IP addresses""" + + def __init__(self): + self.enabled = True + self.allowed_ips = [] + self.status_code = 403 + + @property + def name(self) -> str: + return "ip_whitelist" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - allowed_ips: Comma-separated list of IPs/CIDR ranges + - status_code: HTTP status code to return for denied requests + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.strip()] + + if "status_code" in config: + try: + self.status_code = int(config["status_code"]) + except ValueError: + self.status_code = 403 + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to whitelist specific IPs + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.allowed_ips: + return PluginResult() + + # Create space-separated list of IPs for ACL + ips_str = " ".join(self.allowed_ips) + + # Generate HAProxy config snippet + haproxy_config = f"""# IP Whitelist - Only allow specific IPs + acl whitelisted_ip src {ips_str} + http-request deny deny_status {self.status_code} if !whitelisted_ip""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "allowed_ips": self.allowed_ips, + "status_code": self.status_code + } + ) diff --git a/src/tests/fixtures/services-with-ip-whitelist b/src/tests/fixtures/services-with-ip-whitelist new file mode 100644 index 0000000..0e21abf --- /dev/null +++ b/src/tests/fixtures/services-with-ip-whitelist @@ -0,0 +1,10 @@ +{ + "192.168.1.40": { + "easyhaproxy.http.host": "secure.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "ip_whitelist", + "easyhaproxy.http.plugin.ip_whitelist.allowed_ips": "192.168.1.0/24,10.0.0.5", + "easyhaproxy.http.plugin.ip_whitelist.status_code": "403" + } +} diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index 8defa6e..0d15b7c 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -20,6 +20,7 @@ from plugins import PluginManager, PluginContext from plugins.builtin.cloudflare import CloudflarePlugin from plugins.builtin.cleanup import CleanupPlugin from plugins.builtin.deny_pages import DenyPagesPlugin +from plugins.builtin.ip_whitelist import IpWhitelistPlugin import easymapping @@ -320,6 +321,110 @@ class TestDenyPagesPlugin: assert "http-request deny deny_status 404 if denied_path" in haproxy_config +class TestIpWhitelistPlugin: + """Test cases for IpWhitelistPlugin (DOMAIN plugin)""" + + def test_ip_whitelist_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = IpWhitelistPlugin() + assert plugin.name == "ip_whitelist" + assert plugin.enabled is True + assert plugin.allowed_ips == [] + assert plugin.status_code == 403 + + def test_ip_whitelist_plugin_configuration(self): + """Test plugin configuration""" + plugin = IpWhitelistPlugin() + + # Test allowed IPs + plugin.configure({"allowed_ips": "192.168.1.0/24,10.0.0.1,172.16.0.0/16"}) + assert plugin.allowed_ips == ["192.168.1.0/24", "10.0.0.1", "172.16.0.0/16"] + + # Test status code + plugin.configure({"status_code": "404"}) + assert plugin.status_code == 404 + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_ip_whitelist_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = IpWhitelistPlugin() + plugin.configure({ + "allowed_ips": "192.168.1.0/24,10.0.0.1", + "status_code": "403" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "IP Whitelist" in result.haproxy_config + assert "acl whitelisted_ip src 192.168.1.0/24 10.0.0.1" in result.haproxy_config + assert "http-request deny deny_status 403 if !whitelisted_ip" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["allowed_ips"] == ["192.168.1.0/24", "10.0.0.1"] + assert result.metadata["status_code"] == 403 + + def test_ip_whitelist_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = IpWhitelistPlugin() + plugin.configure({"enabled": "false", "allowed_ips": "192.168.1.0/24"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_ip_whitelist_plugin_no_ips(self): + """Test plugin returns empty config when no IPs configured""" + plugin = IpWhitelistPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_ip_whitelist_plugin_in_haproxy_config(self): + """Test IP Whitelist plugin integration in full HAProxy config generation""" + # Use fixture with ip_whitelist plugin enabled via labels + line_list = load_fixture("services-with-ip-whitelist") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify IP Whitelist config is in the output + assert "IP Whitelist - Only allow specific IPs" in haproxy_config + assert "acl whitelisted_ip src 192.168.1.0/24 10.0.0.5" in haproxy_config + assert "http-request deny deny_status 403 if !whitelisted_ip" in haproxy_config + + class TestPluginManager: """Test cases for PluginManager""" @@ -332,15 +437,17 @@ class TestPluginManager: assert "cloudflare" in manager.plugins assert "cleanup" in manager.plugins assert "deny_pages" in manager.plugins + assert "ip_whitelist" in manager.plugins # Verify plugin types assert len(manager.global_plugins) == 1 # cleanup - assert len(manager.domain_plugins) == 2 # cloudflare, deny_pages + assert len(manager.domain_plugins) == 3 # cloudflare, deny_pages, ip_whitelist # Verify plugin instances assert manager.plugins["cloudflare"].name == "cloudflare" assert manager.plugins["cleanup"].name == "cleanup" assert manager.plugins["deny_pages"].name == "deny_pages" + assert manager.plugins["ip_whitelist"].name == "ip_whitelist" def test_plugin_manager_executes_global_plugins(self): """Test plugin manager executes global plugins correctly""" From ebc4b2bf7ab69b8ed806a6a26eb976f08d085f4a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:48:10 -0500 Subject: [PATCH 05/27] Add JWT Validator Plugin and corresponding tests - Introduced `JwtValidatorPlugin` (DOMAIN): Validates JWT tokens for protected API endpoints using HAProxy's JWT functionality. - Added configuration options: `enabled`, `algorithm`, `issuer`, `audience`, `pubkey_path`, and `pubkey`. - Documented the plugin setup and usage in `plugins.md` and `plugin-development.md`. - Created fixtures for `services-with-jwt-validator`. - Added extensive test cases to validate configuration and HAProxy output generation. - Ensured seamless integration into the plugin framework alongside existing domain plugins. --- docs/plugin-development.md | 66 +++++- docs/plugins.md | 86 ++++++++ src/plugins/builtin/jwt_validator.py | 201 ++++++++++++++++++ .../fixtures/services-with-jwt-validator | 12 ++ src/tests/test_plugins.py | 197 ++++++++++++++++- 5 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 src/plugins/builtin/jwt_validator.py create mode 100644 src/tests/fixtures/services-with-jwt-validator diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 79b0141..4b2ad66 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -47,7 +47,7 @@ This guide explains how to create custom plugins for EasyHAProxy. For informatio ## Built-in Plugins Reference -EasyHAProxy includes four built-in plugins that serve as both functional tools and reference implementations for plugin development. +EasyHAProxy includes five built-in plugins that serve as both functional tools and reference implementations for plugin development. ### CloudflarePlugin (DOMAIN) @@ -151,14 +151,59 @@ http-request deny deny_status 403 if !whitelisted_ip **Usage:** See [IP Whitelist Plugin documentation](plugins.md#ip-whitelist-plugin-domain) +### JwtValidatorPlugin (DOMAIN) + +**Purpose:** Validate JWT authentication tokens + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/jwt_validator.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `algorithm` (str) - JWT signing algorithm (default: `RS256`) +- `issuer` (str) - Expected JWT issuer (optional, `none`/`null` skips validation) +- `audience` (str) - Expected JWT audience (optional, `none`/`null` skips validation) +- `pubkey_path` (str) - Path to public key file (required if `pubkey` not provided) +- `pubkey` (str) - Public key content as string (required if `pubkey_path` not provided) + +**What it does:** +- Validates JWT tokens using HAProxy's JWT functionality +- Checks Authorization header presence +- Validates algorithm, issuer, audience, signature, and expiration +- Optionally skips issuer/audience validation if set to "none" + +**HAProxy config generated:** +``` +# JWT Validator - Validate JWT tokens +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + +**Usage:** See [JWT Validator Plugin documentation](plugins.md#jwt-validator-plugin-domain) + ### Summary Table -| Plugin | Type | Purpose | Config Generated | -|----------------|--------|------------------------------|------------------| -| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | -| `cleanup` | GLOBAL | Clean up temp files | ❌ No | -| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | -| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | +| Plugin | Type | Purpose | Config Generated | +|-----------------|--------|------------------------------|------------------| +| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | +| `cleanup` | GLOBAL | Clean up temp files | ❌ No | +| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | +| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | +| `jwt_validator` | DOMAIN | Validate JWT tokens | ✅ Yes | ### Learning from Built-in Plugins @@ -184,11 +229,18 @@ http-request deny deny_status 403 if !whitelisted_ip - Negated ACLs (`if !whitelisted_ip`) - CIDR range support +5. **JwtValidatorPlugin** shows: + - Complex HAProxy JWT validation + - Optional validation (skip with "none"/"null") + - Dynamic file path generation based on domain + - Multiple configuration options + **View the source code:** - [cloudflare.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cloudflare.py) - [cleanup.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cleanup.py) - [deny_pages.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/deny_pages.py) - [ip_whitelist.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/ip_whitelist.py) +- [jwt_validator.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/jwt_validator.py) ## Plugin API Reference diff --git a/docs/plugins.md b/docs/plugins.md index 6920ccd..24a1682 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -165,6 +165,75 @@ http-request deny deny_status 403 if !whitelisted_ip **Important:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! +### JWT Validator Plugin (Domain) + +Validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JWT functionality. + +**Why use it:** Protect APIs and services with JWT authentication without needing application-level code. + +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `algorithm` - JWT signing algorithm (default: `RS256`) +- `issuer` - Expected JWT issuer (optional, set to `none`/`null` to skip validation) +- `audience` - Expected JWT audience (optional, set to `none`/`null` to skip validation) +- `pubkey_path` - Path to public key file (required if `pubkey` not provided) +- `pubkey` - Public key content as string (required if `pubkey_path` not provided) + +**Enable via container label:** +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + volumes: + - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + +**Skip issuer/audience validation:** +```yaml +labels: + easyhaproxy.http.plugin.jwt_validator.issuer: none + easyhaproxy.http.plugin.jwt_validator.audience: none + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem +``` + +**HAProxy config generated:** +``` +# JWT Validator - Validate JWT tokens +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } +http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + +**What it validates:** +- ✅ Authorization header presence +- ✅ JWT signing algorithm (RS256, RS512, etc.) +- ✅ JWT issuer (if configured) +- ✅ JWT audience (if configured) +- ✅ JWT signature using public key +- ✅ JWT expiration time + +**Important:** Requires HAProxy 2.5+ with JWT support. Mount public key file as read-only volume. + ## Configuration Methods Plugins can be configured using three methods, listed in order of precedence (highest to lowest): @@ -240,6 +309,23 @@ EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst ## Common Use Cases +### Protect API with JWT Authentication + +Secure your API endpoints with JWT token validation: + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth0.myapp.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + ### Restrict Admin Panel to Office IPs Protect admin panel by only allowing access from office network: diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py new file mode 100644 index 0000000..3ca1aa4 --- /dev/null +++ b/src/plugins/builtin/jwt_validator.py @@ -0,0 +1,201 @@ +""" +JWT Validator Plugin for EasyHAProxy + +This plugin validates JWT tokens using HAProxy's built-in JWT functionality. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) + - audience: Expected JWT audience (optional, set to "none"/"null" to skip validation) + - pubkey_path: Path to public key file (required if pubkey not provided) + - pubkey: Public key content as string (required if pubkey_path not provided) + +Example YAML config: + plugins: + jwt_validator: + enabled: true + algorithm: RS256 + issuer: https://myaccount.auth0.com/ + audience: https://api.mywebsite.com + pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem + +Example Container Label: + easyhaproxy.http.plugins: "jwt_validator" + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + +HAProxy Config Generated: + # JWT Validator - Validate JWT tokens + http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + + # Extract JWT header and payload + http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') + http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') + http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') + http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + + # Validate JWT + http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } + http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } + http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } + http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + + # Validate expiration + http-request set-var(txn.now) date() + http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class JwtValidatorPlugin(PluginInterface): + """Plugin to validate JWT tokens""" + + def __init__(self): + self.enabled = True + self.algorithm = "RS256" + self.issuer = None # Optional + self.audience = None # Optional + self.pubkey_path = None # Path to public key file + self.pubkey = None # Public key content (alternative to pubkey_path) + + @property + def name(self) -> str: + return "jwt_validator" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional) + - audience: Expected JWT audience (optional) + - pubkey_path: Path to public key file + - pubkey: Public key content as string + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "algorithm" in config: + self.algorithm = config["algorithm"] + + # Parse issuer (optional - if not set, issuer validation is skipped) + if "issuer" in config: + issuer = str(config["issuer"]).strip() + if issuer: # Only set if not empty + self.issuer = issuer + + # Parse audience (optional - if not set, audience validation is skipped) + if "audience" in config: + audience = str(config["audience"]).strip() + if audience: # Only set if not empty + self.audience = audience + + # Public key configuration + if "pubkey_path" in config: + self.pubkey_path = config["pubkey_path"] + + if "pubkey" in config: + self.pubkey = config["pubkey"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to validate JWT tokens + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled: + return PluginResult() + + # Determine public key file path + if self.pubkey_path: + pubkey_file = self.pubkey_path + elif self.pubkey: + # Generate path for pubkey based on domain + domain_safe = context.domain.replace(".", "_").replace(":", "_") + pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" + else: + loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") + return PluginResult() + + # Build HAProxy configuration + lines = ["# JWT Validator - Validate JWT tokens"] + + # Check for Authorization header + lines.append(" http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }") + + # Extract JWT parts + lines.append("") + lines.append(" # Extract JWT header and payload") + lines.append(" http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')") + lines.append(" http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')") + lines.append(" http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')") + lines.append(" http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')") + + # Validate JWT + lines.append("") + lines.append(" # Validate JWT") + lines.append(f" http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}") + + # Validate issuer (if configured) + if self.issuer: + lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}") + + # Validate audience (if configured) + if self.audience: + lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}") + + # Validate signature + lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}") + + # Validate expiration + lines.append("") + lines.append(" # Validate expiration") + lines.append(" http-request set-var(txn.now) date()") + lines.append(" http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }") + + haproxy_config = "\n".join(lines) + + # Build metadata + metadata = { + "domain": context.domain, + "algorithm": self.algorithm, + "pubkey_file": pubkey_file, + "validates_issuer": self.issuer is not None, + "validates_audience": self.audience is not None + } + + if self.issuer: + metadata["issuer"] = self.issuer + if self.audience: + metadata["audience"] = self.audience + if self.pubkey: + metadata["pubkey_content"] = self.pubkey + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata=metadata + ) diff --git a/src/tests/fixtures/services-with-jwt-validator b/src/tests/fixtures/services-with-jwt-validator new file mode 100644 index 0000000..4b1957e --- /dev/null +++ b/src/tests/fixtures/services-with-jwt-validator @@ -0,0 +1,12 @@ +{ + "192.168.1.50": { + "easyhaproxy.http.host": "api.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "jwt_validator", + "easyhaproxy.http.plugin.jwt_validator.algorithm": "RS256", + "easyhaproxy.http.plugin.jwt_validator.issuer": "https://auth.example.com/", + "easyhaproxy.http.plugin.jwt_validator.audience": "https://api.example.com", + "easyhaproxy.http.plugin.jwt_validator.pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + } +} diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index 0d15b7c..f131bfe 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -21,6 +21,7 @@ from plugins.builtin.cloudflare import CloudflarePlugin from plugins.builtin.cleanup import CleanupPlugin from plugins.builtin.deny_pages import DenyPagesPlugin from plugins.builtin.ip_whitelist import IpWhitelistPlugin +from plugins.builtin.jwt_validator import JwtValidatorPlugin import easymapping @@ -425,6 +426,198 @@ class TestIpWhitelistPlugin: assert "http-request deny deny_status 403 if !whitelisted_ip" in haproxy_config +class TestJwtValidatorPlugin: + """Test cases for JwtValidatorPlugin (DOMAIN plugin)""" + + def test_jwt_validator_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = JwtValidatorPlugin() + assert plugin.name == "jwt_validator" + assert plugin.enabled is True + assert plugin.algorithm == "RS256" + assert plugin.issuer is None + assert plugin.audience is None + assert plugin.pubkey_path is None + assert plugin.pubkey is None + + def test_jwt_validator_plugin_configuration(self): + """Test plugin configuration""" + plugin = JwtValidatorPlugin() + + # Test basic config + plugin.configure({ + "algorithm": "RS512", + "issuer": "https://auth.example.com/", + "audience": "https://api.example.com", + "pubkey_path": "/etc/haproxy/keys/api.pem" + }) + assert plugin.algorithm == "RS512" + assert plugin.issuer == "https://auth.example.com/" + assert plugin.audience == "https://api.example.com" + assert plugin.pubkey_path == "/etc/haproxy/keys/api.pem" + + # Test empty values skip validation (use fresh plugin) + plugin2 = JwtValidatorPlugin() + plugin2.configure({ + "issuer": "", + "audience": "" + }) + assert plugin2.issuer is None + assert plugin2.audience is None + + # Test not providing issuer/audience at all (use fresh plugin) + plugin2b = JwtValidatorPlugin() + plugin2b.configure({ + "algorithm": "RS256", + "pubkey_path": "/etc/haproxy/keys/api.pem" + }) + assert plugin2b.issuer is None + assert plugin2b.audience is None + + # Test enabled (use fresh plugin) + plugin3 = JwtValidatorPlugin() + plugin3.configure({"enabled": "false"}) + assert plugin3.enabled is False + + def test_jwt_validator_plugin_generates_config_with_path(self): + """Test plugin generates correct HAProxy config using pubkey_path""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "algorithm": "RS256", + "issuer": "https://auth.example.com/", + "audience": "https://api.example.com", + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "JWT Validator" in result.haproxy_config + assert "Missing Authorization HTTP header" in result.haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in result.haproxy_config + assert "http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')" in result.haproxy_config + assert "http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')" in result.haproxy_config + assert "http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')" in result.haproxy_config + assert "var(txn.alg) -m str RS256" in result.haproxy_config + assert "var(txn.iss) -m str https://auth.example.com/" in result.haproxy_config + assert "var(txn.aud) -m str https://api.example.com" in result.haproxy_config + assert 'jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem")' in result.haproxy_config + assert "JWT has expired" in result.haproxy_config + assert result.metadata["domain"] == "api.example.com" + assert result.metadata["algorithm"] == "RS256" + assert result.metadata["validates_issuer"] is True + assert result.metadata["validates_audience"] is True + + def test_jwt_validator_plugin_generates_config_with_pubkey_content(self): + """Test plugin generates correct HAProxy config using pubkey content""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "algorithm": "RS256", + "pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "JWT Validator" in result.haproxy_config + assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config + assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + + def test_jwt_validator_plugin_no_issuer_audience_validation(self): + """Test plugin skips issuer/audience validation when not configured""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "Invalid JWT issuer" not in result.haproxy_config + assert "Invalid JWT audience" not in result.haproxy_config + assert result.metadata["validates_issuer"] is False + assert result.metadata["validates_audience"] is False + + def test_jwt_validator_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "enabled": "false", + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_jwt_validator_plugin_no_pubkey(self): + """Test plugin returns empty config when no pubkey configured""" + plugin = JwtValidatorPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_jwt_validator_plugin_in_haproxy_config(self): + """Test JWT Validator plugin integration in full HAProxy config generation""" + line_list = load_fixture("services-with-jwt-validator") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify JWT Validator config is in the output + assert "JWT Validator - Validate JWT tokens" in haproxy_config + assert "Missing Authorization HTTP header" in haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in haproxy_config + assert "jwt_verify" in haproxy_config + + class TestPluginManager: """Test cases for PluginManager""" @@ -438,16 +631,18 @@ class TestPluginManager: assert "cleanup" in manager.plugins assert "deny_pages" in manager.plugins assert "ip_whitelist" in manager.plugins + assert "jwt_validator" in manager.plugins # Verify plugin types assert len(manager.global_plugins) == 1 # cleanup - assert len(manager.domain_plugins) == 3 # cloudflare, deny_pages, ip_whitelist + assert len(manager.domain_plugins) == 4 # cloudflare, deny_pages, ip_whitelist, jwt_validator # Verify plugin instances assert manager.plugins["cloudflare"].name == "cloudflare" assert manager.plugins["cleanup"].name == "cleanup" assert manager.plugins["deny_pages"].name == "deny_pages" assert manager.plugins["ip_whitelist"].name == "ip_whitelist" + assert manager.plugins["jwt_validator"].name == "jwt_validator" def test_plugin_manager_executes_global_plugins(self): """Test plugin manager executes global plugins correctly""" From 75d68337694867a81ee9c33a8fc3109bd646d46e Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:54:07 -0500 Subject: [PATCH 06/27] Enhance plugin documentation and add Kubernetes plugin annotation support - Updated `plugins.md` and `kubernetes.md` with detailed plugin configuration examples for Kubernetes annotations. - Introduced support for `easyhaproxy.plugins` and `easyhaproxy.plugin.{name}.{key}` annotations in Kubernetes. - Added comprehensive examples for JWT validation, IP whitelisting, and deny pages --- docs/kubernetes.md | 135 ++++++++++++++++++++++++++++++++++++++ docs/plugins.md | 60 +++++++++++++++-- src/processor/__init__.py | 17 +++++ 3 files changed, 206 insertions(+), 6 deletions(-) diff --git a/docs/kubernetes.md b/docs/kubernetes.md index c4b8b67..84b5197 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -98,9 +98,144 @@ You don't need to expose any port in your container. | easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | \{"domain":"redirect_url"} | | easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | | easyhaproxy.listen_port | (optional) Override the HTTP listen port created for that ingress | 80 | 8081 | +| easyhaproxy.plugins | (optional) Comma-separated list of plugins to enable for this ingress | *empty* | cloudflare,deny_pages | +| easyhaproxy.plugin.{name}.{key} | (optional) Plugin-specific configuration (see [Using Plugins](plugins.md)) | *varies* | See examples below | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. +## Using Plugins with Kubernetes + +Plugins extend HAProxy configuration with additional functionality like JWT validation, IP whitelisting, or Cloudflare IP restoration. For a complete list of available plugins, see the [Using Plugins](plugins.md) guide. + +### Enabling Plugins for an Ingress + +Add the `easyhaproxy.plugins` annotation with a comma-separated list of plugin names: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "cloudflare,deny_pages" + name: example-ingress + namespace: example +spec: + rules: + - host: example.org + http: + paths: + - backend: + service: + name: example-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +### Configuring Plugin Options + +Use `easyhaproxy.plugin.{plugin_name}.{option}` annotations to configure individual plugins: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/admin,/private,/config" + easyhaproxy.plugin.deny_pages.status_code: "403" + name: secure-app-ingress + namespace: production +spec: + rules: + - host: myapp.example.com + http: + paths: + - backend: + service: + name: myapp-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +### Common Plugin Examples + +**Protect API with JWT validation:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" +``` + +**Note:** For JWT validation, you'll need to mount the public key file into the EasyHAProxy pod. See [Using Plugins](plugins.md#jwt-validator-plugin-domain) for details. + +**Restrict access to specific IPs:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "ip_whitelist" + easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5" + easyhaproxy.plugin.ip_whitelist.status_code: "403" +``` + +**Restore Cloudflare visitor IPs:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "cloudflare" +``` + +**Multiple plugins together:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "cloudflare,deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php" + easyhaproxy.plugin.deny_pages.status_code: "404" +``` + +### Global Plugin Configuration + +Some plugins (like `cleanup`) are global and execute once per discovery cycle. Configure these via environment variables or YAML configuration: + +**Using Helm values.yaml:** + +```yaml +easyhaproxy: + plugins: + enabled: cleanup + config: + cleanup: + max_idle_time: 600 +``` + +**Using environment variables:** + +```yaml +env: + - name: EASYHAPROXY_PLUGINS_ENABLED + value: "cleanup" + - name: EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME + value: "600" +``` + +For more information on plugin types and available plugins, see the [Using Plugins](plugins.md) guide. + ## Certbot / ACME / Letsencrypt It is necessary add the annotation `easyhaproxy.certbot` to the ingress configuration: diff --git a/docs/plugins.md b/docs/plugins.md index 24a1682..db4beb5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -236,11 +236,52 @@ http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn ## Configuration Methods -Plugins can be configured using three methods, listed in order of precedence (highest to lowest): +Plugins can be configured using different methods depending on your deployment environment: -### 1. Container Labels (Domain Plugins Only) +### 1. Kubernetes Annotations (Ingress Resources) -Enable and configure domain plugins for specific containers: +Enable and configure domain plugins for specific Kubernetes ingresses: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable plugins + easyhaproxy.plugins: "jwt_validator,deny_pages" + # Configure jwt_validator plugin + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + # Configure deny_pages plugin + easyhaproxy.plugin.deny_pages.paths: "/admin,/private" + easyhaproxy.plugin.deny_pages.status_code: "403" + name: api-ingress + namespace: production +spec: + rules: + - host: api.example.com + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Annotation format:** +- Enable plugins: `easyhaproxy.plugins: plugin1,plugin2` +- Configure plugin: `easyhaproxy.plugin..: value` + +See the [Kubernetes guide](kubernetes.md#using-plugins-with-kubernetes) for more examples. + +### 2. Container Labels (Docker/Docker Compose) + +Enable and configure domain plugins for specific Docker containers: ```yaml services: @@ -262,7 +303,7 @@ services: **Where `` is:** `http`, `https`, `tcp`, etc. -### 2. Static YAML Configuration +### 3. Static YAML Configuration Configure plugins globally in `/etc/haproxy/static/config.yaml`: @@ -288,7 +329,7 @@ plugins: enabled: false # Disable globally, enable per-container via labels ``` -### 3. Environment Variables +### 4. Environment Variables Configure plugins via environment variables: @@ -461,11 +502,18 @@ DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/e ### Configuration Not Applied **Check precedence order:** + +For Kubernetes deployments: +1. Ingress annotations (highest) +2. YAML configuration +3. Environment variables (lowest) + +For Docker deployments: 1. Container labels (highest) 2. YAML configuration 3. Environment variables (lowest) -Container labels override YAML and env vars. +Per-ingress/per-container settings override global configuration. ### Plugin Output Missing diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 8daf5a2..0695a46 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -251,6 +251,13 @@ class Kubernetes(ProcessorInterface): redirect = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect") mode = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.mode") listen_port = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.listen_port", 80) + plugins = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.plugins") + + # Extract plugin-specific configurations + plugin_annotations = {} + for annotation_key, annotation_value in ingress.metadata.annotations.items(): + if annotation_key.startswith("easyhaproxy.plugin."): + plugin_annotations[annotation_key] = annotation_value data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"), "resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace} @@ -297,6 +304,16 @@ class Kubernetes(ProcessorInterface): rule_data["%s.mode" % definition] = mode rule_data["%s.balance" % definition] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin") + # Add plugin configuration + if plugins is not None: + rule_data["%s.plugins" % definition] = plugins + + # Add plugin-specific configurations + for plugin_key, plugin_value in plugin_annotations.items(): + # Convert easyhaproxy.plugin.X.Y to easyhaproxy.{definition}.plugin.X.Y + plugin_config_key = plugin_key.replace("easyhaproxy.plugin.", "%s.plugin." % definition) + rule_data[plugin_config_key] = plugin_value + service_name = rule.http.paths[0].backend.service.name try: api_response = self.api_instance.read_namespaced_service(service_name, ingress.metadata.namespace) From 06d1d447f84a1504d4b767236f4fc8285dda1c1c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 19:02:39 -0500 Subject: [PATCH 07/27] Add comprehensive Docker, Kubernetes, Swarm, and static configuration examples - Added detailed `README.md` files with step-by-step instructions for Docker Compose, Kubernetes, Swarm, and static configuration examples. - Included use cases for SSL setup, Let's Encrypt integration, load balancing, and advanced features like plugins and path-based routing. - Documented environment variables, service labels, and troubleshooting tips across all examples. - Enhanced examples with clear testing guidelines, SSL certificate generation steps, and debugging workflows. --- examples/docker/README.md | 231 ++++++++++++ examples/kubernetes/README.md | 423 ++++++++++++++++++++++ examples/static/README.md | 462 ++++++++++++++++++++++++ examples/swarm/README.md | 650 ++++++++++++++++++++++++++++++++++ 4 files changed, 1766 insertions(+) create mode 100644 examples/docker/README.md create mode 100644 examples/kubernetes/README.md create mode 100644 examples/static/README.md create mode 100644 examples/swarm/README.md diff --git a/examples/docker/README.md b/examples/docker/README.md new file mode 100644 index 0000000..6a6f927 --- /dev/null +++ b/examples/docker/README.md @@ -0,0 +1,231 @@ +# Docker Compose Examples + +This directory contains various Docker Compose examples demonstrating different EasyHAProxy configurations. + +## Examples Overview + +### 1. Basic Configuration (`docker-compose.yml`) + +**What it demonstrates:** +- Basic SSL setup with two virtual hosts +- SSL redirect (HTTP → HTTPS) +- Custom SSL certificates (embedded and file-based) +- HAProxy stats interface + +**Features:** +- `host1.local`: SSL certificate embedded as base64 in labels +- `host2.local`: SSL certificate loaded from file (`host2.local.pem`) +- Automatic HTTP to HTTPS redirect +- Stats available at port 1936 + +**Usage:** +```bash +docker compose up -d +``` + +**Test:** +```bash +# Test HTTPS +curl -k -H "Host: host1.local" https://127.0.0.1/ +curl -k -H "Host: host2.local" https://127.0.0.1/ + +# Test HTTP redirect +curl -I -H "Host: host1.local" http://127.0.0.1 +# Should return: HTTP/1.1 301 Moved Permanently + +# View SSL certificate +openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local +``` + +**Access stats:** +- URL: http://localhost:1936 +- Username: `admin` +- Password: `password` + +--- + +### 2. ACME/Let's Encrypt (`docker-compose-acme.yml`) + +**What it demonstrates:** +- Automatic SSL certificate generation using Let's Encrypt +- HTTP-01 ACME challenge +- Certificate persistence + +**Requirements:** +- Public IP address pointing to your machine +- Open ports 80 and 443 in firewall +- Valid domain name + +**Configuration:** +```yaml +EASYHAPROXY_CERTBOT_EMAIL: user@example.com # Change this! +easyhaproxy.http.certbot: true # Enable certbot +``` + +**Usage:** +```bash +# Edit docker-compose-acme.yml and set: +# - EASYHAPROXY_CERTBOT_EMAIL to your email +# - easyhaproxy.http.host to your domain + +docker compose -f docker-compose-acme.yml up -d +``` + +**Certificate storage:** +Certificates are persisted in `./certs/certbot/` to avoid re-challenges on restart. + +--- + +### 3. Multiple Containers with Load Balancing (`docker-compose-multi-containers.yml`) + +**What it demonstrates:** +- Multiple containers behind single domain +- Load balancing with round-robin +- Domain redirect functionality + +**Features:** +- 2 replicas of nginx container +- Load balancing across replicas +- Domain redirect: `google.helloworld.com` → `www.google.com` + +**Usage:** +```bash +docker compose -f docker-compose-multi-containers.yml up -d +``` + +**Test:** +```bash +# Test load balancing (hostname changes between containers) +curl -H "Host: www.helloworld.com" localhost:19901 +# Response: f6d8d45b7411 +curl -H "Host: www.helloworld.com" localhost:19901 +# Response: 59b213cb8592 + +# Test redirect +curl -I -H "Host: google.helloworld.com" localhost:19901 +# Should redirect to: www.google.com/ +``` + +--- + +### 4. Changed Label Prefix (`docker-compose-changed-label.yml`) + +**What it demonstrates:** +- Using custom label prefix instead of default `easyhaproxy` +- Useful for running multiple EasyHAProxy instances + +**Configuration:** +```yaml +environment: + EASYHAPROXY_LABEL_PREFIX: myproxy +``` + +**Container labels:** +```yaml +labels: + myproxy.http.host: example.com + myproxy.http.port: 80 +``` + +--- + +### 5. Portainer Integration (`docker-compose-portainer.yml`) + +**What it demonstrates:** +- Running Portainer behind EasyHAProxy +- Real-world application example + +**Access Portainer:** +- URL: http://portainer.local (add to `/etc/hosts` or use real DNS) +- First time: Create admin user + +--- + +### 6. Portainer + App Example (`docker-compose-portainer-app-example.yml`) + +**What it demonstrates:** +- Multiple applications behind EasyHAProxy +- Portainer + custom app setup + +--- + +## Common Configuration Options + +### Environment Variables (HAProxy Container) + +| Variable | Description | Default | +|-----------------------------|---------------------------|----------| +| `EASYHAPROXY_DISCOVER` | Discovery mode | `docker` | +| `EASYHAPROXY_SSL_MODE` | SSL mode (loose/strict) | `strict` | +| `EASYHAPROXY_CERTBOT_EMAIL` | Email for Let's Encrypt | - | +| `HAPROXY_CUSTOMERRORS` | Enable custom error pages | `false` | +| `HAPROXY_USERNAME` | Stats username | - | +| `HAPROXY_PASSWORD` | Stats password | - | +| `HAPROXY_STATS_PORT` | Stats port | `1936` | + +### Container Labels + +| Label | Description | Example | +|---------------------------------|----------------------|---------------| +| `easyhaproxy.http.host` | Virtual host domain | `example.com` | +| `easyhaproxy.http.port` | External port | `80` | +| `easyhaproxy.http.localport` | Container port | `8080` | +| `easyhaproxy.http.redirect_ssl` | Force HTTPS redirect | `true` | +| `easyhaproxy.http.certbot` | Enable Let's Encrypt | `true` | +| `easyhaproxy.https.ssl` | Enable SSL | `true` | +| `easyhaproxy.https.sslcert` | Base64 SSL cert | `LS0t...` | + +For complete documentation, see [Container Labels](../../docs/container-labels.md). + +## Tips + +1. **Local Testing with Fake Domains:** + Add entries to `/etc/hosts`: + ``` + 127.0.0.1 host1.local host2.local portainer.local + ``` + +2. **Viewing Logs:** + ```bash + docker compose logs -f haproxy + ``` + +3. **Reloading Configuration:** + EasyHAProxy automatically detects changes. Watch logs for reload events. + +4. **Generating Test SSL Certificates:** + ```bash + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout host.key -out host.crt \ + -subj "/CN=host1.local" + cat host.crt host.key > host.pem + ``` + +5. **Base64 Encoding SSL Certificate:** + ```bash + base64 -w 0 host.pem + ``` + +## Troubleshooting + +**Issue:** Container not detected +- Check labels are correct (prefix, syntax) +- Verify Docker socket is mounted +- Check logs: `docker compose logs haproxy` + +**Issue:** SSL not working +- Verify certificate format (cert + key in same PEM file) +- Check certificate matches domain +- Verify SSL mode (`loose` vs `strict`) + +**Issue:** Let's Encrypt fails +- Ensure ports 80/443 are publicly accessible +- Verify domain DNS points to your IP +- Check certbot logs in HAProxy container + +## Further Reading + +- [Docker Configuration Guide](../../docs/docker.md) +- [Container Labels Reference](../../docs/container-labels.md) +- [ACME/Let's Encrypt Guide](../../docs/acme.md) +- [Environment Variables](../../docs/environment-variable.md) diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md new file mode 100644 index 0000000..0718de9 --- /dev/null +++ b/examples/kubernetes/README.md @@ -0,0 +1,423 @@ +# Kubernetes Examples + +This directory contains Kubernetes manifest examples demonstrating EasyHAProxy ingress configurations. + +## Prerequisites + +1. **EasyHAProxy installed in your cluster:** + ```bash + kubectl create namespace easyhaproxy + kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml + ``` + +2. **Label the node where EasyHAProxy will run:** + ```bash + kubectl label nodes "easyhaproxy/node=master" + ``` + +See the [Kubernetes Guide](../../docs/kubernetes.md) for complete installation instructions. + +--- + +## Examples Overview + +### 1. Basic Ingress (`service.yml`) + +**What it demonstrates:** +- Basic ingress configuration +- Multiple domains pointing to same service +- Complete deployment + service + ingress setup + +**Components:** +- **Deployment**: `byjg/static-httpserver` container +- **Service**: ClusterIP exposing port 8080 +- **Ingress**: Routes for `example.org` and `www.example.org` + +**Apply:** +```bash +kubectl apply -f service.yml +``` + +**Test:** +```bash +# If using NodePort or port-forward: +curl -H "Host: example.org" http://:31080 + +# Or port-forward for testing: +kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +curl -H "Host: example.org" http://localhost:8080 +``` + +**Manifest breakdown:** +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress # Required! + name: container-example +spec: + rules: + - host: example.org # First domain + http: + paths: + - backend: + service: + name: container-example + port: + number: 8080 + - host: www.example.org # Second domain (same service) + ... +``` + +--- + +### 2. TLS/SSL Ingress (`service_tls.yml`) + +**What it demonstrates:** +- HTTPS/TLS configuration +- Custom SSL certificates via Kubernetes secrets +- SSL redirect (HTTP → HTTPS) +- Certbot/Let's Encrypt integration + +**Components:** +- **Secret**: Custom SSL certificate for `host2.local` +- **Ingress**: TLS configuration + certbot annotation + +**Apply:** +```bash +kubectl apply -f service_tls.yml +``` + +**Features:** + +1. **Custom SSL Certificate:** + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: host2-tls + data: + tls.crt: + tls.key: + type: kubernetes.io/tls + ``` + +2. **Ingress TLS Configuration:** + ```yaml + spec: + tls: + - hosts: + - host2.local + secretName: host2-tls # References the secret above + ``` + +3. **Certbot/Let's Encrypt:** + ```yaml + metadata: + annotations: + easyhaproxy.certbot: 'true' + easyhaproxy.redirect_ssl: 'true' + ``` + +**Test:** +```bash +# Test HTTPS (if host2.local in /etc/hosts) +curl -k https://host2.local + +# Test HTTP redirect +curl -I http://host2.local +# Should return: HTTP/1.1 301 Moved Permanently +``` + +--- + +## Kubernetes Annotations Reference + +All annotations are applied at the **Ingress** level and affect all hosts in that ingress. + +### Required Annotation + +| Annotation | Description | Example | +|-------------------------------|-----------------------|-----------------------| +| `kubernetes.io/ingress.class` | Activates EasyHAProxy | `easyhaproxy-ingress` | + +### Optional Annotations + +| Annotation | Description | Default | Example | +|----------------------------|----------------------|---------|-------------------------| +| `easyhaproxy.redirect_ssl` | Force HTTPS redirect | `false` | `'true'` | +| `easyhaproxy.certbot` | Enable Let's Encrypt | `false` | `'true'` | +| `easyhaproxy.mode` | Protocol mode | `http` | `http` or `tcp` | +| `easyhaproxy.listen_port` | Override listen port | `80` | `8080` | +| `easyhaproxy.plugins` | Enable plugins | - | `cloudflare,deny_pages` | + +See [Kubernetes Guide](../../docs/kubernetes.md#kubernetes-annotations) for complete reference. + +--- + +## Common Use Cases + +### Use Case 1: Simple HTTP Application + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + name: my-app +spec: + rules: + - host: myapp.example.com + http: + paths: + - backend: + service: + name: my-app-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +### Use Case 2: HTTPS with Let's Encrypt + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.certbot: 'true' + easyhaproxy.redirect_ssl: 'true' + name: secure-app +spec: + rules: + - host: secure.example.com + http: + paths: + - backend: + service: + name: secure-app-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Requirements for Let's Encrypt:** +- Cluster must be publicly accessible on ports 80 and 443 +- DNS must point to cluster IP +- Configure certbot email: + ```bash + # Via Helm: + helm upgrade ingress byjg/easyhaproxy \ + --set easyhaproxy.certbot.email=your-email@example.com + + # Or via environment variable in manifest + ``` + +### Use Case 3: Custom SSL Certificate + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: my-tls-secret +type: kubernetes.io/tls +data: + tls.crt: LS0tLS1CRUdJTi... # base64 encoded certificate + tls.key: LS0tLS1CRUdJTi... # base64 encoded private key + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + name: custom-ssl-app +spec: + tls: + - hosts: + - myapp.example.com + secretName: my-tls-secret + rules: + - host: myapp.example.com + http: + paths: + - backend: + service: + name: my-app-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +### Use Case 4: Using Plugins (JWT, IP Whitelist, etc.) + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable plugins + easyhaproxy.plugins: "jwt_validator,deny_pages" + # Configure JWT validator + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + # Configure deny_pages + easyhaproxy.plugin.deny_pages.paths: "/admin,/private" + name: secure-api +spec: + rules: + - host: api.example.com + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +See [Using Plugins with Kubernetes](../../docs/kubernetes.md#using-plugins-with-kubernetes) for more examples. + +--- + +## Creating SSL Secrets + +### From Certificate Files + +```bash +kubectl create secret tls my-tls-secret \ + --cert=path/to/cert.crt \ + --key=path/to/cert.key \ + -n default +``` + +### From PEM File + +```bash +# Extract certificate and key +openssl x509 -in cert.pem -out cert.crt +openssl rsa -in cert.pem -out cert.key + +# Create secret +kubectl create secret tls my-tls-secret \ + --cert=cert.crt \ + --key=cert.key \ + -n default +``` + +### Generate Self-Signed Certificate for Testing + +```bash +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout tls.key -out tls.crt \ + -subj "/CN=myapp.example.com" + +kubectl create secret tls my-tls-secret \ + --cert=tls.crt \ + --key=tls.key +``` + +--- + +## Troubleshooting + +### Ingress Not Detected + +**Check annotation:** +```bash +kubectl get ingress -o yaml | grep annotations -A 5 +``` + +Ensure `kubernetes.io/ingress.class: easyhaproxy-ingress` is present. + +**Check EasyHAProxy logs:** +```bash +kubectl logs -n easyhaproxy deployment/easyhaproxy -f +``` + +### SSL Certificate Not Loading + +**Verify secret exists:** +```bash +kubectl get secret -o yaml +``` + +**Check secret has correct fields:** +- `tls.crt`: base64-encoded certificate +- `tls.key`: base64-encoded private key + +**Check EasyHAProxy logs** for certificate loading errors. + +### Let's Encrypt Fails + +**Requirements:** +- Ports 80 and 443 must be publicly accessible +- DNS must resolve to cluster IP +- Certbot email must be configured + +**Check certbot logs:** +```bash +kubectl logs -n easyhaproxy deployment/easyhaproxy | grep certbot +``` + +### Changes Not Applied + +EasyHAProxy watches ingress changes automatically. If changes aren't applied: + +1. **Check discovery interval:** + ```bash + # Default is 10 seconds, increase if needed + EASYHAPROXY_REFRESH: "30" + ``` + +2. **Force reload:** + ```bash + kubectl rollout restart -n easyhaproxy deployment/easyhaproxy + ``` + +--- + +## Tips + +1. **Local Testing:** + Add entries to `/etc/hosts`: + ``` + example.org www.example.org host2.local + ``` + +2. **View HAProxy Config:** + ```bash + kubectl exec -n easyhaproxy deployment/easyhaproxy -- cat /etc/haproxy/haproxy.cfg + ``` + +3. **Access Stats Interface:** + ```bash + kubectl port-forward -n easyhaproxy deployment/easyhaproxy 1936:1936 + # Open: http://localhost:1936 + ``` + +4. **Debug Mode:** + Enable debug logging: + ```yaml + env: + - name: EASYHAPROXY_LOG_LEVEL + value: DEBUG + ``` + +--- + +## Further Reading + +- [Kubernetes Installation Guide](../../docs/kubernetes.md) +- [Helm Installation](../../docs/helm.md) +- [Using Plugins with Kubernetes](../../docs/kubernetes.md#using-plugins-with-kubernetes) +- [ACME/Let's Encrypt](../../docs/acme.md) +- [Environment Variables](../../docs/environment-variable.md) diff --git a/examples/static/README.md b/examples/static/README.md new file mode 100644 index 0000000..aa90cd7 --- /dev/null +++ b/examples/static/README.md @@ -0,0 +1,462 @@ +# Static Configuration Example + +This directory demonstrates EasyHAProxy using **static configuration** mode instead of dynamic service discovery. + +## What is Static Mode? + +Static mode uses a YAML configuration file (`config.yml`) to define HAProxy routing rules instead of discovering services automatically from Docker/Kubernetes/Swarm labels. + +**Use cases:** +- Non-containerized backends +- Mixed environments (containers + VMs + bare metal) +- Fixed infrastructure where services don't change frequently +- Testing HAProxy configurations + +--- + +## Files in This Example + +- `conf/config.yml` - Static configuration defining hosts and routing +- `docker-compose.yml` - EasyHAProxy container mounting the config file +- `host1.local.pem` - Example SSL certificate + +--- + +## Configuration Structure + +### docker-compose.yml + +```yaml +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./conf:/etc/haproxy/static # Mount static config + - ./host1.local.pem:/certs/haproxy/host1.local.pem + environment: + EASYHAPROXY_DISCOVER: static # Use static mode + EASYHAPROXY_SSL_MODE: "loose" + ports: + - "80:80" + - "443:443" + - "1936:1936" +``` + +### conf/config.yml + +```yaml +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +easymapping: + # HTTP Port 80 - Redirects to HTTPS + - port: 80 + redirect: + host1.local: https://host1.local + www.host1.local: https://host1.local + + # HTTPS Port 443 + - port: 443 + ssl: true + hosts: + host1.local: + containers: + - container:8080 # Backend container +``` + +--- + +## How It Works + +### 1. Port Definitions + +Each item in `easymapping` defines a listening port: + +```yaml +easymapping: + - port: 80 # Listen on port 80 + redirect: {...} # Optional redirects + + - port: 443 # Listen on port 443 + ssl: true # Enable SSL + hosts: {...} # Virtual hosts +``` + +### 2. Redirect Configuration + +Redirect specific domains to different URLs: + +```yaml +- port: 80 + redirect: + host1.local: https://host1.local # HTTP → HTTPS + www.host1.local: https://host1.local # www → non-www + HTTPS + old.domain.com: https://new.domain.com # Domain change +``` + +### 3. Virtual Hosts + +Define hosts and their backend containers: + +```yaml +- port: 443 + ssl: true + hosts: + host1.local: # Virtual host domain + containers: + - container:8080 # Backend: container_name:port + - another_container:3000 # Multiple backends = load balancing + + host2.local: + containers: + - webserver:80 +``` + +**Backend formats:** +- `container_name:port` - Docker container by name +- `ip_address:port` - Direct IP address +- `hostname:port` - Hostname resolution + +### 4. SSL Configuration + +```yaml +- port: 443 + ssl: true # Enable SSL on this port + hosts: + secure.example.com: + containers: + - app:8080 +``` + +SSL certificates must be placed in: +- `/certs/haproxy/.pem` inside container +- `./certs/.pem` on host (if volume mounted) + +Certificate format: PEM file containing both certificate and private key. + +### 5. Stats Interface + +```yaml +stats: + username: admin + password: password + port: 1936 +``` + +Access at: `http://localhost:1936` + +--- + +## Running the Example + +### 1. Start the Example + +```bash +cd examples/static +docker compose up -d +``` + +### 2. Create Backend Container + +The static config references `container:8080`. Create a container with this name: + +```bash +docker run -d --name container \ + -p 8080:8080 \ + byjg/static-httpserver +``` + +Or add to `docker-compose.yml`: + +```yaml +services: + # ... haproxy service ... + + container: + image: byjg/static-httpserver + ports: + - "8080:8080" +``` + +### 3. Test + +```bash +# Add to /etc/hosts: +# 127.0.0.1 host1.local www.host1.local + +# Test HTTP redirect +curl -I http://host1.local +# Should return: HTTP/1.1 301 Moved Permanently +# Location: https://host1.local + +# Test HTTPS +curl -k https://host1.local + +# Access stats +open http://localhost:1936 +# Username: admin +# Password: password +``` + +--- + +## Advanced Configuration + +### Load Balancing Multiple Backends + +```yaml +hosts: + api.example.com: + containers: + - api_server_1:8080 + - api_server_2:8080 + - api_server_3:8080 +``` + +Default algorithm: round-robin + +### Custom Balance Algorithm + +```yaml +hosts: + api.example.com: + balance: leastconn # Use least connections instead of round-robin + containers: + - api_1:8080 + - api_2:8080 +``` + +**Available algorithms:** +- `roundrobin` - Distribute evenly (default) +- `leastconn` - Send to server with fewest connections +- `source` - Same client IP always goes to same server + +### External Backends (Non-Docker) + +```yaml +hosts: + legacy.example.com: + containers: + - 192.168.1.100:8080 # VM + - 192.168.1.101:8080 # Another VM + - database.local:5432 # Database server +``` + +### Health Checks + +```yaml +hosts: + webapp.example.com: + containers: + - server1:8080 + - server2:8080 + healthcheck: + path: /health + interval: 5s +``` + +### Multiple Domains, Same Backend + +```yaml +hosts: + example.com: + containers: + - webapp:8080 + www.example.com: + containers: + - webapp:8080 # Same backend + app.example.com: + containers: + - webapp:8080 # Same backend +``` + +### Path-Based Routing + +While static mode focuses on host-based routing, you can achieve path-based routing using redirects: + +```yaml +- port: 80 + redirect: + api.example.com/v1: https://api-v1.internal:8080 + api.example.com/v2: https://api-v2.internal:8080 +``` + +Or use HAProxy ACLs via custom templates (advanced). + +--- + +## Plugins with Static Configuration + +Enable plugins globally or per-host in static mode: + +### Global Plugin Configuration + +```yaml +plugins: + enabled: [cleanup] + config: + cleanup: + max_idle_time: 600 +``` + +### Per-Host Plugin Configuration (via env vars) + +Since static mode doesn't support per-host plugin configuration directly, use environment variables for domain-specific plugins: + +```yaml +environment: + EASYHAPROXY_PLUGINS_ENABLED: cloudflare,deny_pages + EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH: /etc/haproxy/cloudflare_ips.lst + EASYHAPROXY_PLUGIN_DENY_PAGES_PATHS: /admin,/private +``` + +See [Using Plugins](../../docs/plugins.md) for more details. + +--- + +## Comparison: Static vs. Dynamic Discovery + +| Feature | Static Mode | Docker/Swarm/K8s Mode | +|---------|-------------|----------------------| +| Configuration | YAML file | Container labels / Ingress annotations | +| Backend types | Any (containers, VMs, IPs) | Containers only | +| Updates | Manual config edit + reload | Automatic discovery | +| Use case | Fixed infrastructure | Dynamic container environments | +| Plugin config | Global via YAML/env | Per-container/ingress via labels/annotations | + +--- + +## Tips + +1. **Reloading Configuration:** + ```bash + # EasyHAProxy watches config.yml for changes + # Edit conf/config.yml, changes auto-reload + + # Or manually restart: + docker compose restart haproxy + ``` + +2. **Validate Configuration:** + ```bash + # Check HAProxy config is valid + docker compose exec haproxy haproxy -c -f /etc/haproxy/haproxy.cfg + ``` + +3. **View Generated Config:** + ```bash + docker compose exec haproxy cat /etc/haproxy/haproxy.cfg + ``` + +4. **Debugging:** + ```bash + # Enable debug mode + docker compose up + # Watch logs in real-time + ``` + +5. **SSL Certificate Management:** + ```bash + # Generate self-signed cert + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout host.key -out host.crt \ + -subj "/CN=host1.local" + + # Combine into PEM + cat host.crt host.key > host1.local.pem + ``` + +--- + +## Troubleshooting + +### Backend Unreachable + +**Error:** `503 Service Unavailable` + +**Causes:** +- Backend container not running +- Wrong container name in config +- Wrong port number +- Network connectivity issues + +**Debug:** +```bash +# Check backend container is running +docker ps | grep container_name + +# Test backend directly +curl http://container_name:port + +# Check HAProxy logs +docker compose logs haproxy +``` + +### Configuration Not Reloading + +**Solution:** +```bash +# Restart HAProxy +docker compose restart haproxy + +# Check file is mounted correctly +docker compose exec haproxy cat /etc/haproxy/static/config.yml +``` + +### SSL Certificate Not Found + +**Error:** Certificate errors in logs + +**Solution:** +```bash +# Verify certificate is mounted +docker compose exec haproxy ls -la /certs/haproxy/ + +# Check certificate format (must be PEM with cert + key) +openssl x509 -in host.pem -text -noout +openssl rsa -in host.pem -check +``` + +--- + +## Migration from Dynamic to Static + +If you have Docker labels and want to convert to static config: + +**Docker label:** +```yaml +labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + easyhaproxy.http.redirect_ssl: true +``` + +**Static config equivalent:** +```yaml +easymapping: + - port: 80 + redirect: + api.example.com: https://api.example.com + + - port: 443 + ssl: true + hosts: + api.example.com: + containers: + - container_name:8080 +``` + +--- + +## Further Reading + +- [Static Configuration Guide](../../docs/static.md) +- [Environment Variables](../../docs/environment-variable.md) +- [Using Plugins](../../docs/plugins.md) +- [SSL Configuration](../../docs/ssl.md) diff --git a/examples/swarm/README.md b/examples/swarm/README.md new file mode 100644 index 0000000..e7173b4 --- /dev/null +++ b/examples/swarm/README.md @@ -0,0 +1,650 @@ +# Docker Swarm Examples + +This directory contains Docker Swarm stack examples demonstrating EasyHAProxy in a Swarm cluster environment. + +## What is Docker Swarm Mode? + +Docker Swarm mode enables: +- **Service orchestration** across multiple nodes +- **Service scaling** with replicas +- **Load balancing** across service replicas +- **Rolling updates** with zero downtime +- **Service discovery** via overlay networks + +EasyHAProxy automatically discovers Swarm services and routes traffic based on service labels. + +--- + +## Prerequisites + +### 1. Initialize Docker Swarm + +```bash +# On manager node +docker swarm init + +# On worker nodes (use token from swarm init output) +docker swarm join --token :2377 +``` + +### 2. Create Overlay Network + +```bash +# Create attachable overlay network for EasyHAProxy +docker network create --driver overlay --attachable easyhaproxy +``` + +**Why attachable?** Allows both swarm services and standalone containers to connect. + +--- + +## Files in This Directory + +- `easyhaproxy.yml` - EasyHAProxy service stack +- `services.yml` - Example application services +- `portainer.yml` - Portainer management interface +- `certs/` - Directory for SSL certificates + +--- + +## Quick Start + +### 1. Deploy EasyHAProxy + +```bash +cd examples/swarm + +# Edit easyhaproxy.yml and change: +# EASYHAPROXY_CERTBOT_EMAIL: your-email@example.com + +# Deploy stack +docker stack deploy -c easyhaproxy.yml easyhaproxy +``` + +**What this creates:** +- EasyHAProxy service with 1 replica +- Exposed ports: 80, 443, 1936 +- Mounts Docker socket for service discovery +- Mounts volume for certbot certificates + +### 2. Deploy Example Services + +```bash +docker stack deploy -c services.yml myapp +``` + +### 3. (Optional) Deploy Portainer + +```bash +docker stack deploy -c portainer.yml portainer +``` + +--- + +## Example Files Explained + +### easyhaproxy.yml + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock # Service discovery + - ./certs:/certs/haproxy # SSL certificates + - certs_certbot:/certs/certbot # Let's Encrypt certs + deploy: + replicas: 1 # Single instance + environment: + EASYHAPROXY_DISCOVER: swarm # Swarm mode! + EASYHAPROXY_SSL_MODE: "loose" + EASYHAPROXY_CERTBOT_EMAIL: changeme@example.org # Change this! + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy # Overlay network + +networks: + easyhaproxy: + external: true # Created separately + +volumes: + certs_certbot: # Persistent certbot data +``` + +**Key differences from Docker Compose mode:** +- `EASYHAPROXY_DISCOVER: swarm` - Discovery mode +- `deploy.replicas: 1` - Swarm deployment config +- External overlay network + +--- + +## Service Labels in Swarm + +Service labels are similar to container labels but applied to **services**, not containers. + +### Basic Service Example + +```yaml +version: "3" + +services: + webapp: + image: nginx:alpine + deploy: + replicas: 3 # 3 instances for load balancing + labels: + # Service labels (not container labels!) + easyhaproxy.http.host: "webapp.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "80" + networks: + - easyhaproxy +``` + +**Important:** Use `deploy.labels`, NOT top-level `labels`! + +```yaml +# ✅ CORRECT - Service labels +deploy: + labels: + easyhaproxy.http.host: example.com + +# ❌ WRONG - Container labels (ignored in Swarm) +labels: + easyhaproxy.http.host: example.com +``` + +--- + +## Common Use Cases + +### Use Case 1: Simple HTTP Service + +```yaml +version: "3" + +services: + myapp: + image: my-app:latest + deploy: + replicas: 3 + labels: + easyhaproxy.http.host: "myapp.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "3000" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true +``` + +Deploy: +```bash +docker stack deploy -c myapp.yml myapp +``` + +### Use Case 2: HTTPS with Let's Encrypt + +```yaml +version: "3" + +services: + secure-app: + image: secure-app:latest + deploy: + replicas: 2 + labels: + easyhaproxy.http.host: "secure.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + easyhaproxy.http.certbot: "true" + easyhaproxy.http.redirect_ssl: "true" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true +``` + +**Requirements:** +- Public IP with DNS pointing to swarm +- Ports 80/443 open +- Certbot email configured in `easyhaproxy.yml` + +### Use Case 3: Multiple Domains, One Service + +```yaml +services: + webapp: + image: webapp:latest + deploy: + replicas: 4 + labels: + # Primary domain + easyhaproxy.http.host: "example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Additional domain (www) + easyhaproxy.http2.host: "www.example.com" + easyhaproxy.http2.port: "80" + easyhaproxy.http2.localport: "8080" + + # API subdomain + easyhaproxy.api.host: "api.example.com" + easyhaproxy.api.port: "80" + easyhaproxy.api.localport: "8080" + networks: + - easyhaproxy +``` + +### Use Case 4: Service with Plugins + +```yaml +services: + api: + image: api-server:latest + deploy: + replicas: 3 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + # Enable plugins + easyhaproxy.http.plugins: "jwt_validator,deny_pages" + # Configure JWT validator + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api.pem" + # Configure deny_pages + easyhaproxy.http.plugin.deny_pages.paths: "/admin,/private" + easyhaproxy.http.plugin.deny_pages.status_code: "403" + networks: + - easyhaproxy +``` + +### Use Case 5: Multiple Services with Load Balancing + +```yaml +version: "3" + +services: + frontend: + image: frontend-app:latest + deploy: + replicas: 2 + labels: + easyhaproxy.http.host: "example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "3000" + networks: + - easyhaproxy + + api: + image: api-server:latest + deploy: + replicas: 5 # More replicas for API + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + networks: + - easyhaproxy + + admin: + image: admin-panel:latest + deploy: + replicas: 1 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "4000" + # Restrict access + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true +``` + +--- + +## Scaling Services + +Scale services dynamically: + +```bash +# Scale up +docker service scale myapp_webapp=10 + +# Scale down +docker service scale myapp_webapp=2 + +# Check replicas +docker service ls +``` + +EasyHAProxy automatically detects all replicas and load balances across them. + +--- + +## Rolling Updates + +Update services with zero downtime: + +```bash +# Update service image +docker service update --image webapp:v2 myapp_webapp + +# Update with custom settings +docker service update \ + --image webapp:v2 \ + --update-parallelism 2 \ + --update-delay 10s \ + myapp_webapp +``` + +EasyHAProxy continues routing to healthy containers during rollout. + +--- + +## Management Commands + +### View Stacks + +```bash +docker stack ls +``` + +### View Services in Stack + +```bash +docker stack services myapp +``` + +### View Service Details + +```bash +docker service inspect myapp_webapp +``` + +### View Service Logs + +```bash +docker service logs -f myapp_webapp +``` + +### Update Service Labels + +```bash +docker service update \ + --label-add easyhaproxy.http.certbot=true \ + myapp_webapp +``` + +### Remove Stack + +```bash +docker stack rm myapp +``` + +--- + +## SSL Certificates in Swarm + +### Option 1: Let's Encrypt (Recommended) + +Configure in `easyhaproxy.yml`: +```yaml +environment: + EASYHAPROXY_CERTBOT_EMAIL: your-email@example.com +``` + +Enable per-service: +```yaml +deploy: + labels: + easyhaproxy.http.certbot: "true" +``` + +### Option 2: Custom Certificates + +Mount certificates directory: +```yaml +# easyhaproxy.yml +volumes: + - ./certs:/certs/haproxy +``` + +Place certificate files: +```bash +./certs/ + ├── example.com.pem + ├── api.example.com.pem + └── secure.example.com.pem +``` + +### Option 3: Docker Secrets (Production) + +```bash +# Create secret +docker secret create example_com_cert ./example.com.pem + +# Use in stack +version: "3" +services: + haproxy: + secrets: + - example_com_cert + environment: + EASYHAPROXY_SSL_CERT_example_com: /run/secrets/example_com_cert + +secrets: + example_com_cert: + external: true +``` + +--- + +## Monitoring and Stats + +### HAProxy Stats Interface + +Access at: `http://:1936` +- Username: `admin` (configured in `easyhaproxy.yml`) +- Password: `password` (configured in `easyhaproxy.yml`) + +### Service Health + +```bash +# Check service health +docker service ps myapp_webapp + +# View detailed service info +docker service inspect --pretty myapp_webapp +``` + +--- + +## Troubleshooting + +### Service Not Detected + +**Check service labels:** +```bash +docker service inspect myapp_webapp | grep -A 20 Labels +``` + +Ensure labels are under `deploy.labels`, not top-level `labels`. + +**Check EasyHAProxy logs:** +```bash +docker service logs -f easyhaproxy_haproxy +``` + +### Service Unreachable (503) + +**Causes:** +- Service containers not ready yet +- Wrong network configuration +- Service crashed + +**Debug:** +```bash +# Check service is running +docker service ps myapp_webapp + +# Check network +docker network inspect easyhaproxy + +# Test service directly +docker run --rm --network easyhaproxy alpine \ + wget -O- http://myapp_webapp:8080 +``` + +### Overlay Network Issues + +**Create network if missing:** +```bash +docker network create --driver overlay --attachable easyhaproxy +``` + +**Verify service is on network:** +```bash +docker service inspect myapp_webapp | grep -A 5 Networks +``` + +### EasyHAProxy Not Starting + +**Check Docker socket permissions:** +```bash +docker service logs easyhaproxy_haproxy +``` + +**Verify socket is mounted:** +```bash +docker service inspect easyhaproxy_haproxy | grep -A 5 Mounts +``` + +### Certificate Issues + +**Certbot fails:** +- Ensure swarm is publicly accessible +- Check DNS points to swarm IP +- Verify ports 80/443 are open +- Check certbot logs: `docker service logs easyhaproxy_haproxy | grep certbot` + +**Custom cert not found:** +```bash +# Exec into service container +docker exec -it $(docker ps -q -f name=easyhaproxy) sh +ls -la /certs/haproxy/ +``` + +--- + +## High Availability Setup + +### Multiple Manager Nodes + +```bash +# On additional manager nodes +docker swarm join-token manager +# Use token on new nodes +``` + +### EasyHAProxy Constraints + +Run EasyHAProxy on specific node: + +```yaml +services: + haproxy: + deploy: + placement: + constraints: + - node.role == manager + - node.labels.haproxy == true +``` + +Label node: +```bash +docker node update --label-add haproxy=true +``` + +### Multiple EasyHAProxy Replicas + +**Not recommended** - EasyHAProxy should run as single instance because: +- Multiple instances would compete for port binding +- Use external load balancer (cloud LB, keepalived, etc.) for HA + +**Alternative HA pattern:** +``` +Internet → Cloud Load Balancer → Multiple Swarm Nodes + └→ EasyHAProxy (runs on 1 node) + └→ Services (distributed across nodes) +``` + +--- + +## Best Practices + +1. **Use Overlay Networks:** + - Create dedicated network for EasyHAProxy + - Use `--attachable` for flexibility + +2. **Service Labels:** + - Always use `deploy.labels`, never top-level `labels` + - Use clear, descriptive domain names + +3. **Replicas:** + - Start with 2-3 replicas per service + - Scale based on load monitoring + - Use odd number for consensus (3, 5, 7) + +4. **Updates:** + - Use rolling updates for zero downtime + - Set appropriate `update-delay` + - Test in staging first + +5. **Monitoring:** + - Enable HAProxy stats + - Use Portainer for visual management + - Monitor service health regularly + +6. **Security:** + - Use Docker secrets for sensitive data + - Restrict admin panel access + - Use SSL/TLS for production + - Apply IP whitelisting for admin interfaces + +7. **Persistence:** + - Use volumes for certbot certificates + - Backup certificate volumes + - Store custom certs in version control (encrypted) + +--- + +## Further Reading + +- [Docker Swarm Documentation](../../docs/swarm.md) +- [Container Labels Reference](../../docs/container-labels.md) +- [Using Plugins](../../docs/plugins.md) +- [ACME/Let's Encrypt](../../docs/acme.md) +- [Environment Variables](../../docs/environment-variable.md) +- [Official Docker Swarm Docs](https://docs.docker.com/engine/swarm/) From f75cb8aab2b9a2d1eef82b397a5d7c4b78c13522 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 19:08:48 -0500 Subject: [PATCH 08/27] Add comprehensive plugin usage examples for Docker, Kubernetes, and Swarm - Introduced multiple detailed plugin examples for Cloudflare IP restoration, IP whitelisting, JWT validation, and combined usage. - Added configurations for `docker-compose`, `Kubernetes`, and `Swarm` showcasing individual and multi-plugin use cases. - Included clear prerequisites, setup steps, and testing procedures for each example. - Documented advanced scenarios like path blocking, custom IP lists, and JWT validation for production environments. --- examples/docker/README.md | 211 +++++++++++++ examples/docker/docker-compose-cloudflare.yml | 60 ++++ .../docker/docker-compose-ip-whitelist.yml | 57 ++++ .../docker/docker-compose-jwt-validator.yml | 66 ++++ .../docker-compose-plugins-combined.yml | 106 +++++++ examples/kubernetes/README.md | 269 ++++++++++++++++ examples/kubernetes/cloudflare.yml | 103 +++++++ examples/kubernetes/ip-whitelist.yml | 96 ++++++ examples/kubernetes/jwt-validator.yml | 113 +++++++ examples/kubernetes/plugins-combined.yml | 233 ++++++++++++++ examples/swarm/README.md | 286 ++++++++++++++++++ examples/swarm/cloudflare.yml | 78 +++++ examples/swarm/ip-whitelist.yml | 73 +++++ examples/swarm/jwt-validator.yml | 89 ++++++ examples/swarm/plugins-combined.yml | 137 +++++++++ 15 files changed, 1977 insertions(+) create mode 100644 examples/docker/docker-compose-cloudflare.yml create mode 100644 examples/docker/docker-compose-ip-whitelist.yml create mode 100644 examples/docker/docker-compose-jwt-validator.yml create mode 100644 examples/docker/docker-compose-plugins-combined.yml create mode 100644 examples/kubernetes/cloudflare.yml create mode 100644 examples/kubernetes/ip-whitelist.yml create mode 100644 examples/kubernetes/jwt-validator.yml create mode 100644 examples/kubernetes/plugins-combined.yml create mode 100644 examples/swarm/cloudflare.yml create mode 100644 examples/swarm/ip-whitelist.yml create mode 100644 examples/swarm/jwt-validator.yml create mode 100644 examples/swarm/plugins-combined.yml diff --git a/examples/docker/README.md b/examples/docker/README.md index 6a6f927..ce0640b 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -149,6 +149,217 @@ labels: --- +## Plugin Examples + +### JWT Validator Plugin + +Protect your API with JWT token validation: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + + api: + image: my-api:latest + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + # Enable JWT validation + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem +``` + +**What it validates:** +- Authorization header presence +- JWT signing algorithm +- JWT issuer and audience +- JWT signature using public key +- JWT expiration time + +**Test:** +```bash +# Without token - should fail +curl http://api.example.com/endpoint +# Response: Missing Authorization HTTP header + +# With valid JWT token +curl -H "Authorization: Bearer eyJhbGc..." http://api.example.com/endpoint +# Response: Success +``` + +**Generate test public key:** +```bash +# Generate private key +openssl genrsa -out jwt_private.pem 2048 + +# Extract public key +openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +``` + +--- + +### Cloudflare IP Restoration Plugin + +Restore original visitor IPs when using Cloudflare CDN: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro + environment: + EASYHAPROXY_DISCOVER: docker + ports: + - "80:80/tcp" + - "443:443/tcp" + + webapp: + image: my-webapp:latest + labels: + easyhaproxy.http.host: myapp.com + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 3000 + # Enable Cloudflare plugin + easyhaproxy.http.plugins: cloudflare +``` + +**Setup Cloudflare IP list:** +```bash +# Download Cloudflare IP ranges +curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +``` + +**What it does:** +- Detects requests from Cloudflare IPs +- Restores original visitor IP from `CF-Connecting-IP` header +- Your application logs show real visitor IPs, not Cloudflare IPs + +--- + +### IP Whitelist Plugin + +Restrict access to specific IP addresses: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + EASYHAPROXY_DISCOVER: docker + ports: + - "80:80/tcp" + + admin_panel: + image: admin-panel:latest + labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + # Enable IP whitelist + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 +``` + +**Allowed IP formats:** +- Single IP: `10.0.0.5` +- CIDR range: `192.168.1.0/24` +- Multiple (comma-separated): `192.168.1.0/24,10.0.0.5,172.16.0.100` + +**Test:** +```bash +# From allowed IP +curl http://admin.example.com +# Response: Success + +# From blocked IP +curl http://admin.example.com +# Response: HTTP 403 Forbidden +``` + +--- + +### Multiple Plugins Combined + +Combine multiple plugins for enhanced security: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro + environment: + EASYHAPROXY_DISCOVER: docker + ports: + - "80:80/tcp" + - "443:443/tcp" + + webapp: + image: webapp:latest + labels: + easyhaproxy.http.host: myapp.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + # Enable multiple plugins + easyhaproxy.http.plugins: cloudflare,deny_pages + # Block specific paths + easyhaproxy.http.plugin.deny_pages.paths: /admin,/wp-admin,/wp-login.php,/.env + easyhaproxy.http.plugin.deny_pages.status_code: 404 + + api: + image: api:latest + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 3000 + # Combine JWT + IP whitelist + path blocking + easyhaproxy.http.plugins: jwt_validator,ip_whitelist,deny_pages + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api.pem + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.0.0/16,10.0.0.0/8 + easyhaproxy.http.plugin.deny_pages.paths: /internal,/debug +``` + +**Plugin execution order:** +1. IP Whitelist (blocks non-whitelisted IPs) +2. Deny Pages (blocks specific paths) +3. JWT Validator (validates authentication) + +--- + ## Common Configuration Options ### Environment Variables (HAProxy Container) diff --git a/examples/docker/docker-compose-cloudflare.yml b/examples/docker/docker-compose-cloudflare.yml new file mode 100644 index 0000000..fc2a14a --- /dev/null +++ b/examples/docker/docker-compose-cloudflare.yml @@ -0,0 +1,60 @@ +# Cloudflare IP Restoration Plugin Example +# +# This example demonstrates restoring original visitor IPs when using Cloudflare CDN +# +# Prerequisites: +# 1. Download Cloudflare IP ranges: +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# +# 2. Add to /etc/hosts: +# 127.0.0.1 myapp.local +# +# 3. Start the stack: +# docker compose -f docker-compose-cloudflare.yml up -d +# +# 4. Test (simulating Cloudflare request): +# # Without CF-Connecting-IP header: +# curl -H "Host: myapp.local" http://127.0.0.1/ +# +# # With CF-Connecting-IP header (simulating Cloudflare): +# curl -H "Host: myapp.local" -H "CF-Connecting-IP: 203.0.113.50" http://127.0.0.1/ +# +# Note: This plugin is most useful when your site is actually behind Cloudflare. +# Without Cloudflare, the request won't come from Cloudflare IPs, so the plugin +# won't activate. This example is for demonstration and testing purposes. + +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Mount Cloudflare IP list + - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # Web application behind Cloudflare + webapp: + image: byjg/static-httpserver + environment: + TITLE: "App Behind Cloudflare" + labels: + easyhaproxy.http.host: myapp.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Enable Cloudflare plugin + easyhaproxy.http.plugins: cloudflare + + # Optional: Specify custom IP list path + # easyhaproxy.http.plugin.cloudflare.ip_list_path: /etc/haproxy/cloudflare_ips.lst diff --git a/examples/docker/docker-compose-ip-whitelist.yml b/examples/docker/docker-compose-ip-whitelist.yml new file mode 100644 index 0000000..e4b28b3 --- /dev/null +++ b/examples/docker/docker-compose-ip-whitelist.yml @@ -0,0 +1,57 @@ +# IP Whitelist Plugin Example +# +# This example demonstrates restricting access to specific IP addresses +# +# Prerequisites: +# 1. Add to /etc/hosts: +# 127.0.0.1 admin.local +# +# 2. Start the stack: +# docker compose -f docker-compose-ip-whitelist.yml up -d +# +# 3. Test from localhost (127.0.0.1 is whitelisted): +# curl http://admin.local/ +# # Response: Success (200 OK) +# +# 4. Test from non-whitelisted IP: +# # You'll need to test from another machine or configure the example +# # with your actual IP address in the allowed_ips label +# +# Note: Update the allowed_ips label with your actual IP addresses/networks + +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # Admin panel with IP whitelist + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel - IP Restricted" + labels: + easyhaproxy.http.host: admin.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Enable IP whitelist plugin + easyhaproxy.http.plugins: ip_whitelist + + # Allow localhost and private networks + # UPDATE THIS with your actual IPs/networks! + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 + + # Status code to return for blocked IPs + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml new file mode 100644 index 0000000..2b50bbf --- /dev/null +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -0,0 +1,66 @@ +# JWT Validator Plugin Example +# +# This example demonstrates JWT token validation for API protection +# +# Prerequisites: +# 1. Generate RSA key pair: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# 2. Add to /etc/hosts: +# 127.0.0.1 api.local +# +# 3. Start the stack: +# docker compose -f docker-compose-jwt-validator.yml up -d +# +# 4. Test without token (should fail): +# curl http://api.local/ +# # Response: Missing Authorization HTTP header +# +# 5. Generate test JWT at https://jwt.io with: +# - Algorithm: RS256 +# - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# - Use your jwt_private.pem for signing +# +# 6. Test with token: +# TOKEN="eyJhbGc..." +# curl -H "Authorization: Bearer $TOKEN" http://api.local/ +# # Response: Success + +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Mount the public key for JWT verification + - ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # API service protected by JWT + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API - JWT Required" + labels: + easyhaproxy.http.host: api.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Enable JWT validator plugin + easyhaproxy.http.plugins: jwt_validator + + # JWT validator configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com/ + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem diff --git a/examples/docker/docker-compose-plugins-combined.yml b/examples/docker/docker-compose-plugins-combined.yml new file mode 100644 index 0000000..742b554 --- /dev/null +++ b/examples/docker/docker-compose-plugins-combined.yml @@ -0,0 +1,106 @@ +# Multiple Plugins Combined Example +# +# This example demonstrates using multiple plugins together for enhanced security +# +# Prerequisites: +# 1. Generate JWT keys: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# 2. Download Cloudflare IPs: +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# +# 3. Add to /etc/hosts: +# 127.0.0.1 website.local api.local admin.local +# +# 4. Start the stack: +# docker compose -f docker-compose-plugins-combined.yml up -d +# +# 5. Test each service: +# # Public website (Cloudflare + path blocking) +# curl http://website.local/ +# curl http://website.local/admin # Should be blocked (404) +# +# # Protected API (JWT required) +# curl http://api.local/ # Should fail - no JWT +# curl -H "Authorization: Bearer " http://api.local/ # Success +# +# # Admin panel (IP whitelist only) +# curl http://admin.local/ # Success from localhost + +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro + - ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # Public website with Cloudflare + path blocking + website: + image: byjg/static-httpserver + environment: + TITLE: "Public Website" + labels: + easyhaproxy.http.host: website.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Combine Cloudflare IP restoration + deny pages + easyhaproxy.http.plugins: cloudflare,deny_pages + + # Block admin paths, config files, etc. + easyhaproxy.http.plugin.deny_pages.paths: /admin,/wp-admin,/wp-login.php,/.env,/config + easyhaproxy.http.plugin.deny_pages.status_code: 404 + + # Protected API with JWT validation + path blocking + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API" + labels: + easyhaproxy.http.host: api.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # JWT validation + block internal endpoints + easyhaproxy.http.plugins: jwt_validator,deny_pages + + # JWT configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + + # Block internal/debug endpoints + easyhaproxy.http.plugin.deny_pages.paths: /internal,/debug,/metrics + easyhaproxy.http.plugin.deny_pages.status_code: 403 + + # Admin panel with strict IP restrictions + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel" + labels: + easyhaproxy.http.host: admin.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # IP whitelist only (strictest security) + easyhaproxy.http.plugins: ip_whitelist + + # Only allow local and private networks + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 127.0.0.1,192.168.0.0/16,10.0.0.0/8 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index 0718de9..7bac331 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -288,6 +288,275 @@ See [Using Plugins with Kubernetes](../../docs/kubernetes.md#using-plugins-with- --- +## Plugin Examples + +### JWT Validator Plugin + +Complete example with JWT validation for API protection: + +```yaml +--- +# Create ConfigMap with public key +apiVersion: v1 +kind: ConfigMap +metadata: + name: jwt-keys + namespace: default +data: + api_pubkey.pem: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY----- + +--- +# Mount public key into EasyHAProxy pod +# Add this to your EasyHAProxy deployment: +# volumeMounts: +# - name: jwt-keys +# mountPath: /etc/haproxy/jwt_keys +# volumes: +# - name: jwt-keys +# configMap: +# name: jwt-keys + +--- +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: api + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: my-api:latest + ports: + - containerPort: 8080 + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable JWT validator + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + name: api-ingress + namespace: default +spec: + rules: + - host: api.example.com + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Test:** +```bash +# Without JWT token - should fail +curl http://api.example.com/users +# Response: Missing Authorization HTTP header + +# With valid JWT token +TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +curl -H "Authorization: Bearer $TOKEN" http://api.example.com/users +# Response: Success +``` + +--- + +### Cloudflare IP Restoration Plugin + +Restore original visitor IPs when behind Cloudflare: + +```yaml +--- +# Create ConfigMap with Cloudflare IP ranges +apiVersion: v1 +kind: ConfigMap +metadata: + name: cloudflare-ips + namespace: easyhaproxy +data: + cloudflare_ips.lst: | + 173.245.48.0/20 + 103.21.244.0/22 + 103.22.200.0/22 + 103.31.4.0/22 + 141.101.64.0/18 + 108.162.192.0/18 + 190.93.240.0/20 + 188.114.96.0/20 + 197.234.240.0/22 + 198.41.128.0/17 + 162.158.0.0/15 + 104.16.0.0/13 + 104.24.0.0/14 + 172.64.0.0/13 + 131.0.72.0/22 + +--- +# Mount ConfigMap into EasyHAProxy pod +# Add this to your EasyHAProxy deployment: +# volumeMounts: +# - name: cloudflare-ips +# mountPath: /etc/haproxy/cloudflare_ips.lst +# subPath: cloudflare_ips.lst +# volumes: +# - name: cloudflare-ips +# configMap: +# name: cloudflare-ips + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable Cloudflare plugin + easyhaproxy.plugins: "cloudflare" + name: webapp-ingress + namespace: default +spec: + rules: + - host: myapp.example.com + http: + paths: + - backend: + service: + name: webapp-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Download latest Cloudflare IPs:** +```bash +curl https://www.cloudflare.com/ips-v4 +curl https://www.cloudflare.com/ips-v6 +``` + +--- + +### IP Whitelist Plugin + +Restrict admin panel to office IPs only: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable IP whitelist + easyhaproxy.plugins: "ip_whitelist" + easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.42" + easyhaproxy.plugin.ip_whitelist.status_code: "403" + name: admin-ingress + namespace: default +spec: + rules: + - host: admin.example.com + http: + paths: + - backend: + service: + name: admin-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Test:** +```bash +# From allowed IP (203.0.113.50) +curl http://admin.example.com +# Response: Success + +# From blocked IP +curl http://admin.example.com +# Response: HTTP 403 Forbidden +``` + +--- + +### Multiple Plugins Combined + +Combine Cloudflare + JWT + Path Blocking for maximum security: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable multiple plugins + easyhaproxy.plugins: "cloudflare,jwt_validator,deny_pages" + + # Cloudflare - restore real IPs + # (no config needed if using default path) + + # JWT Validator - validate tokens + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + + # Deny Pages - block sensitive paths + easyhaproxy.plugin.deny_pages.paths: "/internal,/debug,/admin" + easyhaproxy.plugin.deny_pages.status_code: "404" + name: secure-api-ingress + namespace: production +spec: + rules: + - host: api.example.com + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Plugin execution order:** +1. Cloudflare IP restoration (sets correct visitor IP) +2. Deny Pages (blocks blacklisted paths) +3. JWT Validator (validates authentication) + +--- + ## Creating SSL Secrets ### From Certificate Files diff --git a/examples/kubernetes/cloudflare.yml b/examples/kubernetes/cloudflare.yml new file mode 100644 index 0000000..734d228 --- /dev/null +++ b/examples/kubernetes/cloudflare.yml @@ -0,0 +1,103 @@ +# Cloudflare IP Restoration Plugin Example for Kubernetes +# +# This example demonstrates restoring original visitor IPs when using Cloudflare CDN +# +# Prerequisites: +# 1. EasyHAProxy installed in your cluster +# +# 2. Download Cloudflare IP ranges and create ConfigMap: +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# kubectl create configmap cloudflare-ips \ +# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ +# -n easyhaproxy +# +# 3. Mount the ConfigMap in EasyHAProxy deployment (add to volumeMounts and volumes): +# volumeMounts: +# - name: cloudflare-ips +# mountPath: /etc/haproxy/cloudflare_ips.lst +# subPath: cloudflare_ips.lst +# volumes: +# - name: cloudflare-ips +# configMap: +# name: cloudflare-ips +# +# 4. Apply this manifest: +# kubectl apply -f cloudflare.yml +# +# 5. Test: +# curl http://myapp.example.local/ +# +# Note: This plugin is most useful when your site is actually behind Cloudflare. + +--- +apiVersion: v1 +kind: Service +metadata: + name: webapp-service + namespace: default +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + app: webapp + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webapp + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: webapp + template: + metadata: + labels: + app: webapp + spec: + containers: + - name: webapp + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "App Behind Cloudflare" + resources: + limits: + cpu: '0.1' + memory: '64Mi' + requests: + cpu: '0.05' + memory: '32Mi' + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + + # Enable Cloudflare plugin + easyhaproxy.plugins: "cloudflare" + + # Optional: Specify custom IP list path + # easyhaproxy.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" + name: webapp-ingress-cloudflare + namespace: default +spec: + rules: + - host: myapp.example.local + http: + paths: + - backend: + service: + name: webapp-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/ip-whitelist.yml b/examples/kubernetes/ip-whitelist.yml new file mode 100644 index 0000000..4603d43 --- /dev/null +++ b/examples/kubernetes/ip-whitelist.yml @@ -0,0 +1,96 @@ +# IP Whitelist Plugin Example for Kubernetes +# +# This example demonstrates restricting access to specific IP addresses +# +# Prerequisites: +# 1. EasyHAProxy installed in your cluster +# +# 2. Update the allowed_ips annotation with your actual IP addresses/networks +# +# 3. Apply this manifest: +# kubectl apply -f ip-whitelist.yml +# +# 4. Test from allowed IP: +# curl http://admin.example.local/ +# # Response: Success (200 OK) +# +# 5. Test from non-allowed IP: +# # Response: HTTP 403 Forbidden +# +# Note: Update the allowed_ips annotation with your actual office/VPN IPs + +--- +apiVersion: v1 +kind: Service +metadata: + name: admin-service + namespace: default +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + app: admin + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: admin + template: + metadata: + labels: + app: admin + spec: + containers: + - name: admin + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "Admin Panel - IP Restricted" + resources: + limits: + cpu: '0.1' + memory: '64Mi' + requests: + cpu: '0.05' + memory: '32Mi' + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + + # Enable IP whitelist plugin + easyhaproxy.plugins: "ip_whitelist" + + # Allow specific IPs and networks + # UPDATE THIS with your actual office/VPN IPs! + easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.42,10.0.0.0/8" + + # Status code to return for blocked IPs + easyhaproxy.plugin.ip_whitelist.status_code: "403" + name: admin-ingress-whitelist + namespace: default +spec: + rules: + - host: admin.example.local + http: + paths: + - backend: + service: + name: admin-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/jwt-validator.yml b/examples/kubernetes/jwt-validator.yml new file mode 100644 index 0000000..f3d7b90 --- /dev/null +++ b/examples/kubernetes/jwt-validator.yml @@ -0,0 +1,113 @@ +# JWT Validator Plugin Example for Kubernetes +# +# This example demonstrates JWT token validation for API protection in Kubernetes +# +# Prerequisites: +# 1. EasyHAProxy installed in your cluster +# 2. Generate RSA key pair: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# 3. Create ConfigMap with public key: +# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem +# +# 4. Mount the ConfigMap in EasyHAProxy deployment (add to volumeMounts and volumes): +# volumeMounts: +# - name: jwt-keys +# mountPath: /etc/haproxy/jwt_keys +# volumes: +# - name: jwt-keys +# configMap: +# name: jwt-keys +# +# 5. Apply this manifest: +# kubectl apply -f jwt-validator.yml +# +# 6. Test without token (should fail): +# curl http://api.example.local/ +# # Response: Missing Authorization HTTP header +# +# 7. Generate test JWT at https://jwt.io with: +# - Algorithm: RS256 +# - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# - Use your jwt_private.pem for signing +# +# 8. Test with token: +# TOKEN="eyJhbGc..." +# curl -H "Authorization: Bearer $TOKEN" http://api.example.local/ +# # Response: Success + +--- +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: default +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + app: api + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "Protected API - JWT Required" + resources: + limits: + cpu: '0.1' + memory: '64Mi' + requests: + cpu: '0.05' + memory: '32Mi' + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + + # Enable JWT validator plugin + easyhaproxy.plugins: "jwt_validator" + + # JWT validator configuration + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + name: api-ingress-jwt + namespace: default +spec: + rules: + - host: api.example.local + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/plugins-combined.yml b/examples/kubernetes/plugins-combined.yml new file mode 100644 index 0000000..321aeef --- /dev/null +++ b/examples/kubernetes/plugins-combined.yml @@ -0,0 +1,233 @@ +# Multiple Plugins Combined Example for Kubernetes +# +# This example demonstrates using multiple plugins together for enhanced security +# +# Prerequisites: +# 1. EasyHAProxy installed in your cluster +# +# 2. Generate JWT keys and create ConfigMap: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem +# +# 3. Download Cloudflare IPs and create ConfigMap: +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# kubectl create configmap cloudflare-ips \ +# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ +# -n easyhaproxy +# +# 4. Mount ConfigMaps in EasyHAProxy deployment +# +# 5. Apply this manifest: +# kubectl apply -f plugins-combined.yml +# +# This creates three services with different security profiles: +# - Public website: Cloudflare + path blocking +# - Protected API: JWT validation + path blocking +# - Admin panel: Strict IP whitelist + +--- +# Public website service +apiVersion: v1 +kind: Service +metadata: + name: website-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: website + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: website + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: website + template: + metadata: + labels: + app: website + spec: + containers: + - name: website + image: byjg/static-httpserver + env: + - name: TITLE + value: "Public Website" + resources: + requests: + cpu: '0.05' + memory: '32Mi' + +--- +# Public website ingress with Cloudflare + path blocking +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Cloudflare IP restoration + deny pages + easyhaproxy.plugins: "cloudflare,deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/admin,/wp-admin,/wp-login.php,/.env,/config" + easyhaproxy.plugin.deny_pages.status_code: "404" + name: website-ingress + namespace: default +spec: + rules: + - host: website.example.local + http: + paths: + - backend: + service: + name: website-service + port: + number: 8080 + pathType: ImplementationSpecific + +--- +# API service +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: api + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: default +spec: + replicas: 5 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: byjg/static-httpserver + env: + - name: TITLE + value: "Protected API" + resources: + requests: + cpu: '0.05' + memory: '32Mi' + +--- +# API ingress with JWT + path blocking +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # JWT validation + block internal endpoints + easyhaproxy.plugins: "jwt_validator,deny_pages" + # JWT config + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + # Block internal paths + easyhaproxy.plugin.deny_pages.paths: "/internal,/debug,/metrics" + easyhaproxy.plugin.deny_pages.status_code: "403" + name: api-ingress + namespace: default +spec: + rules: + - host: api.example.local + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific + +--- +# Admin service +apiVersion: v1 +kind: Service +metadata: + name: admin-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: admin + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: admin + template: + metadata: + labels: + app: admin + spec: + containers: + - name: admin + image: byjg/static-httpserver + env: + - name: TITLE + value: "Admin Panel" + resources: + requests: + cpu: '0.05' + memory: '32Mi' + +--- +# Admin ingress with strict IP whitelist +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # IP whitelist only (strictest security) + easyhaproxy.plugins: "ip_whitelist" + # UPDATE with your office/VPN IPs! + easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,10.0.0.0/8" + easyhaproxy.plugin.ip_whitelist.status_code: "403" + name: admin-ingress + namespace: default +spec: + rules: + - host: admin.example.local + http: + paths: + - backend: + service: + name: admin-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/swarm/README.md b/examples/swarm/README.md index e7173b4..a4d5568 100644 --- a/examples/swarm/README.md +++ b/examples/swarm/README.md @@ -324,6 +324,292 @@ networks: --- +## Plugin Examples + +### JWT Validator Plugin + +Secure your API with JWT token validation in Swarm: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - jwt_keys:/etc/haproxy/jwt_keys + deploy: + replicas: 1 + environment: + EASYHAPROXY_DISCOVER: swarm + ports: + - "80:80/tcp" + - "443:443/tcp" + networks: + - easyhaproxy + + api: + image: my-api:latest + deploy: + replicas: 5 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + # Enable JWT validation + easyhaproxy.http.plugins: "jwt_validator" + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +volumes: + jwt_keys: +``` + +**Deploy public key using Docker config:** +```bash +# Create Docker config with public key +docker config create jwt_api_pubkey ./api_pubkey.pem + +# Update EasyHAProxy service to use config +docker service update \ + --config-add source=jwt_api_pubkey,target=/etc/haproxy/jwt_keys/api_pubkey.pem \ + easyhaproxy_haproxy +``` + +**Test:** +```bash +# Without token +curl http://api.example.com/users +# Response: Missing Authorization HTTP header + +# With valid token +curl -H "Authorization: Bearer eyJhbGc..." http://api.example.com/users +# Response: Success +``` + +--- + +### Cloudflare IP Restoration Plugin + +Restore original visitor IPs in Swarm environment: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: cloudflare_ips + target: /etc/haproxy/cloudflare_ips.lst + deploy: + replicas: 1 + environment: + EASYHAPROXY_DISCOVER: swarm + ports: + - "80:80/tcp" + - "443:443/tcp" + networks: + - easyhaproxy + + webapp: + image: webapp:latest + deploy: + replicas: 3 + labels: + easyhaproxy.http.host: "myapp.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + # Enable Cloudflare plugin + easyhaproxy.http.plugins: "cloudflare" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + cloudflare_ips: + file: ./cloudflare_ips.lst +``` + +**Create Cloudflare IP list:** +```bash +# Download Cloudflare IPs +curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst + +# Deploy stack +docker stack deploy -c cloudflare-stack.yml myapp +``` + +--- + +### IP Whitelist Plugin + +Restrict admin panel to specific IPs in Swarm: + +```yaml +version: "3" + +services: + admin: + image: admin-panel:latest + deploy: + replicas: 2 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "4000" + # Enable IP whitelist + easyhaproxy.http.plugins: "ip_whitelist" + # Allow office network and VPN + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.0/24,10.8.0.0/16" + easyhaproxy.http.plugin.ip_whitelist.status_code: "403" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true +``` + +**Test:** +```bash +# From office IP (203.0.113.50) +curl http://admin.example.com +# Response: Success + +# From home/blocked IP +curl http://admin.example.com +# Response: HTTP 403 Forbidden +``` + +--- + +### Multiple Plugins Combined + +Production-ready setup with multiple security layers: + +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: cloudflare_ips + target: /etc/haproxy/cloudflare_ips.lst + - source: jwt_pubkey + target: /etc/haproxy/jwt_keys/api_pubkey.pem + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + EASYHAPROXY_SSL_MODE: "loose" + EASYHAPROXY_CERTBOT_EMAIL: admin@example.com + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Public website with Cloudflare + website: + image: website:latest + deploy: + replicas: 4 + labels: + easyhaproxy.http.host: "example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "3000" + easyhaproxy.http.certbot: "true" + easyhaproxy.http.redirect_ssl: "true" + # Cloudflare + block sensitive paths + easyhaproxy.http.plugins: "cloudflare,deny_pages" + easyhaproxy.http.plugin.deny_pages.paths: "/admin,/.env,/config" + easyhaproxy.http.plugin.deny_pages.status_code: "404" + networks: + - easyhaproxy + + # Authenticated API with JWT + api: + image: api:latest + deploy: + replicas: 6 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + easyhaproxy.http.certbot: "true" + # Cloudflare + JWT + block internal endpoints + easyhaproxy.http.plugins: "cloudflare,jwt_validator,deny_pages" + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + easyhaproxy.http.plugin.deny_pages.paths: "/internal,/metrics" + networks: + - easyhaproxy + + # Admin panel with strict IP restrictions + admin: + image: admin:latest + deploy: + replicas: 2 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "4000" + easyhaproxy.http.certbot: "true" + # IP whitelist only (no public access) + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24" + easyhaproxy.http.plugin.ip_whitelist.status_code: "403" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + cloudflare_ips: + file: ./cloudflare_ips.lst + jwt_pubkey: + file: ./api_pubkey.pem +``` + +**Deploy:** +```bash +docker stack deploy -c production-stack.yml production +``` + +**Security layers:** +- **Website**: Cloudflare IP restoration + path blocking +- **API**: Cloudflare + JWT validation + internal path blocking +- **Admin**: Strict IP whitelist (office network only) + +--- + ## Scaling Services Scale services dynamically: diff --git a/examples/swarm/cloudflare.yml b/examples/swarm/cloudflare.yml new file mode 100644 index 0000000..f3c1520 --- /dev/null +++ b/examples/swarm/cloudflare.yml @@ -0,0 +1,78 @@ +# Cloudflare IP Restoration Plugin Example for Docker Swarm +# +# This example demonstrates restoring original visitor IPs when using Cloudflare CDN +# +# Prerequisites: +# 1. Docker Swarm initialized: +# docker swarm init +# +# 2. Create overlay network: +# docker network create --driver overlay --attachable easyhaproxy +# +# 3. Download Cloudflare IPs and create Docker config: +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# docker config create cloudflare_ips cloudflare_ips.lst +# +# 4. Deploy the stack: +# docker stack deploy -c cloudflare.yml webapp +# +# 5. Test: +# curl http:/// +# +# Note: This plugin is most useful when your site is actually behind Cloudflare. + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: cloudflare_ips + target: /etc/haproxy/cloudflare_ips.lst + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Web application behind Cloudflare + webapp: + image: byjg/static-httpserver + environment: + TITLE: "App Behind Cloudflare" + deploy: + replicas: 4 + labels: + easyhaproxy.http.host: "myapp.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Enable Cloudflare plugin + easyhaproxy.http.plugins: "cloudflare" + + # Optional: Specify custom IP list path + # easyhaproxy.http.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + cloudflare_ips: + external: true diff --git a/examples/swarm/ip-whitelist.yml b/examples/swarm/ip-whitelist.yml new file mode 100644 index 0000000..829925b --- /dev/null +++ b/examples/swarm/ip-whitelist.yml @@ -0,0 +1,73 @@ +# IP Whitelist Plugin Example for Docker Swarm +# +# This example demonstrates restricting access to specific IP addresses in Swarm +# +# Prerequisites: +# 1. Docker Swarm initialized: +# docker swarm init +# +# 2. Create overlay network: +# docker network create --driver overlay --attachable easyhaproxy +# +# 3. Update allowed_ips label with your actual IP addresses/networks +# +# 4. Deploy the stack: +# docker stack deploy -c ip-whitelist.yml admin +# +# 5. Test from allowed IP: +# curl http:/// +# # Response: Success (200 OK) +# +# 6. Test from non-allowed IP: +# # Response: HTTP 403 Forbidden + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Admin panel with IP restrictions + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel - IP Restricted" + deploy: + replicas: 3 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Enable IP whitelist plugin + easyhaproxy.http.plugins: "ip_whitelist" + + # Allow specific IPs and networks + # UPDATE THIS with your actual office/VPN IPs! + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.0/24,10.0.0.0/8" + + # Status code to return for blocked IPs + easyhaproxy.http.plugin.ip_whitelist.status_code: "403" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true diff --git a/examples/swarm/jwt-validator.yml b/examples/swarm/jwt-validator.yml new file mode 100644 index 0000000..f225f52 --- /dev/null +++ b/examples/swarm/jwt-validator.yml @@ -0,0 +1,89 @@ +# JWT Validator Plugin Example for Docker Swarm +# +# This example demonstrates JWT token validation for API protection in Swarm +# +# Prerequisites: +# 1. Docker Swarm initialized: +# docker swarm init +# +# 2. Create overlay network: +# docker network create --driver overlay --attachable easyhaproxy +# +# 3. Generate JWT keys and create Docker config: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# docker config create jwt_api_pubkey jwt_pubkey.pem +# +# 4. Deploy the stack: +# docker stack deploy -c jwt-validator.yml api +# +# 5. Test without token (should fail): +# curl http:/// +# # Response: Missing Authorization HTTP header +# +# 6. Generate test JWT at https://jwt.io with: +# - Algorithm: RS256 +# - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# - Use your jwt_private.pem for signing +# +# 7. Test with token: +# TOKEN="eyJhbGc..." +# curl -H "Authorization: Bearer $TOKEN" http:/// +# # Response: Success + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: jwt_api_pubkey + target: /etc/haproxy/jwt_keys/api_pubkey.pem + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Protected API service + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API - JWT Required" + deploy: + replicas: 5 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Enable JWT validator plugin + easyhaproxy.http.plugins: "jwt_validator" + + # JWT validator configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + jwt_api_pubkey: + external: true diff --git a/examples/swarm/plugins-combined.yml b/examples/swarm/plugins-combined.yml new file mode 100644 index 0000000..c9c1f59 --- /dev/null +++ b/examples/swarm/plugins-combined.yml @@ -0,0 +1,137 @@ +# Multiple Plugins Combined Example for Docker Swarm +# +# This example demonstrates using multiple plugins together for enhanced security +# +# Prerequisites: +# 1. Docker Swarm initialized: +# docker swarm init +# +# 2. Create overlay network: +# docker network create --driver overlay --attachable easyhaproxy +# +# 3. Generate JWT keys and create Docker config: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# docker config create jwt_api_pubkey jwt_pubkey.pem +# +# 4. Download Cloudflare IPs and create Docker config: +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# docker config create cloudflare_ips cloudflare_ips.lst +# +# 5. Deploy the stack: +# docker stack deploy -c plugins-combined.yml production +# +# This creates three services with different security profiles: +# - Public website: Cloudflare + path blocking +# - Protected API: JWT validation + path blocking +# - Admin panel: Strict IP whitelist + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: cloudflare_ips + target: /etc/haproxy/cloudflare_ips.lst + - source: jwt_api_pubkey + target: /etc/haproxy/jwt_keys/api_pubkey.pem + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + EASYHAPROXY_SSL_MODE: "loose" + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Public website with Cloudflare + path blocking + website: + image: byjg/static-httpserver + environment: + TITLE: "Public Website" + deploy: + replicas: 4 + labels: + easyhaproxy.http.host: "website.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Cloudflare IP restoration + block sensitive paths + easyhaproxy.http.plugins: "cloudflare,deny_pages" + easyhaproxy.http.plugin.deny_pages.paths: "/admin,/wp-admin,/wp-login.php,/.env,/config" + easyhaproxy.http.plugin.deny_pages.status_code: "404" + networks: + - easyhaproxy + + # Protected API with JWT + path blocking + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API" + deploy: + replicas: 6 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # JWT validation + block internal endpoints + easyhaproxy.http.plugins: "jwt_validator,deny_pages" + + # JWT configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + + # Block internal/debug paths + easyhaproxy.http.plugin.deny_pages.paths: "/internal,/debug,/metrics" + easyhaproxy.http.plugin.deny_pages.status_code: "403" + networks: + - easyhaproxy + + # Admin panel with strict IP whitelist + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel" + deploy: + replicas: 2 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # IP whitelist only (strictest security) + easyhaproxy.http.plugins: "ip_whitelist" + + # Only allow office network + # UPDATE with your actual office/VPN IPs! + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,10.0.0.0/8" + easyhaproxy.http.plugin.ip_whitelist.status_code: "403" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + cloudflare_ips: + external: true + jwt_api_pubkey: + external: true From d3637e737ab0ddaf52093ea2dfa81743e2b293a9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 19:49:32 -0500 Subject: [PATCH 09/27] Refactor plugin configuration examples and improve formatting in documentation - Replaced `config.yml` with modular configuration examples: `config-basic.yml`, `config-certbot.yml`, `config-deny-pages.yml`, and `config-jwt-validator.yml`. - Improved examples with detailed usage instructions, prerequisites, and testing steps for each configuration. - Fixed indentation and formatting inconsistencies across plugin files and HAProxy configuration generation. - Streamlined README comparison table for static vs. dynamic discovery. - Updated `docker-compose-jwt-validator.yml` to correct audience key formatting. --- .../docker/docker-compose-jwt-validator.yml | 2 +- examples/static/README.md | 14 +-- examples/static/conf/config-basic.yml | 31 +++++++ examples/static/conf/config-certbot.yml | 88 +++++++++++++++++++ examples/static/conf/config-deny-pages.yml | 78 ++++++++++++++++ examples/static/conf/config-jwt-validator.yml | 86 ++++++++++++++++++ examples/static/conf/config.yml | 19 ---- src/plugins/builtin/cloudflare.py | 4 +- src/plugins/builtin/deny_pages.py | 4 +- src/plugins/builtin/ip_whitelist.py | 4 +- src/plugins/builtin/jwt_validator.py | 28 +++--- 11 files changed, 311 insertions(+), 47 deletions(-) create mode 100644 examples/static/conf/config-basic.yml create mode 100644 examples/static/conf/config-certbot.yml create mode 100644 examples/static/conf/config-deny-pages.yml create mode 100644 examples/static/conf/config-jwt-validator.yml delete mode 100644 examples/static/conf/config.yml diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml index 2b50bbf..1a304a9 100644 --- a/examples/docker/docker-compose-jwt-validator.yml +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -62,5 +62,5 @@ services: # JWT validator configuration easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ - easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem diff --git a/examples/static/README.md b/examples/static/README.md index aa90cd7..b22d168 100644 --- a/examples/static/README.md +++ b/examples/static/README.md @@ -321,13 +321,13 @@ See [Using Plugins](../../docs/plugins.md) for more details. ## Comparison: Static vs. Dynamic Discovery -| Feature | Static Mode | Docker/Swarm/K8s Mode | -|---------|-------------|----------------------| -| Configuration | YAML file | Container labels / Ingress annotations | -| Backend types | Any (containers, VMs, IPs) | Containers only | -| Updates | Manual config edit + reload | Automatic discovery | -| Use case | Fixed infrastructure | Dynamic container environments | -| Plugin config | Global via YAML/env | Per-container/ingress via labels/annotations | +| Feature | Static Mode | Docker/Swarm/K8s Mode | +|---------------|-----------------------------|----------------------------------------------| +| Configuration | YAML file | Container labels / Ingress annotations | +| Backend types | Any (containers, VMs, IPs) | Containers only | +| Updates | Manual config edit + reload | Automatic discovery | +| Use case | Fixed infrastructure | Dynamic container environments | +| Plugin config | Global via YAML/env | Per-container/ingress via labels/annotations | --- diff --git a/examples/static/conf/config-basic.yml b/examples/static/conf/config-basic.yml new file mode 100644 index 0000000..ea8d4e1 --- /dev/null +++ b/examples/static/conf/config-basic.yml @@ -0,0 +1,31 @@ +# Basic Static Configuration Example +# +# This is a minimal configuration without plugins +# Demonstrates basic HTTP to HTTPS redirect and SSL setup +# +# To use: +# 1. Update the container name and ports to match your setup +# 2. Place SSL certificate at /certs/haproxy/host1.local.pem +# 3. Mount this config: -v ./conf/config-basic.yml:/etc/haproxy/static/config.yml + +stats: + username: admin + password: password + port: 1936 # Optional (default 1936) + +customerrors: true # Optional (default false) + +easymapping: + # HTTP - Redirect to HTTPS + - port: 80 + redirect: + host1.local: https://host1.local + www.host1.local: https://host1.local + + # HTTPS - Serve application + - port: 443 + ssl: true + hosts: + host1.local: + containers: + - container:8080 diff --git a/examples/static/conf/config-certbot.yml b/examples/static/conf/config-certbot.yml new file mode 100644 index 0000000..b61c389 --- /dev/null +++ b/examples/static/conf/config-certbot.yml @@ -0,0 +1,88 @@ +# Certbot/Let's Encrypt Configuration Example +# +# Demonstrates: +# - Automatic SSL certificate generation with Let's Encrypt +# - HTTP to HTTPS redirect +# - Certificate renewal +# +# Prerequisites: +# 1. Public IP address with ports 80 and 443 accessible +# 2. DNS records pointing to your server: +# example.com -> your-server-ip +# www.example.com -> your-server-ip +# +# 3. Set environment variable: +# EASYHAPROXY_CERTBOT_EMAIL=your-email@example.com +# +# 4. Mount this config: +# -v ./conf/config-certbot.yml:/etc/haproxy/static/config.yml +# +# 5. Persist certificates: +# -v ./certs/certbot:/certs/certbot +# +# How it works: +# - EasyHAProxy requests certificates from Let's Encrypt via HTTP-01 challenge +# - Certificates are stored in /certs/certbot/ +# - Certificates auto-renew when needed +# +# Note: Let's Encrypt has rate limits. Use staging environment for testing: +# EASYHAPROXY_CERTBOT_AUTOCONFIG=staging + +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +easymapping: + # HTTP Port 80 + # 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 + app.example.com: + containers: + - app:3000 + certbot: true + redirect_ssl: true + + # Domain without certbot (uses custom certificate) + custom.example.com: + containers: + - custom-app:8080 + # No certbot - expects certificate at /certs/haproxy/custom.example.com.pem + + # HTTPS Port 443 + # Serves HTTPS traffic with auto-generated certificates + - port: 443 + ssl: true + hosts: + example.com: + containers: + - webapp:8080 + # Certificate path (auto-generated by certbot) + # /certs/certbot/example.com/fullchain.pem + + app.example.com: + containers: + - app:3000 + + # Custom certificate example + custom.example.com: + containers: + - custom-app:8080 + # Place your certificate at: + # /certs/haproxy/custom.example.com.pem + +# Multiple domains with different backends +# Certbot will request separate certificates for each domain diff --git a/examples/static/conf/config-deny-pages.yml b/examples/static/conf/config-deny-pages.yml new file mode 100644 index 0000000..2eb7d1f --- /dev/null +++ b/examples/static/conf/config-deny-pages.yml @@ -0,0 +1,78 @@ +# Deny Pages Plugin Configuration Example +# +# Demonstrates: +# - Global plugin configuration (applies to all domains) +# - Per-domain plugin override (custom settings per host) +# +# To use: +# 1. Update container names and ports +# 2. Mount this config: -v ./conf/config-deny-pages.yml:/etc/haproxy/static/config.yml +# 3. Test blocked paths: +# curl http://host1.local/admin # Should return 404 +# curl http://host2.local/wp-admin # Should return 403 (different config) + +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +# Global plugin configuration +# This applies to ALL domains unless overridden +plugins: + enabled: + - deny_pages + + config: + deny_pages: + # Global default: block common admin paths with 404 + paths: + - /admin + - /.env + - /config + status_code: 404 # Hide existence of these paths + +easymapping: + - port: 80 + hosts: + # Domain 1: Uses global deny_pages configuration + host1.local: + containers: + - webapp1:8080 + # No plugins specified = uses global configuration + + # Domain 2: WordPress site with custom blocked paths + host2.local: + containers: + - wordpress:80 + # Override global plugin configuration for this domain + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: + - /wp-admin + - /wp-login.php + - /xmlrpc.php + - /wp-config.php + status_code: 403 # Return forbidden instead of 404 + + # Domain 3: Public site with stricter blocking + host3.local: + containers: + - publicsite:3000 + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: + - /admin + - /administrator + - /manager + - /phpmyadmin + - /.git + - /.env + - /config + - /backup + status_code: 404 diff --git a/examples/static/conf/config-jwt-validator.yml b/examples/static/conf/config-jwt-validator.yml new file mode 100644 index 0000000..76319c0 --- /dev/null +++ b/examples/static/conf/config-jwt-validator.yml @@ -0,0 +1,86 @@ +# JWT Validator Plugin Configuration Example +# +# Demonstrates: +# - JWT token validation for API protection +# - Different JWT configurations per domain +# - Optional issuer/audience validation +# +# Prerequisites: +# 1. Generate RSA key pair: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# 2. Mount public keys: +# -v ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# -v ./jwt_pubkey2.pem:/etc/haproxy/jwt_keys/admin_pubkey.pem:ro +# +# 3. Mount this config: +# -v ./conf/config-jwt-validator.yml:/etc/haproxy/static/config.yml +# +# 4. Test: +# # Without token - should fail +# curl http://api.local/users +# # Response: Missing Authorization HTTP header +# +# # With valid token - should succeed +# curl -H "Authorization: Bearer eyJhbGc..." http://api.local/users + +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +easymapping: + - port: 80 + hosts: + # Public API with full JWT validation + api.local: + containers: + - api-server:8080 + plugins: + - jwt_validator + plugin_config: + jwt_validator: + 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.local: + containers: + - internal-api:3000 + plugins: + - jwt_validator + plugin_config: + jwt_validator: + algorithm: RS256 + # No issuer/audience = skip those validations + pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + + # Admin API - different issuer and key + admin-api.local: + containers: + - admin-api:4000 + plugins: + - jwt_validator + - deny_pages # Also block internal paths + plugin_config: + jwt_validator: + algorithm: RS256 + issuer: https://admin-auth.example.com/ + audience: https://admin.example.com + pubkey_path: /etc/haproxy/jwt_keys/admin_pubkey.pem + deny_pages: + paths: + - /internal + - /debug + status_code: 403 + + # Public website - no JWT required + website.local: + containers: + - website:8080 + # No plugins = public access diff --git a/examples/static/conf/config.yml b/examples/static/conf/config.yml deleted file mode 100644 index a3e2d18..0000000 --- a/examples/static/conf/config.yml +++ /dev/null @@ -1,19 +0,0 @@ -stats: - username: admin - password: password - port: 1936 # Optional (default 1936) - -customerrors: true # Optional (default false) - -easymapping: - - port: 80 - redirect: - host1.local: https://host1.local - www.host1.local: https://host1.local - - - port: 443 - ssl: true - hosts: - host1.local: - containers: - - container:8080 diff --git a/src/plugins/builtin/cloudflare.py b/src/plugins/builtin/cloudflare.py index 3a51a52..7433dad 100644 --- a/src/plugins/builtin/cloudflare.py +++ b/src/plugins/builtin/cloudflare.py @@ -76,8 +76,8 @@ class CloudflarePlugin(PluginInterface): # Generate HAProxy config snippet haproxy_config = f"""# Cloudflare - Restore original visitor IP - acl from_cloudflare src -f {self.ip_list_path} - http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" +acl from_cloudflare src -f {self.ip_list_path} +http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" return PluginResult( haproxy_config=haproxy_config, diff --git a/src/plugins/builtin/deny_pages.py b/src/plugins/builtin/deny_pages.py index f1d3870..5335751 100644 --- a/src/plugins/builtin/deny_pages.py +++ b/src/plugins/builtin/deny_pages.py @@ -92,8 +92,8 @@ class DenyPagesPlugin(PluginInterface): # Generate HAProxy config snippet haproxy_config = f"""# Deny Pages - Block specific paths - acl denied_path path_beg {paths_str} - http-request deny deny_status {self.status_code} if denied_path""" +acl denied_path path_beg {paths_str} +http-request deny deny_status {self.status_code} if denied_path""" return PluginResult( haproxy_config=haproxy_config, diff --git a/src/plugins/builtin/ip_whitelist.py b/src/plugins/builtin/ip_whitelist.py index 8a67222..b54265c 100644 --- a/src/plugins/builtin/ip_whitelist.py +++ b/src/plugins/builtin/ip_whitelist.py @@ -93,8 +93,8 @@ class IpWhitelistPlugin(PluginInterface): # Generate HAProxy config snippet haproxy_config = f"""# IP Whitelist - Only allow specific IPs - acl whitelisted_ip src {ips_str} - http-request deny deny_status {self.status_code} if !whitelisted_ip""" +acl whitelisted_ip src {ips_str} +http-request deny deny_status {self.status_code} if !whitelisted_ip""" return PluginResult( haproxy_config=haproxy_config, diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py index 3ca1aa4..8fa5a09 100644 --- a/src/plugins/builtin/jwt_validator.py +++ b/src/plugins/builtin/jwt_validator.py @@ -144,37 +144,37 @@ class JwtValidatorPlugin(PluginInterface): lines = ["# JWT Validator - Validate JWT tokens"] # Check for Authorization header - lines.append(" http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }") + lines.append("http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }") # Extract JWT parts lines.append("") - lines.append(" # Extract JWT header and payload") - lines.append(" http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')") - lines.append(" http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')") - lines.append(" http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')") - lines.append(" http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')") + lines.append("# Extract JWT header and payload") + lines.append("http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')") + lines.append("http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')") + lines.append("http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')") + lines.append("http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')") # Validate JWT lines.append("") - lines.append(" # Validate JWT") - lines.append(f" http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}") + lines.append("# Validate JWT") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}") # Validate issuer (if configured) if self.issuer: - lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}") # Validate audience (if configured) if self.audience: - lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}") # Validate signature - lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}") # Validate expiration lines.append("") - lines.append(" # Validate expiration") - lines.append(" http-request set-var(txn.now) date()") - lines.append(" http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }") + lines.append("# Validate expiration") + lines.append("http-request set-var(txn.now) date()") + lines.append("http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }") haproxy_config = "\n".join(lines) From 90e0df42a1a0759ff070ade34ccd64c1355d0451 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 20:36:41 -0500 Subject: [PATCH 10/27] Fix blank line addition in docker.txt configuration --- src/tests/expected/docker.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tests/expected/docker.txt b/src/tests/expected/docker.txt index b522852..536d339 100644 --- a/src/tests/expected/docker.txt +++ b/src/tests/expected/docker.txt @@ -22,6 +22,7 @@ defaults timeout client 10s timeout server 10m + frontend stats bind *:1936 mode http From 51f8cd46595fd8f0a38b2eeca75c46269c04dcac Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 28 Nov 2025 10:38:09 -0500 Subject: [PATCH 11/27] Add path-based JWT validation support in JwtValidatorPlugin - Introduced `paths` and `only_paths` configuration options to define protected API paths. - Enhanced HAProxy configuration generation to handle path-specific JWT validation. - Updated plugin metadata to include path details and validation logic. - Modified documentation with detailed examples for protecting paths. - Added comprehensive tests for path-based validation scenarios and edge cases. --- docs/plugins.md | 122 +++++++++++++++++++++++++-- src/plugins/builtin/jwt_validator.py | 88 +++++++++++++++---- src/tests/test_plugins.py | 122 ++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 22 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index db4beb5..6db84fc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -177,9 +177,16 @@ Validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JW - `issuer` - Expected JWT issuer (optional, set to `none`/`null` to skip validation) - `audience` - Expected JWT audience (optional, set to `none`/`null` to skip validation) - `pubkey_path` - Path to public key file (required if `pubkey` not provided) -- `pubkey` - Public key content as string (required if `pubkey_path` not provided) +- `pubkey` - Public key content as base64-encoded string (required if `pubkey_path` not provided) +- `paths` - List of paths that require JWT validation (optional, if not set ALL domain is protected) +- `only_paths` - If `true`, only specified paths are accessible; if `false` (default), only specified paths require JWT validation -**Enable via container label:** +**Path Validation Logic:** +- **No paths configured:** ALL requests to the domain require JWT validation (default behavior) +- **Paths configured + `only_paths=false`:** Only specified paths require JWT validation, other paths pass through without validation +- **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied + +**Enable via container label (protect all paths):** ```yaml services: api: @@ -194,6 +201,24 @@ services: - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro ``` +**Protect specific paths only (others can pass without JWT):** +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: false +``` + +**Only allow specific paths (deny all others):** +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1 + easyhaproxy.http.plugin.jwt_validator.only_paths: true +``` + **Skip issuer/audience validation:** ```yaml labels: @@ -202,7 +227,7 @@ labels: easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem ``` -**HAProxy config generated:** +**HAProxy config generated (all paths protected):** ``` # JWT Validator - Validate JWT tokens http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } @@ -224,6 +249,59 @@ http-request set-var(txn.now) date() http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } ``` +**HAProxy config generated (specific paths, only_paths=false):** +``` +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/admin +acl jwt_protected_path path_beg /api/sensitive + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } if jwt_protected_path + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if jwt_protected_path +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if jwt_protected_path +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if jwt_protected_path + +# Validate JWT (only on protected paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path + +# Validate expiration +http-request set-var(txn.now) date() if jwt_protected_path +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if jwt_protected_path +``` + +**HAProxy config generated (specific paths, only_paths=true):** +``` +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/public +acl jwt_protected_path path_beg /api/v1 + +# Deny access to paths not in the protected list +http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT (all requests at this point are on allowed paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + **What it validates:** - ✅ Authorization header presence - ✅ JWT signing algorithm (RS256, RS512, etc.) @@ -250,11 +328,13 @@ metadata: kubernetes.io/ingress.class: easyhaproxy-ingress # Enable plugins easyhaproxy.plugins: "jwt_validator,deny_pages" - # Configure jwt_validator plugin + # Configure jwt_validator plugin (protect specific paths only) easyhaproxy.plugin.jwt_validator.algorithm: "RS256" easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" + easyhaproxy.plugin.jwt_validator.only_paths: "false" # Configure deny_pages plugin easyhaproxy.plugin.deny_pages.paths: "/admin,/private" easyhaproxy.plugin.deny_pages.status_code: "403" @@ -352,7 +432,7 @@ EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst ### Protect API with JWT Authentication -Secure your API endpoints with JWT token validation: +**Secure entire API domain:** ```yaml services: @@ -367,6 +447,38 @@ services: - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro ``` +**Protect only admin/sensitive endpoints:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/users,/api/billing + easyhaproxy.http.plugin.jwt_validator.only_paths: false + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# /api/health, /api/docs, etc. remain publicly accessible +``` + +**Restrict API to only allow specific endpoints:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/v1,/api/v2 + easyhaproxy.http.plugin.jwt_validator.only_paths: true + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# All paths except /api/v1 and /api/v2 are denied +``` + ### Restrict Admin Panel to Office IPs Protect admin panel by only allowing access from office network: diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py index 8fa5a09..93df35d 100644 --- a/src/plugins/builtin/jwt_validator.py +++ b/src/plugins/builtin/jwt_validator.py @@ -10,7 +10,14 @@ Configuration: - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) - audience: Expected JWT audience (optional, set to "none"/"null" to skip validation) - pubkey_path: Path to public key file (required if pubkey not provided) - - pubkey: Public key content as string (required if pubkey_path not provided) + - pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) + - paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) + - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation + +Path Validation Logic: + - No paths configured: ALL requests to the domain require JWT validation (default behavior) + - Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through + - Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied Example YAML config: plugins: @@ -20,6 +27,10 @@ Example YAML config: issuer: https://myaccount.auth0.com/ audience: https://api.mywebsite.com pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem + paths: + - /api/admin + - /api/sensitive + only_paths: false Example Container Label: easyhaproxy.http.plugins: "jwt_validator" @@ -27,6 +38,8 @@ Example Container Label: easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: true HAProxy Config Generated: # JWT Validator - Validate JWT tokens @@ -49,6 +62,7 @@ HAProxy Config Generated: http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } """ +import base64 import os import sys @@ -69,6 +83,8 @@ class JwtValidatorPlugin(PluginInterface): self.audience = None # Optional self.pubkey_path = None # Path to public key file self.pubkey = None # Public key content (alternative to pubkey_path) + self.paths = [] # List of paths that require JWT validation + self.only_paths = False # If true, only specified paths are accessible @property def name(self) -> str: @@ -89,7 +105,9 @@ class JwtValidatorPlugin(PluginInterface): - issuer: Expected JWT issuer (optional) - audience: Expected JWT audience (optional) - pubkey_path: Path to public key file - - pubkey: Public key content as string + - pubkey: Public key content as base64-encoded string + - paths: List of paths that require JWT validation (optional) + - only_paths: If true, only specified paths are accessible (default: false) """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] @@ -114,7 +132,22 @@ class JwtValidatorPlugin(PluginInterface): self.pubkey_path = config["pubkey_path"] if "pubkey" in config: - self.pubkey = config["pubkey"] + # Decode from base64 (consistent with sslcert parameter) + self.pubkey = base64.b64decode(config["pubkey"]).decode('ascii') + + # Path configuration + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] + + if "only_paths" in config: + self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] def process(self, context: PluginContext) -> PluginResult: """ @@ -143,38 +176,59 @@ class JwtValidatorPlugin(PluginInterface): # Build HAProxy configuration lines = ["# JWT Validator - Validate JWT tokens"] + # Determine path condition suffix + path_condition = "" + if self.paths: + # Define ACL for protected paths + lines.append("") + lines.append("# Define paths that require JWT validation") + for path in self.paths: + lines.append(f"acl jwt_protected_path path_beg {path}") + lines.append("") + + if self.only_paths: + # Deny all paths that are not in the protected list + lines.append("# Deny access to paths not in the protected list") + lines.append("http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path") + lines.append("") + # All remaining requests are on protected paths, no condition needed + path_condition = "" + else: + # Only validate JWT on protected paths + path_condition = " if jwt_protected_path" + # Check for Authorization header - lines.append("http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }") + lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") # Extract JWT parts lines.append("") lines.append("# Extract JWT header and payload") - lines.append("http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')") - lines.append("http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')") - lines.append("http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')") - lines.append("http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')") + lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){path_condition}") + lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}") + lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}") + lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}") # Validate JWT lines.append("") lines.append("# Validate JWT") - lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{path_condition}") # Validate issuer (if configured) if self.issuer: - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{path_condition}") # Validate audience (if configured) if self.audience: - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{path_condition}") # Validate signature - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{path_condition}") # Validate expiration lines.append("") lines.append("# Validate expiration") - lines.append("http-request set-var(txn.now) date()") - lines.append("http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }") + lines.append(f"http-request set-var(txn.now) date(){path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{path_condition}") haproxy_config = "\n".join(lines) @@ -184,7 +238,9 @@ class JwtValidatorPlugin(PluginInterface): "algorithm": self.algorithm, "pubkey_file": pubkey_file, "validates_issuer": self.issuer is not None, - "validates_audience": self.audience is not None + "validates_audience": self.audience is not None, + "path_validation": len(self.paths) > 0, + "only_paths": self.only_paths } if self.issuer: @@ -193,6 +249,8 @@ class JwtValidatorPlugin(PluginInterface): metadata["audience"] = self.audience if self.pubkey: metadata["pubkey_content"] = self.pubkey + if self.paths: + metadata["paths"] = self.paths return PluginResult( haproxy_config=haproxy_config, diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index f131bfe..52600fd 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -518,11 +518,13 @@ class TestJwtValidatorPlugin: assert result.metadata["validates_audience"] is True def test_jwt_validator_plugin_generates_config_with_pubkey_content(self): - """Test plugin generates correct HAProxy config using pubkey content""" + """Test plugin generates correct HAProxy config using pubkey content (base64-encoded)""" plugin = JwtValidatorPlugin() + # Base64-encoded version of "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + pubkey_base64 = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaC4uLgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0=" plugin.configure({ "algorithm": "RS256", - "pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + "pubkey": pubkey_base64 }) context = PluginContext( @@ -539,6 +541,7 @@ class TestJwtValidatorPlugin: assert result.haproxy_config is not None assert "JWT Validator" in result.haproxy_config assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config + # Verify the decoded content is stored in metadata assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" def test_jwt_validator_plugin_no_issuer_audience_validation(self): @@ -617,6 +620,121 @@ class TestJwtValidatorPlugin: assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in haproxy_config assert "jwt_verify" in haproxy_config + def test_jwt_validator_plugin_with_paths_only_paths_false(self): + """Test plugin with paths configured and only_paths=false""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/admin", "/api/sensitive"], + "only_paths": "false" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that ACLs are defined for paths + assert "acl jwt_protected_path path_beg /api/admin" in result.haproxy_config + assert "acl jwt_protected_path path_beg /api/sensitive" in result.haproxy_config + # Check that validation rules have "if jwt_protected_path" condition + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" in result.haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path" in result.haproxy_config + # Check that "Access denied" for non-protected paths is NOT present (only_paths=false) + assert "Access denied" not in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is True + assert result.metadata["only_paths"] is False + assert result.metadata["paths"] == ["/api/admin", "/api/sensitive"] + + def test_jwt_validator_plugin_with_paths_only_paths_true(self): + """Test plugin with paths configured and only_paths=true""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/public"], + "only_paths": "true" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that ACL is defined for path + assert "acl jwt_protected_path path_beg /api/public" in result.haproxy_config + # Check that "Access denied" for non-protected paths IS present (only_paths=true) + assert "http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path" in result.haproxy_config + # Check that validation rules do NOT have "if jwt_protected_path" (since all non-protected paths are denied) + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config + # The rules should not have any condition suffix when only_paths=true + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is True + assert result.metadata["only_paths"] is True + assert result.metadata["paths"] == ["/api/public"] + + def test_jwt_validator_plugin_paths_from_comma_separated_string(self): + """Test plugin parses comma-separated paths from container labels""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": "/api/admin,/api/sensitive,/api/protected" + }) + + assert plugin.paths == ["/api/admin", "/api/sensitive", "/api/protected"] + + def test_jwt_validator_plugin_paths_from_list(self): + """Test plugin parses paths from list (YAML config)""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/admin", "/api/sensitive"] + }) + + assert plugin.paths == ["/api/admin", "/api/sensitive"] + + def test_jwt_validator_plugin_no_paths_protects_all(self): + """Test plugin protects all paths when paths is not configured""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that no ACL is defined + assert "acl jwt_protected_path" not in result.haproxy_config + # Check that validation rules do NOT have any condition suffix (all paths protected) + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is False + class TestPluginManager: """Test cases for PluginManager""" From a993025718000272b8cee835053432a7e99654c8 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 30 Nov 2025 13:23:56 -0500 Subject: [PATCH 12/27] Add FastCGI support for PHP-FPM and backend server protocol configuration - Introduced `proto` and `socket` options for backend communication, supporting protocols like FastCGI (fcgi) and HTTP/2 (h2). - Updated HAProxy configuration templates to handle Unix socket paths and protocol-specific settings. - Enhanced documentation with examples for FastCGI using Unix sockets and TCP connections. - Added new tests to validate FastCGI support, including expected configuration generation. --- docs/container-labels.md | 76 ++++++++++++++++++++++++++++++------ src/easymapping/__init__.py | 22 ++++++++++- src/templates/haproxy.cfg.j2 | 2 +- src/tests/test_parser.py | 41 +++++++++++++++++++ 4 files changed, 126 insertions(+), 15 deletions(-) diff --git a/docs/container-labels.md b/docs/container-labels.md index 9b9bfe2..6e634c9 100644 --- a/docs/container-labels.md +++ b/docs/container-labels.md @@ -6,20 +6,22 @@ sidebar_position: 11 ## Container (Docker or Swarm) labels -| Label | Description | Default | Example | -|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------|------------------------------------------------------------------------------------------------------------------| -| easyhaproxy.[definition].host | Host(s) HAProxy is listening. More than one host use comma as delimiter | **required** | somehost.com OR host1.com,host2.com | -| easyhaproxy.[definition].mode | (Optional) Is this `http` or `tcp` mode in HAProxy. | http | http or tcp | -| easyhaproxy.[definition].port | (Optional) Port HAProxy will listen for the host. | 80 | 3000 | -| easyhaproxy.[definition].localport | (Optional) Port container is listening. | 80 | 8080 | -| easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | \{"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | -| easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `certbot` is enabled. | *empty* | base64 cert + key | -| easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | -| easyhaproxy.[definition].ssl-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | -| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. More info [here](acme.md). | false | true OR false | -| easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | +| Label | Description | Default | Example | +|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|--------------|------------------------------------------------------------------------------------------------------------------| +| easyhaproxy.[definition].host | Host(s) HAProxy is listening. More than one host use comma as delimiter | **required** | somehost.com OR host1.com,host2.com | +| easyhaproxy.[definition].mode | (Optional) Is this `http` or `tcp` mode in HAProxy. | http | http or tcp | +| easyhaproxy.[definition].port | (Optional) Port HAProxy will listen for the host. | 80 | 3000 | +| easyhaproxy.[definition].localport | (Optional) Port container is listening. | 80 | 8080 | +| easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | \{"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | +| easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `certbot` is enabled. | *empty* | base64 cert + key | +| easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | +| easyhaproxy.[definition].ssl-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | +| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. More info [here](acme.md). | false | true OR false | +| easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | | easyhaproxy.[definition].clone_to_ssl | (Optional) It copies the configuration to HTTPS(443) and disable SSL from the current config. **Do not use** this with `ssl` or `certbot` parameters | false | true OR false | -| easyhaproxy.[definition].balance | (Optional) HAProxy balance algorithm. See [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4.2-balance) | roundrobin | roundrobin, source, uri, url_param, hdr, rdp-cookie, leastconn, first, static-rr, rdp-cookie, hdr_dom, map-based | +| easyhaproxy.[definition].balance | (Optional) HAProxy balance algorithm. See [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4.2-balance) | roundrobin | roundrobin, source, uri, url_param, hdr, rdp-cookie, leastconn, first, static-rr, rdp-cookie, hdr_dom, map-based | +| easyhaproxy.[definition].proto | (Optional) Backend server protocol (e.g., fcgi for PHP-FPM, h2 for HTTP/2) | *empty* | fcgi, h2 | +| easyhaproxy.[definition].socket | (Optional) Unix socket path for backend connection (alternative to host:port) | *empty* | /run/php/php-fpm.sock | :::info Understanding Definitions The `[definition]` is a string identifier that groups related configuration labels together. Different definitions create separate HAProxy configurations. @@ -93,6 +95,54 @@ docker run \ some/tcp-service ``` +### FastCGI (PHP-FPM) Support + +EasyHAProxy supports FastCGI protocol for PHP-FPM and other FastCGI applications. + +#### Using Unix Socket + +```yaml title="PHP-FPM with Unix socket" +version: "3" + +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.fcgi.host: phpapp.local + easyhaproxy.fcgi.port: 80 + easyhaproxy.fcgi.socket: /run/php/php-fpm.sock + easyhaproxy.fcgi.proto: fcgi + volumes: + - /run/php:/run/php +``` + +#### Using TCP Connection + +```yaml title="PHP-FPM with TCP connection" +version: "3" + +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.fcgi.host: phpapp.local + easyhaproxy.fcgi.port: 80 + easyhaproxy.fcgi.localport: 9000 + easyhaproxy.fcgi.proto: fcgi +``` + +**Generated HAProxy Configuration:** + +``` +backend srv_phpapp_local_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 } + server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi +``` + ### Redirect Domains ```bash title="Domain redirect configuration" diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index 24b5277..eb810f9 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -175,13 +175,33 @@ class HaproxyConfigGenerator: "" ) + # Protocol for backend server communication (e.g., fcgi, h2) + proto = self.label.get( + self.label.create([definition, "proto"]), + "" + ) + + # Unix socket path (alternative to host:port) + socket_path = self.label.get( + self.label.create([definition, "socket"]), + "" + ) + for hostname in sorted(d[host_label].split(",")): hostname = hostname.strip() self.serving_hosts.append("%s:%s" % (hostname, port)) easymapping[port]["hosts"].setdefault(hostname, {}) easymapping[port]["hosts"][hostname].setdefault("containers", []) easymapping[port]["hosts"][hostname].setdefault("certbot", False) - easymapping[port]["hosts"][hostname]["containers"] += ["{}:{}".format(container, ct_port)] + easymapping[port]["hosts"][hostname].setdefault("proto", proto) + + # Determine server address: Unix socket or TCP host:port + if socket_path: + server_address = socket_path + else: + server_address = "{}:{}".format(container, ct_port) + + easymapping[port]["hosts"][hostname]["containers"] += [server_address] easymapping[port]["hosts"][hostname]["certbot"] = certbot easymapping[port]["hosts"][hostname]["redirect_ssl"] = self.label.get_bool( self.label.create([definition, "redirect_ssl"]) diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index f830e0d..ca69453 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -97,7 +97,7 @@ backend srv_{{ host }} tcp-check connect{{ " ssl" if o["ssl-check"] == "ssl" }} {% endif %} {% for c in o["hosts"][k]["containers"] %} - server srv-{{ loop.index0 }} {{ c }} check weight 1{{ " verify none" if o["ssl-check"] == "ssl" }} + server srv-{{ loop.index0 }} {{ c }} check weight 1{{ " verify none" if o["ssl-check"] == "ssl" }}{{ " proto " + o["hosts"][k]["proto"] if o["hosts"][k].get("proto") }} {% endfor %} {% endfor %} {% endfor %} diff --git a/src/tests/test_parser.py b/src/tests/test_parser.py index 1aa0f4b..519ffa0 100644 --- a/src/tests/test_parser.py +++ b/src/tests/test_parser.py @@ -124,6 +124,7 @@ def test_parser_finds_services_raw(): "my-stack_agent:9001" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] } @@ -143,6 +144,7 @@ def test_parser_finds_services_raw(): "my-stack_cadvisor:8080" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] }, @@ -152,6 +154,7 @@ def test_parser_finds_services_raw(): "my-stack_node-exporter:9100" ], "certbot": True, + "proto": "", "redirect_ssl": False, "plugin_configs": [] } @@ -171,6 +174,7 @@ def test_parser_finds_services_raw(): "my-stack_node-exporter:9100" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] }, @@ -180,6 +184,7 @@ def test_parser_finds_services_raw(): "some-service:80" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] } @@ -204,6 +209,7 @@ def test_parser_finds_services_raw(): "some-service:80" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] } @@ -471,6 +477,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.215:8080" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] }, @@ -480,6 +487,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.62:8080" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] }, @@ -489,6 +497,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.62:8080" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] } @@ -508,6 +517,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.215:8080" ], "certbot": False, + "proto": "", "redirect_ssl": False, "plugin_configs": [] } @@ -525,6 +535,37 @@ def test_parser_finds_services_clone_to_ssl_raw(): assert parsed_object == processed assert [] == cfg.certbot_hosts +def test_parser_fcgi(): + """Test FastCGI support with proto and socket parameters""" + line_list = load_fixture("services-fcgi") + + result = { + "customerrors": False, + "stats": { + "port": 0 + } + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + assert len(haproxy_config) > 0 + + # Verify proto fcgi is in the output + assert "proto fcgi" in haproxy_config + + # Verify Unix socket path is used + assert "/run/php/php-fpm.sock" in haproxy_config + + # Verify TCP connection is also present + assert "172.17.0.3:9000" in haproxy_config + + path = os.path.dirname(os.path.realpath(__file__)) + with open(path + "/expected/services-fcgi.txt", 'r') as expected_file: + assert expected_file.read() == haproxy_config + assert [] == cfg.certbot_hosts + + # test_parser_finds_services_raw() # test_parser_tcp() # test_parser_multiple_hosts() From 883521e7e13f9034aaf49ce410fdb87cf8828227 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 1 Dec 2025 13:04:32 -0500 Subject: [PATCH 13/27] Add FastCGI Plugin for PHP-FPM support with examples, tests, and documentation - Introduced `FastcgiPlugin` for automatic HAProxy `fcgi-app` configuration generation. - Added example Docker Compose setup for PHP-FPM with FastCGI. - Updated documentation with detailed examples and usage instructions for FastCGI. - Included test cases to validate plugin behavior and generated configurations. - Enhanced `easymapping` to support `fcgi-app` definitions in global configs. --- docs/plugins.md | 93 +++++++++++++ examples/docker/README.md | 111 +++++++++++++++ examples/docker/docker-compose-php-fpm.yml | 60 ++++++++ examples/docker/php-app/README.md | 151 ++++++++++++++++++++ examples/docker/php-app/index.php | 152 ++++++++++++++++++++ examples/docker/php-app/info.php | 9 ++ examples/docker/php-app/test-path-info.php | 106 ++++++++++++++ src/easymapping/__init__.py | 10 +- src/plugins/builtin/fastcgi.py | 155 +++++++++++++++++++++ src/tests/expected/services-fcgi.txt | 56 ++++++++ src/tests/fixtures/services-fcgi | 16 +++ src/tests/test_plugins.py | 112 ++++++++++++++- 12 files changed, 1029 insertions(+), 2 deletions(-) create mode 100644 examples/docker/docker-compose-php-fpm.yml create mode 100644 examples/docker/php-app/README.md create mode 100644 examples/docker/php-app/index.php create mode 100644 examples/docker/php-app/info.php create mode 100644 examples/docker/php-app/test-path-info.php create mode 100644 src/plugins/builtin/fastcgi.py create mode 100644 src/tests/expected/services-fcgi.txt create mode 100644 src/tests/fixtures/services-fcgi diff --git a/docs/plugins.md b/docs/plugins.md index 6db84fc..081900f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -312,6 +312,99 @@ http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn **Important:** Requires HAProxy 2.5+ with JWT support. Mount public key file as read-only volume. +### FastCGI Plugin (Domain) + +Configures FastCGI parameters for PHP-FPM and other FastCGI applications. + +**Why use it:** Automatically generates HAProxy `fcgi-app` configuration that defines required CGI parameters for PHP-FPM communication without manual HAProxy configuration. + +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `document_root` - Document root path (default: `/var/www/html`) +- `script_filename` - Custom pattern for SCRIPT_FILENAME (default: `%[path]`, uses HAProxy's default) +- `index_file` - Default index file (default: `index.php`) +- `path_info` - Enable PATH_INFO support (default: `true`) +- `custom_params` - Dictionary of custom FastCGI parameters (optional) + +**Enable via container label (TCP connection):** +```yaml +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 9000 + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + volumes: + - ./app:/var/www/html +``` + +**Or with Unix socket:** +```yaml +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.socket: /run/php/php-fpm.sock + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + volumes: + - ./app:/var/www/html + - /run/php:/run/php +``` + +**Custom document root and index file:** +```yaml +labels: + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp/public + easyhaproxy.http.plugin.fastcgi.index_file: app.php + easyhaproxy.http.plugin.fastcgi.path_info: true +``` + +**HAProxy config generated:** + +The plugin generates a top-level `fcgi-app` section and a `use-fcgi-app` directive in the backend: + +```haproxy +# Top-level fcgi-app definition (added after defaults, before frontends/backends) +fcgi-app fcgi_phpapp_local + docroot /var/www/html + index index.php + path-info ^(/.+\.php)(/.*)?$ + +# Backend configuration (added to the backend section) +backend srv_phpapp_local_80 + use-fcgi-app fcgi_phpapp_local + # TCP connection: + server srv-0 172.19.0.3:9000 proto fcgi + # OR Unix socket: + # server srv-0 /run/php/php-fpm.sock proto fcgi +``` + +**Note:** HAProxy automatically sets standard CGI parameters (SCRIPT_FILENAME, DOCUMENT_ROOT, REQUEST_URI, QUERY_STRING, REQUEST_METHOD, CONTENT_TYPE, CONTENT_LENGTH, SERVER_NAME, SERVER_PORT, etc.) based on the `fcgi-app` configuration when communicating with PHP-FPM via the FastCGI protocol. + +**What it configures:** +- ✅ SCRIPT_FILENAME - Path to PHP script +- ✅ DOCUMENT_ROOT - Document root directory +- ✅ SCRIPT_NAME - Script name from URL +- ✅ REQUEST_URI - Full request URI with query string +- ✅ QUERY_STRING - URL query parameters +- ✅ REQUEST_METHOD - HTTP method (GET, POST, etc.) +- ✅ CONTENT_TYPE & CONTENT_LENGTH - Request body info +- ✅ SERVER_NAME & SERVER_PORT - Server details +- ✅ HTTPS - SSL/TLS status +- ✅ PATH_INFO - Path information (optional) + +**Important:** Use this plugin together with `proto: fcgi` parameter for complete PHP-FPM support. + ## Configuration Methods Plugins can be configured using different methods depending on your deployment environment: diff --git a/examples/docker/README.md b/examples/docker/README.md index ce0640b..cf68e56 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -151,6 +151,117 @@ labels: ## Plugin Examples +### FastCGI Plugin with PHP-FPM + +Run PHP applications with FastCGI protocol support: + +**File:** `docker-compose-php-fpm.yml` + +**What it demonstrates:** +- PHP-FPM 8.5 with TCP connection on port 9000 +- FastCGI protocol support (`proto: fcgi`) +- FastCGI plugin for PHP environment configuration +- Custom document root and index file +- PATH_INFO support for RESTful routing + +**Features:** +- HAProxy forwards requests to PHP-FPM via TCP (port 9000) +- FastCGI plugin generates `fcgi-app` configuration that defines CGI parameters: + - `SCRIPT_FILENAME`, `DOCUMENT_ROOT`, `REQUEST_URI` + - `QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE` + - `SERVER_NAME`, `SERVER_PORT`, `HTTPS` + - `PATH_INFO` (for routing support) +- Sample PHP application included in `php-app/` directory + +**Configuration:** +```yaml +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + EASYHAPROXY_DISCOVER: docker + ports: + - "80:80/tcp" + + php-fpm: + image: byjg/php:8.5-fpm + volumes: + - ./php-app:/var/www/html:ro + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + # PHP-FPM listens on port 9000 + easyhaproxy.http.localport: 9000 + easyhaproxy.http.proto: fcgi + # Enable FastCGI plugin + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: "true" +``` + +**Usage:** +```bash +# Add to /etc/hosts +echo "127.0.0.1 phpapp.local" | sudo tee -a /etc/hosts + +# Start the stack +docker compose -f docker-compose-php-fpm.yml up -d + +# Test PHP application +curl http://phpapp.local/ +curl http://phpapp.local/info.php +curl http://phpapp.local/test-path-info.php/users/123 +``` + +**Alternative: Unix Socket Connection** + +For PHP-FPM images that support Unix sockets, you can use socket connection: + +```yaml +services: + haproxy: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - php-fpm-socket:/run/php + + php-fpm: + image: php:8.2-fpm # Official PHP image supports sockets + volumes: + - php-fpm-socket:/run/php + - ./php-app:/var/www/html:ro + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + easyhaproxy.http.socket: /run/php/php-fpm.sock + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + # ... plugin configuration + +volumes: + php-fpm-socket: +``` + +**Sample Application:** + +The `php-app/` directory contains: +- `index.php` - Main page showing FastCGI environment +- `info.php` - PHP configuration info (phpinfo) +- `test-path-info.php` - PATH_INFO routing demonstration + +**What the FastCGI plugin does:** +1. Sets `SCRIPT_FILENAME` with proper document root path +2. Handles directory requests (appends `index.php`) +3. Sets all standard CGI environment variables +4. Enables `PATH_INFO` for RESTful URL routing +5. Supports custom FastCGI parameters + +--- + ### JWT Validator Plugin Protect your API with JWT token validation: diff --git a/examples/docker/docker-compose-php-fpm.yml b/examples/docker/docker-compose-php-fpm.yml new file mode 100644 index 0000000..8c030c6 --- /dev/null +++ b/examples/docker/docker-compose-php-fpm.yml @@ -0,0 +1,60 @@ +# FastCGI Plugin Example with PHP-FPM +# +# This example demonstrates PHP-FPM configuration with FastCGI protocol support +# using HAProxy as a reverse proxy and the FastCGI plugin for PHP environment setup. +# +# Prerequisites: +# 1. Add to /etc/hosts: +# 127.0.0.1 phpapp.local +# +# 2. Start the stack: +# docker compose -f docker-compose-php-fpm.yml up -d +# +# 3. Test PHP application: +# curl http://phpapp.local/ +# curl http://phpapp.local/info.php +# +# Features: +# - PHP-FPM 8.5 with TCP connection on port 9000 +# - FastCGI protocol support +# - Custom document root +# - PATH_INFO support for routing +# - Custom FastCGI parameters + +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # PHP-FPM service using byjg/php image + php-fpm: + image: byjg/php:8.5-fpm + volumes: + # Mount PHP application files + - ./php-app:/var/www/html:ro + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + # PHP-FPM listens on port 9000 + easyhaproxy.http.localport: 9000 + easyhaproxy.http.proto: fcgi + + # Enable FastCGI plugin for PHP environment configuration + easyhaproxy.http.plugins: fastcgi + + # FastCGI plugin configuration + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: "true" diff --git a/examples/docker/php-app/README.md b/examples/docker/php-app/README.md new file mode 100644 index 0000000..f869c88 --- /dev/null +++ b/examples/docker/php-app/README.md @@ -0,0 +1,151 @@ +# Sample PHP Application for FastCGI Plugin + +This directory contains a sample PHP application that demonstrates the FastCGI plugin functionality with EasyHAProxy. + +## Files + +### index.php +The main page that displays: +- PHP version and configuration +- FastCGI environment variables set by EasyHAProxy +- How the FastCGI plugin works +- Links to test pages + +Access: `http://phpapp.local/` + +### info.php +Standard `phpinfo()` page showing complete PHP configuration. + +Access: `http://phpapp.local/info.php` + +### test-path-info.php +Demonstrates PATH_INFO support for RESTful URL routing. + +Examples: +- `http://phpapp.local/test-path-info.php/users` +- `http://phpapp.local/test-path-info.php/users/123` +- `http://phpapp.local/test-path-info.php/api/v1/products` + +## FastCGI Environment Variables + +The FastCGI plugin generates an `fcgi-app` configuration that defines these CGI parameters for HAProxy to use: + +| Variable | Description | Example | +|----------|-------------|---------| +| `SCRIPT_FILENAME` | Full path to PHP script | `/var/www/html/index.php` | +| `DOCUMENT_ROOT` | Document root directory | `/var/www/html` | +| `SCRIPT_NAME` | Script path | `/index.php` | +| `REQUEST_URI` | Full request URI with query | `/index.php?page=1` | +| `QUERY_STRING` | Query string parameters | `page=1&limit=10` | +| `REQUEST_METHOD` | HTTP method | `GET`, `POST`, etc. | +| `CONTENT_TYPE` | Request content type | `application/json` | +| `CONTENT_LENGTH` | Request body length | `1024` | +| `SERVER_NAME` | Virtual host name | `phpapp.local` | +| `SERVER_PORT` | Server port | `80` or `443` | +| `HTTPS` | SSL status | `on` or `off` | +| `PATH_INFO` | Extra path info (optional) | `/users/123` | + +## How It Works + +1. **FastCGI plugin generates configuration** (at startup) + - Creates an `fcgi-app` section with CGI parameter definitions + - Includes `docroot`, `index`, and `path-info` settings + - Adds `use-fcgi-app` directive to the backend + +2. **Request arrives at HAProxy** (port 80) + - URL: `http://phpapp.local/index.php` + +3. **HAProxy uses the fcgi-app configuration** + - Sets `SCRIPT_FILENAME` to `/var/www/html/index.php` + - Sets `DOCUMENT_ROOT` to `/var/www/html` + - Sets all other CGI variables based on the request + - Handles directory requests (appends `index.php`) + +4. **HAProxy forwards to PHP-FPM** via FastCGI protocol + - Host: `php-fpm` (container name) + - Port: `9000` (TCP) or Unix socket + - Protocol: `fcgi` + - Sends CGI parameters in FastCGI format + +5. **PHP-FPM executes the script** + - Reads the PHP file from `SCRIPT_FILENAME` + - Processes the PHP code with CGI environment + - Returns HTML/JSON response + +6. **HAProxy sends response to client** + +## Customizing + +You can customize the FastCGI plugin configuration in `docker-compose-php-fpm.yml`: + +```yaml +labels: + # Change document root + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/public + + # Change default index file + easyhaproxy.http.plugin.fastcgi.index_file: app.php + + # Disable PATH_INFO + easyhaproxy.http.plugin.fastcgi.path_info: "false" + + # Add custom FastCGI parameters + easyhaproxy.http.plugin.fastcgi.custom_params: '{"PHP_VALUE":"memory_limit=256M","APP_ENV":"production"}' +``` + +## Adding Your Own PHP Application + +Replace the contents of this directory with your own PHP application: + +```bash +# Remove sample files +rm -rf php-app/* + +# Copy your PHP application +cp -r /path/to/your/app/* php-app/ + +# Restart the stack +docker compose -f docker-compose-php-fpm.yml restart +``` + +Make sure your application's entry point matches the `index_file` configuration (default: `index.php`). + +## Troubleshooting + +### "File not found" error + +Check that: +1. The file exists in the `php-app/` directory +2. The `document_root` matches the container path (`/var/www/html`) +3. The volume mount is correct in docker-compose.yml + +### PATH_INFO not working + +Ensure `path_info` is enabled in the plugin configuration: +```yaml +easyhaproxy.http.plugin.fastcgi.path_info: "true" +``` + +### PHP-FPM connection error + +Verify: +1. The `localport: 9000` is set correctly +2. The `proto: fcgi` parameter is set +3. Both containers are running and can communicate + +View logs: +```bash +docker compose -f docker-compose-php-fpm.yml logs php-fpm +docker compose -f docker-compose-php-fpm.yml logs haproxy +``` + +Check connectivity: +```bash +docker compose -f docker-compose-php-fpm.yml exec haproxy ping php-fpm +``` + +## Learn More + +- [FastCGI Plugin Documentation](../../../docs/plugins.md#fastcgi-plugin) +- [Container Labels Reference](../../../docs/container-labels.md) +- [HAProxy FastCGI Documentation](https://docs.haproxy.org/2.8/configuration.html#5.2-proto) diff --git a/examples/docker/php-app/index.php b/examples/docker/php-app/index.php new file mode 100644 index 0000000..34a20f0 --- /dev/null +++ b/examples/docker/php-app/index.php @@ -0,0 +1,152 @@ + + + + + + PHP-FPM with EasyHAProxy + + + +
+

PHP-FPM with EasyHAProxy FastCGI Plugin

+ +
+ Success! PHP is running via FastCGI protocol through HAProxy. +
+ +

FastCGI Environment

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PHP Version
Server Software
Document Root
Script Filename
Request URI
Request Method
Server Name
Server Port
HTTPS
PATH_INFO
Gateway Interface
+ +

Test Links

+ + +

How This Works

+

+ This setup uses HAProxy with EasyHAProxy to proxy requests to PHP-FPM via the FastCGI protocol: +

+
    +
  1. HAProxy receives HTTP request on port 80
  2. +
  3. The FastCGI plugin generates an fcgi-app configuration that defines CGI parameters (SCRIPT_FILENAME, DOCUMENT_ROOT, etc.)
  4. +
  5. HAProxy uses this configuration to communicate with PHP-FPM via the FastCGI protocol
  6. +
  7. HAProxy connects to PHP-FPM (via TCP port 9000 or Unix socket, depending on configuration)
  8. +
  9. PHP-FPM processes the PHP script and returns the response
  10. +
  11. HAProxy sends the response back to the client
  12. +
+ +

Configuration

+

The FastCGI plugin is configured in docker-compose-php-fpm.yml:

+
    +
  • document_root: /var/www/html
  • +
  • index_file: index.php
  • +
  • path_info: true (enables PATH_INFO support)
  • +
+
+ + diff --git a/examples/docker/php-app/info.php b/examples/docker/php-app/info.php new file mode 100644 index 0000000..9a6e273 --- /dev/null +++ b/examples/docker/php-app/info.php @@ -0,0 +1,9 @@ + + + + + + PATH_INFO Test + + + +
+

PATH_INFO Test

+ + +
+ Success! PATH_INFO is working correctly. +
+ +

PATH_INFO Value

+
+ +

Parsed Path Segments

+
+ + +
+ Note: PATH_INFO is not set. Try accessing this page with additional path segments. +
+ + +

Request Information

+
+ +

Example Usage

+

PATH_INFO enables RESTful URL routing. Try these URLs:

+ + +

← Back to Home

+
+ + diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index eb810f9..ccf5812 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -98,7 +98,9 @@ class HaproxyConfigGenerator: enabled_list = [] global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list) - self.global_plugin_configs = [r.haproxy_config for r in global_results if r.haproxy_config] + # Extend instead of replace to preserve fcgi-app definitions from domain plugins + global_configs = [r.haproxy_config for r in global_results if r.haproxy_config] + self.global_plugin_configs.extend(global_configs) except Exception as e: import logging logging.warning(f"Failed to execute global plugins: {e}") @@ -265,6 +267,12 @@ class HaproxyConfigGenerator: easymapping[port]["hosts"][hostname]["plugin_configs"] = [ r.haproxy_config for r in domain_results if r.haproxy_config ] + + # Extract fcgi-app definitions from metadata and add to global configs + for result in domain_results: + if result.metadata and "fcgi_app_definition" in result.metadata: + if result.metadata["fcgi_app_definition"] not in self.global_plugin_configs: + self.global_plugin_configs.append(result.metadata["fcgi_app_definition"]) except Exception as e: import logging logging.warning(f"Failed to execute domain plugins for {hostname}: {e}") diff --git a/src/plugins/builtin/fastcgi.py b/src/plugins/builtin/fastcgi.py new file mode 100644 index 0000000..c50206f --- /dev/null +++ b/src/plugins/builtin/fastcgi.py @@ -0,0 +1,155 @@ +""" +FastCGI Plugin for EasyHAProxy + +This plugin generates HAProxy fcgi-app configuration for PHP-FPM and other FastCGI applications. +It runs as a DOMAIN plugin (once per domain). + +The plugin creates: + 1. A top-level fcgi-app section with CGI parameter definitions + 2. A use-fcgi-app directive in the backend + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - document_root: Document root path (default: /var/www/html) + - script_filename: Pattern for SCRIPT_FILENAME (default: %[path]) + - index_file: Default index file (default: index.php) + - path_info: Enable PATH_INFO support (default: true) + - custom_params: Dictionary of custom FastCGI parameters (optional) + +Example YAML config: + plugins: + fastcgi: + enabled: true + document_root: /var/www/html + index_file: index.php + path_info: true + +Example Container Label: + easyhaproxy.http.plugins: "fastcgi" + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: true + +Example Kubernetes Annotation: + easyhaproxy.plugins: "fastcgi" + easyhaproxy.plugin.fastcgi.document_root: /var/www/myapp + easyhaproxy.plugin.fastcgi.index_file: index.php +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class FastcgiPlugin(PluginInterface): + """Plugin to configure FastCGI parameters for PHP-FPM""" + + 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.custom_params = {} + + @property + def name(self) -> str: + return "fastcgi" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - document_root: Document root path + - script_filename: Pattern for SCRIPT_FILENAME + - index_file: Default index file + - path_info: Enable PATH_INFO support + - custom_params: Dictionary of custom FastCGI parameters + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "document_root" in config: + self.document_root = config["document_root"] + + if "script_filename" in config: + self.script_filename = config["script_filename"] + + if "index_file" in config: + self.index_file = config["index_file"] + + if "path_info" in config: + self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"] + + if "custom_params" in config: + self.custom_params = config["custom_params"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin and generate FastCGI configuration + + Args: + context: Plugin execution context + + Returns: + PluginResult with HAProxy FastCGI configuration + """ + if not self.enabled: + return PluginResult() + + # Generate a unique fcgi-app name based on the domain + # Replace dots and colons with underscores for valid HAProxy identifier + domain_safe = context.domain.replace(".", "_").replace(":", "_") + fcgi_app_name = f"fcgi_{domain_safe}" + + # Generate the use-fcgi-app directive for the backend + backend_config = f"use-fcgi-app {fcgi_app_name}" + + # Generate the fcgi-app section (to be inserted at top level) + fcgi_app_lines = [f"fcgi-app {fcgi_app_name}"] + fcgi_app_lines.append(f" docroot {self.document_root}") + fcgi_app_lines.append(f" index {self.index_file}") + + # PATH_INFO support + if self.path_info: + fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$") + + # Set SCRIPT_FILENAME if customized + if self.script_filename and self.script_filename != "%[path]": + fcgi_app_lines.append(f" set-param SCRIPT_FILENAME {self.script_filename}") + + # Custom parameters + if self.custom_params: + for param_name, param_value in self.custom_params.items(): + fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}") + + fcgi_app_definition = "\n".join(fcgi_app_lines) + + # Build metadata - store fcgi_app_definition to be extracted and added to global configs + metadata = { + "domain": context.domain, + "fcgi_app_name": fcgi_app_name, + "fcgi_app_definition": fcgi_app_definition, # For top-level injection + "document_root": self.document_root, + "index_file": self.index_file, + "path_info": self.path_info, + "custom_params_count": len(self.custom_params) + } + + return PluginResult( + haproxy_config=backend_config, # use-fcgi-app directive for the backend + modified_easymapping=None, + metadata=metadata + ) diff --git a/src/tests/expected/services-fcgi.txt b/src/tests/expected/services-fcgi.txt new file mode 100644 index 0000000..f7c1bf5 --- /dev/null +++ b/src/tests/expected/services-fcgi.txt @@ -0,0 +1,56 @@ +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/haproxy/dhparam + +defaults + log global + option httplog + + timeout connect 3s + timeout client 10s + timeout server 10m + + + +frontend http_in_80 + bind *:80 + mode http + + acl is_rule_phpapp_local_80_1 hdr(host) -i phpapp.local + acl is_rule_phpapp_local_80_2 hdr(host) -i phpapp.local:80 + use_backend srv_phpapp_local_80 if is_rule_phpapp_local_80_1 OR is_rule_phpapp_local_80_2 + + acl is_rule_phpapp-tcp_local_80_1 hdr(host) -i phpapp-tcp.local + acl is_rule_phpapp-tcp_local_80_2 hdr(host) -i phpapp-tcp.local:80 + use_backend srv_phpapp-tcp_local_80 if is_rule_phpapp-tcp_local_80_1 OR is_rule_phpapp-tcp_local_80_2 + +backend srv_phpapp_local_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 } + server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi +backend srv_phpapp-tcp_local_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 } + server srv-0 172.17.0.3:9000 check weight 1 proto fcgi + +backend certbot_backend + mode http + server certbot 127.0.0.1:2080 diff --git a/src/tests/fixtures/services-fcgi b/src/tests/fixtures/services-fcgi new file mode 100644 index 0000000..d4fdc12 --- /dev/null +++ b/src/tests/fixtures/services-fcgi @@ -0,0 +1,16 @@ +{ + "172.17.0.2": { + "easyhaproxy.definitions": "fcgi", + "easyhaproxy.fcgi.host": "phpapp.local", + "easyhaproxy.fcgi.port": "80", + "easyhaproxy.fcgi.socket": "/run/php/php-fpm.sock", + "easyhaproxy.fcgi.proto": "fcgi" + }, + "172.17.0.3": { + "easyhaproxy.definitions": "fcgi-tcp", + "easyhaproxy.fcgi-tcp.host": "phpapp-tcp.local", + "easyhaproxy.fcgi-tcp.port": "80", + "easyhaproxy.fcgi-tcp.localport": "9000", + "easyhaproxy.fcgi-tcp.proto": "fcgi" + } +} diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index 52600fd..188844d 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -22,6 +22,7 @@ from plugins.builtin.cleanup import CleanupPlugin from plugins.builtin.deny_pages import DenyPagesPlugin from plugins.builtin.ip_whitelist import IpWhitelistPlugin from plugins.builtin.jwt_validator import JwtValidatorPlugin +from plugins.builtin.fastcgi import FastcgiPlugin import easymapping @@ -736,6 +737,114 @@ class TestJwtValidatorPlugin: assert result.metadata["path_validation"] is False +class TestFastcgiPlugin: + """Test cases for FastcgiPlugin""" + + def test_fastcgi_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = FastcgiPlugin() + + assert plugin.name == "fastcgi" + 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.custom_params == {} + + def test_fastcgi_plugin_configuration(self): + """Test plugin configuration""" + plugin = FastcgiPlugin() + plugin.configure({ + "document_root": "/var/www/myapp", + "index_file": "app.php", + "path_info": "false" + }) + + assert plugin.document_root == "/var/www/myapp" + assert plugin.index_file == "app.php" + assert plugin.path_info is False + + def test_fastcgi_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = FastcgiPlugin() + plugin.configure({ + "document_root": "/var/www/html", + "index_file": "index.php" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="phpapp.local", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config + + # Check fcgi-app definition in metadata + assert "fcgi_app_definition" in result.metadata + fcgi_app_def = result.metadata["fcgi_app_definition"] + assert "fcgi-app fcgi_phpapp_local" in fcgi_app_def + assert "docroot /var/www/html" in fcgi_app_def + assert "index index.php" in fcgi_app_def + assert result.metadata["document_root"] == "/var/www/html" + assert result.metadata["index_file"] == "index.php" + + def test_fastcgi_plugin_custom_params(self): + """Test plugin with custom FastCGI parameters""" + plugin = FastcgiPlugin() + plugin.configure({ + "custom_params": { + "CUSTOM_VAR": "custom_value", + "APP_ENV": "production" + } + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="phpapp.local", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config + + # Check custom params in fcgi-app definition in metadata + assert "fcgi_app_definition" in result.metadata + fcgi_app_def = result.metadata["fcgi_app_definition"] + assert "set-param CUSTOM_VAR custom_value" in fcgi_app_def + assert "set-param APP_ENV production" in fcgi_app_def + assert result.metadata["custom_params_count"] == 2 + + def test_fastcgi_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = FastcgiPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="phpapp.local", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is None or result.haproxy_config == "" + + class TestPluginManager: """Test cases for PluginManager""" @@ -750,10 +859,11 @@ class TestPluginManager: assert "deny_pages" in manager.plugins assert "ip_whitelist" in manager.plugins assert "jwt_validator" in manager.plugins + assert "fastcgi" in manager.plugins # Verify plugin types assert len(manager.global_plugins) == 1 # cleanup - assert len(manager.domain_plugins) == 4 # cloudflare, deny_pages, ip_whitelist, jwt_validator + assert len(manager.domain_plugins) == 5 # cloudflare, deny_pages, ip_whitelist, jwt_validator, fastcgi # Verify plugin instances assert manager.plugins["cloudflare"].name == "cloudflare" From ae6eb1b55aff92967aa6878c2814a0bc651addba Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 1 Dec 2025 13:36:11 -0500 Subject: [PATCH 14/27] Add detailed plugin documentation for Cleanup, Cloudflare, Deny Pages, IP Whitelist, JWT Validator, and FastCGI plugins - Added individual markdown files with examples, configuration options, and HAProxy outputs for each plugin. - Updated `README.md` to link plugin-specific documentation. - Enhanced `plugins.md` to summarize plugin features and usage. - Included Docker, Kubernetes, Swarm, and static configuration examples for all plugins. --- README.md | 8 +- docs/kubernetes.md | 22 +- docs/plugin-development.md | 2572 +++++++++++++++++++++------------ docs/plugins.md | 373 +---- docs/plugins/cleanup.md | 74 + docs/plugins/cloudflare.md | 91 ++ docs/plugins/deny-pages.md | 113 ++ docs/plugins/fastcgi.md | 159 ++ docs/plugins/ip-whitelist.md | 110 ++ docs/plugins/jwt-validator.md | 226 +++ 10 files changed, 2431 insertions(+), 1317 deletions(-) create mode 100644 docs/plugins/cleanup.md create mode 100644 docs/plugins/cloudflare.md create mode 100644 docs/plugins/deny-pages.md create mode 100644 docs/plugins/fastcgi.md create mode 100644 docs/plugins/ip-whitelist.md create mode 100644 docs/plugins/jwt-validator.md diff --git a/README.md b/README.md index 5e8bff9..76bcb35 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,13 @@ Detailed configuration guides for advanced setups: - [Container Labels](docs/container-labels.md) - Configure Docker/Swarm containers with labels - [Environment Variables](docs/environment-variable.md) - Configure EasyHAProxy behavior - [Volumes](docs/volumes.md) - Map volumes for certificates, config, and custom files -- [Plugins](docs/plugins.md) - Extend HAProxy with custom plugins +- [Plugins](docs/plugins.md) - Extend HAProxy with plugins ([Development Guide](docs/plugin-development.md)) + - [Cloudflare](docs/plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN + - [Cleanup](docs/plugins/cleanup.md) - Automatic cleanup of temporary files + - [Deny Pages](docs/plugins/deny-pages.md) - Block access to specific paths + - [IP Whitelist](docs/plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges + - [JWT Validator](docs/plugins/jwt-validator.md) - JWT authentication validation + - [FastCGI](docs/plugins/fastcgi.md) - PHP-FPM and FastCGI application support - [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.) - [Limitations](docs/limitations.md) - Important limitations and considerations diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 84b5197..3fccdec 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -90,16 +90,16 @@ You don't need to expose any port in your container. ## Kubernetes annotations -| annotation | Description | Default | Example | -|----------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------| -| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | -| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | -| easyhaproxy.certbot | (optional) Boolean. It will request certbot certificates for the ingresses domains. | false | true or false | -| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | \{"domain":"redirect_url"} | -| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | -| easyhaproxy.listen_port | (optional) Override the HTTP listen port created for that ingress | 80 | 8081 | -| easyhaproxy.plugins | (optional) Comma-separated list of plugins to enable for this ingress | *empty* | cloudflare,deny_pages | -| easyhaproxy.plugin.{name}.{key} | (optional) Plugin-specific configuration (see [Using Plugins](plugins.md)) | *varies* | See examples below | +| annotation | Description | Default | Example | +|-------------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------| +| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | +| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | +| easyhaproxy.certbot | (optional) Boolean. It will request certbot certificates for the ingresses domains. | false | true or false | +| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | \{"domain":"redirect_url"} | +| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | +| easyhaproxy.listen_port | (optional) Override the HTTP listen port created for that ingress | 80 | 8081 | +| easyhaproxy.plugins | (optional) Comma-separated list of plugins to enable for this ingress | *empty* | cloudflare,deny_pages | +| easyhaproxy.plugin.`{name}`.`{key}` | (optional) Plugin-specific configuration (see [Using Plugins](plugins.md)) | *varies* | See examples below | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. @@ -176,7 +176,7 @@ metadata: easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" ``` -**Note:** For JWT validation, you'll need to mount the public key file into the EasyHAProxy pod. See [Using Plugins](plugins.md#jwt-validator-plugin-domain) for details. +**Note:** For JWT validation, you'll need to mount the public key file into the EasyHAProxy pod. See [Using Plugins](plugins.md#protect-api-with-jwt-authentication) for details. **Restrict access to specific IPs:** diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 4b2ad66..54be8cd 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -2,637 +2,161 @@ sidebar_position: 17 --- -# Plugin Developer Guide +# Plugin Development Guide -This guide explains how to create custom plugins for EasyHAProxy. For information on using existing plugins, see [Using Plugins](plugins.md). +This comprehensive guide covers everything you need to know about developing plugins for EasyHAProxy. Plugins extend HAProxy configuration with custom functionality and can be integrated seamlessly with Docker, Kubernetes, and Swarm environments. -## Architecture Overview +## Table of Contents + +1. [Overview](#overview) +2. [Plugin Architecture](#plugin-architecture) +3. [Quick Start Guide](#quick-start-guide) +4. [API Reference](#api-reference) +5. [Advanced Examples](#advanced-examples) +6. [Best Practices](#best-practices) +7. [Testing Guidelines](#testing-guidelines) +8. [Troubleshooting](#troubleshooting) +9. [Distribution](#distribution) + +--- + +## Overview + +### What is a Plugin? + +A plugin is a Python class that implements the `PluginInterface` and extends HAProxy's configuration during the discovery cycle. Plugins can: + +- **Inject HAProxy configuration** - Add custom HAProxy directives (ACLs, http-request rules, etc.) +- **Modify discovery data** - Transform the easymapping structure before HAProxy config generation +- **Perform maintenance tasks** - Execute cleanup, monitoring, or integration tasks +- **Integrate with external services** - Connect to APIs, databases, or third-party systems + +### Why Build a Plugin? + +Build a plugin when you need to: + +- Add domain-specific HAProxy configuration based on labels/annotations +- Integrate with CDNs, load balancers, or security services +- Implement custom authentication or authorization logic +- Perform scheduled maintenance or monitoring tasks +- Extend EasyHAProxy without modifying core code + +### Plugin System Benefits + +- **Zero code changes** - Plugins don't modify EasyHAProxy core +- **Hot reload support** - Plugins reload on each discovery cycle +- **Configuration flexibility** - Configure via YAML, environment variables, or container labels +- **Error isolation** - Plugin errors don't crash the main application (configurable) +- **Easy distribution** - Share plugins as single Python files + +--- + +## Plugin Architecture + +### Plugin Types + +EasyHAProxy supports two plugin execution models: + +#### 1. GLOBAL Plugins + +Execute **once per discovery cycle**, regardless of discovered domains. + +**Execution timing:** After discovery, before domain processing + +**Use cases:** +- Cleanup tasks (removing old temp files) +- Global monitoring (health checks, metrics) +- DNS updates (updating external DNS records) +- Log rotation or archiving +- Integration with global services + +**Example:** CleanupPlugin - removes old temporary files once per cycle + +#### 2. DOMAIN Plugins + +Execute **once per discovered domain/host**. + +**Execution timing:** During domain processing, before backend config generation + +**Use cases:** +- Domain-specific HAProxy rules (IP whitelisting, rate limiting) +- CDN integration (Cloudflare IP restoration) +- Path-based controls (blocking specific URLs) +- Custom headers or redirects per domain +- JWT validation or authentication + +**Example:** CloudflarePlugin - restores visitor IP for each Cloudflare-enabled domain ### Plugin Lifecycle ``` -1. Discovery Cycle Starts +1. LOAD PHASE + ├─ PluginManager scans plugins directory + ├─ Imports plugin modules + ├─ Instantiates plugin classes + └─ Categorizes by type (GLOBAL/DOMAIN) + +2. CONFIGURE PHASE + ├─ Loads configuration from YAML/env + ├─ Calls plugin.configure(config) for each plugin + └─ Validates configuration (plugin responsibility) + +3. EXECUTION PHASE (per discovery cycle) + ├─ GLOBAL PLUGINS + │ └─ Executes all global plugins once │ -2. PluginManager loads plugins - ├─ Load builtin plugins from src/plugins/builtin/ - └─ Load external plugins from /etc/haproxy/plugins/ - │ -3. PluginManager configures plugins - ├─ Read global config (YAML/env vars) - └─ Call plugin.configure(config) - │ -4. Parse container metadata → easymapping - │ -5. Execute GLOBAL plugins (once) - ├─ Create PluginContext (no domain info) - ├─ Call plugin.process(context) - └─ Collect PluginResult - │ -6. For each discovered domain: - ├─ Extract plugin list from labels - ├─ Extract plugin configs from labels - ├─ Call plugin.configure(label_config) - ├─ Execute DOMAIN plugins - │ ├─ Create PluginContext (with domain info) - │ ├─ Call plugin.process(context) - │ └─ Collect PluginResult - └─ Store plugin HAProxy configs - │ -7. Render Jinja2 template - ├─ Inject global plugin configs - └─ Inject domain plugin configs per backend - │ -8. Generate final HAProxy config + └─ DOMAIN PLUGINS + └─ For each discovered domain: + └─ Executes all domain plugins + +4. RESULT PROCESSING + ├─ Collects PluginResult from each plugin + ├─ Injects haproxy_config into generated config + ├─ Applies modified_easymapping if provided + └─ Logs metadata for debugging ``` -## Built-in Plugins Reference +### Plugin Loading Order -EasyHAProxy includes five built-in plugins that serve as both functional tools and reference implementations for plugin development. +1. **Builtin plugins** - Loaded from `/src/plugins/builtin/` +2. **External plugins** - Loaded from `/etc/haproxy/plugins/` -### CloudflarePlugin (DOMAIN) +Plugins are discovered automatically by filename (`*.py` excluding `__*.py`). -**Purpose:** Restore original visitor IP addresses when using Cloudflare CDN - -**Type:** DOMAIN - Executes once per domain - -**Source:** `src/plugins/builtin/cloudflare.py` - -**Configuration Options:** -- `enabled` (bool) - Enable/disable plugin (default: `true`) -- `ip_list_path` (str) - Path to Cloudflare IP list file (default: `/etc/haproxy/cloudflare_ips.lst`) - -**What it does:** -- Reads Cloudflare IP ranges from a file -- Checks if request comes from Cloudflare IP -- Restores original visitor IP from `CF-Connecting-IP` header - -**HAProxy config generated:** -``` -# Cloudflare - Restore original visitor IP -acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst -http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare -``` - -**Usage:** See [Cloudflare Plugin documentation](plugins.md#cloudflare-plugin-domain) - -### CleanupPlugin (GLOBAL) - -**Purpose:** Perform cleanup tasks during discovery cycle - -**Type:** GLOBAL - Executes once per discovery cycle - -**Source:** `src/plugins/builtin/cleanup.py` - -**Configuration Options:** -- `enabled` (bool) - Enable/disable plugin (default: `true`) -- `max_idle_time` (int) - Max file age in seconds before deletion (default: `300`) -- `cleanup_temp_files` (bool) - Enable temp file cleanup (default: `true`) - -**What it does:** -- Scans `/tmp` for files prefixed with `easyhaproxy_` -- Removes files older than `max_idle_time` seconds -- Logs cleanup actions to metadata - -**HAProxy config generated:** None (performs cleanup only) - -**Usage:** See [Cleanup Plugin documentation](plugins.md#cleanup-plugin-global) - -### DenyPagesPlugin (DOMAIN) - -**Purpose:** Block access to specific paths for a domain - -**Type:** DOMAIN - Executes once per domain - -**Source:** `src/plugins/builtin/deny_pages.py` - -**Configuration Options:** -- `enabled` (bool) - Enable/disable plugin (default: `true`) -- `paths` (str) - Comma-separated list of paths to block (e.g., `/admin,/private`) -- `status_code` (int) - HTTP status code to return (default: `403`) - -**What it does:** -- Parses comma-separated list of paths -- Creates HAProxy ACL matching those paths -- Returns specified HTTP status code for matching requests - -**HAProxy config generated:** -``` -# Deny Pages - Block specific paths -acl denied_path path_beg /admin /private -http-request deny deny_status 403 if denied_path -``` - -**Usage:** See [Deny Pages Plugin documentation](plugins.md#deny-pages-plugin-domain) - -### IpWhitelistPlugin (DOMAIN) - -**Purpose:** Restrict domain access to specific IP addresses or CIDR ranges - -**Type:** DOMAIN - Executes once per domain - -**Source:** `src/plugins/builtin/ip_whitelist.py` - -**Configuration Options:** -- `enabled` (bool) - Enable/disable plugin (default: `true`) -- `allowed_ips` (str) - Comma-separated list of IPs/CIDR ranges to allow (e.g., `192.168.1.0/24,10.0.0.1`) -- `status_code` (int) - HTTP status code to return for blocked IPs (default: `403`) - -**What it does:** -- Parses comma-separated list of IPs and CIDR ranges -- Creates HAProxy ACL matching whitelisted IPs -- Denies all requests NOT from whitelisted IPs - -**HAProxy config generated:** -``` -# IP Whitelist - Only allow specific IPs -acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 -http-request deny deny_status 403 if !whitelisted_ip -``` - -**Usage:** See [IP Whitelist Plugin documentation](plugins.md#ip-whitelist-plugin-domain) - -### JwtValidatorPlugin (DOMAIN) - -**Purpose:** Validate JWT authentication tokens - -**Type:** DOMAIN - Executes once per domain - -**Source:** `src/plugins/builtin/jwt_validator.py` - -**Configuration Options:** -- `enabled` (bool) - Enable/disable plugin (default: `true`) -- `algorithm` (str) - JWT signing algorithm (default: `RS256`) -- `issuer` (str) - Expected JWT issuer (optional, `none`/`null` skips validation) -- `audience` (str) - Expected JWT audience (optional, `none`/`null` skips validation) -- `pubkey_path` (str) - Path to public key file (required if `pubkey` not provided) -- `pubkey` (str) - Public key content as string (required if `pubkey_path` not provided) - -**What it does:** -- Validates JWT tokens using HAProxy's JWT functionality -- Checks Authorization header presence -- Validates algorithm, issuer, audience, signature, and expiration -- Optionally skips issuer/audience validation if set to "none" - -**HAProxy config generated:** -``` -# JWT Validator - Validate JWT tokens -http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } - -# Extract JWT header and payload -http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') -http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') -http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') -http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') - -# Validate JWT -http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } -http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } - -# Validate expiration -http-request set-var(txn.now) date() -http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } -``` - -**Usage:** See [JWT Validator Plugin documentation](plugins.md#jwt-validator-plugin-domain) - -### Summary Table - -| Plugin | Type | Purpose | Config Generated | -|-----------------|--------|------------------------------|------------------| -| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | -| `cleanup` | GLOBAL | Clean up temp files | ❌ No | -| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | -| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | -| `jwt_validator` | DOMAIN | Validate JWT tokens | ✅ Yes | - -### Learning from Built-in Plugins - -**Best practices demonstrated:** - -1. **CloudflarePlugin** shows: - - Reading external files (IP list) - - Conditional HAProxy ACLs - - Header manipulation - -2. **CleanupPlugin** shows: - - GLOBAL plugin pattern - - File system operations - - Metadata-only results (no HAProxy config) - -3. **DenyPagesPlugin** shows: - - Parsing comma-separated config values - - Configurable status codes - - Path-based ACLs - -4. **IpWhitelistPlugin** shows: - - IP-based access control - - Negated ACLs (`if !whitelisted_ip`) - - CIDR range support - -5. **JwtValidatorPlugin** shows: - - Complex HAProxy JWT validation - - Optional validation (skip with "none"/"null") - - Dynamic file path generation based on domain - - Multiple configuration options - -**View the source code:** -- [cloudflare.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cloudflare.py) -- [cleanup.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cleanup.py) -- [deny_pages.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/deny_pages.py) -- [ip_whitelist.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/ip_whitelist.py) -- [jwt_validator.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/jwt_validator.py) - -## Plugin API Reference - -### PluginInterface (Abstract Base Class) - -All plugins must inherit from `PluginInterface` and implement these abstract methods: - -```python -from plugins import PluginInterface, PluginType, PluginContext, PluginResult - -class MyPlugin(PluginInterface): - @property - @abstractmethod - def name(self) -> str: - """ - Return unique plugin identifier. - - Used for: - - Configuration lookups - - Enable/disable via labels - - Logging - - Must be: - - Unique across all plugins - - Lowercase with underscores - - Match filename (e.g., my_plugin.py → "my_plugin") - """ - pass - - @property - @abstractmethod - def plugin_type(self) -> PluginType: - """ - Return plugin execution type. - - Returns: - PluginType.GLOBAL - Execute once per discovery cycle - PluginType.DOMAIN - Execute once per domain/host - """ - pass - - @abstractmethod - def configure(self, config: dict) -> None: - """ - Configure plugin with settings from YAML/env/labels. - - Called: - - Once at startup with global config - - Before each execution with label-specific config (domain plugins) - - Args: - config: Dictionary with plugin configuration - Keys are configuration option names - Values are strings from YAML/env/labels - - Common pattern: - if "enabled" in config: - self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "my_option" in config: - self.my_option = config["my_option"] - """ - pass - - @abstractmethod - def process(self, context: PluginContext) -> PluginResult: - """ - Execute plugin logic. - - Args: - context: PluginContext with all execution data - - Returns: - PluginResult with HAProxy config snippets and/or metadata - - Common pattern: - if not self.enabled: - return PluginResult() # Empty result when disabled - - # Generate config - haproxy_config = "..." - - return PluginResult( - haproxy_config=haproxy_config, - metadata={"info": "value"} - ) - """ - pass -``` - -### PluginType (Enum) - -Defines when plugins execute: - -```python -class PluginType(Enum): - GLOBAL = "global" # Execute once per discovery cycle - DOMAIN = "domain" # Execute once per domain/host -``` - -**GLOBAL plugins:** -- Execute once regardless of how many domains exist -- Receive empty domain/port/host_config in context -- Use cases: cleanup, monitoring, DNS updates - -**DOMAIN plugins:** -- Execute for each discovered domain -- Receive domain-specific data in context -- Use cases: per-domain config, headers, path blocking - -### PluginContext (Data Class) - -Contains all data available to plugins during execution: - -```python -@dataclass -class PluginContext: - parsed_object: dict # Discovery data: {IP: labels} - easymapping: list # HAProxy mapping structure - container_env: dict # Global environment config - domain: Optional[str] = None # Domain name (DOMAIN plugins only) - port: Optional[str] = None # Port (DOMAIN plugins only) - host_config: Optional[dict] = None # Host config (DOMAIN plugins only) -``` - -**Fields explained:** - -#### `parsed_object: dict` - -Container discovery data mapping IP addresses to labels. - -**Example:** -```python -{ - "192.168.1.10": { - "easyhaproxy.http.host": "example.com", - "easyhaproxy.http.port": "80", - "easyhaproxy.http.localport": "8080", - "easyhaproxy.http.plugins": "cloudflare,deny_pages", - "easyhaproxy.http.plugin.deny_pages.paths": "/admin" - }, - "192.168.1.20": { - "easyhaproxy.http.host": "other.com", - "easyhaproxy.http.port": "80" - } -} -``` - -**Use cases:** -- Iterate over all discovered containers -- Access container labels directly -- Global plugins analyzing all containers - -#### `easymapping: list` - -Parsed HAProxy configuration structure before template rendering. - -**Example:** -```python -[ - { - "port": "80", - "mode": "http", - "ssl-check": "", - "hosts": { - "example.com": { - "containers": ["192.168.1.10:8080"], - "balance": "roundrobin", - "certbot": False, - "redirect_ssl": False, - "plugin_configs": [] - } - }, - "redirect": {} - } -] -``` - -**Use cases:** -- Analyze discovered hosts -- Modify discovery structure (advanced) - -#### `container_env: dict` - -Global configuration from YAML and environment variables. - -**Example:** -```python -{ - "customerrors": False, - "ssl_mode": "default", - "certbot": { - "email": "admin@example.com" - }, - "stats": { - "port": 1936, - "username": "admin" - }, - "plugins": { - "enabled": ["cleanup"], - "abort_on_error": False, - "config": { - "cleanup": { - "max_idle_time": "300" - } - } - } -} -``` - -**Use cases:** -- Access global settings -- Check certbot configuration -- Read global plugin config - -#### `domain: Optional[str]` - -Domain name for DOMAIN plugins. `None` for GLOBAL plugins. - -**Example:** `"example.com"` - -#### `port: Optional[str]` - -Port for DOMAIN plugins. `None` for GLOBAL plugins. - -**Example:** `"80"`, `"443"` - -#### `host_config: Optional[dict]` - -Host-specific configuration for DOMAIN plugins. `None` for GLOBAL plugins. - -**Example:** -```python -{ - "containers": ["192.168.1.10:8080"], - "balance": "roundrobin", - "certbot": False, - "redirect_ssl": False, - "plugin_configs": [] -} -``` - -### PluginResult (Data Class) +### Data Flow ``` -Plugin.process(context) → PluginResult - ├─ haproxy_config (what to add to HAProxy config) - ├─ modified_easymapping (optional: modify discovery data) - └─ metadata (optional: debug/logging info) +Container Labels/Annotations + ↓ +Discovery (Docker/K8s/Swarm) + ↓ +parsed_object: {IP: labels} + ↓ +[GLOBAL PLUGINS] ← PluginContext (parsed_object, easymapping, env) + ↓ +easymapping: [list of domain configs] + ↓ +For each domain: + [DOMAIN PLUGINS] ← PluginContext (domain, port, host_config, ...) + ↓ + PluginResult → haproxy_config snippets + ↓ +HAProxy Configuration File + ↓ +HAProxy Reload ``` -**What is PluginResult?** +--- -`PluginResult` is a container object that your plugin returns from its `process()` method. It holds everything the plugin produced during execution. - -**Why does it exist?** - -Plugins need to return multiple pieces of information: -- HAProxy configuration snippets to inject -- Optional modifications to the discovery data -- Metadata for logging and debugging - -Instead of returning multiple values, plugins return one `PluginResult` object containing all this information. - -**How do you use it?** - -Every `process()` method must return a `PluginResult`: - -```python -def process(self, context: PluginContext) -> PluginResult: - # Plugin is disabled - return empty result - if not self.enabled: - return PluginResult() - - # Plugin is enabled - return config - return PluginResult( - haproxy_config="http-request set-header X-Custom-Header 'value'", - metadata={"info": "some debug info"} - ) -``` - -**PluginResult structure:** - -```python -@dataclass -class PluginResult: - haproxy_config: str = "" # HAProxy config to inject - modified_easymapping: Optional[list] = None # Modified discovery data (advanced) - metadata: Dict[str, Any] = field(default_factory=dict) # Debug/logging info -``` - -**Common usage patterns:** - -```python -# Empty result (plugin disabled or nothing to do) -return PluginResult() - -# Config only (most common) -return PluginResult( - haproxy_config="# My config\n directive value" -) - -# Config + metadata (recommended) -return PluginResult( - haproxy_config="# My config\n directive value", - metadata={"domain": context.domain, "option": self.my_option} -) - -# All fields (advanced) -return PluginResult( - haproxy_config="# My config\n directive value", - modified_easymapping=modified_data, - metadata={"info": "value"} -) -``` - -**Fields explained:** - -#### `haproxy_config: str` - -HAProxy configuration snippet to inject into the generated config. - -**For DOMAIN plugins:** Injected into the backend section for that domain. - -**Example:** -```python -haproxy_config = """# Cloudflare IP Restoration - acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst - http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" -``` - -**Important:** -- Include leading spaces/tabs for proper indentation -- Add comment describing what the config does -- Use HAProxy directives that make sense for backend context (domain plugins) -- Return empty string `""` when plugin is disabled or has nothing to add - -#### `modified_easymapping: Optional[list]` - -Modified easymapping structure. **Advanced feature, rarely used.** - -**When to use:** -- Modify discovery data before template rendering -- Add/remove hosts dynamically -- Change port mappings - -**Default:** `None` (don't modify easymapping) - -**Example:** -```python -# Add a new host to port 80 -modified = context.easymapping.copy() -modified[0]["hosts"]["new-host.com"] = { - "containers": ["192.168.1.30:8080"], - "balance": "roundrobin", - "certbot": False, - "redirect_ssl": False, - "plugin_configs": [] -} -return PluginResult(modified_easymapping=modified) -``` - -#### `metadata: Dict[str, Any]` - -Metadata for logging and debugging. Not used in HAProxy config. - -**Example:** -```python -metadata = { - "domain": context.domain, - "blocked_paths": ["/admin", "/private"], - "status_code": 403, - "files_cleaned": 5 -} -``` - -**Use cases:** -- Debug information -- Statistics -- Audit trail - -**Logged at DEBUG level:** -``` -DEBUG: Plugin deny_pages metadata: {'domain': 'example.com', 'blocked_paths': ['/admin'], 'status_code': 403} -``` - -## Creating a Plugin: Step-by-Step +## Quick Start Guide ### Step 1: Create Plugin File -Create `/etc/haproxy/plugins/my_plugin.py`: +Create a new Python file in `/etc/haproxy/plugins/` (or builtin location for core plugins): ```python +# /etc/haproxy/plugins/my_plugin.py + import os import sys @@ -640,528 +164,1692 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from plugins import PluginInterface, PluginType, PluginContext, PluginResult -from functions import loggerEasyHaproxy # Optional: for logging -``` +from functions import loggerEasyHaproxy -### Step 2: Define Plugin Class -```python class MyPlugin(PluginInterface): - """ - Brief description of what your plugin does. - - Configuration: - - enabled: Enable/disable plugin (default: true) - - my_option: Description of option - - Example: - easyhaproxy.http.plugins: my_plugin - easyhaproxy.http.plugin.my_plugin.my_option: value - """ + """My custom plugin description""" def __init__(self): - """Initialize plugin with default values""" + # Initialize default configuration self.enabled = True - self.my_option = "default_value" -``` + self.my_setting = "default_value" -### Step 3: Implement Required Methods - -```python @property def name(self) -> str: - return "my_plugin" # Must match filename + """Return unique plugin name""" + return "my_plugin" @property def plugin_type(self) -> PluginType: - return PluginType.DOMAIN # or PluginType.GLOBAL + """Return plugin type (GLOBAL or DOMAIN)""" + return PluginType.DOMAIN def configure(self, config: dict) -> None: - """Parse configuration from YAML/env/labels""" + """ + Configure plugin from YAML/env/labels + + Args: + config: Dictionary with plugin configuration + """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "my_option" in config: - self.my_option = config["my_option"] - - loggerEasyHaproxy.debug(f"Configured {self.name}: enabled={self.enabled}, my_option={self.my_option}") + if "my_setting" in config: + self.my_setting = config["my_setting"] def process(self, context: PluginContext) -> PluginResult: - """Execute plugin logic""" - # Return empty result if disabled + """ + Process plugin logic and return result + + Args: + context: PluginContext with execution data + + Returns: + PluginResult with HAProxy config and metadata + """ if not self.enabled: return PluginResult() - # Generate HAProxy config snippet - haproxy_config = f"""# My Plugin - Description - http-request set-header X-My-Header "{self.my_option}\"""" + # Generate HAProxy configuration + haproxy_config = f"""# My Plugin - Custom functionality +http-request set-header X-My-Header {self.my_setting}""" - # Return result return PluginResult( haproxy_config=haproxy_config, metadata={ "domain": context.domain, - "my_option": self.my_option + "setting_value": self.my_setting } ) ``` -### Step 4: Test Your Plugin +### Step 2: Enable Plugin -Enable debug logging: -```bash -EASYHAPROXY_LOG_LEVEL=DEBUG -``` +**Via container label (Docker):** -Enable plugin via label: ```yaml services: - test: + myapp: labels: - easyhaproxy.http.host: test.example.com + easyhaproxy.http.host: example.com easyhaproxy.http.plugins: my_plugin - easyhaproxy.http.plugin.my_plugin.my_option: custom_value + easyhaproxy.http.plugin.my_plugin.my_setting: custom_value ``` -Check logs for: -``` -INFO: Loaded external plugin: my_plugin (domain) -DEBUG: Configured my_plugin: enabled=True, my_option=custom_value -DEBUG: Executing domain plugin: my_plugin for domain: test.example.com -DEBUG: Plugin my_plugin metadata: {'domain': 'test.example.com', 'my_option': 'custom_value'} -``` +**Via YAML configuration:** -## Complete Examples - -### Example 1: Custom Header Plugin (DOMAIN) - -Add custom headers to specific domains: - -```python -import os -import sys -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from plugins import PluginInterface, PluginType, PluginContext, PluginResult - - -class CustomHeaderPlugin(PluginInterface): - """Add custom HTTP headers to requests""" - - def __init__(self): - self.enabled = True - self.headers = {} # {header_name: header_value} - - @property - def name(self) -> str: - return "custom_header" - - @property - def plugin_type(self) -> PluginType: - return PluginType.DOMAIN - - def configure(self, config: dict) -> None: - if "enabled" in config: - self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - - # Parse headers from config - # Format: header1:value1,header2:value2 - if "headers" in config: - for header_pair in config["headers"].split(","): - if ":" in header_pair: - name, value = header_pair.split(":", 1) - self.headers[name.strip()] = value.strip() - - def process(self, context: PluginContext) -> PluginResult: - if not self.enabled or not self.headers: - return PluginResult() - - # Generate HAProxy directives for each header - lines = ["# Custom Headers"] - for name, value in self.headers.items(): - lines.append(f' http-request set-header {name} "{value}"') - - return PluginResult( - haproxy_config="\n".join(lines), - metadata={"domain": context.domain, "headers": self.headers} - ) -``` - -**Usage:** ```yaml -labels: - easyhaproxy.http.plugins: custom_header - easyhaproxy.http.plugin.custom_header.headers: X-App-Name:MyApp,X-Environment:Production +# /etc/haproxy/static/config.yaml +plugins: + enabled: [my_plugin] + config: + my_plugin: + enabled: true + my_setting: custom_value ``` -### Example 2: Rate Limiting Plugin (DOMAIN) +**Via environment variable:** -Add HAProxy rate limiting per domain: +```bash +EASYHAPROXY_PLUGINS_ENABLED=my_plugin +EASYHAPROXY_PLUGIN_MY_PLUGIN_MY_SETTING=custom_value +``` + +### Step 3: Test Plugin + +Restart EasyHAProxy and check logs: + +```bash +docker-compose restart haproxy +docker-compose logs -f haproxy | grep my_plugin +``` + +Expected output: +``` +[INFO] Loaded external plugin: my_plugin (domain) +[DEBUG] Configured plugin: my_plugin with config: {'my_setting': 'custom_value'} +[DEBUG] Executing domain plugin: my_plugin for domain: example.com +``` + +--- + +## API Reference + +### PluginInterface + +Base class all plugins must inherit from. ```python +class PluginInterface(ABC): + """Base class all plugins must inherit""" + + @property + @abstractmethod + def name(self) -> str: + """Return the unique plugin name""" + pass + + @property + @abstractmethod + def plugin_type(self) -> PluginType: + """Return the plugin type (GLOBAL or DOMAIN)""" + pass + + @abstractmethod + def configure(self, config: dict) -> None: + """ + Configure the plugin with settings from YAML/env/labels + + Args: + config: Dictionary with plugin-specific configuration + """ + pass + + @abstractmethod + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin logic and return result + + Args: + context: PluginContext with all necessary data + + Returns: + PluginResult with HAProxy config snippets and/or modified data + """ + pass +``` + +**Properties:** + +- `name` - Unique identifier (used in configuration and logs) +- `plugin_type` - Execution model (`PluginType.GLOBAL` or `PluginType.DOMAIN`) + +**Methods:** + +- `configure(config)` - Receives plugin configuration during initialization +- `process(context)` - Main execution logic, returns `PluginResult` + +### PluginType + +Enum defining plugin execution types. + +```python +class PluginType(Enum): + """Plugin execution types""" + GLOBAL = "global" # Execute once per discovery cycle + DOMAIN = "domain" # Execute per domain/host +``` + +### PluginContext + +Container for all plugin execution data. + +```python +@dataclass +class PluginContext: + """Container for all plugin execution data""" + parsed_object: dict # {IP: labels} from discovery + easymapping: list # Current HAProxy mapping structure + container_env: dict # Environment configuration + domain: Optional[str] = None # Domain name (for DOMAIN plugins) + port: Optional[str] = None # Port (for DOMAIN plugins) + host_config: Optional[dict] = None # Domain-specific config +``` + +**Fields:** + +- `parsed_object` - Raw discovery data: `{IP: {label: value, ...}, ...}` +- `easymapping` - Current mapping structure (list of domain configurations) +- `container_env` - Environment variables and global configuration +- `domain` - Domain name (only for DOMAIN plugins) +- `port` - Port number (only for DOMAIN plugins) +- `host_config` - Domain-specific labels/annotations (only for DOMAIN plugins) + +**Usage in GLOBAL plugins:** + +```python +def process(self, context: PluginContext) -> PluginResult: + # Access all discovered services + for ip, labels in context.parsed_object.items(): + print(f"Found service at {ip}: {labels}") + + # Access global environment + debug_mode = context.container_env.get("DEBUG", "false") +``` + +**Usage in DOMAIN plugins:** + +```python +def process(self, context: PluginContext) -> PluginResult: + # Access domain-specific data + domain = context.domain # e.g., "example.com" + port = context.port # e.g., "80" + + # Check domain-specific labels + custom_label = context.host_config.get("custom_label", "default") +``` + +### PluginResult + +Plugin execution result containing configuration and metadata. + +```python +@dataclass +class PluginResult: + """Plugin execution result""" + haproxy_config: str = "" # HAProxy config snippet to inject + modified_easymapping: Optional[list] = None # Modified easymapping structure + metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging +``` + +**Fields:** + +- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend) +- `modified_easymapping` - Modified easymapping structure (optional, advanced use) +- `metadata` - Dictionary with debugging/logging information + +**Examples:** + +```python +# Simple config injection +return PluginResult( + haproxy_config="http-request deny deny_status 403" +) + +# With metadata +return PluginResult( + haproxy_config="acl whitelisted src 10.0.0.0/8", + metadata={ + "domain": context.domain, + "allowed_networks": ["10.0.0.0/8"], + "rules_added": 1 + } +) + +# No operation (plugin disabled or no action needed) +return PluginResult() +``` + +### PluginManager + +Manages plugin loading, configuration, and execution. + +```python +class PluginManager: + """Manages plugin loading, configuration, and execution""" + + def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False): + """ + Initialize the plugin manager + + Args: + plugins_dir: Directory containing plugin files + abort_on_error: If True, abort on plugin errors; if False, log and continue + """ + + def load_plugins(self) -> None: + """Discover and load plugins from the plugins directory""" + + def configure_plugins(self, plugins_config: dict) -> None: + """Configure all loaded plugins with their settings""" + + def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """Execute all global plugins""" + + def execute_domain_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """Execute all domain plugins for a specific domain""" +``` + +**Note:** You typically don't interact with PluginManager directly when writing plugins. It's used by EasyHAProxy core. + +--- + +## Advanced Examples + +### Example 1: IP Whitelist Plugin (DOMAIN) + +Restrict access to specific IP addresses per domain. + +```python +""" +IP Whitelist Plugin for EasyHAProxy + +This plugin restricts access to a domain to only specific IP addresses or CIDR ranges. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - allowed_ips: Comma-separated list of IPs/CIDR ranges to allow + - status_code: HTTP status code to return for blocked IPs (default: 403) + +Example YAML config: + plugins: + ip_whitelist: + enabled: true + allowed_ips: "192.168.1.0/24,10.0.0.1,172.16.0.0/16" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.1" + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 + +HAProxy Config Generated: + # IP Whitelist - Only allow specific IPs + acl whitelisted_ip src 192.168.1.0/24 10.0.0.1 + http-request deny deny_status 403 if !whitelisted_ip +""" + import os import sys + +# Add parent directory to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from plugins import PluginInterface, PluginType, PluginContext, PluginResult -class RateLimitPlugin(PluginInterface): - """Rate limit requests per domain""" +class IpWhitelistPlugin(PluginInterface): + """Plugin to restrict access to specific IP addresses""" def __init__(self): self.enabled = True - self.requests_per_second = 100 - self.burst = 200 + self.allowed_ips = [] + self.status_code = 403 @property def name(self) -> str: - return "rate_limit" + return "ip_whitelist" @property def plugin_type(self) -> PluginType: return PluginType.DOMAIN def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - allowed_ips: Comma-separated list of IPs/CIDR ranges + - status_code: HTTP status code to return for denied requests + """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "requests_per_second" in config: - try: - self.requests_per_second = int(config["requests_per_second"]) - except ValueError: - pass + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.strip()] - if "burst" in config: + if "status_code" in config: try: - self.burst = int(config["burst"]) + self.status_code = int(config["status_code"]) except ValueError: - pass + self.status_code = 403 def process(self, context: PluginContext) -> PluginResult: - if not self.enabled: + """ + Generate HAProxy config to whitelist specific IPs + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.allowed_ips: return PluginResult() - # Use HAProxy stick tables for rate limiting - domain_safe = context.domain.replace(".", "_") + # Create space-separated list of IPs for ACL + ips_str = " ".join(self.allowed_ips) - haproxy_config = f"""# Rate Limiting - {self.requests_per_second} req/s - stick-table type ip size 100k expire 30s store http_req_rate({self.requests_per_second}s) - http-request track-sc0 src - http-request deny deny_status 429 if {{ sc_http_req_rate(0) gt {self.burst} }}""" + # Generate HAProxy config snippet + haproxy_config = f"""# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src {ips_str} +http-request deny deny_status {self.status_code} if !whitelisted_ip""" return PluginResult( haproxy_config=haproxy_config, + modified_easymapping=None, metadata={ "domain": context.domain, - "rate_limit": self.requests_per_second, - "burst": self.burst + "allowed_ips": self.allowed_ips, + "status_code": self.status_code } ) ``` -**Usage:** -```yaml -labels: - easyhaproxy.http.plugins: rate_limit - easyhaproxy.http.plugin.rate_limit.requests_per_second: 50 - easyhaproxy.http.plugin.rate_limit.burst: 100 -``` +### Example 2: FastCGI Plugin (DOMAIN) -### Example 3: Maintenance Mode Plugin (GLOBAL) - -Put all sites in maintenance mode: +Configure FastCGI parameters for PHP-FPM and other FastCGI applications. ```python +""" +FastCGI Plugin for EasyHAProxy + +This plugin generates HAProxy fcgi-app configuration for PHP-FPM and other FastCGI applications. +It runs as a DOMAIN plugin (once per domain). + +The plugin creates: + 1. A top-level fcgi-app section with CGI parameter definitions + 2. A use-fcgi-app directive in the backend + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - document_root: Document root path (default: /var/www/html) + - script_filename: Pattern for SCRIPT_FILENAME (default: %[path]) + - index_file: Default index file (default: index.php) + - path_info: Enable PATH_INFO support (default: true) + - custom_params: Dictionary of custom FastCGI parameters (optional) + +Example YAML config: + plugins: + fastcgi: + enabled: true + document_root: /var/www/html + index_file: index.php + path_info: true + +Example Container Label: + easyhaproxy.http.plugins: "fastcgi" + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: true +""" + import os import sys + +# Add parent directory to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy -class MaintenanceModePlugin(PluginInterface): - """Enable/disable maintenance mode globally""" +class FastcgiPlugin(PluginInterface): + """Plugin to configure FastCGI parameters for PHP-FPM""" def __init__(self): - self.enabled = False # Disabled by default - self.message = "Site is under maintenance" + self.enabled = True + self.document_root = "/var/www/html" + self.script_filename = "%[path]" + self.index_file = "index.php" + self.path_info = True + self.custom_params = {} @property def name(self) -> str: - return "maintenance_mode" + return "fastcgi" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - document_root: Document root path + - script_filename: Pattern for SCRIPT_FILENAME + - index_file: Default index file + - path_info: Enable PATH_INFO support + - custom_params: Dictionary of custom FastCGI parameters + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "document_root" in config: + self.document_root = config["document_root"] + + if "script_filename" in config: + self.script_filename = config["script_filename"] + + if "index_file" in config: + self.index_file = config["index_file"] + + if "path_info" in config: + self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"] + + if "custom_params" in config: + self.custom_params = config["custom_params"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin and generate FastCGI configuration + + Args: + context: Plugin execution context + + Returns: + PluginResult with HAProxy FastCGI configuration + """ + if not self.enabled: + return PluginResult() + + # Generate a unique fcgi-app name based on the domain + # Replace dots and colons with underscores for valid HAProxy identifier + domain_safe = context.domain.replace(".", "_").replace(":", "_") + fcgi_app_name = f"fcgi_{domain_safe}" + + # Generate the use-fcgi-app directive for the backend + backend_config = f"use-fcgi-app {fcgi_app_name}" + + # Generate the fcgi-app section (to be inserted at top level) + fcgi_app_lines = [f"fcgi-app {fcgi_app_name}"] + fcgi_app_lines.append(f" docroot {self.document_root}") + fcgi_app_lines.append(f" index {self.index_file}") + + # PATH_INFO support + if self.path_info: + fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$") + + # Set SCRIPT_FILENAME if customized + if self.script_filename and self.script_filename != "%[path]": + fcgi_app_lines.append(f" set-param SCRIPT_FILENAME {self.script_filename}") + + # Custom parameters + if self.custom_params: + for param_name, param_value in self.custom_params.items(): + fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}") + + fcgi_app_definition = "\n".join(fcgi_app_lines) + + # Build metadata - store fcgi_app_definition to be extracted and added to global configs + metadata = { + "domain": context.domain, + "fcgi_app_name": fcgi_app_name, + "fcgi_app_definition": fcgi_app_definition, # For top-level injection + "document_root": self.document_root, + "index_file": self.index_file, + "path_info": self.path_info, + "custom_params_count": len(self.custom_params) + } + + return PluginResult( + haproxy_config=backend_config, # use-fcgi-app directive for the backend + modified_easymapping=None, + metadata=metadata + ) +``` + +### Example 3: JWT Validator Plugin (DOMAIN) + +Validate JWT tokens using HAProxy's built-in JWT functionality with path-based validation. + +```python +""" +JWT Validator Plugin for EasyHAProxy + +This plugin validates JWT tokens using HAProxy's built-in JWT functionality. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) + - audience: Expected JWT audience (optional, set to "none"/"null" to skip validation) + - pubkey_path: Path to public key file (required if pubkey not provided) + - pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) + - paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) + - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation + +Path Validation Logic: + - No paths configured: ALL requests to the domain require JWT validation (default behavior) + - Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through + - Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied + +Example YAML config: + plugins: + jwt_validator: + enabled: true + algorithm: RS256 + issuer: https://myaccount.auth0.com/ + audience: https://api.mywebsite.com + pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem + paths: + - /api/admin + - /api/sensitive + only_paths: false + +Example Container Label: + easyhaproxy.http.plugins: "jwt_validator" + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: true +""" + +import base64 +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class JwtValidatorPlugin(PluginInterface): + """Plugin to validate JWT tokens""" + + def __init__(self): + self.enabled = True + self.algorithm = "RS256" + self.issuer = None # Optional + self.audience = None # Optional + self.pubkey_path = None # Path to public key file + self.pubkey = None # Public key content (alternative to pubkey_path) + self.paths = [] # List of paths that require JWT validation + self.only_paths = False # If true, only specified paths are accessible + + @property + def name(self) -> str: + return "jwt_validator" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional) + - audience: Expected JWT audience (optional) + - pubkey_path: Path to public key file + - pubkey: Public key content as base64-encoded string + - paths: List of paths that require JWT validation (optional) + - only_paths: If true, only specified paths are accessible (default: false) + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "algorithm" in config: + self.algorithm = config["algorithm"] + + # Parse issuer (optional - if not set, issuer validation is skipped) + if "issuer" in config: + issuer = str(config["issuer"]).strip() + if issuer: # Only set if not empty + self.issuer = issuer + + # Parse audience (optional - if not set, audience validation is skipped) + if "audience" in config: + audience = str(config["audience"]).strip() + if audience: # Only set if not empty + self.audience = audience + + # Public key configuration + if "pubkey_path" in config: + self.pubkey_path = config["pubkey_path"] + + if "pubkey" in config: + # Decode from base64 (consistent with sslcert parameter) + self.pubkey = base64.b64decode(config["pubkey"]).decode('ascii') + + # Path configuration + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] + + if "only_paths" in config: + self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to validate JWT tokens + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled: + return PluginResult() + + # Determine public key file path + if self.pubkey_path: + pubkey_file = self.pubkey_path + elif self.pubkey: + # Generate path for pubkey based on domain + domain_safe = context.domain.replace(".", "_").replace(":", "_") + pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" + else: + loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") + return PluginResult() + + # Build HAProxy configuration + lines = ["# JWT Validator - Validate JWT tokens"] + + # Determine path condition suffix + path_condition = "" + if self.paths: + # Define ACL for protected paths + lines.append("") + lines.append("# Define paths that require JWT validation") + for path in self.paths: + lines.append(f"acl jwt_protected_path path_beg {path}") + lines.append("") + + if self.only_paths: + # Deny all paths that are not in the protected list + lines.append("# Deny access to paths not in the protected list") + lines.append("http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path") + lines.append("") + # All remaining requests are on protected paths, no condition needed + path_condition = "" + else: + # Only validate JWT on protected paths + path_condition = " if jwt_protected_path" + + # Check for Authorization header + lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") + + # Extract JWT parts + lines.append("") + lines.append("# Extract JWT header and payload") + lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){path_condition}") + lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}") + lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}") + lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}") + + # Validate JWT + lines.append("") + lines.append("# Validate JWT") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{path_condition}") + + # Validate issuer (if configured) + if self.issuer: + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{path_condition}") + + # Validate audience (if configured) + if self.audience: + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{path_condition}") + + # Validate signature + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{path_condition}") + + # Validate expiration + lines.append("") + lines.append("# Validate expiration") + lines.append(f"http-request set-var(txn.now) date(){path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{path_condition}") + + haproxy_config = "\n".join(lines) + + # Build metadata + metadata = { + "domain": context.domain, + "algorithm": self.algorithm, + "pubkey_file": pubkey_file, + "validates_issuer": self.issuer is not None, + "validates_audience": self.audience is not None, + "path_validation": len(self.paths) > 0, + "only_paths": self.only_paths + } + + if self.issuer: + metadata["issuer"] = self.issuer + if self.audience: + metadata["audience"] = self.audience + if self.pubkey: + metadata["pubkey_content"] = self.pubkey + if self.paths: + metadata["paths"] = self.paths + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata=metadata + ) +``` + +### Example 4: Cleanup Plugin (GLOBAL) + +Perform cleanup tasks during each discovery cycle. + +```python +""" +Cleanup Plugin for EasyHAProxy + +This plugin performs cleanup tasks during each discovery cycle. +It runs as a GLOBAL plugin (once per cycle). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - max_idle_time: Maximum idle time before cleanup in seconds (default: 300) + - cleanup_temp_files: Clean up temporary files (default: true) + +Example YAML config: + plugins: + cleanup: + enabled: true + max_idle_time: 300 + cleanup_temp_files: true + +Example Environment Variable: + EASYHAPROXY_PLUGINS_ENABLED=cleanup + EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +""" + +import os +import sys +import glob +import time + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class CleanupPlugin(PluginInterface): + """Plugin to perform cleanup tasks during discovery cycle""" + + def __init__(self): + self.enabled = True + self.max_idle_time = 300 # 5 minutes + self.cleanup_temp_files = True + + @property + def name(self) -> str: + return "cleanup" @property def plugin_type(self) -> PluginType: return PluginType.GLOBAL def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - max_idle_time: Maximum idle time in seconds + - cleanup_temp_files: Whether to clean up temp files + """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "message" in config: - self.message = config["message"] + if "max_idle_time" in config: + try: + self.max_idle_time = int(config["max_idle_time"]) + except ValueError: + loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") + + if "cleanup_temp_files" in config: + self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] def process(self, context: PluginContext) -> PluginResult: + """ + Perform cleanup tasks + + Args: + context: Plugin execution context + + Returns: + PluginResult with metadata about cleanup actions + """ if not self.enabled: return PluginResult() - # When enabled, return maintenance page for all requests - # This would need custom error pages configured in HAProxy + cleanup_actions = [] + + # Cleanup temporary files + if self.cleanup_temp_files: + temp_dirs = ["/tmp", "/var/tmp"] + current_time = time.time() + + for temp_dir in temp_dirs: + if not os.path.exists(temp_dir): + continue + + try: + # Find old EasyHAProxy temp files + pattern = os.path.join(temp_dir, "easyhaproxy_*") + for filepath in glob.glob(pattern): + try: + file_age = current_time - os.path.getmtime(filepath) + if file_age > self.max_idle_time: + os.remove(filepath) + cleanup_actions.append(f"Removed old temp file: {filepath}") + loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") + + # Log cleanup summary + if cleanup_actions: + loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") + return PluginResult( + haproxy_config="", # No HAProxy config needed for cleanup + modified_easymapping=None, metadata={ - "maintenance_mode": True, - "message": self.message + "actions_performed": len(cleanup_actions), + "actions": cleanup_actions } ) ``` +--- + ## Best Practices ### 1. Error Handling -Always handle errors gracefully: +Always handle errors gracefully to avoid breaking HAProxy configuration. + +**Do:** +```python +def configure(self, config: dict) -> None: + if "port" in config: + try: + self.port = int(config["port"]) + except ValueError: + loggerEasyHaproxy.warning(f"Invalid port value: {config['port']}, using default") + self.port = 8080 +``` + +**Don't:** +```python +def configure(self, config: dict) -> None: + self.port = int(config["port"]) # Crashes if not an integer! +``` + +### 2. Configuration Validation + +Validate configuration during `configure()` phase, not during `process()`. + +**Do:** +```python +def configure(self, config: dict) -> None: + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.strip()] + + # Validate IPs + if not self.allowed_ips: + loggerEasyHaproxy.warning("IP whitelist plugin: No valid IPs configured") + self.enabled = False +``` + +**Don't:** +```python +def process(self, context: PluginContext) -> PluginResult: + # Too late - validation should happen during configure() + if not self.allowed_ips: + raise ValueError("No IPs configured") +``` + +### 3. Use Metadata for Debugging + +Include useful debugging information in metadata. + +```python +return PluginResult( + haproxy_config=config_snippet, + metadata={ + "domain": context.domain, + "rules_generated": 5, + "algorithm": self.algorithm, + "validation_enabled": True, + "paths_protected": self.paths + } +) +``` + +### 4. Handle Boolean Configuration + +Support multiple boolean formats (true/false, 1/0, yes/no). + +```python +def configure(self, config: dict) -> None: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] +``` + +### 5. Support Multiple Configuration Formats + +Support both list and comma-separated string formats for lists. + +```python +def configure(self, config: dict) -> None: + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] +``` + +### 6. Use Descriptive Names + +Use clear, descriptive names for plugins, configuration keys, and ACLs. + +**Do:** +```python +@property +def name(self) -> str: + return "jwt_validator" # Clear and descriptive + +# In generated config: +acl jwt_protected_path path_beg /api +``` + +**Don't:** +```python +@property +def name(self) -> str: + return "jv" # Too cryptic + +# In generated config: +acl p1 path_beg /api # What is p1? +``` + +### 7. Document Your Plugin + +Include comprehensive docstrings with configuration examples. + +```python +""" +Plugin Name for EasyHAProxy + +Brief description of what the plugin does. + +Configuration: + - option1: Description (default: value) + - option2: Description (default: value) + +Example YAML config: + plugins: + plugin_name: + option1: value1 + option2: value2 + +Example Container Label: + easyhaproxy.http.plugins: "plugin_name" + easyhaproxy.http.plugin.plugin_name.option1: value1 +""" +``` + +### 8. Return Empty Result When Disabled + +Always check `enabled` flag and return empty result early. ```python def process(self, context: PluginContext) -> PluginResult: - try: - # Your plugin logic - result = do_something() - return PluginResult(haproxy_config=result) - except Exception as e: - loggerEasyHaproxy.error(f"Plugin {self.name} failed: {e}") - return PluginResult() # Return empty result on error + if not self.enabled: + return PluginResult() + + # Plugin logic here... ``` -### 2. Logging +### 9. Use Logger Appropriately -Use structured logging: +Use appropriate log levels for different messages. ```python from functions import loggerEasyHaproxy -# Info level for important events -loggerEasyHaproxy.info(f"Plugin {self.name} executed successfully") +# For debugging +loggerEasyHaproxy.debug(f"Processing domain: {context.domain}") -# Debug level for detailed info -loggerEasyHaproxy.debug(f"Plugin {self.name} config: {self.my_option}") +# For informational messages +loggerEasyHaproxy.info(f"Loaded plugin configuration: {self.name}") -# Warning for non-critical issues -loggerEasyHaproxy.warning(f"Plugin {self.name}: config missing, using default") +# For warnings (non-fatal issues) +loggerEasyHaproxy.warning(f"Invalid configuration value, using default") -# Error for failures -loggerEasyHaproxy.error(f"Plugin {self.name} failed: {error}") +# For errors (fatal issues) +loggerEasyHaproxy.error(f"Failed to load required file: {filepath}") ``` -### 3. Configuration Validation +### 10. Make Domain-Safe Identifiers -Validate configuration values: +Replace special characters when generating HAProxy identifiers. ```python -def configure(self, config: dict) -> None: - if "timeout" in config: - try: - timeout = int(config["timeout"]) - if timeout < 0: - loggerEasyHaproxy.warning(f"{self.name}: timeout must be positive, using default") - self.timeout = 30 - else: - self.timeout = timeout - except ValueError: - loggerEasyHaproxy.warning(f"{self.name}: invalid timeout value, using default") - self.timeout = 30 +# Replace dots and colons with underscores for valid HAProxy identifier +domain_safe = context.domain.replace(".", "_").replace(":", "_") +fcgi_app_name = f"fcgi_{domain_safe}" + +# example.com:8080 → fcgi_example_com_8080 ``` -### 4. Documentation +--- -Document your plugin thoroughly: - -```python -class MyPlugin(PluginInterface): - """ - One-line description. - - Detailed description of what the plugin does and why you'd use it. - - Configuration Options: - enabled (bool): Enable/disable plugin (default: true) - option1 (str): Description of option1 (default: "value") - option2 (int): Description of option2 (default: 100) - - Example YAML: - plugins: - my_plugin: - enabled: true - option1: custom_value - option2: 200 - - Example Container Label: - easyhaproxy.http.plugins: my_plugin - easyhaproxy.http.plugin.my_plugin.option1: custom_value - easyhaproxy.http.plugin.my_plugin.option2: 200 - - HAProxy Config Generated: - # My Plugin - Description - directive1 value - directive2 value - """ -``` - -### 5. Testing - -Test your plugin with various configurations: - -```python -# Test 1: Plugin disabled -config = {"enabled": "false"} -plugin.configure(config) -result = plugin.process(context) -assert result.haproxy_config == "" - -# Test 2: Plugin with default config -plugin = MyPlugin() -result = plugin.process(context) -assert "expected output" in result.haproxy_config - -# Test 3: Plugin with custom config -config = {"my_option": "custom"} -plugin.configure(config) -result = plugin.process(context) -assert "custom" in result.haproxy_config -``` - -### 6. Performance - -Keep plugins lightweight: - -```python -# DON'T: Make external API calls in domain plugins -def process(self, context: PluginContext) -> PluginResult: - # This runs for EVERY domain! - data = requests.get("https://api.example.com/data") # BAD - -# DO: Cache data or use global plugins for external calls -def process(self, context: PluginContext) -> PluginResult: - # Use cached data - data = self.cached_data -``` - -## Testing Plugins +## Testing Guidelines ### Unit Testing -Create tests in `src/tests/test_my_plugin.py`: +Create unit tests for your plugin in `/src/tests/test_plugins.py`. ```python +"""Test cases for MyPlugin""" + import sys import os + +# Add src to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from plugins import PluginContext -from plugins.my_plugin import MyPlugin +from plugins.builtin.my_plugin import MyPlugin -def test_plugin_initialization(): - plugin = MyPlugin() - assert plugin.name == "my_plugin" - assert plugin.enabled is True +class TestMyPlugin: + """Test cases for MyPlugin (DOMAIN plugin)""" + def test_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = MyPlugin() + assert plugin.name == "my_plugin" + assert plugin.enabled is True + assert plugin.my_setting == "default_value" -def test_plugin_configuration(): - plugin = MyPlugin() - plugin.configure({"enabled": "false", "my_option": "custom"}) - assert plugin.enabled is False - assert plugin.my_option == "custom" + def test_plugin_configuration(self): + """Test plugin configuration""" + plugin = MyPlugin() + # Test custom setting + plugin.configure({"my_setting": "custom_value"}) + assert plugin.my_setting == "custom_value" -def test_plugin_generates_config(): - plugin = MyPlugin() - context = PluginContext( - parsed_object={}, - easymapping=[], - container_env={}, - domain="example.com", - port="80", - host_config={} - ) + # Test disabling + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False - result = plugin.process(context) - assert "X-My-Header" in result.haproxy_config - assert result.metadata["domain"] == "example.com" + # Test enabling with various values + plugin.configure({"enabled": "true"}) + assert plugin.enabled is True + plugin.configure({"enabled": "1"}) + assert plugin.enabled is True -def test_plugin_disabled(): - plugin = MyPlugin() - plugin.configure({"enabled": "false"}) + def test_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = MyPlugin() + plugin.configure({"my_setting": "test_value"}) - context = PluginContext( - parsed_object={}, - easymapping=[], - container_env={}, - domain="example.com" - ) + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) - result = plugin.process(context) - assert result.haproxy_config == "" + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "My Plugin" in result.haproxy_config + assert "X-My-Header test_value" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["setting_value"] == "test_value" + + def test_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = MyPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} ``` -Run tests: +### Integration Testing + +Test your plugin in a real environment. + +**Create test fixture:** + ```bash -pytest src/tests/test_my_plugin.py -v +# Create test service configuration +mkdir -p /home/jg/Projects/opensource/github/byjg/docker-easy-haproxy/src/tests/fixtures/services-my-plugin ``` +**Create expected output:** + +```bash +# Create expected HAProxy configuration +cat > /home/jg/Projects/opensource/github/byjg/docker-easy-haproxy/src/tests/expected/services-my-plugin.txt << 'EOF' +# Generated HAProxy configuration with my_plugin enabled +backend be_example_com_80 + # My Plugin - Custom functionality + http-request set-header X-My-Header custom_value +EOF +``` + +**Run tests:** + +```bash +cd /home/jg/Projects/opensource/github/byjg/docker-easy-haproxy/src +python -m pytest tests/test_plugins.py::TestMyPlugin -v +``` + +### Manual Testing + +Test your plugin with a live container: + +```yaml +# docker-compose.yml +version: '3.8' + +services: + web: + image: nginx:latest + labels: + easyhaproxy.http.host: test.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_setting: test_value + + haproxy: + build: . + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./my_plugin.py:/etc/haproxy/plugins/my_plugin.py + environment: + - EASYHAPROXY_DISCOVER=docker +``` + +**Verify plugin loading:** + +```bash +docker-compose up -d +docker-compose logs haproxy | grep my_plugin +``` + +Expected output: +``` +[INFO] Loaded external plugin: my_plugin (domain) +[DEBUG] Configured plugin: my_plugin with config: {'my_setting': 'test_value'} +[DEBUG] Executing domain plugin: my_plugin for domain: test.example.com +``` + +**Verify generated configuration:** + +```bash +docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin" +``` + +--- + ## Troubleshooting ### Plugin Not Loading -**Check logs:** -``` -ERROR: Failed to load plugin from /etc/haproxy/plugins/my_plugin.py: -``` +**Symptom:** Plugin not appearing in logs. -**Common causes:** -- Syntax errors in Python code -- Missing imports -- Class doesn't inherit from `PluginInterface` -- `__init__.py` in plugins directory (remove it) +**Possible causes:** + +1. **File not in plugins directory** + ```bash + ls -la /etc/haproxy/plugins/ + # Ensure my_plugin.py exists + ``` + +2. **Invalid Python syntax** + ```bash + python3 -m py_compile /etc/haproxy/plugins/my_plugin.py + # Check for syntax errors + ``` + +3. **Class doesn't inherit PluginInterface** + ```python + # Wrong: + class MyPlugin: + pass + + # Correct: + class MyPlugin(PluginInterface): + pass + ``` + +4. **Missing required imports** + ```python + # Add this at the top of your plugin: + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from plugins import PluginInterface, PluginType, PluginContext, PluginResult + ``` ### Plugin Not Executing -**Check logs:** -``` -DEBUG: Executing domain plugin: my_plugin for domain: example.com -``` +**Symptom:** Plugin loads but doesn't execute. -**If missing:** -- Plugin not enabled in labels/YAML/env -- Plugin name mismatch -- Plugin returned by `name` property doesn't match +**Possible causes:** -### Configuration Not Working +1. **Plugin not enabled in configuration** + ```yaml + # Add to config.yaml: + plugins: + enabled: [my_plugin] + ``` + +2. **Wrong plugin type for use case** + - GLOBAL plugins don't receive domain context + - DOMAIN plugins execute per domain, not globally + +3. **Plugin disabled via configuration** + ```python + # Check enabled flag: + if not self.enabled: + return PluginResult() # Plugin is disabled + ``` + +### Configuration Not Applied + +**Symptom:** Plugin executes but configuration not applied. + +**Possible causes:** + +1. **Configuration key mismatch** + ```yaml + # Wrong: + plugins: + config: + my-plugin: # Hyphen instead of underscore + my_setting: value + + # Correct: + plugins: + config: + my_plugin: # Must match plugin.name + my_setting: value + ``` + +2. **Configuration not parsed in configure()** + ```python + def configure(self, config: dict) -> None: + # Make sure to check for your config key: + if "my_setting" in config: + self.my_setting = config["my_setting"] + ``` + +### HAProxy Configuration Invalid + +**Symptom:** HAProxy fails to reload with syntax error. + +**Possible causes:** + +1. **Invalid HAProxy syntax in generated config** + ```bash + # Test configuration manually: + haproxy -c -f /etc/haproxy/haproxy.cfg + ``` + +2. **Missing quotes or escaping** + ```python + # Wrong: + config = f"http-request set-header X-Value {value}" + + # Correct (if value contains spaces): + config = f"http-request set-header X-Value \"{value}\"" + ``` + +3. **Invalid ACL names** + ```python + # Wrong (contains special characters): + acl_name = f"acl_{context.domain}" # example.com → acl_example.com (dot invalid) + + # Correct: + acl_name = f"acl_{context.domain.replace('.', '_')}" # example_com + ``` + +### Plugin Errors + +**Symptom:** Plugin crashes or throws exceptions. + +**Debug steps:** + +1. **Enable debug logging** + ```bash + # Set environment variable: + EASYHAPROXY_LOG_LEVEL=DEBUG + ``` + +2. **Add debug statements** + ```python + def process(self, context: PluginContext) -> PluginResult: + loggerEasyHaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}") + loggerEasyHaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}") + # ... rest of plugin logic + ``` + +3. **Check abort_on_error setting** + ```python + # In PluginManager initialization: + # abort_on_error=False (default) - logs errors and continues + # abort_on_error=True - crashes on errors for debugging + ``` + +4. **Wrap risky operations** + ```python + def process(self, context: PluginContext) -> PluginResult: + try: + # Risky operation + result = self.do_something_risky() + except Exception as e: + loggerEasyHaproxy.error(f"Plugin {self.name} error: {str(e)}") + return PluginResult() # Return empty result on error + ``` + +### Metadata Not Appearing in Logs + +**Symptom:** Plugin metadata not visible in logs. + +**Solution:** + +1. **Enable debug logging** + ```bash + EASYHAPROXY_LOG_LEVEL=DEBUG + ``` + +2. **Ensure metadata is returned** + ```python + return PluginResult( + haproxy_config=config, + metadata={ + "domain": context.domain, + "setting": self.my_setting + } + ) + ``` + +--- + +## Distribution + +### Sharing Your Plugin + +#### Option 1: Single File Distribution + +Share your plugin as a single `.py` file: -**Enable debug logging:** ```bash -EASYHAPROXY_LOG_LEVEL=DEBUG +# Users copy the file to their plugins directory: +cp my_plugin.py /etc/haproxy/plugins/ ``` -**Check:** +**Advantages:** +- Simple distribution +- No installation required +- Works immediately + +**Best for:** Simple plugins without dependencies + +#### Option 2: GitHub Repository + +Create a GitHub repository with installation instructions: + ``` -DEBUG: Configured my_plugin: enabled=True, option=value +my-easyhaproxy-plugin/ +├── README.md +├── my_plugin.py +├── tests/ +│ └── test_my_plugin.py +└── examples/ + ├── docker-compose.yml + └── config.yaml ``` -**Verify precedence:** -1. Container labels (highest) -2. YAML config -3. Environment variables (lowest) +**Installation:** +```bash +# Users download and install: +wget https://raw.githubusercontent.com/user/my-plugin/main/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py +``` -## Further Reading +#### Option 3: Docker Image with Plugin -- [Using Plugins](plugins.md) - How to use existing plugins -- [Built-in Plugin Source Code](https://github.com/byjg/docker-easy-haproxy/tree/master/src/plugins/builtin) - Examples to learn from -- [HAProxy Configuration Manual](http://docs.haproxy.org/2.8/configuration.html) - HAProxy directive reference +Create a custom EasyHAProxy image with your plugin included: -## Support +```dockerfile +FROM byjg/easy-haproxy:latest -For issues or questions: -- GitHub Issues: https://github.com/byjg/docker-easy-haproxy/issues -- Documentation: https://byjg.github.io/docker-easy-haproxy +# Copy plugin to builtin directory +COPY my_plugin.py /app/src/plugins/builtin/ + +# Optional: Add default configuration +COPY plugin_config.yaml /etc/haproxy/static/config.yaml +``` + +**Build and distribute:** +```bash +docker build -t my-org/easy-haproxy-with-plugin:latest . +docker push my-org/easy-haproxy-with-plugin:latest +``` + +### Documentation + +Include comprehensive documentation with your plugin: + +```markdown +# My Plugin for EasyHAProxy + +Brief description of what your plugin does. + +## Features + +- Feature 1 +- Feature 2 +- Feature 3 + +## Installation + +### Docker +\`\`\`bash +wget https://example.com/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py +\`\`\` + +### Kubernetes +\`\`\`yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: haproxy-plugins +data: + my_plugin.py: | + # Plugin content here +\`\`\` + +## Configuration + +### Options + +- `enabled` (boolean, default: true) - Enable/disable plugin +- `option1` (string, default: "value") - Description + +### Examples + +#### Docker Compose +\`\`\`yaml +services: + web: + labels: + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.option1: value +\`\`\` + +#### YAML Config +\`\`\`yaml +plugins: + my_plugin: + enabled: true + option1: value +\`\`\` + +## Troubleshooting + +Common issues and solutions. + +## License + +MIT +``` + +### Version Control + +Use semantic versioning for your plugin: + +```python +class MyPlugin(PluginInterface): + """ + My Plugin for EasyHAProxy + + Version: 1.0.0 + Author: Your Name + License: MIT + """ + + VERSION = "1.0.0" +``` + +### Contributing to EasyHAProxy + +To contribute your plugin to the EasyHAProxy core: + +1. **Fork the repository** + ```bash + git clone https://github.com/byjg/docker-easy-haproxy.git + ``` + +2. **Add your plugin to builtin/** + ```bash + cp my_plugin.py src/plugins/builtin/ + ``` + +3. **Add tests** + ```bash + # Add test class to src/tests/test_plugins.py + ``` + +4. **Update documentation** + ```bash + # Add plugin to docs/plugins.md + ``` + +5. **Create pull request** + - Describe plugin functionality + - Include usage examples + - Show test results + +--- + +## Conclusion + +You now have a comprehensive understanding of the EasyHAProxy plugin system. Key takeaways: + +- **Plugin Types:** GLOBAL (once per cycle) vs DOMAIN (per domain) +- **Plugin Lifecycle:** Load → Configure → Execute → Result +- **API:** PluginInterface, PluginContext, PluginResult +- **Best Practices:** Error handling, validation, logging, testing +- **Distribution:** Single file, GitHub, or Docker image + +For more examples, see the builtin plugins in `/src/plugins/builtin/`: +- `cloudflare.py` - Simple DOMAIN plugin +- `fastcgi.py` - Advanced DOMAIN plugin with complex config +- `jwt_validator.py` - Security plugin with path-based logic +- `ip_whitelist.py` - Access control plugin +- `cleanup.py` - GLOBAL plugin example + +Happy plugin development! diff --git a/docs/plugins.md b/docs/plugins.md index 081900f..86d1e1b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -42,368 +42,14 @@ Execute **once for each discovered domain/host**. ## Built-in Plugins -### Cloudflare Plugin (Domain) - -Restores the original visitor IP address when requests come through Cloudflare's CDN. - -**Why use it:** Cloudflare replaces the visitor's IP with its own. This plugin restores the original IP from the `CF-Connecting-IP` header. - -**Configuration options:** -- `enabled` - Enable/disable plugin (default: `true`) -- `ip_list_path` - Path to Cloudflare IP list (default: `/etc/haproxy/cloudflare_ips.lst`) - -**Enable via container label:** -```yaml -services: - myapp: - labels: - easyhaproxy.http.host: example.com - easyhaproxy.http.plugins: cloudflare -``` - -**Custom IP list path:** -```yaml -labels: - easyhaproxy.http.plugins: cloudflare - easyhaproxy.http.plugin.cloudflare.ip_list_path: /custom/path/cf_ips.lst -``` - -**HAProxy config generated:** -``` -# Cloudflare - Restore original visitor IP -acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst -http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare -``` - -**Required:** Download Cloudflare IP list from [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786). - -### Cleanup Plugin (Global) - -Performs cleanup tasks during each discovery cycle, such as removing old temporary files. - -**Why use it:** Prevents disk space issues by automatically cleaning up temporary files created by EasyHAProxy. - -**Configuration options:** -- `enabled` - Enable/disable plugin (default: `true`) -- `max_idle_time` - Maximum age in seconds before deleting files (default: `300`) -- `cleanup_temp_files` - Enable temp file cleanup (default: `true`) - -**Enable via YAML:** -```yaml -# /etc/haproxy/static/config.yaml -plugins: - enabled: [cleanup] - config: - cleanup: - max_idle_time: 600 - cleanup_temp_files: true -``` - -**Enable via environment variable:** -```bash -EASYHAPROXY_PLUGINS_ENABLED=cleanup -EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 -``` - -### Deny Pages Plugin (Domain) - -Blocks access to specific paths for a domain, returning a configurable HTTP status code. - -**Why use it:** Protect admin panels, internal APIs, or debugging endpoints from public access. - -**Configuration options:** -- `enabled` - Enable/disable plugin (default: `true`) -- `paths` - Comma-separated list of paths to block (e.g., `/admin,/private`) -- `status_code` - HTTP status code to return (default: `403`) - -**Enable via container label:** -```yaml -services: - webapp: - labels: - easyhaproxy.http.host: example.com - easyhaproxy.http.plugins: deny_pages - easyhaproxy.http.plugin.deny_pages.paths: /admin,/private,/debug - easyhaproxy.http.plugin.deny_pages.status_code: 404 -``` - -**HAProxy config generated:** -``` -# Deny Pages - Block specific paths -acl denied_path path_beg /admin /private /debug -http-request deny deny_status 404 if denied_path -``` - -### IP Whitelist Plugin (Domain) - -Restricts access to a domain to only specific IP addresses or CIDR ranges. - -**Why use it:** Restrict access to internal tools, admin panels, or staging environments to only trusted IP addresses. - -**Configuration options:** -- `enabled` - Enable/disable plugin (default: `true`) -- `allowed_ips` - Comma-separated list of IPs/CIDR ranges to allow (e.g., `192.168.1.0/24,10.0.0.1`) -- `status_code` - HTTP status code to return for blocked IPs (default: `403`) - -**Enable via container label:** -```yaml -services: - admin: - labels: - easyhaproxy.http.host: admin.example.com - easyhaproxy.http.plugins: ip_whitelist - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 - easyhaproxy.http.plugin.ip_whitelist.status_code: 403 -``` - -**HAProxy config generated:** -``` -# IP Whitelist - Only allow specific IPs -acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 -http-request deny deny_status 403 if !whitelisted_ip -``` - -**Important:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! - -### JWT Validator Plugin (Domain) - -Validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JWT functionality. - -**Why use it:** Protect APIs and services with JWT authentication without needing application-level code. - -**Configuration options:** -- `enabled` - Enable/disable plugin (default: `true`) -- `algorithm` - JWT signing algorithm (default: `RS256`) -- `issuer` - Expected JWT issuer (optional, set to `none`/`null` to skip validation) -- `audience` - Expected JWT audience (optional, set to `none`/`null` to skip validation) -- `pubkey_path` - Path to public key file (required if `pubkey` not provided) -- `pubkey` - Public key content as base64-encoded string (required if `pubkey_path` not provided) -- `paths` - List of paths that require JWT validation (optional, if not set ALL domain is protected) -- `only_paths` - If `true`, only specified paths are accessible; if `false` (default), only specified paths require JWT validation - -**Path Validation Logic:** -- **No paths configured:** ALL requests to the domain require JWT validation (default behavior) -- **Paths configured + `only_paths=false`:** Only specified paths require JWT validation, other paths pass through without validation -- **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied - -**Enable via container label (protect all paths):** -```yaml -services: - api: - labels: - easyhaproxy.http.host: api.example.com - easyhaproxy.http.plugins: jwt_validator - easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 - easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ - easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com - easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem - volumes: - - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro -``` - -**Protect specific paths only (others can pass without JWT):** -```yaml -labels: - easyhaproxy.http.plugins: jwt_validator - easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem - easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive - easyhaproxy.http.plugin.jwt_validator.only_paths: false -``` - -**Only allow specific paths (deny all others):** -```yaml -labels: - easyhaproxy.http.plugins: jwt_validator - easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem - easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1 - easyhaproxy.http.plugin.jwt_validator.only_paths: true -``` - -**Skip issuer/audience validation:** -```yaml -labels: - easyhaproxy.http.plugin.jwt_validator.issuer: none - easyhaproxy.http.plugin.jwt_validator.audience: none - easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem -``` - -**HAProxy config generated (all paths protected):** -``` -# JWT Validator - Validate JWT tokens -http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } - -# Extract JWT header and payload -http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') -http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') -http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') -http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') - -# Validate JWT -http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } -http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } -http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } -http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } - -# Validate expiration -http-request set-var(txn.now) date() -http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } -``` - -**HAProxy config generated (specific paths, only_paths=false):** -``` -# JWT Validator - Validate JWT tokens - -# Define paths that require JWT validation -acl jwt_protected_path path_beg /api/admin -acl jwt_protected_path path_beg /api/sensitive - -http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } if jwt_protected_path - -# Extract JWT header and payload -http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path -http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if jwt_protected_path -http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if jwt_protected_path -http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if jwt_protected_path - -# Validate JWT (only on protected paths) -http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path -http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path - -# Validate expiration -http-request set-var(txn.now) date() if jwt_protected_path -http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if jwt_protected_path -``` - -**HAProxy config generated (specific paths, only_paths=true):** -``` -# JWT Validator - Validate JWT tokens - -# Define paths that require JWT validation -acl jwt_protected_path path_beg /api/public -acl jwt_protected_path path_beg /api/v1 - -# Deny access to paths not in the protected list -http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path - -http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } - -# Extract JWT header and payload -http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') -http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') -http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') -http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') - -# Validate JWT (all requests at this point are on allowed paths) -http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } -http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } - -# Validate expiration -http-request set-var(txn.now) date() -http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } -``` - -**What it validates:** -- ✅ Authorization header presence -- ✅ JWT signing algorithm (RS256, RS512, etc.) -- ✅ JWT issuer (if configured) -- ✅ JWT audience (if configured) -- ✅ JWT signature using public key -- ✅ JWT expiration time - -**Important:** Requires HAProxy 2.5+ with JWT support. Mount public key file as read-only volume. - -### FastCGI Plugin (Domain) - -Configures FastCGI parameters for PHP-FPM and other FastCGI applications. - -**Why use it:** Automatically generates HAProxy `fcgi-app` configuration that defines required CGI parameters for PHP-FPM communication without manual HAProxy configuration. - -**Configuration options:** -- `enabled` - Enable/disable plugin (default: `true`) -- `document_root` - Document root path (default: `/var/www/html`) -- `script_filename` - Custom pattern for SCRIPT_FILENAME (default: `%[path]`, uses HAProxy's default) -- `index_file` - Default index file (default: `index.php`) -- `path_info` - Enable PATH_INFO support (default: `true`) -- `custom_params` - Dictionary of custom FastCGI parameters (optional) - -**Enable via container label (TCP connection):** -```yaml -services: - php-fpm: - image: php:8.2-fpm - labels: - easyhaproxy.http.host: phpapp.local - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 9000 - easyhaproxy.http.proto: fcgi - easyhaproxy.http.plugins: fastcgi - easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html - easyhaproxy.http.plugin.fastcgi.index_file: index.php - volumes: - - ./app:/var/www/html -``` - -**Or with Unix socket:** -```yaml -services: - php-fpm: - image: php:8.2-fpm - labels: - easyhaproxy.http.host: phpapp.local - easyhaproxy.http.socket: /run/php/php-fpm.sock - easyhaproxy.http.proto: fcgi - easyhaproxy.http.plugins: fastcgi - easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html - easyhaproxy.http.plugin.fastcgi.index_file: index.php - volumes: - - ./app:/var/www/html - - /run/php:/run/php -``` - -**Custom document root and index file:** -```yaml -labels: - easyhaproxy.http.plugins: fastcgi - easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp/public - easyhaproxy.http.plugin.fastcgi.index_file: app.php - easyhaproxy.http.plugin.fastcgi.path_info: true -``` - -**HAProxy config generated:** - -The plugin generates a top-level `fcgi-app` section and a `use-fcgi-app` directive in the backend: - -```haproxy -# Top-level fcgi-app definition (added after defaults, before frontends/backends) -fcgi-app fcgi_phpapp_local - docroot /var/www/html - index index.php - path-info ^(/.+\.php)(/.*)?$ - -# Backend configuration (added to the backend section) -backend srv_phpapp_local_80 - use-fcgi-app fcgi_phpapp_local - # TCP connection: - server srv-0 172.19.0.3:9000 proto fcgi - # OR Unix socket: - # server srv-0 /run/php/php-fpm.sock proto fcgi -``` - -**Note:** HAProxy automatically sets standard CGI parameters (SCRIPT_FILENAME, DOCUMENT_ROOT, REQUEST_URI, QUERY_STRING, REQUEST_METHOD, CONTENT_TYPE, CONTENT_LENGTH, SERVER_NAME, SERVER_PORT, etc.) based on the `fcgi-app` configuration when communicating with PHP-FPM via the FastCGI protocol. - -**What it configures:** -- ✅ SCRIPT_FILENAME - Path to PHP script -- ✅ DOCUMENT_ROOT - Document root directory -- ✅ SCRIPT_NAME - Script name from URL -- ✅ REQUEST_URI - Full request URI with query string -- ✅ QUERY_STRING - URL query parameters -- ✅ REQUEST_METHOD - HTTP method (GET, POST, etc.) -- ✅ CONTENT_TYPE & CONTENT_LENGTH - Request body info -- ✅ SERVER_NAME & SERVER_PORT - Server details -- ✅ HTTPS - SSL/TLS status -- ✅ PATH_INFO - Path information (optional) - -**Important:** Use this plugin together with `proto: fcgi` parameter for complete PHP-FPM support. +EasyHAProxy includes several built-in plugins ready to use: + +- [Cloudflare](plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN +- [Cleanup](plugins/cleanup.md) - Cleanup temporary files +- [Deny Pages](plugins/deny-pages.md) - Block specific paths +- [IP Whitelist](plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges +- [JWT Validator](plugins/jwt-validator.md) - Validate JWT tokens +- [FastCGI](plugins/fastcgi.md) - Configure PHP-FPM and FastCGI applications ## Configuration Methods @@ -746,7 +392,7 @@ Per-ingress/per-container settings override global configuration. ## Creating Custom Plugins -To create your own plugins, see the [Plugin Developer Guide](plugin-development.md). +Want to create your own plugins? See the [Plugin Developer Guide](plugin-development.md) for detailed instructions on building custom plugins that extend EasyHAProxy functionality. ## Further Reading @@ -754,3 +400,4 @@ To create your own plugins, see the [Plugin Developer Guide](plugin-development. - [Container Labels](container-labels.md) - Label configuration reference - [Environment Variables](environment-variable.md) - Environment variable reference - [Static Configuration](static.md) - YAML configuration reference +- [Kubernetes Guide](kubernetes.md) - Using plugins with Kubernetes diff --git a/docs/plugins/cleanup.md b/docs/plugins/cleanup.md new file mode 100644 index 0000000..6932813 --- /dev/null +++ b/docs/plugins/cleanup.md @@ -0,0 +1,74 @@ +# Cleanup Plugin + +**Type:** Global Plugin +**Runs:** Once per discovery cycle + +## Overview + +The Cleanup plugin performs cleanup tasks during each discovery cycle, such as removing old temporary files. + +## Why Use It + +Prevents disk space issues by automatically cleaning up temporary files created by EasyHAProxy. + +## Configuration Options + +| Option | Description | Default | +|----------------------|----------------------------------------------|---------| +| `enabled` | Enable/disable plugin | `true` | +| `max_idle_time` | Maximum age in seconds before deleting files | `300` | +| `cleanup_temp_files` | Enable temp file cleanup | `true` | + +## Configuration Examples + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + max_idle_time: 600 + cleanup_temp_files: true +``` + +### Environment Variables + +```bash +EASYHAPROXY_PLUGINS_ENABLED=cleanup +EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +``` + +### Custom Idle Time (1 hour) + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + enabled: true + max_idle_time: 3600 # 1 hour +``` + +## How It Works + +The cleanup plugin: +- Runs once during each discovery cycle +- Scans temporary directories for old files +- Removes files older than `max_idle_time` seconds +- Helps maintain disk space efficiency + +## Important Notes + +- This is a **global plugin** - it runs once per discovery cycle, not per domain +- Does not generate HAProxy configuration +- Performs maintenance operations in the background +- Safe to enable in production environments + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Environment Variables Reference](../environment-variable.md) +- [Static Configuration Reference](../static.md) diff --git a/docs/plugins/cloudflare.md b/docs/plugins/cloudflare.md new file mode 100644 index 0000000..dd0fb84 --- /dev/null +++ b/docs/plugins/cloudflare.md @@ -0,0 +1,91 @@ +# Cloudflare Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The Cloudflare plugin restores the original visitor IP address when requests come through Cloudflare's CDN. + +## Why Use It + +Cloudflare replaces the visitor's IP with its own. This plugin restores the original IP from the `CF-Connecting-IP` header. + +## Configuration Options + +| Option | Description | Default | +|----------------|----------------------------|-----------------------------------| +| `enabled` | Enable/disable plugin | `true` | +| `ip_list_path` | Path to Cloudflare IP list | `/etc/haproxy/cloudflare_ips.lst` | + +## Configuration Examples + +### Docker/Docker Compose (Basic) + +```yaml +services: + myapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: cloudflare +``` + +### Docker/Docker Compose (Custom IP List Path) + +```yaml +labels: + easyhaproxy.http.plugins: cloudflare + easyhaproxy.http.plugin.cloudflare.ip_list_path: /custom/path/cf_ips.lst +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "cloudflare" + easyhaproxy.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" +spec: + rules: + - host: example.com + http: + paths: + - path: / + backend: + service: + name: myapp + port: + number: 80 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + config: + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst +``` + +## Generated HAProxy Configuration + +```haproxy +# Cloudflare - Restore original visitor IP +acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst +http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare +``` + +## Important Notes + +- **Required:** Download Cloudflare IP list from [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786) +- The plugin runs once per domain during the discovery cycle +- Ensure the IP list file is mounted and accessible to HAProxy + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/plugins/deny-pages.md b/docs/plugins/deny-pages.md new file mode 100644 index 0000000..51c3eb8 --- /dev/null +++ b/docs/plugins/deny-pages.md @@ -0,0 +1,113 @@ +# Deny Pages Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The Deny Pages plugin blocks access to specific paths for a domain, returning a configurable HTTP status code. + +## Why Use It + +Protect admin panels, internal APIs, or debugging endpoints from public access. + +## Configuration Options + +| Option | Description | Default | +|---------------|----------------------------------------|------------| +| `enabled` | Enable/disable plugin | `true` | +| `paths` | Comma-separated list of paths to block | (required) | +| `status_code` | HTTP status code to return | `403` | + +## Configuration Examples + +### Docker/Docker Compose (Basic) + +```yaml +services: + webapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/private,/debug + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### WordPress Protection + +```yaml +labels: + easyhaproxy.http.host: wordpress.example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /wp-admin,/wp-login.php,/.env + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/admin,/private" + easyhaproxy.plugin.deny_pages.status_code: "403" +spec: + rules: + - host: example.com + http: + paths: + - path: / + backend: + service: + name: webapp + port: + number: 80 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: example.com + port: 80 + container: webapp:80 + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: /admin,/private,/debug + status_code: 403 +``` + +### Multiple Plugins (with Cloudflare) + +```yaml +labels: + easyhaproxy.http.host: secure-app.com + easyhaproxy.http.plugins: cloudflare,deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/config + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +## Generated HAProxy Configuration + +```haproxy +# Deny Pages - Block specific paths +acl denied_path path_beg /admin /private /debug +http-request deny deny_status 404 if denied_path +``` + +## Important Notes + +- The plugin runs once per domain during the discovery cycle +- Path matching uses `path_beg` (prefix matching), so `/admin` blocks `/admin/*` too +- Consider using `404` instead of `403` to hide the existence of blocked paths +- Works well in combination with other security plugins + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/plugins/fastcgi.md b/docs/plugins/fastcgi.md new file mode 100644 index 0000000..9d74d8b --- /dev/null +++ b/docs/plugins/fastcgi.md @@ -0,0 +1,159 @@ +# FastCGI Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The FastCGI plugin configures HAProxy to communicate with PHP-FPM and other FastCGI applications. It automatically generates the necessary HAProxy `fcgi-app` configuration that defines CGI parameters for proper PHP-FPM communication. + +## Why Use It + +Automatically generates HAProxy `fcgi-app` configuration that defines required CGI parameters for PHP-FPM communication without manual HAProxy configuration. + +## 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) | + +## Configuration Examples + +### Docker/Docker Compose (TCP connection) + +```yaml +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 9000 + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + volumes: + - ./app:/var/www/html +``` + +### Docker/Docker Compose (Unix socket) + +```yaml +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.socket: /run/php/php-fpm.sock + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + volumes: + - ./app:/var/www/html + - /run/php:/run/php +``` + +### Custom Document Root and Index File + +```yaml +labels: + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp/public + easyhaproxy.http.plugin.fastcgi.index_file: app.php + easyhaproxy.http.plugin.fastcgi.path_info: true +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "fastcgi" + easyhaproxy.plugin.fastcgi.document_root: "/var/www/html" + easyhaproxy.plugin.fastcgi.index_file: "index.php" +spec: + rules: + - host: phpapp.example.com + http: + paths: + - path: / + backend: + service: + name: php-fpm + port: + number: 9000 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: phpapp.local + port: 80 + container: php-fpm:9000 + proto: fcgi + plugins: + - fastcgi + plugin_config: + fastcgi: + document_root: /var/www/html + index_file: index.php + path_info: true +``` + +## Generated HAProxy Configuration + +The plugin generates a top-level `fcgi-app` section and a `use-fcgi-app` directive in the backend: + +```haproxy +# Top-level fcgi-app definition (added after defaults, before frontends/backends) +fcgi-app fcgi_phpapp_local + docroot /var/www/html + index index.php + path-info ^(/.+\.php)(/.*)?$ + +# Backend configuration (added to the backend section) +backend srv_phpapp_local_80 + use-fcgi-app fcgi_phpapp_local + # TCP connection: + server srv-0 172.19.0.3:9000 proto fcgi + # OR Unix socket: + # server srv-0 /run/php/php-fpm.sock proto fcgi +``` + +## CGI Parameters + +**Note:** HAProxy automatically sets standard CGI parameters based on the `fcgi-app` configuration when communicating with PHP-FPM via the FastCGI protocol. + +The plugin configures: +- ✅ **SCRIPT_FILENAME** - Path to PHP script +- ✅ **DOCUMENT_ROOT** - Document root directory +- ✅ **SCRIPT_NAME** - Script name from URL +- ✅ **REQUEST_URI** - Full request URI with query string +- ✅ **QUERY_STRING** - URL query parameters +- ✅ **REQUEST_METHOD** - HTTP method (GET, POST, etc.) +- ✅ **CONTENT_TYPE & CONTENT_LENGTH** - Request body info +- ✅ **SERVER_NAME & SERVER_PORT** - Server details +- ✅ **HTTPS** - SSL/TLS status +- ✅ **PATH_INFO** - Path information (optional) + +## Important Notes + +- **Required:** Use this plugin together with `proto: fcgi` parameter for complete PHP-FPM support +- The plugin runs once per domain during the discovery cycle +- HAProxy handles the actual FastCGI protocol communication and CGI parameter transmission + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) \ No newline at end of file diff --git a/docs/plugins/ip-whitelist.md b/docs/plugins/ip-whitelist.md new file mode 100644 index 0000000..223bc59 --- /dev/null +++ b/docs/plugins/ip-whitelist.md @@ -0,0 +1,110 @@ +# IP Whitelist Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The IP Whitelist plugin restricts access to a domain to only specific IP addresses or CIDR ranges. + +## Why Use It + +Restrict access to internal tools, admin panels, or staging environments to only trusted IP addresses. + +## Configuration Options + +| Option | Description | Default | +|---------------|--------------------------------------------------|------------| +| `enabled` | Enable/disable plugin | `true` | +| `allowed_ips` | Comma-separated list of IPs/CIDR ranges to allow | (required) | +| `status_code` | HTTP status code to return for blocked IPs | `403` | + +## Configuration Examples + +### Docker/Docker Compose (Basic) + +```yaml +services: + admin: + labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 +``` + +### Office Network Access + +```yaml +labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 203.0.113.0/24,198.51.100.42 +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "ip_whitelist" + easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5" + easyhaproxy.plugin.ip_whitelist.status_code: "403" +spec: + rules: + - host: admin.example.com + http: + paths: + - path: / + backend: + service: + name: admin-panel + port: + number: 80 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: admin.example.com + port: 443 + container: admin-panel:443 + plugins: + - ip_whitelist + plugin_config: + ip_whitelist: + allowed_ips: 192.168.1.0/24,10.0.0.5 + status_code: 403 +``` + +## Generated HAProxy Configuration + +```haproxy +# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 +http-request deny deny_status 403 if !whitelisted_ip +``` + +## IP Address Formats + +The plugin supports: +- **Single IPs:** `10.0.0.5`, `203.0.113.42` +- **CIDR ranges:** `192.168.1.0/24`, `10.0.0.0/8` +- **Multiple entries:** Comma-separated list of IPs and/or CIDR ranges + +## Important Notes + +- **Warning:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! +- The plugin runs once per domain during the discovery cycle +- Test thoroughly before deploying to production +- Consider using VPN CIDR ranges for remote access +- Works well with staging and admin environments + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/plugins/jwt-validator.md b/docs/plugins/jwt-validator.md new file mode 100644 index 0000000..c2c594c --- /dev/null +++ b/docs/plugins/jwt-validator.md @@ -0,0 +1,226 @@ +# JWT Validator Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The JWT Validator plugin validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JWT functionality. + +## Why Use It + +Protect APIs and services with JWT authentication without needing application-level code. + +## Configuration Options + +| Option | Description | Default | +|---------------|----------------------------------------------------------------------------------------------|-------------| +| `enabled` | Enable/disable plugin | `true` | +| `algorithm` | JWT signing algorithm | `RS256` | +| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) | +| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | +| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | +| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) | +| `paths` | List of paths that require JWT validation (optional) | (all paths) | +| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` | + +## Path Validation Logic + +- **No paths configured:** ALL requests to the domain require JWT validation (default behavior) +- **Paths configured + `only_paths=false`:** Only specified paths require JWT validation, other paths pass through without validation +- **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied + +## Configuration Examples + +### Docker/Docker Compose (Protect All Paths) + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + volumes: + - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + +### Protect Specific Paths Only + +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: false +# /api/health, /api/docs, etc. remain publicly accessible +``` + +### Only Allow Specific Paths + +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1 + easyhaproxy.http.plugin.jwt_validator.only_paths: true +# All paths except /api/public and /api/v1 are denied +``` + +### Skip Issuer/Audience Validation + +```yaml +labels: + easyhaproxy.http.plugin.jwt_validator.issuer: none + easyhaproxy.http.plugin.jwt_validator.audience: none + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" + easyhaproxy.plugin.jwt_validator.only_paths: "false" +spec: + rules: + - host: api.example.com + http: + paths: + - path: / + backend: + service: + name: api-service + port: + number: 8080 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: api.example.com + port: 443 + container: api-service:8080 + plugins: + - jwt_validator + plugin_config: + jwt_validator: + algorithm: RS256 + issuer: https://auth.example.com/ + audience: https://api.example.com + pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem +``` + +## Generated HAProxy Configuration + +### All Paths Protected + +```haproxy +# JWT Validator - Validate JWT tokens +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } +http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + +### Specific Paths Only (only_paths=false) + +```haproxy +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/admin +acl jwt_protected_path path_beg /api/sensitive + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } if jwt_protected_path + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if jwt_protected_path +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if jwt_protected_path +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if jwt_protected_path + +# Validate JWT (only on protected paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path + +# Validate expiration +http-request set-var(txn.now) date() if jwt_protected_path +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if jwt_protected_path +``` + +### Specific Paths Only (only_paths=true) + +```haproxy +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/public +acl jwt_protected_path path_beg /api/v1 + +# Deny access to paths not in the protected list +http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT (all requests at this point are on allowed paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + +## What It Validates + +- ✅ Authorization header presence +- ✅ JWT signing algorithm (RS256, RS512, etc.) +- ✅ JWT issuer (if configured) +- ✅ JWT audience (if configured) +- ✅ JWT signature using public key +- ✅ JWT expiration time + +## Important Notes + +- **Required:** HAProxy 2.5+ with JWT support +- Mount public key file as read-only volume +- The plugin runs once per domain during the discovery cycle +- Test thoroughly with your JWT provider before deploying to production + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) From 56fc86d77d7d1367000c873ce1195a140d4189a9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 1 Dec 2025 17:43:05 -0500 Subject: [PATCH 15/27] Add `allow_anonymous` option to JwtValidatorPlugin - Introduced `allow_anonymous` configuration option to permit requests without an Authorization header. - Updated HAProxy configuration to handle optional JWT validation for anonymous access. - Enhanced documentation with use cases, examples, and configuration details for `allow_anonymous`. - Adjusted tests and plugin logic to support anonymous access scenarios. --- docs/plugins/jwt-validator.md | 73 ++++++++++++++++++++++++---- src/plugins/builtin/jwt_validator.py | 47 +++++++++++++----- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/docs/plugins/jwt-validator.md b/docs/plugins/jwt-validator.md index c2c594c..d7cdb07 100644 --- a/docs/plugins/jwt-validator.md +++ b/docs/plugins/jwt-validator.md @@ -13,16 +13,17 @@ Protect APIs and services with JWT authentication without needing application-le ## Configuration Options -| Option | Description | Default | -|---------------|----------------------------------------------------------------------------------------------|-------------| -| `enabled` | Enable/disable plugin | `true` | -| `algorithm` | JWT signing algorithm | `RS256` | -| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) | -| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | -| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | -| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) | -| `paths` | List of paths that require JWT validation (optional) | (all paths) | -| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` | +| Option | Description | Default | +|-------------------|----------------------------------------------------------------------------------------------|-------------| +| `enabled` | Enable/disable plugin | `true` | +| `algorithm` | JWT signing algorithm | `RS256` | +| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) | +| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | +| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | +| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) | +| `paths` | List of paths that require JWT validation (optional) | (all paths) | +| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` | +| `allow_anonymous` | If `true`, allows requests without Authorization header (validates JWT if present) | `false` | ## Path Validation Logic @@ -30,6 +31,17 @@ Protect APIs and services with JWT authentication without needing application-le - **Paths configured + `only_paths=false`:** Only specified paths require JWT validation, other paths pass through without validation - **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied +## Anonymous Access Logic + +- **`allow_anonymous=false` (default):** Requests without `Authorization` header are denied with "Missing Authorization HTTP header" +- **`allow_anonymous=true`:** Requests without `Authorization` header are allowed to pass through, but JWTs are validated if the header is present + +**Use Cases for `allow_anonymous=true`:** +- Optional authentication (show different content for authenticated vs anonymous users) +- Mixed public/private content where some users have enhanced access with JWT +- Gradual JWT authentication rollout +- Public APIs that provide additional features to authenticated users + ## Configuration Examples ### Docker/Docker Compose (Protect All Paths) @@ -79,6 +91,23 @@ labels: easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem ``` +### Allow Anonymous Access (Optional JWT) + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.allow_anonymous: true + volumes: + - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# Requests without Authorization header are allowed +# Requests with Authorization header are validated +# Invalid JWTs are rejected +``` + ### Kubernetes Annotations ```yaml @@ -204,6 +233,30 @@ http-request set-var(txn.now) date() http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } ``` +### Allow Anonymous Access (allow_anonymous=true) + +```haproxy +# JWT Validator - Validate JWT tokens + +# Allow anonymous access - validate JWT only if Authorization header is present + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if { req.hdr(authorization) -m found } +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if { req.hdr(authorization) -m found } +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if { req.hdr(authorization) -m found } +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if { req.hdr(authorization) -m found } + +# Validate JWT (only if Authorization header is present) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if { req.hdr(authorization) -m found } + +# Validate expiration (only if Authorization header is present) +http-request set-var(txn.now) date() if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if { req.hdr(authorization) -m found } +``` + ## What It Validates - ✅ Authorization header presence diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py index 93df35d..5a7b4af 100644 --- a/src/plugins/builtin/jwt_validator.py +++ b/src/plugins/builtin/jwt_validator.py @@ -13,12 +13,17 @@ Configuration: - pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) - paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation + - allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header Path Validation Logic: - No paths configured: ALL requests to the domain require JWT validation (default behavior) - Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through - Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied +Anonymous Access Logic: + - allow_anonymous=false (default): Requests without Authorization header are denied + - allow_anonymous=true: Requests without Authorization header are allowed, but JWTs are validated if present + Example YAML config: plugins: jwt_validator: @@ -85,6 +90,7 @@ class JwtValidatorPlugin(PluginInterface): self.pubkey = None # Public key content (alternative to pubkey_path) self.paths = [] # List of paths that require JWT validation self.only_paths = False # If true, only specified paths are accessible + self.allow_anonymous = False # If true, allow requests without Authorization header @property def name(self) -> str: @@ -108,6 +114,7 @@ class JwtValidatorPlugin(PluginInterface): - pubkey: Public key content as base64-encoded string - paths: List of paths that require JWT validation (optional) - only_paths: If true, only specified paths are accessible (default: false) + - allow_anonymous: If true, allow requests without Authorization header (default: false) """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] @@ -149,6 +156,9 @@ class JwtValidatorPlugin(PluginInterface): if "only_paths" in config: self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] + if "allow_anonymous" in config: + self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"] + def process(self, context: PluginContext) -> PluginResult: """ Generate HAProxy config to validate JWT tokens @@ -198,37 +208,49 @@ class JwtValidatorPlugin(PluginInterface): path_condition = " if jwt_protected_path" # Check for Authorization header - lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") + if not self.allow_anonymous: + # Require Authorization header (default behavior) + lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") + jwt_condition = path_condition + else: + # Allow anonymous access - only validate JWT if Authorization header is present + lines.append("") + lines.append("# Allow anonymous access - validate JWT only if Authorization header is present") + if path_condition: + # Combine path condition with Authorization header check + jwt_condition = f"{path_condition} if {{ req.hdr(authorization) -m found }}" + else: + jwt_condition = " if { req.hdr(authorization) -m found }" # Extract JWT parts lines.append("") lines.append("# Extract JWT header and payload") - lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){path_condition}") - lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}") - lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}") - lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}") + lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){jwt_condition}") + lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){jwt_condition}") + lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){jwt_condition}") + lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){jwt_condition}") # Validate JWT lines.append("") lines.append("# Validate JWT") - lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{jwt_condition}") # Validate issuer (if configured) if self.issuer: - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{jwt_condition}") # Validate audience (if configured) if self.audience: - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{jwt_condition}") # Validate signature - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{jwt_condition}") # Validate expiration lines.append("") lines.append("# Validate expiration") - lines.append(f"http-request set-var(txn.now) date(){path_condition}") - lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{path_condition}") + lines.append(f"http-request set-var(txn.now) date(){jwt_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{jwt_condition}") haproxy_config = "\n".join(lines) @@ -240,7 +262,8 @@ class JwtValidatorPlugin(PluginInterface): "validates_issuer": self.issuer is not None, "validates_audience": self.audience is not None, "path_validation": len(self.paths) > 0, - "only_paths": self.only_paths + "only_paths": self.only_paths, + "allow_anonymous": self.allow_anonymous } if self.issuer: From b4944ac544d6f66bd76194889edaa4db23ba293a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 1 Dec 2025 18:00:52 -0500 Subject: [PATCH 16/27] Add `use_builtin_ips` support and reorganize plugin documentation - Enhanced Cloudflare Plugin with `use_builtin_ips` option to automatically use and update built-in IP ranges. - Updated Cloudflare IP restoration logic, including metadata and HAProxy config generation. - Reorganized plugin documentation with sidebar positions for improved accessibility. - Extended Cloudflare documentation to detail built-in IP ranges, examples, and configurations. - Added new test cases to validate `use_builtin_ips` functionality and file handling. --- docs/plugins/cleanup.md | 4 ++ docs/plugins/cloudflare.md | 46 +++++++++++++++----- docs/plugins/deny-pages.md | 4 ++ docs/plugins/fastcgi.md | 4 ++ docs/plugins/ip-whitelist.md | 4 ++ docs/plugins/jwt-validator.md | 4 ++ src/plugins/builtin/cloudflare.py | 61 +++++++++++++++++++++++++- src/tests/test_plugins.py | 71 +++++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 11 deletions(-) diff --git a/docs/plugins/cleanup.md b/docs/plugins/cleanup.md index 6932813..fcef7b2 100644 --- a/docs/plugins/cleanup.md +++ b/docs/plugins/cleanup.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 6 +--- + # Cleanup Plugin **Type:** Global Plugin diff --git a/docs/plugins/cloudflare.md b/docs/plugins/cloudflare.md index dd0fb84..301e9ed 100644 --- a/docs/plugins/cloudflare.md +++ b/docs/plugins/cloudflare.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 5 +--- + # Cloudflare Plugin **Type:** Domain Plugin @@ -5,7 +9,7 @@ ## Overview -The Cloudflare plugin restores the original visitor IP address when requests come through Cloudflare's CDN. +The Cloudflare plugin restores the original visitor IP address when requests come through Cloudflare's CDN. The plugin includes **built-in Cloudflare IP ranges** that are automatically written to the IP list file - no manual configuration required! ## Why Use It @@ -13,14 +17,15 @@ Cloudflare replaces the visitor's IP with its own. This plugin restores the orig ## Configuration Options -| Option | Description | Default | -|----------------|----------------------------|-----------------------------------| -| `enabled` | Enable/disable plugin | `true` | -| `ip_list_path` | Path to Cloudflare IP list | `/etc/haproxy/cloudflare_ips.lst` | +| Option | Description | Default | +|-------------------|------------------------------------------|-----------------------------------| +| `enabled` | Enable/disable plugin | `true` | +| `use_builtin_ips` | Use built-in Cloudflare IP ranges | `true` | +| `ip_list_path` | Path to Cloudflare IP list | `/etc/haproxy/cloudflare_ips.lst` | ## Configuration Examples -### Docker/Docker Compose (Basic) +### Docker/Docker Compose (Basic - Uses Built-in IPs) ```yaml services: @@ -28,13 +33,17 @@ services: labels: easyhaproxy.http.host: example.com easyhaproxy.http.plugins: cloudflare +# Built-in Cloudflare IPs are automatically used - no additional configuration needed! ``` -### Docker/Docker Compose (Custom IP List Path) +### Docker/Docker Compose (Custom IP List) + +If you want to use your own IP list file instead of the built-in ranges: ```yaml labels: easyhaproxy.http.plugins: cloudflare + easyhaproxy.http.plugin.cloudflare.use_builtin_ips: false easyhaproxy.http.plugin.cloudflare.ip_list_path: /custom/path/cf_ips.lst ``` @@ -68,7 +77,7 @@ plugins: config: cloudflare: enabled: true - ip_list_path: /etc/haproxy/cloudflare_ips.lst + use_builtin_ips: true # Uses built-in Cloudflare IPs (default) ``` ## Generated HAProxy Configuration @@ -79,11 +88,28 @@ acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare ``` +## Built-in Cloudflare IP Ranges + +The plugin includes the current Cloudflare IP ranges (22 ranges total): + +**IPv4 Ranges (15):** +- 173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22, 103.31.4.0/22 +- 141.101.64.0/18, 108.162.192.0/18, 190.93.240.0/20, 188.114.96.0/20 +- 197.234.240.0/22, 198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13 +- 104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22 + +**IPv6 Ranges (7):** +- 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32 +- 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32 + +These ranges are automatically written to `/etc/haproxy/cloudflare_ips.lst` during each discovery cycle. + ## Important Notes -- **Required:** Download Cloudflare IP list from [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786) +- ✅ **No manual configuration required** - Built-in Cloudflare IPs are included! - The plugin runs once per domain during the discovery cycle -- Ensure the IP list file is mounted and accessible to HAProxy +- IP list file is automatically created and updated +- To update Cloudflare IPs in the future, simply update the plugin source code and rebuild ## Related Documentation diff --git a/docs/plugins/deny-pages.md b/docs/plugins/deny-pages.md index 51c3eb8..3bea22f 100644 --- a/docs/plugins/deny-pages.md +++ b/docs/plugins/deny-pages.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 4 +--- + # Deny Pages Plugin **Type:** Domain Plugin diff --git a/docs/plugins/fastcgi.md b/docs/plugins/fastcgi.md index 9d74d8b..ab836bc 100644 --- a/docs/plugins/fastcgi.md +++ b/docs/plugins/fastcgi.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 1 +--- + # FastCGI Plugin **Type:** Domain Plugin diff --git a/docs/plugins/ip-whitelist.md b/docs/plugins/ip-whitelist.md index 223bc59..fd0a297 100644 --- a/docs/plugins/ip-whitelist.md +++ b/docs/plugins/ip-whitelist.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 3 +--- + # IP Whitelist Plugin **Type:** Domain Plugin diff --git a/docs/plugins/jwt-validator.md b/docs/plugins/jwt-validator.md index d7cdb07..27eec2e 100644 --- a/docs/plugins/jwt-validator.md +++ b/docs/plugins/jwt-validator.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 2 +--- + # JWT Validator Plugin **Type:** Domain Plugin diff --git a/src/plugins/builtin/cloudflare.py b/src/plugins/builtin/cloudflare.py index 7433dad..f8cb0be 100644 --- a/src/plugins/builtin/cloudflare.py +++ b/src/plugins/builtin/cloudflare.py @@ -4,14 +4,19 @@ Cloudflare Plugin for EasyHAProxy This plugin restores the original visitor IP address from Cloudflare's CF-Connecting-IP header when requests come through Cloudflare's CDN. +The plugin includes built-in Cloudflare IP ranges that are automatically +updated and written to the IP list file. + Configuration: - ip_list_path: Path to file containing Cloudflare IP ranges (default: /etc/haproxy/cloudflare_ips.lst) + - use_builtin_ips: Use built-in Cloudflare IP ranges (default: true) Example YAML config: plugins: cloudflare: enabled: true ip_list_path: /etc/haproxy/cloudflare_ips.lst + use_builtin_ips: true Example Container Label: easyhaproxy.http.plugins: "cloudflare" @@ -29,14 +34,45 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy class CloudflarePlugin(PluginInterface): """Plugin to restore original visitor IP from Cloudflare""" + # Current Cloudflare IP ranges (IPv4 and IPv6) + # Source: https://www.cloudflare.com/ips/ + CLOUDFLARE_IPS = [ + # IPv4 + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + # IPv6 + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + ] + def __init__(self): self.ip_list_path = "/etc/haproxy/cloudflare_ips.lst" self.enabled = True + self.use_builtin_ips = True @property def name(self) -> str: @@ -54,6 +90,7 @@ class CloudflarePlugin(PluginInterface): config: Dictionary with configuration options - ip_list_path: Path to Cloudflare IP list file - enabled: Whether plugin is enabled + - use_builtin_ips: Use built-in Cloudflare IP ranges (default: true) """ if "ip_list_path" in config: self.ip_list_path = config["ip_list_path"] @@ -61,6 +98,9 @@ class CloudflarePlugin(PluginInterface): if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + if "use_builtin_ips" in config: + self.use_builtin_ips = str(config["use_builtin_ips"]).lower() in ["true", "1", "yes"] + def process(self, context: PluginContext) -> PluginResult: """ Generate HAProxy config to restore original IP from Cloudflare @@ -74,6 +114,23 @@ class CloudflarePlugin(PluginInterface): if not self.enabled: return PluginResult() + # Write built-in Cloudflare IPs to file if using built-in IPs + if self.use_builtin_ips: + try: + # Create directory if it doesn't exist + ip_list_dir = os.path.dirname(self.ip_list_path) + if ip_list_dir and not os.path.exists(ip_list_dir): + os.makedirs(ip_list_dir, exist_ok=True) + + # Write Cloudflare IPs to file + with open(self.ip_list_path, 'w') as f: + for ip_range in self.CLOUDFLARE_IPS: + f.write(f"{ip_range}\n") + + loggerEasyHaproxy.info(f"Cloudflare plugin: Written {len(self.CLOUDFLARE_IPS)} IP ranges to {self.ip_list_path}") + except Exception as e: + loggerEasyHaproxy.warning(f"Cloudflare plugin: Failed to write IP list to {self.ip_list_path}: {e}") + # Generate HAProxy config snippet haproxy_config = f"""# Cloudflare - Restore original visitor IP acl from_cloudflare src -f {self.ip_list_path} @@ -84,6 +141,8 @@ http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_clo modified_easymapping=None, metadata={ "domain": context.domain, - "ip_list_path": self.ip_list_path + "ip_list_path": self.ip_list_path, + "use_builtin_ips": self.use_builtin_ips, + "ip_count": len(self.CLOUDFLARE_IPS) if self.use_builtin_ips else None } ) diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index 188844d..ae53f4c 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -42,7 +42,9 @@ class TestCloudflarePlugin: plugin = CloudflarePlugin() assert plugin.name == "cloudflare" assert plugin.enabled is True + assert plugin.use_builtin_ips is True assert plugin.ip_list_path == "/etc/haproxy/cloudflare_ips.lst" + assert len(plugin.CLOUDFLARE_IPS) == 22 # 15 IPv4 + 7 IPv6 def test_cloudflare_plugin_configuration(self): """Test plugin configuration""" @@ -123,6 +125,75 @@ class TestCloudflarePlugin: assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in haproxy_config assert "http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]" in haproxy_config + def test_cloudflare_plugin_builtin_ips_enabled(self): + """Test plugin uses built-in Cloudflare IPs and writes to file""" + # Use temp directory for testing + with tempfile.TemporaryDirectory() as tmpdir: + ip_list_path = os.path.join(tmpdir, "cloudflare_ips.lst") + + plugin = CloudflarePlugin() + plugin.configure({ + "use_builtin_ips": "true", + "ip_list_path": ip_list_path + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + # Verify config is generated + assert result.haproxy_config is not None + assert f"acl from_cloudflare src -f {ip_list_path}" in result.haproxy_config + + # Verify metadata + assert result.metadata["use_builtin_ips"] is True + assert result.metadata["ip_count"] == 22 + + # Verify file was written + assert os.path.exists(ip_list_path) + + # Verify file contains correct number of IPs + with open(ip_list_path, 'r') as f: + lines = [line.strip() for line in f if line.strip()] + assert len(lines) == 22 + # Verify some known Cloudflare IPs are in the file + assert "173.245.48.0/20" in lines + assert "2606:4700::/32" in lines + + def test_cloudflare_plugin_builtin_ips_disabled(self): + """Test plugin doesn't write to file when use_builtin_ips is disabled""" + plugin = CloudflarePlugin() + plugin.configure({ + "use_builtin_ips": "false", + "ip_list_path": "/custom/cloudflare_ips.lst" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + # Verify config is generated with custom path + assert result.haproxy_config is not None + assert "acl from_cloudflare src -f /custom/cloudflare_ips.lst" in result.haproxy_config + + # Verify metadata + assert result.metadata["use_builtin_ips"] is False + assert result.metadata["ip_count"] is None + class TestCleanupPlugin: """Test cases for CleanupPlugin (GLOBAL plugin)""" From 50ffb4fe16072e80fe2dd64e10333dc62c838ede Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 2 Dec 2025 11:47:19 -0500 Subject: [PATCH 17/27] Add environment variable documentation for all plugins, reorganize plugin files for consistency - Introduced environment variable support in documentation for Cloudflare, Cleanup, Deny Pages, IP Whitelist, JWT Validator, and FastCGI plugins. - Updated plugin files with consistent headings, examples, and tables for easier configuration reference. - Adjusted `README.md` and `plugins.md` to reflect updated plugin paths and environment variable usage. --- README.md | 12 ++--- docs/{plugins => Plugins}/cleanup.md | 14 ++++-- docs/{plugins => Plugins}/cloudflare.md | 12 +++++ docs/{plugins => Plugins}/deny-pages.md | 12 +++++ docs/{plugins => Plugins}/fastcgi.md | 14 ++++++ docs/{plugins => Plugins}/ip-whitelist.md | 12 +++++ docs/{plugins => Plugins}/jwt-validator.md | 18 +++++++ docs/plugins.md | 57 ++++++++++++++-------- 8 files changed, 122 insertions(+), 29 deletions(-) rename docs/{plugins => Plugins}/cleanup.md (65%) rename docs/{plugins => Plugins}/cloudflare.md (77%) rename docs/{plugins => Plugins}/deny-pages.md (77%) rename docs/{plugins => Plugins}/fastcgi.md (79%) rename docs/{plugins => Plugins}/ip-whitelist.md (76%) rename docs/{plugins => Plugins}/jwt-validator.md (87%) diff --git a/README.md b/README.md index 76bcb35..b0122b5 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,12 @@ Detailed configuration guides for advanced setups: - [Environment Variables](docs/environment-variable.md) - Configure EasyHAProxy behavior - [Volumes](docs/volumes.md) - Map volumes for certificates, config, and custom files - [Plugins](docs/plugins.md) - Extend HAProxy with plugins ([Development Guide](docs/plugin-development.md)) - - [Cloudflare](docs/plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN - - [Cleanup](docs/plugins/cleanup.md) - Automatic cleanup of temporary files - - [Deny Pages](docs/plugins/deny-pages.md) - Block access to specific paths - - [IP Whitelist](docs/plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges - - [JWT Validator](docs/plugins/jwt-validator.md) - JWT authentication validation - - [FastCGI](docs/plugins/fastcgi.md) - PHP-FPM and FastCGI application support + - [JWT Validator](docs/Plugins/jwt-validator.md) - JWT authentication validation + - [FastCGI](docs/Plugins/fastcgi.md) - PHP-FPM and FastCGI application support + - [Cloudflare](docs/Plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN + - [IP Whitelist](docs/Plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges + - [Deny Pages](docs/Plugins/deny-pages.md) - Block access to specific paths + - [Cleanup](docs/Plugins/cleanup.md) - Automatic cleanup of temporary files - [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.) - [Limitations](docs/limitations.md) - Important limitations and considerations diff --git a/docs/plugins/cleanup.md b/docs/Plugins/cleanup.md similarity index 65% rename from docs/plugins/cleanup.md rename to docs/Plugins/cleanup.md index fcef7b2..76ac12d 100644 --- a/docs/plugins/cleanup.md +++ b/docs/Plugins/cleanup.md @@ -39,10 +39,16 @@ plugins: ### Environment Variables -```bash -EASYHAPROXY_PLUGINS_ENABLED=cleanup -EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 -``` +Configure the Cleanup plugin globally: + +| Environment Variable | Config Key | Type | Default | Description | +|-------------------------------------------------|----------------------|----------|---------|----------------------------------------------| +| `EASYHAPROXY_PLUGINS_ENABLED` | - | string | - | Enable cleanup plugin (value: `cleanup`) | +| `EASYHAPROXY_PLUGIN_CLEANUP_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin | +| `EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME` | `max_idle_time` | integer | `300` | Maximum age in seconds before deleting files | +| `EASYHAPROXY_PLUGIN_CLEANUP_CLEANUP_TEMP_FILES` | `cleanup_temp_files` | boolean | `true` | Enable temp file cleanup | + +**Note:** This is a global plugin - configuration applies to the entire system. ### Custom Idle Time (1 hour) diff --git a/docs/plugins/cloudflare.md b/docs/Plugins/cloudflare.md similarity index 77% rename from docs/plugins/cloudflare.md rename to docs/Plugins/cloudflare.md index 301e9ed..ff6bf66 100644 --- a/docs/plugins/cloudflare.md +++ b/docs/Plugins/cloudflare.md @@ -80,6 +80,18 @@ plugins: use_builtin_ips: true # Uses built-in Cloudflare IPs (default) ``` +### Environment Variables + +Configure Cloudflare plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|-------------------------------------------------|-------------------|----------|-----------------------------------|---------------------------------------| +| `EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS` | `use_builtin_ips` | boolean | `true` | Use built-in Cloudflare IP ranges | +| `EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH` | `ip_list_path` | string | `/etc/haproxy/cloudflare_ips.lst` | Path to Cloudflare IP list file | + +**Note:** Environment variables set defaults for ALL domains. To enable/disable per-domain, use container labels or Kubernetes annotations. + ## Generated HAProxy Configuration ```haproxy diff --git a/docs/plugins/deny-pages.md b/docs/Plugins/deny-pages.md similarity index 77% rename from docs/plugins/deny-pages.md rename to docs/Plugins/deny-pages.md index 3bea22f..0d7cff8 100644 --- a/docs/plugins/deny-pages.md +++ b/docs/Plugins/deny-pages.md @@ -96,6 +96,18 @@ labels: easyhaproxy.http.plugin.deny_pages.status_code: 403 ``` +### Environment Variables + +Configure Deny Pages plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|---------------------------------------------|---------------|---------|---------|----------------------------------------| +| `EASYHAPROXY_PLUGIN_DENY_PAGES_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_DENY_PAGES_PATHS` | `paths` | string | - | Comma-separated list of paths to block | +| `EASYHAPROXY_PLUGIN_DENY_PAGES_STATUS_CODE` | `status_code` | integer | `403` | HTTP status code to return | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. + ## Generated HAProxy Configuration ```haproxy diff --git a/docs/plugins/fastcgi.md b/docs/Plugins/fastcgi.md similarity index 79% rename from docs/plugins/fastcgi.md rename to docs/Plugins/fastcgi.md index ab836bc..aa1316d 100644 --- a/docs/plugins/fastcgi.md +++ b/docs/Plugins/fastcgi.md @@ -115,6 +115,20 @@ easymapping: path_info: true ``` +### Environment Variables + +Configure FastCGI plugin defaults for all domains: + +| 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 | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. Custom params (`custom_params`) cannot be configured via environment variables - use YAML or labels instead. + ## Generated HAProxy Configuration The plugin generates a top-level `fcgi-app` section and a `use-fcgi-app` directive in the backend: diff --git a/docs/plugins/ip-whitelist.md b/docs/Plugins/ip-whitelist.md similarity index 76% rename from docs/plugins/ip-whitelist.md rename to docs/Plugins/ip-whitelist.md index fd0a297..0cc6132 100644 --- a/docs/plugins/ip-whitelist.md +++ b/docs/Plugins/ip-whitelist.md @@ -85,6 +85,18 @@ easymapping: status_code: 403 ``` +### Environment Variables + +Configure IP Whitelist plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|-----------------------------------------------|---------------|----------|---------|--------------------------------------------------| +| `EASYHAPROXY_PLUGIN_IP_WHITELIST_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_IP_WHITELIST_ALLOWED_IPS` | `allowed_ips` | string | - | Comma-separated list of IPs/CIDR ranges to allow | +| `EASYHAPROXY_PLUGIN_IP_WHITELIST_STATUS_CODE` | `status_code` | integer | `403` | HTTP status code to return for blocked IPs | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. + ## Generated HAProxy Configuration ```haproxy diff --git a/docs/plugins/jwt-validator.md b/docs/Plugins/jwt-validator.md similarity index 87% rename from docs/plugins/jwt-validator.md rename to docs/Plugins/jwt-validator.md index 27eec2e..28fb53b 100644 --- a/docs/plugins/jwt-validator.md +++ b/docs/Plugins/jwt-validator.md @@ -157,6 +157,24 @@ easymapping: pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem ``` +### Environment Variables + +Configure JWT Validator plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|----------------------------------------------------|-------------------|---------|---------|---------------------------------------------| +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ALGORITHM` | `algorithm` | string | `RS256` | JWT signing algorithm | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ISSUER` | `issuer` | string | - | Expected JWT issuer (optional) | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_AUDIENCE` | `audience` | string | - | Expected JWT audience (optional) | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_PUBKEY_PATH` | `pubkey_path` | string | - | Path to public key file | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_PUBKEY` | `pubkey` | string | - | Public key as base64-encoded string | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_PATHS` | `paths` | string | - | Comma-separated paths requiring JWT | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ONLY_PATHS` | `only_paths` | boolean | `false` | If true, only specified paths accessible | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ALLOW_ANONYMOUS` | `allow_anonymous` | boolean | `false` | Allow requests without Authorization header | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. + ## Generated HAProxy Configuration ### All Paths Protected diff --git a/docs/plugins.md b/docs/plugins.md index 86d1e1b..3feedfd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -44,12 +44,12 @@ Execute **once for each discovered domain/host**. EasyHAProxy includes several built-in plugins ready to use: -- [Cloudflare](plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN -- [Cleanup](plugins/cleanup.md) - Cleanup temporary files -- [Deny Pages](plugins/deny-pages.md) - Block specific paths -- [IP Whitelist](plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges -- [JWT Validator](plugins/jwt-validator.md) - Validate JWT tokens -- [FastCGI](plugins/fastcgi.md) - Configure PHP-FPM and FastCGI applications +- [Cloudflare](Plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN +- [Cleanup](Plugins/cleanup.md) - Cleanup temporary files +- [Deny Pages](Plugins/deny-pages.md) - Block specific paths +- [IP Whitelist](Plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges +- [JWT Validator](Plugins/jwt-validator.md) - Validate JWT tokens +- [FastCGI](Plugins/fastcgi.md) - Configure PHP-FPM and FastCGI applications ## Configuration Methods @@ -124,49 +124,68 @@ services: ### 3. Static YAML Configuration -Configure plugins globally in `/etc/haproxy/static/config.yaml`: +Configure plugins in `/etc/haproxy/static/config.yaml`: ```yaml plugins: # Global settings abort_on_error: false # Log and continue on errors (recommended) - # Enable global plugins + # Enable GLOBAL plugins (run once per discovery cycle) enabled: [cleanup] - # Configure individual plugins + # Configure plugins (both global and domain plugins) config: - cloudflare: - enabled: true - ip_list_path: /etc/haproxy/cloudflare_ips.lst - + # Global plugin configuration (cleanup runs once per cycle) cleanup: enabled: true max_idle_time: 600 + # Domain plugin configuration (applies to ALL domains by default) + cloudflare: + enabled: true # Apply to all domains + use_builtin_ips: true # Use built-in Cloudflare IPs + + # Domain plugin disabled by default (enable per-domain via labels/annotations) deny_pages: - enabled: false # Disable globally, enable per-container via labels + enabled: false ``` +**Important distinctions:** + +- **Global plugins** (like `cleanup`): Run once per discovery cycle, configured here only +- **Domain plugins** (like `cloudflare`, `deny_pages`, `jwt_validator`): + - Configuration here sets **defaults for ALL domains** + - Can be enabled/disabled per-domain via container labels or Kubernetes annotations + - Per-domain configuration overrides these defaults + ### 4. Environment Variables -Configure plugins via environment variables: +Configure plugins via environment variables. **Note:** Environment variables set system-wide defaults and cannot configure plugins per-domain. ```bash -# Global settings +# Enable GLOBAL plugins (run once per discovery cycle) EASYHAPROXY_PLUGINS_ENABLED=cleanup EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false -# Plugin-specific configuration +# Configure GLOBAL plugins EASYHAPROXY_PLUGIN_CLEANUP_ENABLED=true EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 -EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst + +# Configure DOMAIN plugins (sets defaults for ALL domains) +EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED=true +EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS=true ``` **Variable format:** -- Enable plugins: `EASYHAPROXY_PLUGINS_ENABLED=plugin1,plugin2` +- Enable global plugins: `EASYHAPROXY_PLUGINS_ENABLED=plugin1,plugin2` - Configure plugin: `EASYHAPROXY_PLUGIN__=value` +**Scope limitations:** +- **Global plugins**: Environment variables configure the single instance +- **Domain plugins**: Environment variables set defaults for ALL domains +- **Per-domain configuration**: Use container labels (Docker) or annotations (Kubernetes) instead + ## Common Use Cases ### Protect API with JWT Authentication From d3149637b4b196d85e771cbd32dc657f3fee0295 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 2 Dec 2025 11:54:47 -0500 Subject: [PATCH 18/27] Add detailed release guide and update Makefile test command - Introduced `RELEASE.md` with comprehensive steps for creating, automating, and troubleshooting EasyHAProxy releases. - Enhanced the Makefile `test` command to include verbosity in the pytest execution. --- Makefile | 2 +- RELEASE.md | 350 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 RELEASE.md diff --git a/Makefile b/Makefile index 0b7e07f..4c9c278 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,4 @@ build: .PHONY: test test: - pytest tests/ + cd src/ && pytest tests/ -vv diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..008a242 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,350 @@ +# EasyHAProxy Release Guide + +This guide explains how to create a new release of EasyHAProxy, including Docker images, Helm charts, and documentation updates. + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Release Process](#release-process) +- [Automated Release (Recommended)](#automated-release-recommended) +- [Manual Release](#manual-release) +- [Helm Chart Release](#helm-chart-release) +- [Post-Release Checklist](#post-release-checklist) +- [Troubleshooting](#troubleshooting) + +## Overview + +The EasyHAProxy release process uses GitHub Actions to automatically: +- Run tests +- Build multi-architecture Docker images (amd64, arm64) +- Publish Docker images to Docker Hub +- Update Helm chart versions +- Publish Helm charts +- Update documentation + +## Prerequisites + +Before creating a release, ensure you have: + +1. **Permissions:** + - Write access to the GitHub repository + - Docker Hub credentials (for maintainers) + - Access to GitHub secrets (for CI/CD) + +2. **Local Setup:** + - Git configured with your credentials + - Docker installed (for local testing) + - Python 3.x with pytest (for running tests) + +3. **Repository Secrets (for maintainers):** + - `DOCKER_REGISTRY`: Docker Hub registry URL + - `DOCKER_REGISTRY_USER`: Docker Hub username + - `DOCKER_REGISTRY_TOKEN`: Docker Hub access token + - `DOC_TOKEN`: GitHub token for documentation updates + +## Release Process + +### Version Numbering + +EasyHAProxy follows [Semantic Versioning](https://semver.org/): + +- **MAJOR.MINOR.PATCH** (e.g., `4.6.0`) + - **MAJOR**: Breaking changes or major architectural updates + - **MINOR**: New features, plugin additions, backward-compatible changes + - **PATCH**: Bug fixes, documentation updates, minor improvements + +**Current Version:** `4.6.0` (as of Chart.yaml) + +## Automated Release (Recommended) + +The automated release process is triggered by pushing a semantic version tag. + +### Step 1: Prepare the Release + +1. **Ensure all changes are committed and pushed:** + ```bash + git status + git add . + git commit -m "Prepare release X.Y.Z" + git push origin master + ``` + +2. **Run tests locally:** + ```bash + cd src/ + pytest tests/ -vv + ``` + +3. **Build and test Docker image locally:** + ```bash + make build + # Or manually: + docker build -t byjg/easy-haproxy:local -f build/Dockerfile . + ``` + +### Step 2: Create and Push a Release Tag + +1. **Create a new semantic version tag:** + ```bash + # For a new minor version (new features) + git tag 4.7.0 + + # For a patch version (bug fixes) + git tag 4.6.1 + + # For a major version (breaking changes) + git tag 5.0.0 + ``` + +2. **Push the tag to GitHub:** + ```bash + git push origin 4.7.0 + ``` + +3. **Monitor the GitHub Actions workflow:** + - Go to: https://github.com/byjg/docker-easy-haproxy/actions + - Watch the "Docker" workflow progress + - Verify all jobs complete successfully: + - ✅ Test + - ✅ Build (multi-arch) + - ✅ Helm + - ✅ HelmDeploy + - ✅ Documentation + +### Step 3: What Happens Automatically + +When you push a semantic version tag, GitHub Actions will: + +1. **Run Tests** (`Test` job): + - Install Python dependencies + - Run pytest on all tests + +2. **Build Multi-Arch Docker Images** (`Build` job): + - Build for `linux/amd64` and `linux/arm64` + - Tag image with version number (e.g., `byjg/easy-haproxy:4.7.0`) + - Push to Docker Hub + +3. **Update Versions** (`Helm` job): + - Update `helm/easyhaproxy/Chart.yaml`: + - `appVersion`: Set to new version (e.g., `4.7.0`) + - `version`: Auto-increment patch version (e.g., `0.1.9` → `0.1.10`) + - Update all version references in: + - `deploy/docker/docker-compose.yml` + - `deploy/kubernetes/easyhaproxy-*.yml` + - `docs/kubernetes.md` + - `examples/*/*.yml` + - Commit and push changes with message: `[skip ci] Update from X.Y.Z to A.B.C` + +4. **Publish Helm Chart** (`HelmDeploy` job): + - Package Helm chart + - Publish to Helm repository at https://opensource.byjg.com/helm/ + +5. **Update Documentation** (`Documentation` job): + - Publish documentation updates + +### Step 4: Verify the Release + +1. **Check Docker Hub:** + ```bash + docker pull byjg/easy-haproxy:4.7.0 + docker images | grep easy-haproxy + ``` + +2. **Verify Helm chart:** + ```bash + helm repo add byjg https://opensource.byjg.com/helm + helm repo update + helm search repo easyhaproxy + ``` + +3. **Create GitHub Release:** + - Go to: https://github.com/byjg/docker-easy-haproxy/releases/new + - Select the tag you created + - Generate release notes + - Add highlights of changes + - Publish release + +## Manual Release + +For emergency releases or when CI/CD is unavailable. + +### Manual Docker Build (Multi-Arch) + +1. **Set up environment:** + ```bash + export DOCKER_USERNAME=your-username + export DOCKER_PASSWORD=your-token + export DOCKER_REGISTRY=docker.io + export VERSIONS="4.7.0" + ``` + +2. **Run multi-arch build:** + ```bash + ./build-multiarch.sh + ``` + + This script uses `buildah` and `podman` to create multi-architecture images. + +### Manual Helm Chart Update + +1. **Update Chart.yaml:** + ```bash + cd helm/easyhaproxy/ + + # Update appVersion + sed -i 's/appVersion: ".*"/appVersion: "4.7.0"/' Chart.yaml + + # Increment chart version + # From: version: 0.1.9 + # To: version: 0.1.10 + nano Chart.yaml + ``` + +2. **Package and publish Helm chart:** + ```bash + helm package helm/easyhaproxy/ + # Follow your Helm repository's publishing process + ``` + +## Helm Chart Release + +The Helm chart version is automatically managed by CI/CD, but you can manually control it: + +### Helm Chart Version Strategy + +- **Chart version** (`version` in Chart.yaml): + - Auto-incremented by CI/CD (patch version) + - Format: `0.1.X` where X increments with each Docker release + - Manual override: Edit Chart.yaml before tagging + +- **App version** (`appVersion` in Chart.yaml): + - Set to Docker image version (e.g., `4.7.0`) + - Automatically updated by CI/CD + +### Current Helm Chart + +- **Chart Version:** `0.1.9` +- **App Version:** `4.6.0` +- **Repository:** https://opensource.byjg.com/helm/ + +## Post-Release Checklist + +After a successful release: + +- [ ] Verify Docker image on Docker Hub +- [ ] Test Docker image: `docker run byjg/easy-haproxy:X.Y.Z --version` +- [ ] Verify Helm chart availability +- [ ] Test Helm installation +- [ ] Create GitHub Release with changelog +- [ ] Update project README if needed +- [ ] Announce release (if major/minor) +- [ ] Update dependent projects (if applicable) + +## Troubleshooting + +### Build Fails + +**Problem:** GitHub Actions build job fails + +**Solutions:** +1. Check test output in GitHub Actions logs +2. Run tests locally: `cd src/ && pytest tests/ -vv` +3. Fix failing tests and push changes +4. Delete and recreate tag: + ```bash + git tag -d 4.7.0 + git push origin :refs/tags/4.7.0 + git tag 4.7.0 + git push origin 4.7.0 + ``` + +### Docker Push Fails + +**Problem:** Cannot push to Docker Hub + +**Solutions:** +1. Verify Docker Hub credentials in GitHub secrets +2. Check Docker Hub token permissions +3. Ensure image name matches: `byjg/easy-haproxy` + +### Helm Chart Not Published + +**Problem:** Helm chart doesn't appear in repository + +**Solutions:** +1. Check `HelmDeploy` job logs in GitHub Actions +2. Verify `DOC_TOKEN` secret is valid +3. Wait a few minutes for chart to propagate +4. Clear Helm cache: `helm repo update` + +### Version Not Updated + +**Problem:** Version references not updated in docs/examples + +**Solutions:** +1. Check `Helm` job logs for sed command errors +2. Verify commit was pushed with `[skip ci]` message +3. Manually update version references if needed: + ```bash + find examples -type f -name '*.yml' -exec sed -i "s/\(byjg\/easy-haproxy:\)[0-9\.]*/\1X.Y.Z/g" {} \; + ``` + +### Multi-Arch Build Issues + +**Problem:** ARM64 build fails + +**Solutions:** +1. Verify QEMU is set up in GitHub Actions +2. Check build logs for architecture-specific errors +3. Test locally with Docker Buildx: + ```bash + docker buildx create --use + docker buildx build --platform linux/amd64,linux/arm64 -t test . + ``` + +## Quick Reference + +### Commands + +```bash +# Local build +make build + +# Run tests +cd src/ && pytest tests/ -vv + +# Create release tag +git tag 4.7.0 && git push origin 4.7.0 + +# Pull specific version +docker pull byjg/easy-haproxy:4.7.0 + +# Install Helm chart +helm install easyhaproxy byjg/easyhaproxy --version 0.1.10 + +# Check Helm chart info +helm show chart byjg/easyhaproxy +``` + +### Important URLs + +- **GitHub Repository:** https://github.com/byjg/docker-easy-haproxy +- **Docker Hub:** https://hub.docker.com/r/byjg/easy-haproxy +- **Helm Repository:** https://opensource.byjg.com/helm/ +- **Documentation:** https://opensource.byjg.com/devops/docker-easy-haproxy/ +- **GitHub Actions:** https://github.com/byjg/docker-easy-haproxy/actions + +### Version History + +| Version | Release Date | Type | Highlights | +|---------|-------------|------|------------| +| 4.6.0 | 2024-11-27 | Minor | FastCGI plugin, JWT enhancements | +| 4.5.0 | 2024-XX-XX | Minor | Previous release | +| ... | ... | ... | ... | + +--- + +**Need Help?** +- Open an issue: https://github.com/byjg/docker-easy-haproxy/issues +- Check documentation: https://opensource.byjg.com/devops/docker-easy-haproxy/ From 3a4b428e46c7f76baea27071a8642b46a6ad9570 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 2 Dec 2025 12:28:22 -0500 Subject: [PATCH 19/27] Add `generate-keys.sh` script for SSL and JWT key generation and update examples - Added `generate-keys.sh` script to automate the generation of SSL certificates and JWT keys for testing. - Updated examples and documentation across Docker, Kubernetes, Swarm, and static directories to include instructions for running the script. - Removed hardcoded .pem files and replaced with dynamically generated keys and certificates. - Emphasized testing-only usage of self-signed certificates. --- examples/docker/README.md | 18 ++++ .../certs/haproxy/.place_holder_cert.pem | 51 ---------- examples/docker/host2.local.pem | 50 ---------- examples/generate-keys.sh | 97 +++++++++++++++++++ examples/kubernetes/README.md | 17 +++- examples/static/README.md | 14 +++ examples/static/host1.local.pem | 82 ---------------- examples/swarm/README.md | 18 ++++ examples/swarm/certs/host1.local.pem | 82 ---------------- examples/swarm/certs/host2.local.pem | 50 ---------- 10 files changed, 162 insertions(+), 317 deletions(-) delete mode 100644 examples/docker/certs/haproxy/.place_holder_cert.pem delete mode 100644 examples/docker/host2.local.pem create mode 100755 examples/generate-keys.sh delete mode 100644 examples/static/host1.local.pem delete mode 100644 examples/swarm/certs/host1.local.pem delete mode 100644 examples/swarm/certs/host2.local.pem diff --git a/examples/docker/README.md b/examples/docker/README.md index cf68e56..3c43984 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -2,6 +2,24 @@ This directory contains various Docker Compose examples demonstrating different EasyHAProxy configurations. +## Prerequisites: Generate SSL Certificates + +**IMPORTANT:** Before running any examples, you must generate the required SSL certificates and JWT keys: + +```bash +# From the repository root +./examples/generate-keys.sh +``` + +This script automatically generates: +- SSL certificates for host1.local and host2.local +- JWT keys (jwt_private.pem and jwt_pubkey.pem) for JWT validation examples +- All other .pem files needed for testing + +**Note:** These are self-signed certificates for testing only. Do not use in production. + +--- + ## Examples Overview ### 1. Basic Configuration (`docker-compose.yml`) diff --git a/examples/docker/certs/haproxy/.place_holder_cert.pem b/examples/docker/certs/haproxy/.place_holder_cert.pem deleted file mode 100644 index 49558f4..0000000 --- a/examples/docker/certs/haproxy/.place_holder_cert.pem +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDyTCCArGgAwIBAgIUVpIokbXupa29drpQBmRlKTViM+UwDQYJKoZIhvcNAQEL -BQAwdDELMAkGA1UEBhMCQVUxFTATBgNVBAgMDFBsYWNlIEhvbGRlcjEVMBMGA1UE -BwwMUGxhY2UgSG9sZGVyMSEwHwYDVQQKDBhQbGFjZSBIb2xkZXIgQ2VydGlmaWNh -dGUxFDASBgNVBAMMC2V4YW1wbGUub3JnMB4XDTIyMDgxNTA0NTEwMFoXDTMyMDgx -MjA0NTEwMFowdDELMAkGA1UEBhMCQVUxFTATBgNVBAgMDFBsYWNlIEhvbGRlcjEV -MBMGA1UEBwwMUGxhY2UgSG9sZGVyMSEwHwYDVQQKDBhQbGFjZSBIb2xkZXIgQ2Vy -dGlmaWNhdGUxFDASBgNVBAMMC2V4YW1wbGUub3JnMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAqufw4FdqYcJep7mHHcYGUN79GNBLpvAIdg+1NbKx+cB/ -PtaDuozqVkkT8CmM0Mruay4vCbYkMytCeKgHj2+hLMy7oUQvx2pK/V0i0foPAC0m -gAvgmaWZbQTENHX4A0Rwvim0yixgeBVhz4hTMOIunilSXbKRkFBUidCnYQe1Nzy1 -dbH/fh8++fzLCglDE2kydrE3Zq/54G2xFOxPt1DZRnQ3RBYaMIR/uPPjpVxRWl+p -w4ucklAIZcu2htlOpGl7/3baMtnhpTo9LrkWzSNS7CJQvj6BvDULbcN3+hNPOoVE -MM9MCaM9V+FS25kf+DfyaVUVDVIv0thpwa+f3tCXSwIDAQABo1MwUTAdBgNVHQ4E -FgQUV831A1qfpV+Sy/J+lN6LKLgopsQwHwYDVR0jBBgwFoAUV831A1qfpV+Sy/J+ -lN6LKLgopsQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhnf7 -aW/jpr8JWC0l2lo0z1HpOaSNehhcfNyM6Y43lR3Lr1avibbIXkAOzCuSWanFNnex -dOq8PbG9bmO1ncM6qzXRBzk4pLVJmzzDZs/fPVghSZumY2bzzJFtdCQ5FiLaaE/c -cKtBPvoUjfvrBU9OwFSb9UaoQdxtateb/Kk6JfzWi6YZqxSXFNa0ZTWJaoRFQVj5 -bZZe+wgpGRz46p+2YMwsNolXxa+7yY9x6kOMqZP6++5LZGXm5iWxjWbN4WtqmNnN -cK35fAdLlg4d3wn5tVuTkpKH0FcaRlgpBSjVKejgnFTCxKcOwOCqOfu+nuSSWpa7 -aRqdAbMTeVFQhyLXhQ== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCq5/DgV2phwl6n -uYcdxgZQ3v0Y0Eum8Ah2D7U1srH5wH8+1oO6jOpWSRPwKYzQyu5rLi8JtiQzK0J4 -qAePb6EszLuhRC/Hakr9XSLR+g8ALSaAC+CZpZltBMQ0dfgDRHC+KbTKLGB4FWHP -iFMw4i6eKVJdspGQUFSJ0KdhB7U3PLV1sf9+Hz75/MsKCUMTaTJ2sTdmr/ngbbEU -7E+3UNlGdDdEFhowhH+48+OlXFFaX6nDi5ySUAhly7aG2U6kaXv/dtoy2eGlOj0u -uRbNI1LsIlC+PoG8NQttw3f6E086hUQwz0wJoz1X4VLbmR/4N/JpVRUNUi/S2GnB -r5/e0JdLAgMBAAECggEAI0nY9rmWAbF8ke1A9OjajQA+Ck2YEVQmqxn7NKc9EHCq -1XK9qFtIV6CnOUObC9GbAQ58L+kn+FjKVNd9GCTYhsOPSnEl3GsaKM5+ThTv2/12 -oaHSMmd7EoOVb6+cEjCjhuBdsBERqjngBFYFt2Y8cfPeSfKBE+dCTWKD7QkGZe0Q -tEII2NeE1AoQwG34TANxyn+HbZG809i+k21Pm0tPNyAFwhPvotpjSftMNgco8jcx -39DCt7rG1etpc4VhIUUl8GqsNAHYg8/SfscE18PYeX7RJpKGS128E1mKCqWqn+Ie -VCiMmWoQ4ZBlUFp3tS4+FWcQtDr8Z82QTZQn3ODwIQKBgQDV1NR+SxjBBnzpHDZC -cTxCK0PU37VmPjyr6BAXlftCPLGLgKEVIDRuna6EoiZ0/K0RMDZRoaJgDQPoAXDV -UwJOWYtlJBzUxwQbBJc3S7C3kEC+qeRT9lRwIrn7P6gT1DMcSRsV4ULwGRWAMa2Q -6apbK/K+56Ds7LiStu3OcJSxDwKBgQDMnAvLm0uMo9ZceKG515ruqzQj2YPz2+Zc -f38pD1hESDRj1bzgT09FAsejPnlN7KPp8TFgRUB9Rqb6DZCkx49zdtcDkuZIKfQH -Ga7ITBXnTwe8M+nwq2Q2LJYPdB/p8mBqh4ujA2XIS7ZHCKqOFGsseP4H1uxZeORI -pIPQp4C+BQKBgQDKHw9tAZc4feV8g4pWa6rF8ReBFKTnLFU1OXpckQybo7s/Xirl -STfGh437GTq4wk7lPGlb6CkQGb1jhFkfjANWBBZbWDNYfXZIA6LcRdOY7+YDU5vc -Ma/G/0xFTfqWI7LcPc44dGFNiqhkMJEbtYOuAnDGOzRGP8yIAhnvVUN3yQKBgQC1 -eaYglYGBoQMMi1Xt7iQVkbWyIkedr6l22wJe2aRRE7Wb4sQeM1m8fMWyrUOL8NpF -MU6481NKibKp0AQ9kl5Sa9Iy8kTbNpKhBY93SbyXpwnWTDku4+UDA7KozDdOGVKY -ydX45JeO+lAWWsJjOAsCq+Gr9F020jmvkHL1SsuuPQKBgQDHHCSOlUycLnzxEAks -uiu5MseFzkmdWN1ShrjPkcJ4HLmSD5zRgCySpBB92WPU+yBANlY7WN6fJHVJk/d7 -sNAg78GuAxcWrNAQwu6DRjhnb8zTVICJX8HmDLmsoOHwLsShdckWMks5XEk3NzoV -4ym6aG0tDX+rkBkP/VIjSXA+Cg== ------END PRIVATE KEY----- diff --git a/examples/docker/host2.local.pem b/examples/docker/host2.local.pem deleted file mode 100644 index 917062a..0000000 --- a/examples/docker/host2.local.pem +++ /dev/null @@ -1,50 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDqTCCApGgAwIBAgIUId5b9t9uqH78g02EzbWF6FKVw3gwDQYJKoZIhvcNAQEL -BQAwZDELMAkGA1UEBhMCQlIxFzAVBgNVBAgMDlJpbyBkZSBKYW5laXJvMRcwFQYD -VQQHDA5SaW8gZGUgSmFuZWlybzENMAsGA1UECgwEQUNNRTEUMBIGA1UEAwwLaG9z -dDIubG9jYWwwHhcNMjIwODE1MDQyNzA1WhcNMjMwODE1MDQyNzA1WjBkMQswCQYD -VQQGEwJCUjEXMBUGA1UECAwOUmlvIGRlIEphbmVpcm8xFzAVBgNVBAcMDlJpbyBk -ZSBKYW5laXJvMQ0wCwYDVQQKDARBQ01FMRQwEgYDVQQDDAtob3N0Mi5sb2NhbDCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMSKvrOahavCXnvSF5131hpo -6k65C57jgRQ84FaDj5MbJOVlYQVFtMG0XOk7a+hh5v1fe4wH0R7I6FDo0V9sS+ss -ko5bsElc1xYlg5HbuKq89vRSKg6EDlztx3BKbi912Pmt5vFGNJ16zcw77DUrQIXo -4I/b4a3pmBiWj43NoTIrmSWHtsGwwOj3iDvSweqdYXJIr3hpHH5u6pohjDoQvqDz -K6Mu8p6mhCUKNs7KFJnNInNG25oQT6O0n4OGtmgRjLWopdEnOhMkKsfIoI1XtlXB -LBDv7huICk3t5ywtfCQyO09kX7lFIgd5rn7+MjwH5WNeqbQJxuaqjoXQnNZUgUsC -AwEAAaNTMFEwHQYDVR0OBBYEFNhMBG8q6a+iK2nECwVTn6B9EXZOMB8GA1UdIwQY -MBaAFNhMBG8q6a+iK2nECwVTn6B9EXZOMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI -hvcNAQELBQADggEBAJmudvx8+p5iIUsT8fm/fbVM0DA6qWALDYUJnTn3j6Lq4vpf -PFC+q1LmuWfBQMyqKrHrP3e493EctXoiSKZO6iN5dVJIur02OjGuiAEcsYuY1nLn -s9piiI+UEwxH6ux1NaHUnzsWauoBvRhzjXvO6SAVSZJYa9dY5mizXklDyDNuG5U0 -lXv9egMGBsy0dG6eFXkU5CPdxWU540yI2sCtSAj7z+WRUD5k7gJ7tVoY3//jHQZG -5STTmm5t9kpIZTWkptyJos9oZJFYMIXqW2Fc6tyLZpRp31R78tDs6ETIkToDc0RR -jz66th6HI+ZlgIBQhw09+hYAhBDe9+Dmd/SzQZc= ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEir6zmoWrwl57 -0hedd9YaaOpOuQue44EUPOBWg4+TGyTlZWEFRbTBtFzpO2voYeb9X3uMB9EeyOhQ -6NFfbEvrLJKOW7BJXNcWJYOR27iqvPb0UioOhA5c7cdwSm4vddj5rebxRjSdes3M -O+w1K0CF6OCP2+Gt6ZgYlo+NzaEyK5klh7bBsMDo94g70sHqnWFySK94aRx+buqa -IYw6EL6g8yujLvKepoQlCjbOyhSZzSJzRtuaEE+jtJ+DhrZoEYy1qKXRJzoTJCrH -yKCNV7ZVwSwQ7+4biApN7ecsLXwkMjtPZF+5RSIHea5+/jI8B+VjXqm0Ccbmqo6F -0JzWVIFLAgMBAAECggEBAKceitVROQQ5e/mxRR9CfK1sNH/H3Ne3/1PkB6XIrFab -qB3evEatZOuon7A6NKEeTjl37Se+pdSVZOUXcqC/BzbraZre3+EhrkpIj72ApV+Y -2iwZiWVaaJQgI4uZ3mNAw8RaWJsj5S1a9I8LDOiQ5IZ45CmvABDPJeMScvJSvRRY -e5N0L6stqS7Z+IoyGVKUfp1iNO0YyywOUiSkIRXgscuRXZGYpiGPomsJ+Js1ejzW -jyStlZJEr4L1285rGPrmHqjTwFd+hG80Wc4179xL+WRE6HBEUZSiy95fe6kcPHXX -BgiVYtcFKmiBi2dTbxl4e94ut239i0HtlJ1ZJtLh+BECgYEA8a298K2zXkHosxhN -tRrH7XfMPTkHDDd3rxM21LT+fIXqinGUp9LYaDcbjuTPs8e33uKMd7R6Q40x89yW -IXNka/VL0PXUeV67aCVLLqgXDLGudluJinH0XvmI0CmBecFSMqIFmQlgqERoGGs3 -UMac0p876T4XkGQQJdf62bFpE4UCgYEA0DBH0PDlOpwXccDgXrMfayr8HAhI+G5R -yWQ//9iirtU83chwIWwkh53eLLMzLgdqJnPiWyaUW5BqzmYuD23nhxcQ7PNdIqOO -H1sE6zqLNshv46t5QKlh1Q4qjd7UqtgrSrY63RXJCMWTwnNMeDLtj8gaKbjkrG3R -BM2ildt6Uo8CgYBX7NDUli1SloH1XlsvD047S8FHaM7yl994F3J0UmDfpszcj1P4 -9pF64Mmq4/3Yt0li0mMuTb/Jgb3xrYgFJXkcecKahEVH3ropup+umsLAAIirUMQq -VSkFwJ0Qtnj/deDUwPNuaOX8cd65O5CFV6zIR9xBEDD8fBsP2ZLOzmefDQKBgQDF -m24vVthd/1cJdCgD+0VxNYXDHeIVXLFo1S0iLYCNLn3tjZlRQBKUXzZJe3ay0/rf -sNND7aSYHMYkTzydDJbc1PoNzxmyDUiTXpOWqyUExM/fbB1VUPE5h47AxqdZ2oGN -EtdgjpMZLmCIC2SkGsL+3NJok8UKHdpuErmmQIMk5QKBgQCgEWcYtLXC3YDYMFdI -UgcTebFqSs3mLYgub1xekW3IXR2yom4V5fQTLiF7Yfn2dpDW4IcMU0UJFYVsUlhK -aGtet4Vm5Nn8+Mghot5yAjqO9yAUaub7wgifKIe99tQKd8uZyCvJ0hhvmDDSfx4m -B/TEiFAO99yF49iSxEVSAS6pqQ== ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/generate-keys.sh b/examples/generate-keys.sh new file mode 100755 index 0000000..96d0cf6 --- /dev/null +++ b/examples/generate-keys.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -e + +# Generate SSL Certificates and JWT Keys for EasyHAProxy Examples +# This script creates all .pem files needed for the examples directory + +echo "Generating SSL certificates and JWT keys for EasyHAProxy examples..." +echo "" + +# Create necessary directories +mkdir -p examples/static +mkdir -p examples/docker +mkdir -p examples/docker/certs/haproxy +mkdir -p examples/swarm/certs + +# ============================================================================ +# Generate SSL Certificate for host1.local (4096-bit RSA, 10-year validity) +# ============================================================================ +echo "Generating host1.local certificate (4096-bit RSA, 10-year validity)..." +openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ + -keyout examples/static/host1.local.pem \ + -out examples/static/host1.local.pem \ + -subj "/C=US/ST=State/L=City/O=Organization/CN=host1.local" + +# Copy to swarm directory +cp examples/static/host1.local.pem examples/swarm/certs/host1.local.pem +echo " Created host1.local.pem (4096-bit, 10 years)" +echo " - examples/static/host1.local.pem" +echo " - examples/swarm/certs/host1.local.pem" +echo "" + +# ============================================================================ +# Generate SSL Certificate for host2.local (2048-bit RSA, 1-year validity) +# ============================================================================ +echo "Generating host2.local certificate (2048-bit RSA, 1-year validity)..." +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout examples/docker/host2.local.pem \ + -out examples/docker/host2.local.pem \ + -subj "/C=US/ST=State/L=City/O=Organization/CN=host2.local" + +# Copy to swarm directory +cp examples/docker/host2.local.pem examples/swarm/certs/host2.local.pem +echo " Created host2.local.pem (2048-bit, 1 year)" +echo " - examples/docker/host2.local.pem" +echo " - examples/swarm/certs/host2.local.pem" +echo "" + +# ============================================================================ +# Generate JWT RSA Key Pair (2048-bit) +# ============================================================================ +echo "Generating JWT RSA key pair (2048-bit)..." + +# Generate private key +openssl genrsa -out examples/docker/jwt_private.pem 2048 + +# Extract public key +openssl rsa -in examples/docker/jwt_private.pem -pubout -out examples/docker/jwt_pubkey.pem + +echo " Created JWT key pair (2048-bit)" +echo " - examples/docker/jwt_private.pem (private key)" +echo " - examples/docker/jwt_pubkey.pem (public key)" +echo "" + +# ============================================================================ +# Generate Placeholder Certificate +# ============================================================================ +echo "Generating placeholder certificate..." +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout examples/docker/certs/haproxy/.place_holder_cert.pem \ + -out examples/docker/certs/haproxy/.place_holder_cert.pem \ + -subj "/C=US/ST=State/L=City/O=Organization/CN=placeholder" + +echo " Created placeholder certificate" +echo " - examples/docker/certs/haproxy/.place_holder_cert.pem" +echo "" + +# ============================================================================ +# Summary +# ============================================================================ +echo "============================================" +echo "All certificates and keys generated successfully!" +echo "============================================" +echo "" +echo "SSL Certificates:" +echo " - host1.local (4096-bit, 10 years)" +echo " - host2.local (2048-bit, 1 year)" +echo "" +echo "JWT Keys:" +echo " - jwt_private.pem (private key for signing)" +echo " - jwt_pubkey.pem (public key for validation)" +echo "" +echo "IMPORTANT NOTES:" +echo " - These are self-signed certificates for TESTING ONLY" +echo " - DO NOT use these certificates in production" +echo " - Browsers will show security warnings for self-signed certificates" +echo " - JWT keys should be kept secure and rotated regularly" +echo "" diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index 7bac331..4a867cb 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -4,13 +4,26 @@ This directory contains Kubernetes manifest examples demonstrating EasyHAProxy i ## Prerequisites -1. **EasyHAProxy installed in your cluster:** +1. **Generate SSL Certificates (Required for TLS examples):** + ```bash + # From the repository root + ./examples/generate-keys.sh + ``` + + This script automatically generates: + - SSL certificates for testing (host1.local, host2.local) + - JWT keys for authentication examples + - All other .pem files needed for examples + + **Note:** These are self-signed certificates for testing only. For production, use Let's Encrypt or your own certificates. + +2. **EasyHAProxy installed in your cluster:** ```bash kubectl create namespace easyhaproxy kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml ``` -2. **Label the node where EasyHAProxy will run:** +3. **Label the node where EasyHAProxy will run:** ```bash kubectl label nodes "easyhaproxy/node=master" ``` diff --git a/examples/static/README.md b/examples/static/README.md index b22d168..8f5bac6 100644 --- a/examples/static/README.md +++ b/examples/static/README.md @@ -154,6 +154,20 @@ Access at: `http://localhost:1936` ## Running the Example +### Prerequisites: Generate SSL Certificates + +Before running the examples, you need to generate the required SSL certificates: + +```bash +# From the repository root +./examples/generate-keys.sh +``` + +This script automatically generates: +- SSL certificates for host1.local and host2.local +- JWT keys for authentication examples +- All other .pem files needed for testing + ### 1. Start the Example ```bash diff --git a/examples/static/host1.local.pem b/examples/static/host1.local.pem deleted file mode 100644 index 0d7eb39..0000000 --- a/examples/static/host1.local.pem +++ /dev/null @@ -1,82 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFDTCCAvWgAwIBAgIURi+w1ZVgeedTlNIAwqQBMJv6dXswDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAwwLaG9zdDEubG9jYWwwHhcNMjEwODEwMTg0OTA2WhcNMzEw -ODA4MTg0OTA2WjAWMRQwEgYDVQQDDAtob3N0MS5sb2NhbDCCAiIwDQYJKoZIhvcN -AQEBBQADggIPADCCAgoCggIBAMBDAhLAygJuaW6w6ffigzTAAGXpmEz0tIxn1k4Z -x5wN5rpv/qu0QMYz+Av2u1eOKEKZeaFRVpT0r93dX7IvbEZHt25GPiBvlLGqhjKR -PnSk/7U8XmsnttUAV7rVEK1UrdFw8/IwriQC+dhr0mnYfSDMkvBoMFpdhVNTrbAZ -1TB6rQjE7Ar0Mt8my96XJmwrcjK2Tj+E2rgPIUz1e5cekFYIDSBatmw+3+vr+T5x -FNFkJ2o30W5o8ZflCJJzrVaihqQics6ZKDgpf7iqXMFiwWIlhdQpGvx5Gf/KFTK9 -UaOnRZz/X+2CebAFaTHR3k/PYppWTgBBBuRvlpCw+wdnkmteC0SQRF91QWVr7ejo -7KaOlGI5VtvMUsWvTeAZmpaymIaATETuOJaY0JU11OmLeD9DOj5E2SQ7qIX/pFcp -xpzG5j4c+MlgvxP2VAkNTeAXCaYiPBQH5ZZg0HE2WnB1KhLRFlHd4iHQD2GJ5yN/ -6fCFBfZfKSeK8JauwxgWkra53OcDq/mKd+DA/dK+/ruG7tqwVgIa04HOplzM7LYR -GB0Irs9+lr5/PJbQZmU073Mdn6cXAg3p+6wvwFlDkS5v13gBDYNHtF62bc551edF -Z6kGzJ7wmGRo84aBP7MuRZeReLOrSS67a1wLdzZsMnP1TJ7x9Lfr9MKl2uDnQdnY -ex8DAgMBAAGjUzBRMB0GA1UdDgQWBBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAfBgNV -HSMEGDAWgBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAPBgNVHRMBAf8EBTADAQH/MA0G -CSqGSIb3DQEBCwUAA4ICAQChQYNuah3+mTpIBDYxGrjTJNuOTIMaWzMyi1tkf+L0 -sEGwpbmAO2mWWQYF7WVLsi98PULh3adjt2jiud9VlaaC6gnwn5Zo1+Pilo9sNLLW -6ij0+rN4kwIm/pNqi+jDuu2cvAuHIwZWeh8bEe/5UCxo4ihmWFQN8eJ6TUKCphRC -6Eor/SSZZBQHgPl0BchzHOkwu7R3LCndRqxjhAoVb9yQOV+ZsmTeJXulwNzJ1uLt -T8OIgIiDpmBo7HSN2H0k3chx00AsjUyJ9mmAWPejFe/KXLRPcVZR17jhzgfIBEzs -M5WtWFm1aHDjVv6M6iteVm61E9T+k/M11ru1e2YwsxTDvb6x04mcrNu9soqddBbr -VfpluuoQ/hEAbXtFNPoTySpz0cwOwcHCowVOLmdKgvImszZiMyHHG8VGGmPh88n7 -wVxb0gV0P4RMrcMLdeTdn55YQr1CqBr34eB6ol6AsbTm3VzBHRVmFNksl1o5JB5t -tXLgF/G8/rzJ/4m1PaVuxrB7DxUmIk8EPbSIVkvZvd7LBzKwQ6IfVaucewHfEajQ -VIiexSMiFc7lw3KnxjOHZjf6FM9VYg3No++GdC99s7LkIuJwAMLNqTQ7Hvhn7YvP -4FlSIgc6xj0YkGZEQlb5o/5nauEqQU0ABgw6jtI4NxrNLT6cp7CO4M0xIDEg/3YD -aA== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDAQwISwMoCbmlu -sOn34oM0wABl6ZhM9LSMZ9ZOGcecDea6b/6rtEDGM/gL9rtXjihCmXmhUVaU9K/d -3V+yL2xGR7duRj4gb5SxqoYykT50pP+1PF5rJ7bVAFe61RCtVK3RcPPyMK4kAvnY -a9Jp2H0gzJLwaDBaXYVTU62wGdUweq0IxOwK9DLfJsvelyZsK3Iytk4/hNq4DyFM -9XuXHpBWCA0gWrZsPt/r6/k+cRTRZCdqN9FuaPGX5QiSc61WooakInLOmSg4KX+4 -qlzBYsFiJYXUKRr8eRn/yhUyvVGjp0Wc/1/tgnmwBWkx0d5Pz2KaVk4AQQbkb5aQ -sPsHZ5JrXgtEkERfdUFla+3o6OymjpRiOVbbzFLFr03gGZqWspiGgExE7jiWmNCV -NdTpi3g/Qzo+RNkkO6iF/6RXKcacxuY+HPjJYL8T9lQJDU3gFwmmIjwUB+WWYNBx -NlpwdSoS0RZR3eIh0A9hiecjf+nwhQX2XyknivCWrsMYFpK2udznA6v5infgwP3S -vv67hu7asFYCGtOBzqZczOy2ERgdCK7Pfpa+fzyW0GZlNO9zHZ+nFwIN6fusL8BZ -Q5Eub9d4AQ2DR7Retm3OedXnRWepBsye8JhkaPOGgT+zLkWXkXizq0kuu2tcC3c2 -bDJz9Uye8fS36/TCpdrg50HZ2HsfAwIDAQABAoICAQC/xZbZ0cctqagsqvaVNTEe -eq1q+hfaGvPEYQaYHIrIE+2i5XcnGcLKcKfodxDjAn8R/zgdOp6cMX0CVn/PohHk -AEDtE8+AVwwAM1FsOwgLHVGaGz8qrxBlYdQgHcpmueIu2PXbC8eHUBiaUOIuhaw5 -/RRMDAC/Ai2ssfi7gOjvVE4oQxQW0QG1KGOOAUJn/uYHw2RFY2Uu1pimxO2kDO53 -gcxmC1WOnyCHmHaiW/Uh7z6JamfSM4dXtTJZslyh37dhHKNbg9VkP7CQKA4hLzop -hbf5qY6rargONiny1HgMPxrmwKuUouJyOtN0yBtxjDCUNaXUBwiy7sNGS+H4vsyB -5P9HhIHStu+FZt3HG7EIqCndiaSKDS4jWaVQAbbo4nZ2Zs2BD+xDePRCRUqX7rM4 -4XzPIRWWXmmWf/7Ig29Hbrp4a9LcOmQ2leCJtbaTFSN96OLUJ5E+hQ0ulCZgBVmQ -RCUYkJP4lOzbaKdzjxgHMrHzm45eUFf8LirOxi2uyxXHQmDNu4b3X18kt3PgUmUm -3dXpl3fqSyJa7SCV8ZNBrsrDq1E+thYtu91QbVSGxHd9HrNVe3XdLbOCdU9CuC69 -Nglznaa7sZLqmyKejTfGsY7xrWdNcMPl4p4fcID/O4EpASZforpTeKNT0ZIfZZew -b0mAQeYZqQM8i/qMYN/uAQKCAQEA5qg1sRNMc6VdM/tRglasGYoxjgRC2OqADZgs -mAXMUJ3kErpyxt+eCimy8ibuYpzRTIQ8fBTWRkCtRZXJ7+KcLVtk9QZIoLbhyNwd -4IxEQZFuUljDbvSjTLSycsHvo65ibWIfTL7bgWlLGgGq/UOzfGsgH6S9wLp5G30G -8ELyjI5eTIYICrfTmVL+c45MRpEMKo+cvz8PysiaOFTn3cyswPVdYaeEEqMQjU8w -IGNsGZLytY7BABBcY0ldrtba/O+Fv/+RH7uUtzP7xpCIwFCx80ZzN+WRy9NvI63U -zq3yIBoW9GyApD2+PLaPNxf7QLTUChY1Zz/dYRltKOxv2Aa5gQKCAQEA1WLWNqp0 -fhB/ZtfSEShxFMM89cjN6Aaz1WKL7uTBou9oSJnxjkhkaV76acnT/iqXtxMNgHi1 -fImDpU3PvM0Y4Ud2T47oHc6P1BrZPN/GmXy/s6BAEdPwLe7J+4nTISHAdGmrh+a/ -5pktu32g9lWqftxecFIVSLPWkxT0XKiMxp1ffkL+OavpMgMFZK41iKs3dNShKPog -L8GSPcP9x/yn78P2eK3N+PGjlA6pPzrANyWU7N0/bmHcB9TKP+udYWcjVhru7MYN -wNrE4kKdC8v8i7x7tDbvb79T+Fo6PIh53p0OsnZzA8UR0QNR+vDQufQuyaj8REC+ -ZG8YyCKsvk8ygwKCAQA/fsSxB0f/eeErYx6wC536teEoYCHqxrsTgvWbr9TryFs1 -kJ/yATLnR01cfb0X5mVzc9+WpMHLuxg31KEvaSlnDwa+sMkjfNSwz29mFhbgGeHN -x2OdUrj1b7TEBIEshN/RjrZhERUqDcs/0H+6kn2BXZgNPfOCb5LRL1zOnQ9aBAMP -e8IQ+UPFrGQheWWj81/vA3O57ekyAID7ytu9Yg+YWrMnI88mtj7jN45fDB+A9sPb -mP2mP9q+9j5U2A6WnHUsQnU30BKDUEsaAUWz80LZXmZvV8IH4x9wKfUwJBBIKAZz -qL7M97Y7zmGkX/Spfl30nOJ8lschaLd1EYlEZa2BAoIBAQCye25T4TV5MJFv0zuZ -MGuNg1Sc/O4Fkn2fEUOceWjhwUBH4cPjT/f1DwWDsNaJ9NRbxCr5931OArPDc5c8 -A404+Y4jM5RBQkKZli94tHAod+jc9UBB6TUvJll59SlMwC9679wS21ZOKnfPKGCX -SsZGQEsZxf6ZhhsHgXJ3gl/lzUJPmPeOA5YVR+Od9/09KIFFTojSfoynhVCuKx49 -xb4uVYn2HOJ4xJ0fPTghdCHMvrmXeeQRjvb88eaNmqVUEHHFFtgb4fklA5fE7RTx -BhliRDBwZ7bUkINK6yVk9n6BTns5mMvRLmgdnJpYvE7KC02LTbZb3I+j8C0ZUa+N -qy7DAoIBAAieribS7WUcl2aBlkm5+W7qNm/INm5zvnoSPo6V3wa5hs6f9+C/kbdF -87jQPA/YFe3uR2sAJ7slX5euZK8WmfpFmgzlu0sEz81MLQ/WypZtZytyVtWzB2Pu -XCW1tdSH9eI2BmhXgokHNTM48Nk/xOENrP/seXrIx5LK0hnDHZotu/z6+YSkB9hF -cm2fZygD1dMLX6liRimxyFY+dICJNB95JifTLWYnWeGddkwPtXUeGXE1olzvNkLD -zMzE09uhkx/lRJnteOBEZaf80OB/09Oi9b9/rxY59dwsH6GaxLoTfEKuPnvBVMNR -YkU14WzQKleFkiBJI9lVvnfgGnOlgg0= ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/swarm/README.md b/examples/swarm/README.md index a4d5568..5627b0f 100644 --- a/examples/swarm/README.md +++ b/examples/swarm/README.md @@ -47,6 +47,24 @@ docker network create --driver overlay --attachable easyhaproxy --- +## Prerequisites: Generate SSL Certificates + +**IMPORTANT:** Before running any examples, you must generate the required SSL certificates: + +```bash +# From the repository root +./examples/generate-keys.sh +``` + +This script automatically generates: +- SSL certificates for host1.local and host2.local (placed in `examples/swarm/certs/`) +- JWT keys for authentication examples +- All other .pem files needed for testing + +**Note:** These are self-signed certificates for testing only. Do not use in production. + +--- + ## Quick Start ### 1. Deploy EasyHAProxy diff --git a/examples/swarm/certs/host1.local.pem b/examples/swarm/certs/host1.local.pem deleted file mode 100644 index 0d7eb39..0000000 --- a/examples/swarm/certs/host1.local.pem +++ /dev/null @@ -1,82 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFDTCCAvWgAwIBAgIURi+w1ZVgeedTlNIAwqQBMJv6dXswDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAwwLaG9zdDEubG9jYWwwHhcNMjEwODEwMTg0OTA2WhcNMzEw -ODA4MTg0OTA2WjAWMRQwEgYDVQQDDAtob3N0MS5sb2NhbDCCAiIwDQYJKoZIhvcN -AQEBBQADggIPADCCAgoCggIBAMBDAhLAygJuaW6w6ffigzTAAGXpmEz0tIxn1k4Z -x5wN5rpv/qu0QMYz+Av2u1eOKEKZeaFRVpT0r93dX7IvbEZHt25GPiBvlLGqhjKR -PnSk/7U8XmsnttUAV7rVEK1UrdFw8/IwriQC+dhr0mnYfSDMkvBoMFpdhVNTrbAZ -1TB6rQjE7Ar0Mt8my96XJmwrcjK2Tj+E2rgPIUz1e5cekFYIDSBatmw+3+vr+T5x -FNFkJ2o30W5o8ZflCJJzrVaihqQics6ZKDgpf7iqXMFiwWIlhdQpGvx5Gf/KFTK9 -UaOnRZz/X+2CebAFaTHR3k/PYppWTgBBBuRvlpCw+wdnkmteC0SQRF91QWVr7ejo -7KaOlGI5VtvMUsWvTeAZmpaymIaATETuOJaY0JU11OmLeD9DOj5E2SQ7qIX/pFcp -xpzG5j4c+MlgvxP2VAkNTeAXCaYiPBQH5ZZg0HE2WnB1KhLRFlHd4iHQD2GJ5yN/ -6fCFBfZfKSeK8JauwxgWkra53OcDq/mKd+DA/dK+/ruG7tqwVgIa04HOplzM7LYR -GB0Irs9+lr5/PJbQZmU073Mdn6cXAg3p+6wvwFlDkS5v13gBDYNHtF62bc551edF -Z6kGzJ7wmGRo84aBP7MuRZeReLOrSS67a1wLdzZsMnP1TJ7x9Lfr9MKl2uDnQdnY -ex8DAgMBAAGjUzBRMB0GA1UdDgQWBBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAfBgNV -HSMEGDAWgBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAPBgNVHRMBAf8EBTADAQH/MA0G -CSqGSIb3DQEBCwUAA4ICAQChQYNuah3+mTpIBDYxGrjTJNuOTIMaWzMyi1tkf+L0 -sEGwpbmAO2mWWQYF7WVLsi98PULh3adjt2jiud9VlaaC6gnwn5Zo1+Pilo9sNLLW -6ij0+rN4kwIm/pNqi+jDuu2cvAuHIwZWeh8bEe/5UCxo4ihmWFQN8eJ6TUKCphRC -6Eor/SSZZBQHgPl0BchzHOkwu7R3LCndRqxjhAoVb9yQOV+ZsmTeJXulwNzJ1uLt -T8OIgIiDpmBo7HSN2H0k3chx00AsjUyJ9mmAWPejFe/KXLRPcVZR17jhzgfIBEzs -M5WtWFm1aHDjVv6M6iteVm61E9T+k/M11ru1e2YwsxTDvb6x04mcrNu9soqddBbr -VfpluuoQ/hEAbXtFNPoTySpz0cwOwcHCowVOLmdKgvImszZiMyHHG8VGGmPh88n7 -wVxb0gV0P4RMrcMLdeTdn55YQr1CqBr34eB6ol6AsbTm3VzBHRVmFNksl1o5JB5t -tXLgF/G8/rzJ/4m1PaVuxrB7DxUmIk8EPbSIVkvZvd7LBzKwQ6IfVaucewHfEajQ -VIiexSMiFc7lw3KnxjOHZjf6FM9VYg3No++GdC99s7LkIuJwAMLNqTQ7Hvhn7YvP -4FlSIgc6xj0YkGZEQlb5o/5nauEqQU0ABgw6jtI4NxrNLT6cp7CO4M0xIDEg/3YD -aA== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDAQwISwMoCbmlu -sOn34oM0wABl6ZhM9LSMZ9ZOGcecDea6b/6rtEDGM/gL9rtXjihCmXmhUVaU9K/d -3V+yL2xGR7duRj4gb5SxqoYykT50pP+1PF5rJ7bVAFe61RCtVK3RcPPyMK4kAvnY -a9Jp2H0gzJLwaDBaXYVTU62wGdUweq0IxOwK9DLfJsvelyZsK3Iytk4/hNq4DyFM -9XuXHpBWCA0gWrZsPt/r6/k+cRTRZCdqN9FuaPGX5QiSc61WooakInLOmSg4KX+4 -qlzBYsFiJYXUKRr8eRn/yhUyvVGjp0Wc/1/tgnmwBWkx0d5Pz2KaVk4AQQbkb5aQ -sPsHZ5JrXgtEkERfdUFla+3o6OymjpRiOVbbzFLFr03gGZqWspiGgExE7jiWmNCV -NdTpi3g/Qzo+RNkkO6iF/6RXKcacxuY+HPjJYL8T9lQJDU3gFwmmIjwUB+WWYNBx -NlpwdSoS0RZR3eIh0A9hiecjf+nwhQX2XyknivCWrsMYFpK2udznA6v5infgwP3S -vv67hu7asFYCGtOBzqZczOy2ERgdCK7Pfpa+fzyW0GZlNO9zHZ+nFwIN6fusL8BZ -Q5Eub9d4AQ2DR7Retm3OedXnRWepBsye8JhkaPOGgT+zLkWXkXizq0kuu2tcC3c2 -bDJz9Uye8fS36/TCpdrg50HZ2HsfAwIDAQABAoICAQC/xZbZ0cctqagsqvaVNTEe -eq1q+hfaGvPEYQaYHIrIE+2i5XcnGcLKcKfodxDjAn8R/zgdOp6cMX0CVn/PohHk -AEDtE8+AVwwAM1FsOwgLHVGaGz8qrxBlYdQgHcpmueIu2PXbC8eHUBiaUOIuhaw5 -/RRMDAC/Ai2ssfi7gOjvVE4oQxQW0QG1KGOOAUJn/uYHw2RFY2Uu1pimxO2kDO53 -gcxmC1WOnyCHmHaiW/Uh7z6JamfSM4dXtTJZslyh37dhHKNbg9VkP7CQKA4hLzop -hbf5qY6rargONiny1HgMPxrmwKuUouJyOtN0yBtxjDCUNaXUBwiy7sNGS+H4vsyB -5P9HhIHStu+FZt3HG7EIqCndiaSKDS4jWaVQAbbo4nZ2Zs2BD+xDePRCRUqX7rM4 -4XzPIRWWXmmWf/7Ig29Hbrp4a9LcOmQ2leCJtbaTFSN96OLUJ5E+hQ0ulCZgBVmQ -RCUYkJP4lOzbaKdzjxgHMrHzm45eUFf8LirOxi2uyxXHQmDNu4b3X18kt3PgUmUm -3dXpl3fqSyJa7SCV8ZNBrsrDq1E+thYtu91QbVSGxHd9HrNVe3XdLbOCdU9CuC69 -Nglznaa7sZLqmyKejTfGsY7xrWdNcMPl4p4fcID/O4EpASZforpTeKNT0ZIfZZew -b0mAQeYZqQM8i/qMYN/uAQKCAQEA5qg1sRNMc6VdM/tRglasGYoxjgRC2OqADZgs -mAXMUJ3kErpyxt+eCimy8ibuYpzRTIQ8fBTWRkCtRZXJ7+KcLVtk9QZIoLbhyNwd -4IxEQZFuUljDbvSjTLSycsHvo65ibWIfTL7bgWlLGgGq/UOzfGsgH6S9wLp5G30G -8ELyjI5eTIYICrfTmVL+c45MRpEMKo+cvz8PysiaOFTn3cyswPVdYaeEEqMQjU8w -IGNsGZLytY7BABBcY0ldrtba/O+Fv/+RH7uUtzP7xpCIwFCx80ZzN+WRy9NvI63U -zq3yIBoW9GyApD2+PLaPNxf7QLTUChY1Zz/dYRltKOxv2Aa5gQKCAQEA1WLWNqp0 -fhB/ZtfSEShxFMM89cjN6Aaz1WKL7uTBou9oSJnxjkhkaV76acnT/iqXtxMNgHi1 -fImDpU3PvM0Y4Ud2T47oHc6P1BrZPN/GmXy/s6BAEdPwLe7J+4nTISHAdGmrh+a/ -5pktu32g9lWqftxecFIVSLPWkxT0XKiMxp1ffkL+OavpMgMFZK41iKs3dNShKPog -L8GSPcP9x/yn78P2eK3N+PGjlA6pPzrANyWU7N0/bmHcB9TKP+udYWcjVhru7MYN -wNrE4kKdC8v8i7x7tDbvb79T+Fo6PIh53p0OsnZzA8UR0QNR+vDQufQuyaj8REC+ -ZG8YyCKsvk8ygwKCAQA/fsSxB0f/eeErYx6wC536teEoYCHqxrsTgvWbr9TryFs1 -kJ/yATLnR01cfb0X5mVzc9+WpMHLuxg31KEvaSlnDwa+sMkjfNSwz29mFhbgGeHN -x2OdUrj1b7TEBIEshN/RjrZhERUqDcs/0H+6kn2BXZgNPfOCb5LRL1zOnQ9aBAMP -e8IQ+UPFrGQheWWj81/vA3O57ekyAID7ytu9Yg+YWrMnI88mtj7jN45fDB+A9sPb -mP2mP9q+9j5U2A6WnHUsQnU30BKDUEsaAUWz80LZXmZvV8IH4x9wKfUwJBBIKAZz -qL7M97Y7zmGkX/Spfl30nOJ8lschaLd1EYlEZa2BAoIBAQCye25T4TV5MJFv0zuZ -MGuNg1Sc/O4Fkn2fEUOceWjhwUBH4cPjT/f1DwWDsNaJ9NRbxCr5931OArPDc5c8 -A404+Y4jM5RBQkKZli94tHAod+jc9UBB6TUvJll59SlMwC9679wS21ZOKnfPKGCX -SsZGQEsZxf6ZhhsHgXJ3gl/lzUJPmPeOA5YVR+Od9/09KIFFTojSfoynhVCuKx49 -xb4uVYn2HOJ4xJ0fPTghdCHMvrmXeeQRjvb88eaNmqVUEHHFFtgb4fklA5fE7RTx -BhliRDBwZ7bUkINK6yVk9n6BTns5mMvRLmgdnJpYvE7KC02LTbZb3I+j8C0ZUa+N -qy7DAoIBAAieribS7WUcl2aBlkm5+W7qNm/INm5zvnoSPo6V3wa5hs6f9+C/kbdF -87jQPA/YFe3uR2sAJ7slX5euZK8WmfpFmgzlu0sEz81MLQ/WypZtZytyVtWzB2Pu -XCW1tdSH9eI2BmhXgokHNTM48Nk/xOENrP/seXrIx5LK0hnDHZotu/z6+YSkB9hF -cm2fZygD1dMLX6liRimxyFY+dICJNB95JifTLWYnWeGddkwPtXUeGXE1olzvNkLD -zMzE09uhkx/lRJnteOBEZaf80OB/09Oi9b9/rxY59dwsH6GaxLoTfEKuPnvBVMNR -YkU14WzQKleFkiBJI9lVvnfgGnOlgg0= ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/swarm/certs/host2.local.pem b/examples/swarm/certs/host2.local.pem deleted file mode 100644 index 917062a..0000000 --- a/examples/swarm/certs/host2.local.pem +++ /dev/null @@ -1,50 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDqTCCApGgAwIBAgIUId5b9t9uqH78g02EzbWF6FKVw3gwDQYJKoZIhvcNAQEL -BQAwZDELMAkGA1UEBhMCQlIxFzAVBgNVBAgMDlJpbyBkZSBKYW5laXJvMRcwFQYD -VQQHDA5SaW8gZGUgSmFuZWlybzENMAsGA1UECgwEQUNNRTEUMBIGA1UEAwwLaG9z -dDIubG9jYWwwHhcNMjIwODE1MDQyNzA1WhcNMjMwODE1MDQyNzA1WjBkMQswCQYD -VQQGEwJCUjEXMBUGA1UECAwOUmlvIGRlIEphbmVpcm8xFzAVBgNVBAcMDlJpbyBk -ZSBKYW5laXJvMQ0wCwYDVQQKDARBQ01FMRQwEgYDVQQDDAtob3N0Mi5sb2NhbDCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMSKvrOahavCXnvSF5131hpo -6k65C57jgRQ84FaDj5MbJOVlYQVFtMG0XOk7a+hh5v1fe4wH0R7I6FDo0V9sS+ss -ko5bsElc1xYlg5HbuKq89vRSKg6EDlztx3BKbi912Pmt5vFGNJ16zcw77DUrQIXo -4I/b4a3pmBiWj43NoTIrmSWHtsGwwOj3iDvSweqdYXJIr3hpHH5u6pohjDoQvqDz -K6Mu8p6mhCUKNs7KFJnNInNG25oQT6O0n4OGtmgRjLWopdEnOhMkKsfIoI1XtlXB -LBDv7huICk3t5ywtfCQyO09kX7lFIgd5rn7+MjwH5WNeqbQJxuaqjoXQnNZUgUsC -AwEAAaNTMFEwHQYDVR0OBBYEFNhMBG8q6a+iK2nECwVTn6B9EXZOMB8GA1UdIwQY -MBaAFNhMBG8q6a+iK2nECwVTn6B9EXZOMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI -hvcNAQELBQADggEBAJmudvx8+p5iIUsT8fm/fbVM0DA6qWALDYUJnTn3j6Lq4vpf -PFC+q1LmuWfBQMyqKrHrP3e493EctXoiSKZO6iN5dVJIur02OjGuiAEcsYuY1nLn -s9piiI+UEwxH6ux1NaHUnzsWauoBvRhzjXvO6SAVSZJYa9dY5mizXklDyDNuG5U0 -lXv9egMGBsy0dG6eFXkU5CPdxWU540yI2sCtSAj7z+WRUD5k7gJ7tVoY3//jHQZG -5STTmm5t9kpIZTWkptyJos9oZJFYMIXqW2Fc6tyLZpRp31R78tDs6ETIkToDc0RR -jz66th6HI+ZlgIBQhw09+hYAhBDe9+Dmd/SzQZc= ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEir6zmoWrwl57 -0hedd9YaaOpOuQue44EUPOBWg4+TGyTlZWEFRbTBtFzpO2voYeb9X3uMB9EeyOhQ -6NFfbEvrLJKOW7BJXNcWJYOR27iqvPb0UioOhA5c7cdwSm4vddj5rebxRjSdes3M -O+w1K0CF6OCP2+Gt6ZgYlo+NzaEyK5klh7bBsMDo94g70sHqnWFySK94aRx+buqa -IYw6EL6g8yujLvKepoQlCjbOyhSZzSJzRtuaEE+jtJ+DhrZoEYy1qKXRJzoTJCrH -yKCNV7ZVwSwQ7+4biApN7ecsLXwkMjtPZF+5RSIHea5+/jI8B+VjXqm0Ccbmqo6F -0JzWVIFLAgMBAAECggEBAKceitVROQQ5e/mxRR9CfK1sNH/H3Ne3/1PkB6XIrFab -qB3evEatZOuon7A6NKEeTjl37Se+pdSVZOUXcqC/BzbraZre3+EhrkpIj72ApV+Y -2iwZiWVaaJQgI4uZ3mNAw8RaWJsj5S1a9I8LDOiQ5IZ45CmvABDPJeMScvJSvRRY -e5N0L6stqS7Z+IoyGVKUfp1iNO0YyywOUiSkIRXgscuRXZGYpiGPomsJ+Js1ejzW -jyStlZJEr4L1285rGPrmHqjTwFd+hG80Wc4179xL+WRE6HBEUZSiy95fe6kcPHXX -BgiVYtcFKmiBi2dTbxl4e94ut239i0HtlJ1ZJtLh+BECgYEA8a298K2zXkHosxhN -tRrH7XfMPTkHDDd3rxM21LT+fIXqinGUp9LYaDcbjuTPs8e33uKMd7R6Q40x89yW -IXNka/VL0PXUeV67aCVLLqgXDLGudluJinH0XvmI0CmBecFSMqIFmQlgqERoGGs3 -UMac0p876T4XkGQQJdf62bFpE4UCgYEA0DBH0PDlOpwXccDgXrMfayr8HAhI+G5R -yWQ//9iirtU83chwIWwkh53eLLMzLgdqJnPiWyaUW5BqzmYuD23nhxcQ7PNdIqOO -H1sE6zqLNshv46t5QKlh1Q4qjd7UqtgrSrY63RXJCMWTwnNMeDLtj8gaKbjkrG3R -BM2ildt6Uo8CgYBX7NDUli1SloH1XlsvD047S8FHaM7yl994F3J0UmDfpszcj1P4 -9pF64Mmq4/3Yt0li0mMuTb/Jgb3xrYgFJXkcecKahEVH3ropup+umsLAAIirUMQq -VSkFwJ0Qtnj/deDUwPNuaOX8cd65O5CFV6zIR9xBEDD8fBsP2ZLOzmefDQKBgQDF -m24vVthd/1cJdCgD+0VxNYXDHeIVXLFo1S0iLYCNLn3tjZlRQBKUXzZJe3ay0/rf -sNND7aSYHMYkTzydDJbc1PoNzxmyDUiTXpOWqyUExM/fbB1VUPE5h47AxqdZ2oGN -EtdgjpMZLmCIC2SkGsL+3NJok8UKHdpuErmmQIMk5QKBgQCgEWcYtLXC3YDYMFdI -UgcTebFqSs3mLYgub1xekW3IXR2yom4V5fQTLiF7Yfn2dpDW4IcMU0UJFYVsUlhK -aGtet4Vm5Nn8+Mghot5yAjqO9yAUaub7wgifKIe99tQKd8uZyCvJ0hhvmDDSfx4m -B/TEiFAO99yF49iSxEVSAS6pqQ== ------END PRIVATE KEY----- \ No newline at end of file From 99f4ff325b36ae4821a5b2356b41df61b578f8ca Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 09:01:12 -0500 Subject: [PATCH 20/27] Update logging level for plugin loading and extend static example documentation - Changed logging level from `info` to `debug` in plugin loading to reduce verbosity during runtime. - Significantly expanded `examples/static/README.md` with new sections, examples (basic, certbot, deny pages, JWT validator), and detailed usage instructions. - Improved `examples/docker/docker-compose-changed-label.yml` with clearer redirect syntax using JSON for better readability. - Fixed handling of empty or invalid JSON in labels within `easymapping` with fallback to default values and error logging. - Added additional entries to `.gitignore` to exclude dynamic and temporary files like auto-generated configuration files and certificates. --- .gitignore | 8 + examples/docker/README.md | 19 + .../docker/docker-compose-changed-label.yml | 2 +- examples/static/README.md | 734 ++++++++---------- src/easymapping/__init__.py | 21 +- src/main.py | 1 - src/plugins/__init__.py | 2 +- 7 files changed, 384 insertions(+), 403 deletions(-) diff --git a/.gitignore b/.gitignore index 1610fa1..7cfbb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,11 @@ __pycache__ .pytest_cache *.pyc .env +/examples/static/conf/config.yml +/examples/docker/certs/haproxy/.place_holder_cert.pem +/examples/static/host1.local.pem +/examples/swarm/certs/host1.local.pem +/examples/docker/host2.local.pem +/examples/swarm/certs/host2.local.pem +/examples/docker/jwt_private.pem +/examples/docker/jwt_pubkey.pem diff --git a/examples/docker/README.md b/examples/docker/README.md index 3c43984..8ebbb67 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -145,6 +145,25 @@ labels: myproxy.http.port: 80 ``` + +**Usage:** +```bash +docker compose -f docker-compose-changed-label.yml up -d +``` + +**Test:** +```bash +# Test load balancing (hostname changes between containers) +curl -H "Host: www.helloworld.com" localhost:19901 +# Response: f6d8d45b7411 +curl -H "Host: www.helloworld.com" localhost:19901 +# Response: 59b213cb8592 + +# Test redirect +curl -I -H "Host: google.helloworld.com" localhost:19901 +# Should redirect to: www.google.com/ +``` + --- ### 5. Portainer Integration (`docker-compose-portainer.yml`) diff --git a/examples/docker/docker-compose-changed-label.yml b/examples/docker/docker-compose-changed-label.yml index 76ca38c..b5f7259 100644 --- a/examples/docker/docker-compose-changed-label.yml +++ b/examples/docker/docker-compose-changed-label.yml @@ -27,7 +27,7 @@ services: container: image: byjg/static-httpserver labels: - haproxy.http.redirect: host1.local--https://host1.local + haproxy.http.redirect: '{"host1.local": "https://host1.local"}' haproxy.http.host: host1.local haproxy.http.port: 80 diff --git a/examples/static/README.md b/examples/static/README.md index 8f5bac6..d6fefbc 100644 --- a/examples/static/README.md +++ b/examples/static/README.md @@ -1,49 +1,312 @@ # Static Configuration Example -This directory demonstrates EasyHAProxy using **static configuration** mode instead of dynamic service discovery. +This directory demonstrates EasyHAProxy using **static YAML configuration** instead of dynamic service discovery. -## What is Static Mode? - -Static mode uses a YAML configuration file (`config.yml`) to define HAProxy routing rules instead of discovering services automatically from Docker/Kubernetes/Swarm labels. - -**Use cases:** -- Non-containerized backends -- Mixed environments (containers + VMs + bare metal) -- Fixed infrastructure where services don't change frequently -- Testing HAProxy configurations +Static mode is useful for: +- Non-containerized backends (VMs, bare metal) +- Fixed infrastructure +- Explicit routing control --- -## Files in This Example +## Prerequisites -- `conf/config.yml` - Static configuration defining hosts and routing -- `docker-compose.yml` - EasyHAProxy container mounting the config file -- `host1.local.pem` - Example SSL certificate +### 1. Generate SSL Certificates ---- - -## Configuration Structure - -### docker-compose.yml - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./conf:/etc/haproxy/static # Mount static config - - ./host1.local.pem:/certs/haproxy/host1.local.pem - environment: - EASYHAPROXY_DISCOVER: static # Use static mode - EASYHAPROXY_SSL_MODE: "loose" - ports: - - "80:80" - - "443:443" - - "1936:1936" +```bash +# From repository root +./examples/generate-keys.sh ``` -### conf/config.yml +### 2. Add Host Entry + +```bash +echo "127.0.0.1 host1.local www.host1.local" | sudo tee -a /etc/hosts +echo "127.0.0.1 host2.local www.host2.local" | sudo tee -a /etc/hosts +``` + +--- + +## Scenario 1: Basic (HTTP → HTTPS Redirect) + +**What it does:** Simple HTTP to HTTPS redirect with SSL termination. + +### Getting Started + +```bash +cd examples/static + +# 1. Copy the basic config +cp conf/config-basic.yml conf/config.yml + +# 2. Start backend container +docker run -d --name container -p 8080:8080 byjg/static-httpserver + +# 3. Start EasyHAProxy +docker compose up -d +``` + +### Test + +```bash +# Test HTTP redirect +curl -I http://host1.local +# Expected: HTTP/1.1 301 Moved Permanently +# Expected: Location: https://host1.local + +# Test HTTPS +curl -k https://host1.local +# Expected: Hello from Static HTTP Server! + +# Test www redirect +curl -I http://www.host1.local +# Expected: HTTP/1.1 301 Moved Permanently +# Expected: Location: https://host1.local +``` + +### Stats Interface + +Open: http://localhost:1936 +- Username: `admin` +- Password: `password` + +### Clean Up + +```bash +docker compose down +docker stop container && docker rm container +``` + +--- + +## Scenario 2: Certbot (Let's Encrypt SSL) + +**What it does:** Automatic SSL certificates from Let's Encrypt using ACME HTTP-01 challenge. + +### Requirements + +- Public IP address +- Domain pointing to your IP +- Ports 80/443 publicly accessible + +### Getting Started + +```bash +cd examples/static + +# 1. Copy the certbot config +cp conf/config-certbot.yml conf/config.yml + +# 2. Edit config.yml and change: +# - Replace "example.com" with your real domain +# - Update EASYHAPROXY_CERTBOT_EMAIL in docker-compose.yml + +# 3. Start backend container +docker run -d --name container -p 8080:8080 byjg/static-httpserver + +# 4. Start EasyHAProxy +docker compose up -d + +# 5. Check logs for certificate generation +docker compose logs -f +``` + +### What to Expect + +``` +# Certbot will: +# 1. Request certificate from Let's Encrypt +# 2. Complete HTTP-01 challenge +# 3. Save certificate in /certs/certbot/ +# 4. Reload HAProxy with new certificate +``` + +### Test + +```bash +# Test HTTPS with real certificate +curl https://your-domain.com +# Expected: No certificate warnings (valid SSL) + +# Test HTTP redirect +curl -I http://your-domain.com +# Expected: HTTP/1.1 301 Moved Permanently +``` + +### Clean Up + +```bash +docker compose down +docker stop container && docker rm container +``` + +**Note:** Certificates are stored in Docker volume `certs_certbot` and persist across restarts. + +--- + +## Scenario 3: Deny Pages (Block Specific Paths) + +**What it does:** Blocks access to sensitive paths like `/admin`, `/wp-login.php`, etc. + +### Getting Started + +```bash +cd examples/static + +# 1. Copy the deny-pages config +cp conf/config-deny-pages.yml conf/config.yml + +# 2. Start backend container +docker run -d --name container -p 8080:8080 byjg/static-httpserver + +# 3. Start EasyHAProxy +docker compose up -d +``` + +### Test + +```bash +# Test normal page (should work) +curl -k https://host1.local/ +# Expected: Hello from Static HTTP Server! + +# Test blocked path (should fail) +curl -I -k https://host1.local/admin +# Expected: HTTP/1.1 404 Not Found + +curl -I -k https://host1.local/wp-login.php +# Expected: HTTP/1.1 404 Not Found + +curl -I -k https://host1.local/.env +# Expected: HTTP/1.1 404 Not Found +``` + +### What's Blocked + +The example blocks these paths: +- `/admin` +- `/wp-admin` +- `/wp-login.php` +- `/.env` +- `/config` + +### Customize Blocked Paths + +Edit `conf/config.yml`: + +```yaml +plugin_config: + deny_pages: + paths: /admin,/private,/internal + status_code: 403 # or 404 +``` + +### Clean Up + +```bash +docker compose down +docker stop container && docker rm container +``` + +--- + +## Scenario 4: JWT Validator (API Authentication) + +**What it does:** Validates JWT tokens in Authorization header before allowing access. + +### Getting Started + +```bash +cd examples/static + +# 1. Copy the JWT validator config +cp conf/config-jwt-validator.yml conf/config.yml + +# 2. JWT keys were already generated by generate-keys.sh +# Location: examples/docker/jwt_pubkey.pem and jwt_private.pem + +# 3. Start backend container +docker run -d --name container -p 8080:8080 byjg/static-httpserver + +# 4. Start EasyHAProxy +docker compose up -d +``` + +### Test Without Token (Should Fail) + +```bash +curl -k https://host1.local/ +# Expected: Missing Authorization HTTP header +``` + +### Test With Valid Token + +```bash +# 1. Generate a test JWT token using jwt_private.pem +# You can use https://jwt.io or a JWT library + +# 2. Example with valid token: +TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +curl -k -H "Authorization: Bearer $TOKEN" https://host1.local/ +# Expected: Hello from Static HTTP Server! (if token is valid) +``` + +### Generate Test Token + +```python +# Python example using PyJWT +import jwt +from datetime import datetime, timedelta + +with open('examples/docker/jwt_private.pem', 'r') as f: + private_key = f.read() + +payload = { + 'iss': 'https://auth.example.com/', + 'aud': 'https://api.example.com', + 'exp': datetime.utcnow() + timedelta(hours=1) +} + +token = jwt.encode(payload, private_key, algorithm='RS256') +print(token) +``` + +### What's Validated + +- Authorization header must be present +- Token must be valid JWT format +- Signature must match public key (`jwt_pubkey.pem`) +- Issuer must match: `https://auth.example.com/` +- Audience must match: `https://api.example.com` +- Token must not be expired + +### Customize JWT Settings + +Edit `conf/config.yml`: + +```yaml +plugin_config: + jwt_validator: + algorithm: RS256 + issuer: https://your-auth-server.com/ + audience: https://your-api.com + pubkey_path: /certs/haproxy/jwt_pubkey.pem +``` + +### Clean Up + +```bash +docker compose down +docker stop container && docker rm container +``` + +--- + +## Configuration File Reference + +All scenarios use `/etc/haproxy/static/config.yml` mounted from `./conf/config.yml`. + +### Basic Structure ```yaml stats: @@ -54,416 +317,102 @@ stats: customerrors: true easymapping: - # HTTP Port 80 - Redirects to HTTPS - port: 80 redirect: host1.local: https://host1.local - www.host1.local: https://host1.local - # HTTPS Port 443 - port: 443 ssl: true hosts: host1.local: containers: - - container:8080 # Backend container + - container:8080 ``` ---- - -## How It Works - -### 1. Port Definitions - -Each item in `easymapping` defines a listening port: +### With Plugins ```yaml easymapping: - - port: 80 # Listen on port 80 - redirect: {...} # Optional redirects - - - port: 443 # Listen on port 443 - ssl: true # Enable SSL - hosts: {...} # Virtual hosts -``` - -### 2. Redirect Configuration - -Redirect specific domains to different URLs: - -```yaml -- port: 80 - redirect: - host1.local: https://host1.local # HTTP → HTTPS - www.host1.local: https://host1.local # www → non-www + HTTPS - old.domain.com: https://new.domain.com # Domain change -``` - -### 3. Virtual Hosts - -Define hosts and their backend containers: - -```yaml -- port: 443 - ssl: true - hosts: - host1.local: # Virtual host domain - containers: - - container:8080 # Backend: container_name:port - - another_container:3000 # Multiple backends = load balancing - - host2.local: - containers: - - webserver:80 -``` - -**Backend formats:** -- `container_name:port` - Docker container by name -- `ip_address:port` - Direct IP address -- `hostname:port` - Hostname resolution - -### 4. SSL Configuration - -```yaml -- port: 443 - ssl: true # Enable SSL on this port - hosts: - secure.example.com: - containers: - - app:8080 -``` - -SSL certificates must be placed in: -- `/certs/haproxy/.pem` inside container -- `./certs/.pem` on host (if volume mounted) - -Certificate format: PEM file containing both certificate and private key. - -### 5. Stats Interface - -```yaml -stats: - username: admin - password: password - port: 1936 -``` - -Access at: `http://localhost:1936` - ---- - -## Running the Example - -### Prerequisites: Generate SSL Certificates - -Before running the examples, you need to generate the required SSL certificates: - -```bash -# From the repository root -./examples/generate-keys.sh -``` - -This script automatically generates: -- SSL certificates for host1.local and host2.local -- JWT keys for authentication examples -- All other .pem files needed for testing - -### 1. Start the Example - -```bash -cd examples/static -docker compose up -d -``` - -### 2. Create Backend Container - -The static config references `container:8080`. Create a container with this name: - -```bash -docker run -d --name container \ - -p 8080:8080 \ - byjg/static-httpserver -``` - -Or add to `docker-compose.yml`: - -```yaml -services: - # ... haproxy service ... - - container: - image: byjg/static-httpserver - ports: - - "8080:8080" -``` - -### 3. Test - -```bash -# Add to /etc/hosts: -# 127.0.0.1 host1.local www.host1.local - -# Test HTTP redirect -curl -I http://host1.local -# Should return: HTTP/1.1 301 Moved Permanently -# Location: https://host1.local - -# Test HTTPS -curl -k https://host1.local - -# Access stats -open http://localhost:1936 -# Username: admin -# Password: password + - port: 443 + ssl: true + hosts: + host1.local: + containers: + - container:8080 + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: /admin,/private + status_code: 404 ``` --- -## Advanced Configuration +## Advanced: Multiple Backends -### Load Balancing Multiple Backends +Load balance across multiple containers: ```yaml hosts: api.example.com: containers: - - api_server_1:8080 - - api_server_2:8080 - - api_server_3:8080 + - api1:8080 + - api2:8080 + - api3:8080 ``` -Default algorithm: round-robin +--- -### Custom Balance Algorithm +## Advanced: External Backends -```yaml -hosts: - api.example.com: - balance: leastconn # Use least connections instead of round-robin - containers: - - api_1:8080 - - api_2:8080 -``` - -**Available algorithms:** -- `roundrobin` - Distribute evenly (default) -- `leastconn` - Send to server with fewest connections -- `source` - Same client IP always goes to same server - -### External Backends (Non-Docker) +Route to non-Docker backends: ```yaml hosts: legacy.example.com: containers: - - 192.168.1.100:8080 # VM - - 192.168.1.101:8080 # Another VM - - database.local:5432 # Database server + - 192.168.1.100:8080 + - 192.168.1.101:8080 ``` -### Health Checks - -```yaml -hosts: - webapp.example.com: - containers: - - server1:8080 - - server2:8080 - healthcheck: - path: /health - interval: 5s -``` - -### Multiple Domains, Same Backend - -```yaml -hosts: - example.com: - containers: - - webapp:8080 - www.example.com: - containers: - - webapp:8080 # Same backend - app.example.com: - containers: - - webapp:8080 # Same backend -``` - -### Path-Based Routing - -While static mode focuses on host-based routing, you can achieve path-based routing using redirects: - -```yaml -- port: 80 - redirect: - api.example.com/v1: https://api-v1.internal:8080 - api.example.com/v2: https://api-v2.internal:8080 -``` - -Or use HAProxy ACLs via custom templates (advanced). - ---- - -## Plugins with Static Configuration - -Enable plugins globally or per-host in static mode: - -### Global Plugin Configuration - -```yaml -plugins: - enabled: [cleanup] - config: - cleanup: - max_idle_time: 600 -``` - -### Per-Host Plugin Configuration (via env vars) - -Since static mode doesn't support per-host plugin configuration directly, use environment variables for domain-specific plugins: - -```yaml -environment: - EASYHAPROXY_PLUGINS_ENABLED: cloudflare,deny_pages - EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH: /etc/haproxy/cloudflare_ips.lst - EASYHAPROXY_PLUGIN_DENY_PAGES_PATHS: /admin,/private -``` - -See [Using Plugins](../../docs/plugins.md) for more details. - ---- - -## Comparison: Static vs. Dynamic Discovery - -| Feature | Static Mode | Docker/Swarm/K8s Mode | -|---------------|-----------------------------|----------------------------------------------| -| Configuration | YAML file | Container labels / Ingress annotations | -| Backend types | Any (containers, VMs, IPs) | Containers only | -| Updates | Manual config edit + reload | Automatic discovery | -| Use case | Fixed infrastructure | Dynamic container environments | -| Plugin config | Global via YAML/env | Per-container/ingress via labels/annotations | - ---- - -## Tips - -1. **Reloading Configuration:** - ```bash - # EasyHAProxy watches config.yml for changes - # Edit conf/config.yml, changes auto-reload - - # Or manually restart: - docker compose restart haproxy - ``` - -2. **Validate Configuration:** - ```bash - # Check HAProxy config is valid - docker compose exec haproxy haproxy -c -f /etc/haproxy/haproxy.cfg - ``` - -3. **View Generated Config:** - ```bash - docker compose exec haproxy cat /etc/haproxy/haproxy.cfg - ``` - -4. **Debugging:** - ```bash - # Enable debug mode - docker compose up - # Watch logs in real-time - ``` - -5. **SSL Certificate Management:** - ```bash - # Generate self-signed cert - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout host.key -out host.crt \ - -subj "/CN=host1.local" - - # Combine into PEM - cat host.crt host.key > host1.local.pem - ``` - --- ## Troubleshooting -### Backend Unreachable +### FileNotFoundError: config.yml -**Error:** `503 Service Unavailable` - -**Causes:** -- Backend container not running -- Wrong container name in config -- Wrong port number -- Network connectivity issues - -**Debug:** ```bash -# Check backend container is running -docker ps | grep container_name +# Make sure config.yml exists +ls conf/config.yml -# Test backend directly -curl http://container_name:port - -# Check HAProxy logs -docker compose logs haproxy +# If missing, copy from an example: +cp conf/config-basic.yml conf/config.yml ``` -### Configuration Not Reloading +### 503 Service Unavailable -**Solution:** ```bash -# Restart HAProxy -docker compose restart haproxy - -# Check file is mounted correctly -docker compose exec haproxy cat /etc/haproxy/static/config.yml +# Check backend is running +docker ps | grep container +curl http://localhost:8080 ``` ### SSL Certificate Not Found -**Error:** Certificate errors in logs - -**Solution:** ```bash -# Verify certificate is mounted -docker compose exec haproxy ls -la /certs/haproxy/ +# Verify certificate exists +ls -la host1.local.pem -# Check certificate format (must be PEM with cert + key) -openssl x509 -in host.pem -text -noout -openssl rsa -in host.pem -check +# Regenerate if needed +cd ../.. && ./examples/generate-keys.sh ``` ---- +### Changes Not Applied -## Migration from Dynamic to Static - -If you have Docker labels and want to convert to static config: - -**Docker label:** -```yaml -labels: - easyhaproxy.http.host: api.example.com - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 8080 - easyhaproxy.http.redirect_ssl: true -``` - -**Static config equivalent:** -```yaml -easymapping: - - port: 80 - redirect: - api.example.com: https://api.example.com - - - port: 443 - ssl: true - hosts: - api.example.com: - containers: - - container_name:8080 +```bash +# Restart to reload config +docker compose restart ``` --- @@ -471,6 +420,5 @@ easymapping: ## Further Reading - [Static Configuration Guide](../../docs/static.md) -- [Environment Variables](../../docs/environment-variable.md) - [Using Plugins](../../docs/plugins.md) -- [SSL Configuration](../../docs/ssl.md) +- [Environment Variables](../../docs/environment-variable.md) diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index ccf5812..77930a4 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -4,6 +4,7 @@ import os import re from jinja2 import Environment, FileSystemLoader +from functions import loggerEasyHaproxy class DockerLabelHandler: @@ -32,7 +33,16 @@ class DockerLabelHandler: def get_json(self, label, default_value={}): if self.has_label(label): - return json.loads(self.__data[label]) + value = self.__data[label] + if not value: # Handle empty strings + return default_value + try: + return json.loads(value) + except json.JSONDecodeError as e: + loggerEasyHaproxy.error( + f"Invalid JSON in label '{label}': {value}. Error: {e}. Using default value." + ) + return default_value return default_value def set_data(self, data): @@ -67,8 +77,7 @@ class HaproxyConfigGenerator: self.global_plugin_configs = [] except Exception as e: # If plugin system fails to initialize, log but continue - import logging - logging.warning(f"Failed to initialize plugin system: {e}") + loggerEasyHaproxy.warning(f"Failed to initialize plugin system: {e}") self.plugin_manager = None self.global_plugin_configs = [] @@ -102,8 +111,7 @@ class HaproxyConfigGenerator: global_configs = [r.haproxy_config for r in global_results if r.haproxy_config] self.global_plugin_configs.extend(global_configs) except Exception as e: - import logging - logging.warning(f"Failed to execute global plugins: {e}") + loggerEasyHaproxy.warning(f"Failed to execute global plugins: {e}") templates_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'templates') file_loader = FileSystemLoader(templates_dir) @@ -274,8 +282,7 @@ class HaproxyConfigGenerator: if result.metadata["fcgi_app_definition"] not in self.global_plugin_configs: self.global_plugin_configs.append(result.metadata["fcgi_app_definition"]) except Exception as e: - import logging - logging.warning(f"Failed to execute domain plugins for {hostname}: {e}") + loggerEasyHaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}") easymapping[port]["hosts"][hostname]["plugin_configs"] = [] else: easymapping[port]["hosts"][hostname]["plugin_configs"] = [] diff --git a/src/main.py b/src/main.py index 30412a3..7af2bbf 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,4 @@ import os -import logging from deepdiff import DeepDiff diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py index ac7d1c4..e822d92 100644 --- a/src/plugins/__init__.py +++ b/src/plugins/__init__.py @@ -145,7 +145,7 @@ class PluginManager: elif plugin.plugin_type == PluginType.DOMAIN: self.domain_plugins.append(plugin) - self.logger.info(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})") + self.logger.debug(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})") except Exception as e: self._handle_error(f"Failed to load plugin from {filepath}: {str(e)}") From 5397f0166e5fd1f132e1e485f4ef5574f05d22ff Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 09:25:05 -0500 Subject: [PATCH 21/27] Remove `version` directive from all Docker Compose and Swarm configuration files. - Eliminated the `version` field across various examples and documentation for simplicity and alignment with newer Docker Compose and Swarm standards. - Updated relevant documentation and example usage instructions accordingly. --- deploy/docker/docker-compose.yml | 2 -- docs/container-labels.md | 6 ------ docs/swarm.md | 4 ---- examples/docker/README.md | 20 +++++++++---------- examples/docker/docker-compose-acme.yml | 2 -- .../docker/docker-compose-changed-label.yml | 2 -- examples/docker/docker-compose-cloudflare.yml | 2 -- .../docker/docker-compose-ip-whitelist.yml | 2 -- .../docker/docker-compose-jwt-validator.yml | 2 -- .../docker-compose-multi-containers.yml | 2 -- examples/docker/docker-compose-php-fpm.yml | 2 -- .../docker-compose-plugins-combined.yml | 2 -- .../docker-compose-portainer-app-example.yml | 2 -- examples/docker/docker-compose-portainer.yml | 2 -- examples/docker/docker-compose.yml | 2 -- examples/static/docker-compose.yml | 2 -- examples/swarm/README.md | 20 +------------------ examples/swarm/easyhaproxy.yml | 2 -- examples/swarm/portainer.yml | 2 -- examples/swarm/services.yml | 2 -- 20 files changed, 11 insertions(+), 71 deletions(-) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 96aaddd..26df7b8 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: easyhaproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/docs/container-labels.md b/docs/container-labels.md index 6e634c9..b791457 100644 --- a/docs/container-labels.md +++ b/docs/container-labels.md @@ -69,8 +69,6 @@ docker run \ If you are using docker-compose you can use this way: ```yaml -version: "3" - services: mycontainer: image: some/myimage @@ -102,8 +100,6 @@ EasyHAProxy supports FastCGI protocol for PHP-FPM and other FastCGI applications #### Using Unix Socket ```yaml title="PHP-FPM with Unix socket" -version: "3" - services: php-fpm: image: php:8.2-fpm @@ -119,8 +115,6 @@ services: #### Using TCP Connection ```yaml title="PHP-FPM with TCP connection" -version: "3" - services: php-fpm: image: php:8.2-fpm diff --git a/docs/swarm.md b/docs/swarm.md index 348224e..52aeb9e 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -31,8 +31,6 @@ docker network create -d overlay --attachable easyhaproxy And then deploy the EasyHAProxy stack: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -76,8 +74,6 @@ Mapping to `/var/run/docker.sock` is necessary to discover the docker containers To make your containers "discoverable" by EasyHAProxy, that is the minimum configuration you need: ```yaml -version: "3" - services: container: image: my/image:tag diff --git a/examples/docker/README.md b/examples/docker/README.md index 8ebbb67..04085ff 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -172,6 +172,11 @@ curl -I -H "Host: google.helloworld.com" localhost:19901 - Running Portainer behind EasyHAProxy - Real-world application example +**Usage:** +```bash +docker compose -f docker-compose-portainer.yml up -d +``` + **Access Portainer:** - URL: http://portainer.local (add to `/etc/hosts` or use real DNS) - First time: Create admin user @@ -184,6 +189,11 @@ curl -I -H "Host: google.helloworld.com" localhost:19901 - Multiple applications behind EasyHAProxy - Portainer + custom app setup +**Usage:** +```bash +docker compose -f docker-compose-portainer-app-example.yml up -d +``` + --- ## Plugin Examples @@ -212,8 +222,6 @@ Run PHP applications with FastCGI protocol support: **Configuration:** ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -304,8 +312,6 @@ The `php-app/` directory contains: Protect your API with JWT token validation: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -370,8 +376,6 @@ openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem Restore original visitor IPs when using Cloudflare CDN: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -413,8 +417,6 @@ curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst Restrict access to specific IP addresses: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -460,8 +462,6 @@ curl http://admin.example.com Combine multiple plugins for enhanced security: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-acme.yml b/examples/docker/docker-compose-acme.yml index 79cbebd..495ac45 100644 --- a/examples/docker/docker-compose-acme.yml +++ b/examples/docker/docker-compose-acme.yml @@ -4,8 +4,6 @@ # - public IP pointing your machine # - open ports 80 and 443 in your firewall -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-changed-label.yml b/examples/docker/docker-compose-changed-label.yml index b5f7259..94d1253 100644 --- a/examples/docker/docker-compose-changed-label.yml +++ b/examples/docker/docker-compose-changed-label.yml @@ -4,8 +4,6 @@ # or add to /etc/hosts # 127.0.0.1 host1.local -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-cloudflare.yml b/examples/docker/docker-compose-cloudflare.yml index fc2a14a..79c7518 100644 --- a/examples/docker/docker-compose-cloudflare.yml +++ b/examples/docker/docker-compose-cloudflare.yml @@ -24,8 +24,6 @@ # Without Cloudflare, the request won't come from Cloudflare IPs, so the plugin # won't activate. This example is for demonstration and testing purposes. -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-ip-whitelist.yml b/examples/docker/docker-compose-ip-whitelist.yml index e4b28b3..87be787 100644 --- a/examples/docker/docker-compose-ip-whitelist.yml +++ b/examples/docker/docker-compose-ip-whitelist.yml @@ -19,8 +19,6 @@ # # Note: Update the allowed_ips label with your actual IP addresses/networks -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml index 1a304a9..b5e4b70 100644 --- a/examples/docker/docker-compose-jwt-validator.yml +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -27,8 +27,6 @@ # curl -H "Authorization: Bearer $TOKEN" http://api.local/ # # Response: Success -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-multi-containers.yml b/examples/docker/docker-compose-multi-containers.yml index 3f07803..92181bf 100644 --- a/examples/docker/docker-compose-multi-containers.yml +++ b/examples/docker/docker-compose-multi-containers.yml @@ -7,8 +7,6 @@ # content-length: 0 # location: www.google.com/ -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-php-fpm.yml b/examples/docker/docker-compose-php-fpm.yml index 8c030c6..c8e8737 100644 --- a/examples/docker/docker-compose-php-fpm.yml +++ b/examples/docker/docker-compose-php-fpm.yml @@ -21,8 +21,6 @@ # - PATH_INFO support for routing # - Custom FastCGI parameters -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-plugins-combined.yml b/examples/docker/docker-compose-plugins-combined.yml index 742b554..7fd26f1 100644 --- a/examples/docker/docker-compose-plugins-combined.yml +++ b/examples/docker/docker-compose-plugins-combined.yml @@ -29,8 +29,6 @@ # # Admin panel (IP whitelist only) # curl http://admin.local/ # Success from localhost -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose-portainer-app-example.yml b/examples/docker/docker-compose-portainer-app-example.yml index 99c9ed1..2c2f105 100644 --- a/examples/docker/docker-compose-portainer-app-example.yml +++ b/examples/docker/docker-compose-portainer-app-example.yml @@ -1,5 +1,3 @@ -version: "3" - services: container: image: byjg/static-httpserver diff --git a/examples/docker/docker-compose-portainer.yml b/examples/docker/docker-compose-portainer.yml index 140e656..5f23d90 100644 --- a/examples/docker/docker-compose-portainer.yml +++ b/examples/docker/docker-compose-portainer.yml @@ -4,8 +4,6 @@ # docker network create easyhaproxy -version: "3" - services: easyhaproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose.yml b/examples/docker/docker-compose.yml index 8894a4b..ad82ed4 100644 --- a/examples/docker/docker-compose.yml +++ b/examples/docker/docker-compose.yml @@ -15,8 +15,6 @@ # Test SSL: # openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/static/docker-compose.yml b/examples/static/docker-compose.yml index 809379b..49c1dc8 100644 --- a/examples/static/docker-compose.yml +++ b/examples/static/docker-compose.yml @@ -1,8 +1,6 @@ # To test: # curl -k -H "Host: host1.local" https://127.0.0.1/ -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/swarm/README.md b/examples/swarm/README.md index 5627b0f..f0fce59 100644 --- a/examples/swarm/README.md +++ b/examples/swarm/README.md @@ -104,8 +104,6 @@ docker stack deploy -c portainer.yml portainer ### easyhaproxy.yml ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -152,8 +150,6 @@ Service labels are similar to container labels but applied to **services**, not ### Basic Service Example ```yaml -version: "3" - services: webapp: image: nginx:alpine @@ -188,8 +184,6 @@ labels: ### Use Case 1: Simple HTTP Service ```yaml -version: "3" - services: myapp: image: my-app:latest @@ -215,8 +209,6 @@ docker stack deploy -c myapp.yml myapp ### Use Case 2: HTTPS with Let's Encrypt ```yaml -version: "3" - services: secure-app: image: secure-app:latest @@ -296,8 +288,6 @@ services: ### Use Case 5: Multiple Services with Load Balancing ```yaml -version: "3" - services: frontend: image: frontend-app:latest @@ -349,8 +339,6 @@ networks: Secure your API with JWT token validation in Swarm: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -421,8 +409,6 @@ curl -H "Authorization: Bearer eyJhbGc..." http://api.example.com/users Restore original visitor IPs in Swarm environment: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -480,8 +466,6 @@ docker stack deploy -c cloudflare-stack.yml myapp Restrict admin panel to specific IPs in Swarm: ```yaml -version: "3" - services: admin: image: admin-panel:latest @@ -522,8 +506,6 @@ curl http://admin.example.com Production-ready setup with multiple security layers: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -750,7 +732,7 @@ Place certificate files: docker secret create example_com_cert ./example.com.pem # Use in stack -version: "3" + services: haproxy: secrets: diff --git a/examples/swarm/easyhaproxy.yml b/examples/swarm/easyhaproxy.yml index 3238896..cb9e967 100644 --- a/examples/swarm/easyhaproxy.yml +++ b/examples/swarm/easyhaproxy.yml @@ -3,8 +3,6 @@ # docker stack deploy -c easyhaproxy.yml easyhaproxy -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/swarm/portainer.yml b/examples/swarm/portainer.yml index d259988..aa4f4fc 100644 --- a/examples/swarm/portainer.yml +++ b/examples/swarm/portainer.yml @@ -1,8 +1,6 @@ # To install: # docker stack deploy -c portainer.yml portainer -version: "3" - services: portainer: image: portainer/portainer-ce:latest diff --git a/examples/swarm/services.yml b/examples/swarm/services.yml index ad16ce2..fc9ac69 100644 --- a/examples/swarm/services.yml +++ b/examples/swarm/services.yml @@ -18,8 +18,6 @@ # Test SSL: # openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local -version: "3" - services: container: image: byjg/static-httpserver From b9a9bee0b929d8f3022d1534d997a6bf25ff2b1c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 10:07:17 -0500 Subject: [PATCH 22/27] Revamp Swarm example documentation and streamline deployment instructions - Consolidated and simplified Swarm example README to focus on YAML header comments for documentation. - Removed redundant and extended sections, replacing with concise steps for getting started. - Standardized YAML headers across `easyhaproxy.yml`, `services.yml`, and `portainer.yml` with a clearer and more readable format. - Enhanced user guidance for deployment, setup requirements, verification, and cleanup. --- examples/docker/README.md | 608 +---------- examples/docker/docker-compose-acme.yml | 62 +- .../docker/docker-compose-changed-label.yml | 40 +- examples/docker/docker-compose-cloudflare.yml | 56 +- .../docker/docker-compose-ip-whitelist.yml | 54 +- .../docker/docker-compose-jwt-validator.yml | 67 +- .../docker-compose-multi-containers.yml | 53 +- examples/docker/docker-compose-php-fpm.yml | 60 +- .../docker-compose-plugins-combined.yml | 78 +- .../docker-compose-portainer-app-example.yml | 43 + examples/docker/docker-compose-portainer.yml | 53 + examples/docker/docker-compose.yml | 58 +- examples/kubernetes/README.md | 719 +------------ examples/kubernetes/cloudflare.yml | 79 +- examples/kubernetes/ip-whitelist.yml | 51 +- examples/kubernetes/jwt-validator.yml | 88 +- examples/kubernetes/plugins-combined.yml | 81 +- examples/kubernetes/service.yml | 54 + examples/kubernetes/service_tls.yml | 55 + examples/static/README.md | 463 ++------- examples/static/docker-compose.yml | 59 +- examples/swarm/README.md | 941 +----------------- examples/swarm/cloudflare.yml | 75 +- examples/swarm/easyhaproxy.yml | 53 +- examples/swarm/ip-whitelist.yml | 70 +- examples/swarm/jwt-validator.yml | 91 +- examples/swarm/plugins-combined.yml | 104 +- examples/swarm/portainer.yml | 43 +- examples/swarm/services.yml | 62 +- 29 files changed, 1459 insertions(+), 2861 deletions(-) diff --git a/examples/docker/README.md b/examples/docker/README.md index 04085ff..ecf146f 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -1,590 +1,52 @@ # Docker Compose Examples -This directory contains various Docker Compose examples demonstrating different EasyHAProxy configurations. +Self-contained examples for EasyHAProxy. **All documentation is in the docker-compose files as header comments.** -## Prerequisites: Generate SSL Certificates +## Quick Start -**IMPORTANT:** Before running any examples, you must generate the required SSL certificates and JWT keys: +1. Pick an example below +2. Open the docker-compose file +3. Read the header comments for complete instructions +4. Run the commands step-by-step -```bash -# From the repository root -./examples/generate-keys.sh -``` +## Basic Examples -This script automatically generates: -- SSL certificates for host1.local and host2.local -- JWT keys (jwt_private.pem and jwt_pubkey.pem) for JWT validation examples -- All other .pem files needed for testing +| File | Description | +|----------------------------------------------------------------------------|----------------------------------------------------------------| +| [docker-compose.yml](docker-compose.yml) | Basic SSL setup with two virtual hosts and stats interface | +| [docker-compose-acme.yml](docker-compose-acme.yml) | Let's Encrypt SSL with automatic certificate generation | +| [docker-compose-multi-containers.yml](docker-compose-multi-containers.yml) | Load balancing across multiple container replicas | +| [docker-compose-changed-label.yml](docker-compose-changed-label.yml) | Using custom label prefix (for multiple EasyHAProxy instances) | -**Note:** These are self-signed certificates for testing only. Do not use in production. +## Real-World Application Examples ---- - -## Examples Overview - -### 1. Basic Configuration (`docker-compose.yml`) - -**What it demonstrates:** -- Basic SSL setup with two virtual hosts -- SSL redirect (HTTP → HTTPS) -- Custom SSL certificates (embedded and file-based) -- HAProxy stats interface - -**Features:** -- `host1.local`: SSL certificate embedded as base64 in labels -- `host2.local`: SSL certificate loaded from file (`host2.local.pem`) -- Automatic HTTP to HTTPS redirect -- Stats available at port 1936 - -**Usage:** -```bash -docker compose up -d -``` - -**Test:** -```bash -# Test HTTPS -curl -k -H "Host: host1.local" https://127.0.0.1/ -curl -k -H "Host: host2.local" https://127.0.0.1/ - -# Test HTTP redirect -curl -I -H "Host: host1.local" http://127.0.0.1 -# Should return: HTTP/1.1 301 Moved Permanently - -# View SSL certificate -openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local -``` - -**Access stats:** -- URL: http://localhost:1936 -- Username: `admin` -- Password: `password` - ---- - -### 2. ACME/Let's Encrypt (`docker-compose-acme.yml`) - -**What it demonstrates:** -- Automatic SSL certificate generation using Let's Encrypt -- HTTP-01 ACME challenge -- Certificate persistence - -**Requirements:** -- Public IP address pointing to your machine -- Open ports 80 and 443 in firewall -- Valid domain name - -**Configuration:** -```yaml -EASYHAPROXY_CERTBOT_EMAIL: user@example.com # Change this! -easyhaproxy.http.certbot: true # Enable certbot -``` - -**Usage:** -```bash -# Edit docker-compose-acme.yml and set: -# - EASYHAPROXY_CERTBOT_EMAIL to your email -# - easyhaproxy.http.host to your domain - -docker compose -f docker-compose-acme.yml up -d -``` - -**Certificate storage:** -Certificates are persisted in `./certs/certbot/` to avoid re-challenges on restart. - ---- - -### 3. Multiple Containers with Load Balancing (`docker-compose-multi-containers.yml`) - -**What it demonstrates:** -- Multiple containers behind single domain -- Load balancing with round-robin -- Domain redirect functionality - -**Features:** -- 2 replicas of nginx container -- Load balancing across replicas -- Domain redirect: `google.helloworld.com` → `www.google.com` - -**Usage:** -```bash -docker compose -f docker-compose-multi-containers.yml up -d -``` - -**Test:** -```bash -# Test load balancing (hostname changes between containers) -curl -H "Host: www.helloworld.com" localhost:19901 -# Response: f6d8d45b7411 -curl -H "Host: www.helloworld.com" localhost:19901 -# Response: 59b213cb8592 - -# Test redirect -curl -I -H "Host: google.helloworld.com" localhost:19901 -# Should redirect to: www.google.com/ -``` - ---- - -### 4. Changed Label Prefix (`docker-compose-changed-label.yml`) - -**What it demonstrates:** -- Using custom label prefix instead of default `easyhaproxy` -- Useful for running multiple EasyHAProxy instances - -**Configuration:** -```yaml -environment: - EASYHAPROXY_LABEL_PREFIX: myproxy -``` - -**Container labels:** -```yaml -labels: - myproxy.http.host: example.com - myproxy.http.port: 80 -``` - - -**Usage:** -```bash -docker compose -f docker-compose-changed-label.yml up -d -``` - -**Test:** -```bash -# Test load balancing (hostname changes between containers) -curl -H "Host: www.helloworld.com" localhost:19901 -# Response: f6d8d45b7411 -curl -H "Host: www.helloworld.com" localhost:19901 -# Response: 59b213cb8592 - -# Test redirect -curl -I -H "Host: google.helloworld.com" localhost:19901 -# Should redirect to: www.google.com/ -``` - ---- - -### 5. Portainer Integration (`docker-compose-portainer.yml`) - -**What it demonstrates:** -- Running Portainer behind EasyHAProxy -- Real-world application example - -**Usage:** -```bash -docker compose -f docker-compose-portainer.yml up -d -``` - -**Access Portainer:** -- URL: http://portainer.local (add to `/etc/hosts` or use real DNS) -- First time: Create admin user - ---- - -### 6. Portainer + App Example (`docker-compose-portainer-app-example.yml`) - -**What it demonstrates:** -- Multiple applications behind EasyHAProxy -- Portainer + custom app setup - -**Usage:** -```bash -docker compose -f docker-compose-portainer-app-example.yml up -d -``` - ---- +| File | Description | +|--------------------------------------------------------------------------------------|-----------------------------------------------------| +| [docker-compose-portainer.yml](docker-compose-portainer.yml) | Portainer behind EasyHAProxy with Let's Encrypt | +| [docker-compose-portainer-app-example.yml](docker-compose-portainer-app-example.yml) | Additional app alongside Portainer (shared network) | ## Plugin Examples -### FastCGI Plugin with PHP-FPM +| File | Description | +|----------------------------------------------------------------------------|-----------------------------------------------------| +| [docker-compose-php-fpm.yml](docker-compose-php-fpm.yml) | FastCGI plugin with PHP-FPM and PATH_INFO routing | +| [docker-compose-jwt-validator.yml](docker-compose-jwt-validator.yml) | JWT token validation for API protection | +| [docker-compose-ip-whitelist.yml](docker-compose-ip-whitelist.yml) | IP whitelist for admin panels or sensitive services | +| [docker-compose-cloudflare.yml](docker-compose-cloudflare.yml) | Restore real client IPs when behind Cloudflare CDN | +| [docker-compose-plugins-combined.yml](docker-compose-plugins-combined.yml) | Multiple plugins combined for layered security | -Run PHP applications with FastCGI protocol support: +## Documentation Structure -**File:** `docker-compose-php-fpm.yml` +Each docker-compose file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Command to launch the stack +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to stop and remove resources -**What it demonstrates:** -- PHP-FPM 8.5 with TCP connection on port 9000 -- FastCGI protocol support (`proto: fcgi`) -- FastCGI plugin for PHP environment configuration -- Custom document root and index file -- PATH_INFO support for RESTful routing +## Additional Documentation -**Features:** -- HAProxy forwards requests to PHP-FPM via TCP (port 9000) -- FastCGI plugin generates `fcgi-app` configuration that defines CGI parameters: - - `SCRIPT_FILENAME`, `DOCUMENT_ROOT`, `REQUEST_URI` - - `QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE` - - `SERVER_NAME`, `SERVER_PORT`, `HTTPS` - - `PATH_INFO` (for routing support) -- Sample PHP application included in `php-app/` directory - -**Configuration:** -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - environment: - EASYHAPROXY_DISCOVER: docker - ports: - - "80:80/tcp" - - php-fpm: - image: byjg/php:8.5-fpm - volumes: - - ./php-app:/var/www/html:ro - labels: - easyhaproxy.http.host: phpapp.local - easyhaproxy.http.port: 80 - # PHP-FPM listens on port 9000 - easyhaproxy.http.localport: 9000 - easyhaproxy.http.proto: fcgi - # Enable FastCGI plugin - easyhaproxy.http.plugins: fastcgi - easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html - easyhaproxy.http.plugin.fastcgi.index_file: index.php - easyhaproxy.http.plugin.fastcgi.path_info: "true" -``` - -**Usage:** -```bash -# Add to /etc/hosts -echo "127.0.0.1 phpapp.local" | sudo tee -a /etc/hosts - -# Start the stack -docker compose -f docker-compose-php-fpm.yml up -d - -# Test PHP application -curl http://phpapp.local/ -curl http://phpapp.local/info.php -curl http://phpapp.local/test-path-info.php/users/123 -``` - -**Alternative: Unix Socket Connection** - -For PHP-FPM images that support Unix sockets, you can use socket connection: - -```yaml -services: - haproxy: - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - php-fpm-socket:/run/php - - php-fpm: - image: php:8.2-fpm # Official PHP image supports sockets - volumes: - - php-fpm-socket:/run/php - - ./php-app:/var/www/html:ro - labels: - easyhaproxy.http.host: phpapp.local - easyhaproxy.http.port: 80 - easyhaproxy.http.socket: /run/php/php-fpm.sock - easyhaproxy.http.proto: fcgi - easyhaproxy.http.plugins: fastcgi - # ... plugin configuration - -volumes: - php-fpm-socket: -``` - -**Sample Application:** - -The `php-app/` directory contains: -- `index.php` - Main page showing FastCGI environment -- `info.php` - PHP configuration info (phpinfo) -- `test-path-info.php` - PATH_INFO routing demonstration - -**What the FastCGI plugin does:** -1. Sets `SCRIPT_FILENAME` with proper document root path -2. Handles directory requests (appends `index.php`) -3. Sets all standard CGI environment variables -4. Enables `PATH_INFO` for RESTful URL routing -5. Supports custom FastCGI parameters - ---- - -### JWT Validator Plugin - -Protect your API with JWT token validation: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro - environment: - EASYHAPROXY_DISCOVER: docker - HAPROXY_USERNAME: admin - HAPROXY_PASSWORD: password - HAPROXY_STATS_PORT: 1936 - ports: - - "80:80/tcp" - - "443:443/tcp" - - "1936:1936/tcp" - - api: - image: my-api:latest - labels: - easyhaproxy.http.host: api.example.com - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 8080 - # Enable JWT validation - easyhaproxy.http.plugins: jwt_validator - easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 - easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ - easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com - easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem -``` - -**What it validates:** -- Authorization header presence -- JWT signing algorithm -- JWT issuer and audience -- JWT signature using public key -- JWT expiration time - -**Test:** -```bash -# Without token - should fail -curl http://api.example.com/endpoint -# Response: Missing Authorization HTTP header - -# With valid JWT token -curl -H "Authorization: Bearer eyJhbGc..." http://api.example.com/endpoint -# Response: Success -``` - -**Generate test public key:** -```bash -# Generate private key -openssl genrsa -out jwt_private.pem 2048 - -# Extract public key -openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem -``` - ---- - -### Cloudflare IP Restoration Plugin - -Restore original visitor IPs when using Cloudflare CDN: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro - environment: - EASYHAPROXY_DISCOVER: docker - ports: - - "80:80/tcp" - - "443:443/tcp" - - webapp: - image: my-webapp:latest - labels: - easyhaproxy.http.host: myapp.com - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 3000 - # Enable Cloudflare plugin - easyhaproxy.http.plugins: cloudflare -``` - -**Setup Cloudflare IP list:** -```bash -# Download Cloudflare IP ranges -curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst -``` - -**What it does:** -- Detects requests from Cloudflare IPs -- Restores original visitor IP from `CF-Connecting-IP` header -- Your application logs show real visitor IPs, not Cloudflare IPs - ---- - -### IP Whitelist Plugin - -Restrict access to specific IP addresses: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - environment: - EASYHAPROXY_DISCOVER: docker - ports: - - "80:80/tcp" - - admin_panel: - image: admin-panel:latest - labels: - easyhaproxy.http.host: admin.example.com - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 8080 - # Enable IP whitelist - easyhaproxy.http.plugins: ip_whitelist - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 - easyhaproxy.http.plugin.ip_whitelist.status_code: 403 -``` - -**Allowed IP formats:** -- Single IP: `10.0.0.5` -- CIDR range: `192.168.1.0/24` -- Multiple (comma-separated): `192.168.1.0/24,10.0.0.5,172.16.0.100` - -**Test:** -```bash -# From allowed IP -curl http://admin.example.com -# Response: Success - -# From blocked IP -curl http://admin.example.com -# Response: HTTP 403 Forbidden -``` - ---- - -### Multiple Plugins Combined - -Combine multiple plugins for enhanced security: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro - environment: - EASYHAPROXY_DISCOVER: docker - ports: - - "80:80/tcp" - - "443:443/tcp" - - webapp: - image: webapp:latest - labels: - easyhaproxy.http.host: myapp.example.com - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 8080 - # Enable multiple plugins - easyhaproxy.http.plugins: cloudflare,deny_pages - # Block specific paths - easyhaproxy.http.plugin.deny_pages.paths: /admin,/wp-admin,/wp-login.php,/.env - easyhaproxy.http.plugin.deny_pages.status_code: 404 - - api: - image: api:latest - labels: - easyhaproxy.http.host: api.example.com - easyhaproxy.http.port: 80 - easyhaproxy.http.localport: 3000 - # Combine JWT + IP whitelist + path blocking - easyhaproxy.http.plugins: jwt_validator,ip_whitelist,deny_pages - easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 - easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ - easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api.pem - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.0.0/16,10.0.0.0/8 - easyhaproxy.http.plugin.deny_pages.paths: /internal,/debug -``` - -**Plugin execution order:** -1. IP Whitelist (blocks non-whitelisted IPs) -2. Deny Pages (blocks specific paths) -3. JWT Validator (validates authentication) - ---- - -## Common Configuration Options - -### Environment Variables (HAProxy Container) - -| Variable | Description | Default | -|-----------------------------|---------------------------|----------| -| `EASYHAPROXY_DISCOVER` | Discovery mode | `docker` | -| `EASYHAPROXY_SSL_MODE` | SSL mode (loose/strict) | `strict` | -| `EASYHAPROXY_CERTBOT_EMAIL` | Email for Let's Encrypt | - | -| `HAPROXY_CUSTOMERRORS` | Enable custom error pages | `false` | -| `HAPROXY_USERNAME` | Stats username | - | -| `HAPROXY_PASSWORD` | Stats password | - | -| `HAPROXY_STATS_PORT` | Stats port | `1936` | - -### Container Labels - -| Label | Description | Example | -|---------------------------------|----------------------|---------------| -| `easyhaproxy.http.host` | Virtual host domain | `example.com` | -| `easyhaproxy.http.port` | External port | `80` | -| `easyhaproxy.http.localport` | Container port | `8080` | -| `easyhaproxy.http.redirect_ssl` | Force HTTPS redirect | `true` | -| `easyhaproxy.http.certbot` | Enable Let's Encrypt | `true` | -| `easyhaproxy.https.ssl` | Enable SSL | `true` | -| `easyhaproxy.https.sslcert` | Base64 SSL cert | `LS0t...` | - -For complete documentation, see [Container Labels](../../docs/container-labels.md). - -## Tips - -1. **Local Testing with Fake Domains:** - Add entries to `/etc/hosts`: - ``` - 127.0.0.1 host1.local host2.local portainer.local - ``` - -2. **Viewing Logs:** - ```bash - docker compose logs -f haproxy - ``` - -3. **Reloading Configuration:** - EasyHAProxy automatically detects changes. Watch logs for reload events. - -4. **Generating Test SSL Certificates:** - ```bash - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout host.key -out host.crt \ - -subj "/CN=host1.local" - cat host.crt host.key > host.pem - ``` - -5. **Base64 Encoding SSL Certificate:** - ```bash - base64 -w 0 host.pem - ``` - -## Troubleshooting - -**Issue:** Container not detected -- Check labels are correct (prefix, syntax) -- Verify Docker socket is mounted -- Check logs: `docker compose logs haproxy` - -**Issue:** SSL not working -- Verify certificate format (cert + key in same PEM file) -- Check certificate matches domain -- Verify SSL mode (`loose` vs `strict`) - -**Issue:** Let's Encrypt fails -- Ensure ports 80/443 are publicly accessible -- Verify domain DNS points to your IP -- Check certbot logs in HAProxy container - -## Further Reading - -- [Docker Configuration Guide](../../docs/docker.md) - [Container Labels Reference](../../docs/container-labels.md) -- [ACME/Let's Encrypt Guide](../../docs/acme.md) +- [Docker Configuration Guide](../../docs/docker.md) - [Environment Variables](../../docs/environment-variable.md) +- [Plugin Documentation](../../docs/plugins/) diff --git a/examples/docker/docker-compose-acme.yml b/examples/docker/docker-compose-acme.yml index 495ac45..a7bb4c6 100644 --- a/examples/docker/docker-compose-acme.yml +++ b/examples/docker/docker-compose-acme.yml @@ -1,8 +1,62 @@ -# This example shows how to setup HTTP-01 ACME CA Challenge +# ============================================================================== +# EXAMPLE: Let's Encrypt SSL with ACME/Certbot +# ============================================================================== # -# You need -# - public IP pointing your machine -# - open ports 80 and 443 in your firewall +# WHAT THIS DEMONSTRATES: +# - Automatic SSL certificate generation using Let's Encrypt +# - HTTP-01 ACME challenge protocol +# - Certificate persistence across container restarts +# - Auto-renewal of certificates +# +# REQUIREMENTS (run these first): +# ```bash +# # You MUST have: +# # - A public IP address pointing to your machine +# # - Ports 80 and 443 open in your firewall +# # - A valid domain name with DNS configured +# +# # Edit this file and change: +# # - Line 21: EASYHAPROXY_CERTBOT_EMAIL to your email +# # - Line 36: easyhaproxy.http.host to your real domain +# +# # Create certs directory +# mkdir -p ./certs/certbot +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-acme.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check logs for certificate issuance +# docker compose -f docker-compose-acme.yml logs -f haproxy +# # Look for: "Successfully received certificate" +# +# # Test HTTPS with real domain (replace test.xpto.us with your domain) +# curl https://test.xpto.us/ +# # Expected: 200 OK with valid SSL certificate +# +# # Verify certificate +# openssl s_client -showcerts -connect test.xpto.us:443 < /dev/null | grep "Issuer:" +# # Expected: Issuer: C = US, O = Let's Encrypt +# +# # Check certificate files +# ls -la ./certs/certbot/ +# # Expected: Your domain certificate files +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-acme.yml down +# # Keep certificates: +# # docker compose -f docker-compose-acme.yml down +# # Remove certificates too: +# # docker compose -f docker-compose-acme.yml down && rm -rf ./certs/certbot +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-changed-label.yml b/examples/docker/docker-compose-changed-label.yml index 94d1253..70af8c3 100644 --- a/examples/docker/docker-compose-changed-label.yml +++ b/examples/docker/docker-compose-changed-label.yml @@ -1,8 +1,40 @@ -# To test: -# curl -k -H "Host: host1.local" https://127.0.0.1/ +# ============================================================================== +# EXAMPLE: Custom Label Prefix +# ============================================================================== # -# or add to /etc/hosts -# 127.0.0.1 host1.local +# WHAT THIS DEMONSTRATES: +# - Using a custom label prefix instead of default "easyhaproxy" +# - Useful for running multiple EasyHAProxy instances +# - Custom label configuration (haproxy.* instead of easyhaproxy.*) +# +# REQUIREMENTS (run these first): +# ```bash +# # Add to /etc/hosts (idempotent) +# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-changed-label.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test HTTPS +# curl -k -H "Host: host1.local" https://127.0.0.1/ +# # Expected: 200 OK with hostname in response +# +# # Verify custom label prefix is working +# docker inspect $(docker ps -q -f "ancestor=byjg/static-httpserver") | grep "haproxy.http" +# # Expected: Labels starting with "haproxy." instead of "easyhaproxy." +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-changed-label.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-cloudflare.yml b/examples/docker/docker-compose-cloudflare.yml index 79c7518..49bfe50 100644 --- a/examples/docker/docker-compose-cloudflare.yml +++ b/examples/docker/docker-compose-cloudflare.yml @@ -1,28 +1,48 @@ -# Cloudflare IP Restoration Plugin Example +# ============================================================================== +# EXAMPLE: Cloudflare IP Restoration Plugin +# ============================================================================== # -# This example demonstrates restoring original visitor IPs when using Cloudflare CDN +# WHAT THIS DEMONSTRATES: +# - Restoring original visitor IPs when behind Cloudflare CDN +# - Detecting requests from Cloudflare IP ranges +# - Using CF-Connecting-IP header for real client IP +# - Accurate IP logging for applications behind Cloudflare # -# Prerequisites: -# 1. Download Cloudflare IP ranges: -# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# REQUIREMENTS (run these first): +# ```bash +# # Download Cloudflare IP ranges (idempotent - overwrites if exists) +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # -# 2. Add to /etc/hosts: -# 127.0.0.1 myapp.local +# # Add to /etc/hosts (idempotent) +# grep -q "myapp.local" /etc/hosts || echo "127.0.0.1 myapp.local" | sudo tee -a /etc/hosts +# ``` # -# 3. Start the stack: -# docker compose -f docker-compose-cloudflare.yml up -d +# HOW TO START: +# ```bash +# docker compose -f docker-compose-cloudflare.yml up -d +# ``` # -# 4. Test (simulating Cloudflare request): -# # Without CF-Connecting-IP header: -# curl -H "Host: myapp.local" http://127.0.0.1/ +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test normal request +# curl -H "Host: myapp.local" http://127.0.0.1/ +# # Expected: 200 OK # -# # With CF-Connecting-IP header (simulating Cloudflare): -# curl -H "Host: myapp.local" -H "CF-Connecting-IP: 203.0.113.50" http://127.0.0.1/ +# # Test with CF-Connecting-IP header (simulating Cloudflare) +# curl -H "Host: myapp.local" -H "CF-Connecting-IP: 203.0.113.50" http://127.0.0.1/ +# # Expected: 200 OK (backend sees 203.0.113.50 as client IP) # -# Note: This plugin is most useful when your site is actually behind Cloudflare. -# Without Cloudflare, the request won't come from Cloudflare IPs, so the plugin -# won't activate. This example is for demonstration and testing purposes. +# # Note: This plugin is most useful when your site is actually behind Cloudflare +# # In production, requests come from Cloudflare IPs and the plugin restores real client IPs +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-cloudflare.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-ip-whitelist.yml b/examples/docker/docker-compose-ip-whitelist.yml index 87be787..02d7970 100644 --- a/examples/docker/docker-compose-ip-whitelist.yml +++ b/examples/docker/docker-compose-ip-whitelist.yml @@ -1,23 +1,49 @@ -# IP Whitelist Plugin Example +# ============================================================================== +# EXAMPLE: IP Whitelist Plugin +# ============================================================================== # -# This example demonstrates restricting access to specific IP addresses +# WHAT THIS DEMONSTRATES: +# - Restricting access to specific IP addresses or CIDR ranges +# - Single IP, CIDR notation, and multiple IP support +# - Custom HTTP status code for blocked requests +# - Admin panel or sensitive application protection # -# Prerequisites: -# 1. Add to /etc/hosts: -# 127.0.0.1 admin.local +# REQUIREMENTS (run these first): +# ```bash +# # Add to /etc/hosts (idempotent) +# grep -q "admin.local" /etc/hosts || echo "127.0.0.1 admin.local" | sudo tee -a /etc/hosts # -# 2. Start the stack: -# docker compose -f docker-compose-ip-whitelist.yml up -d +# # IMPORTANT: Update the allowed_ips in this file (line 52) with your actual IPs! +# # Default allows localhost and private networks for testing +# ``` # -# 3. Test from localhost (127.0.0.1 is whitelisted): -# curl http://admin.local/ -# # Response: Success (200 OK) +# HOW TO START: +# ```bash +# docker compose -f docker-compose-ip-whitelist.yml up -d +# ``` # -# 4. Test from non-whitelisted IP: -# # You'll need to test from another machine or configure the example -# # with your actual IP address in the allowed_ips label +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test from localhost (127.0.0.1 is whitelisted) +# curl http://admin.local/ +# # Expected: 200 OK - Access granted # -# Note: Update the allowed_ips label with your actual IP addresses/networks +# # Test from non-whitelisted IP +# # You'll need to test from another machine or temporarily remove your IP +# # from the allowed_ips list to see the 403 Forbidden response +# +# # View HAProxy stats to see blocked requests +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-ip-whitelist.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml index b5e4b70..f3f49a1 100644 --- a/examples/docker/docker-compose-jwt-validator.yml +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -1,31 +1,56 @@ -# JWT Validator Plugin Example +# ============================================================================== +# EXAMPLE: JWT Validator Plugin +# ============================================================================== # -# This example demonstrates JWT token validation for API protection +# WHAT THIS DEMONSTRATES: +# - JWT token validation for API protection +# - RS256 algorithm signature verification +# - Issuer and audience validation +# - Public key-based JWT verification # -# Prerequisites: -# 1. Generate RSA key pair: -# openssl genrsa -out jwt_private.pem 2048 -# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# REQUIREMENTS (run these first): +# ```bash +# # Generate RSA key pair (idempotent - skips if exists) +# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem # -# 2. Add to /etc/hosts: -# 127.0.0.1 api.local +# # Add to /etc/hosts (idempotent) +# grep -q "api.local" /etc/hosts || echo "127.0.0.1 api.local" | sudo tee -a /etc/hosts +# ``` # -# 3. Start the stack: -# docker compose -f docker-compose-jwt-validator.yml up -d +# HOW TO START: +# ```bash +# docker compose -f docker-compose-jwt-validator.yml up -d +# ``` # -# 4. Test without token (should fail): -# curl http://api.local/ -# # Response: Missing Authorization HTTP header +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test without token (should fail) +# curl http://api.local/ +# # Expected: HTTP 403 - Missing Authorization HTTP header # -# 5. Generate test JWT at https://jwt.io with: -# - Algorithm: RS256 -# - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} -# - Use your jwt_private.pem for signing +# # Generate test JWT at https://jwt.io with: +# # - Algorithm: RS256 +# # - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# # - Paste contents of jwt_private.pem in private key field # -# 6. Test with token: -# TOKEN="eyJhbGc..." -# curl -H "Authorization: Bearer $TOKEN" http://api.local/ -# # Response: Success +# # Test with valid token +# TOKEN="eyJhbGc..." # Replace with your generated token +# curl -H "Authorization: Bearer $TOKEN" http://api.local/ +# # Expected: 200 OK with API response +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-jwt-validator.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-multi-containers.yml b/examples/docker/docker-compose-multi-containers.yml index 92181bf..aaf8c90 100644 --- a/examples/docker/docker-compose-multi-containers.yml +++ b/examples/docker/docker-compose-multi-containers.yml @@ -1,11 +1,48 @@ -# curl -H Host:www.helloworld.com localhost:19901 -# f6d8d45b7411 -# 59b213cb8592 - -# curl -I -H Host:google.helloworld.com localhost:19901 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: www.google.com/ +# ============================================================================== +# EXAMPLE: Load Balancing with Multiple Container Replicas +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Multiple container replicas behind a single domain +# - Round-robin load balancing across replicas +# - Domain redirect functionality +# - Custom port configuration +# +# REQUIREMENTS (run these first): +# ```bash +# # No special requirements - this example runs on localhost:19901 +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-multi-containers.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test load balancing - hostname should alternate between containers +# curl -H "Host: www.helloworld.com" localhost:19901 +# # Expected: Container ID (e.g., f6d8d45b7411) +# curl -H "Host: www.helloworld.com" localhost:19901 +# # Expected: Different container ID (e.g., 59b213cb8592) +# +# # Test domain redirect +# curl -I -H "Host: google.helloworld.com" localhost:19901 +# # Expected: HTTP/1.1 301 Moved Permanently, Location: www.google.com/ +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# # You should see 2 backend servers +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-multi-containers.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-php-fpm.yml b/examples/docker/docker-compose-php-fpm.yml index c8e8737..35355a0 100644 --- a/examples/docker/docker-compose-php-fpm.yml +++ b/examples/docker/docker-compose-php-fpm.yml @@ -1,25 +1,51 @@ -# FastCGI Plugin Example with PHP-FPM +# ============================================================================== +# EXAMPLE: FastCGI Plugin with PHP-FPM +# ============================================================================== # -# This example demonstrates PHP-FPM configuration with FastCGI protocol support -# using HAProxy as a reverse proxy and the FastCGI plugin for PHP environment setup. +# WHAT THIS DEMONSTRATES: +# - PHP-FPM configuration with FastCGI protocol (proto: fcgi) +# - FastCGI plugin for PHP environment variable configuration +# - TCP connection to PHP-FPM on port 9000 +# - PATH_INFO support for RESTful routing +# - Custom document root and index file configuration # -# Prerequisites: -# 1. Add to /etc/hosts: -# 127.0.0.1 phpapp.local +# REQUIREMENTS (run these first): +# ```bash +# # Add to /etc/hosts (idempotent) +# grep -q "phpapp.local" /etc/hosts || echo "127.0.0.1 phpapp.local" | sudo tee -a /etc/hosts +# ``` # -# 2. Start the stack: -# docker compose -f docker-compose-php-fpm.yml up -d +# HOW TO START: +# ```bash +# docker compose -f docker-compose-php-fpm.yml up -d +# ``` # -# 3. Test PHP application: -# curl http://phpapp.local/ -# curl http://phpapp.local/info.php +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test main page +# curl http://phpapp.local/ +# # Expected: 200 OK with PHP environment info # -# Features: -# - PHP-FPM 8.5 with TCP connection on port 9000 -# - FastCGI protocol support -# - Custom document root -# - PATH_INFO support for routing -# - Custom FastCGI parameters +# # Test PHP info page +# curl http://phpapp.local/info.php +# # Expected: phpinfo() output +# +# # Test PATH_INFO routing +# curl http://phpapp.local/test-path-info.php/users/123 +# # Expected: PATH_INFO=/users/123 +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-php-fpm.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-plugins-combined.yml b/examples/docker/docker-compose-plugins-combined.yml index 7fd26f1..a2165bf 100644 --- a/examples/docker/docker-compose-plugins-combined.yml +++ b/examples/docker/docker-compose-plugins-combined.yml @@ -1,33 +1,67 @@ -# Multiple Plugins Combined Example +# ============================================================================== +# EXAMPLE: Multiple Plugins Combined +# ============================================================================== # -# This example demonstrates using multiple plugins together for enhanced security +# WHAT THIS DEMONSTRATES: +# - Using multiple security plugins together +# - Different plugin combinations for different services +# - Cloudflare + path blocking for public sites +# - JWT validation + path blocking for APIs +# - IP whitelist for admin panels +# - Layered security approach # -# Prerequisites: -# 1. Generate JWT keys: -# openssl genrsa -out jwt_private.pem 2048 -# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# REQUIREMENTS (run these first): +# ```bash +# # Generate JWT keys (idempotent - skips if exists) +# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem # -# 2. Download Cloudflare IPs: -# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# # Download Cloudflare IPs (idempotent - overwrites if exists) +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # -# 3. Add to /etc/hosts: -# 127.0.0.1 website.local api.local admin.local +# # Add to /etc/hosts (idempotent) +# grep -q "website.local" /etc/hosts || echo "127.0.0.1 website.local api.local admin.local" | sudo tee -a /etc/hosts +# ``` # -# 4. Start the stack: -# docker compose -f docker-compose-plugins-combined.yml up -d +# HOW TO START: +# ```bash +# docker compose -f docker-compose-plugins-combined.yml up -d +# ``` # -# 5. Test each service: -# # Public website (Cloudflare + path blocking) -# curl http://website.local/ -# curl http://website.local/admin # Should be blocked (404) +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test public website (Cloudflare + path blocking) +# curl http://website.local/ +# # Expected: 200 OK +# curl http://website.local/admin +# # Expected: HTTP 404 - Path blocked # -# # Protected API (JWT required) -# curl http://api.local/ # Should fail - no JWT -# curl -H "Authorization: Bearer " http://api.local/ # Success +# # Test protected API (JWT required) +# curl http://api.local/ +# # Expected: HTTP 403 - Missing Authorization header +# # Generate JWT at https://jwt.io (see jwt-validator example for details) +# TOKEN="eyJhbGc..." # Replace with your token +# curl -H "Authorization: Bearer $TOKEN" http://api.local/ +# # Expected: 200 OK # -# # Admin panel (IP whitelist only) -# curl http://admin.local/ # Success from localhost +# # Test admin panel (IP whitelist) +# curl http://admin.local/ +# # Expected: 200 OK from localhost +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# # You should see 3 backends with different security configurations +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-plugins-combined.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-portainer-app-example.yml b/examples/docker/docker-compose-portainer-app-example.yml index 2c2f105..ffbd963 100644 --- a/examples/docker/docker-compose-portainer-app-example.yml +++ b/examples/docker/docker-compose-portainer-app-example.yml @@ -1,3 +1,46 @@ +# ============================================================================== +# EXAMPLE: Additional Application with Portainer +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Adding more applications to an existing EasyHAProxy setup +# - Using the shared "easyhaproxy" network +# - Multiple applications behind the same HAProxy instance +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. First start the Portainer stack (creates network and HAProxy) +# docker compose -f docker-compose-portainer.yml up -d +# +# # 2. Edit this file and change: +# # - Line 6: easyhaproxy.http.host to your real domain +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-portainer-app-example.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check container is running +# docker compose -f docker-compose-portainer-app-example.yml ps +# +# # Test the application +# curl http://test.xpto.us +# # OR with /etc/hosts: echo "127.0.0.1 test.xpto.us" | sudo tee -a /etc/hosts +# +# # Verify both apps in HAProxy stats (port 1936) +# # You should see backends for both portainer.xpto.us and test.xpto.us +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-portainer-app-example.yml down +# ``` +# +# ============================================================================== + services: container: image: byjg/static-httpserver diff --git a/examples/docker/docker-compose-portainer.yml b/examples/docker/docker-compose-portainer.yml index 5f23d90..39c4917 100644 --- a/examples/docker/docker-compose-portainer.yml +++ b/examples/docker/docker-compose-portainer.yml @@ -1,7 +1,60 @@ +# ============================================================================== +# EXAMPLE: Portainer Behind EasyHAProxy +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Running Portainer (Docker management UI) behind EasyHAProxy +# - Using external volumes and networks for shared infrastructure +# - Real-world application example with Let's Encrypt +# - HTTP to HTTPS redirect with Certbot +# +# REQUIREMENTS (run these first): +# ```bash +# # Create required volumes (idempotent) # docker volume create certs_certbot # docker volume create certs_haproxy # docker volume create portainer_data +# +# # Create shared network (idempotent) # docker network create easyhaproxy +# +# # Edit this file and change: +# # - Line 18: EASYHAPROXY_CERTBOT_EMAIL to your email +# # - Line 38: easyhaproxy.http.host to your real domain +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-portainer.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check containers are running +# docker compose -f docker-compose-portainer.yml ps +# # Expected: Both easyhaproxy and portainer containers running +# +# # Access Portainer (replace with your domain or use /etc/hosts) +# # First time: Create admin user +# curl http://portainer.xpto.us +# # OR with /etc/hosts: echo "127.0.0.1 portainer.xpto.us" | sudo tee -a /etc/hosts +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-portainer.yml down +# # To also remove volumes: +# # docker compose -f docker-compose-portainer.yml down -v +# # docker volume rm certs_certbot certs_haproxy portainer_data +# # docker network rm easyhaproxy +# ``` +# +# ============================================================================== services: diff --git a/examples/docker/docker-compose.yml b/examples/docker/docker-compose.yml index ad82ed4..db59b38 100644 --- a/examples/docker/docker-compose.yml +++ b/examples/docker/docker-compose.yml @@ -1,19 +1,53 @@ -# To test: +# ============================================================================== +# EXAMPLE: Basic SSL Setup with Two Virtual Hosts +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Basic SSL setup with custom certificates (embedded base64 and file-based) +# - Automatic HTTP to HTTPS redirect +# - Two virtual hosts (host1.local and host2.local) +# - HAProxy stats interface +# +# REQUIREMENTS (run these first): +# ```bash +# # Add to /etc/hosts (idempotent) +# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local host2.local" | sudo tee -a /etc/hosts +# +# # Generate SSL certificates +# cd ../.. && ./examples/generate-keys.sh && cd examples/docker +# ``` +# +# HOW TO START: +# ```bash +# docker compose up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test HTTPS # curl -k -H "Host: host1.local" https://127.0.0.1/ # curl -k -H "Host: host2.local" https://127.0.0.1/ -# -# curl -I -H Host:host1.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Expected: 200 OK with hostname in response # -# curl -I -H Host:host2.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Test HTTP redirect +# curl -I -H "Host: host1.local" http://127.0.0.1 +# # Expected: HTTP/1.1 301 Moved Permanently, Location: https://host1.local/ # -# Test SSL: -# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local +# # View SSL certificate +# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local < /dev/null +# +# # Access stats interface +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index 4a867cb..2ab7e02 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -1,705 +1,50 @@ # Kubernetes Examples -This directory contains Kubernetes manifest examples demonstrating EasyHAProxy ingress configurations. +Self-contained examples for EasyHAProxy ingress controller. **All documentation is in the YAML files as header comments.** + +## Quick Start + +1. Pick an example below +2. Open the YAML file +3. Read the header comments for complete instructions +4. Run the commands step-by-step ## Prerequisites -1. **Generate SSL Certificates (Required for TLS examples):** - ```bash - # From the repository root - ./examples/generate-keys.sh - ``` +All examples require: +- EasyHAProxy installed in your Kubernetes cluster +- Node labeled for EasyHAProxy deployment - This script automatically generates: - - SSL certificates for testing (host1.local, host2.local) - - JWT keys for authentication examples - - All other .pem files needed for examples +See header comments in each file for detailed setup instructions. - **Note:** These are self-signed certificates for testing only. For production, use Let's Encrypt or your own certificates. +## Basic Examples -2. **EasyHAProxy installed in your cluster:** - ```bash - kubectl create namespace easyhaproxy - kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml - ``` - -3. **Label the node where EasyHAProxy will run:** - ```bash - kubectl label nodes "easyhaproxy/node=master" - ``` - -See the [Kubernetes Guide](../../docs/kubernetes.md) for complete installation instructions. - ---- - -## Examples Overview - -### 1. Basic Ingress (`service.yml`) - -**What it demonstrates:** -- Basic ingress configuration -- Multiple domains pointing to same service -- Complete deployment + service + ingress setup - -**Components:** -- **Deployment**: `byjg/static-httpserver` container -- **Service**: ClusterIP exposing port 8080 -- **Ingress**: Routes for `example.org` and `www.example.org` - -**Apply:** -```bash -kubectl apply -f service.yml -``` - -**Test:** -```bash -# If using NodePort or port-forward: -curl -H "Host: example.org" http://:31080 - -# Or port-forward for testing: -kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 -curl -H "Host: example.org" http://localhost:8080 -``` - -**Manifest breakdown:** -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress # Required! - name: container-example -spec: - rules: - - host: example.org # First domain - http: - paths: - - backend: - service: - name: container-example - port: - number: 8080 - - host: www.example.org # Second domain (same service) - ... -``` - ---- - -### 2. TLS/SSL Ingress (`service_tls.yml`) - -**What it demonstrates:** -- HTTPS/TLS configuration -- Custom SSL certificates via Kubernetes secrets -- SSL redirect (HTTP → HTTPS) -- Certbot/Let's Encrypt integration - -**Components:** -- **Secret**: Custom SSL certificate for `host2.local` -- **Ingress**: TLS configuration + certbot annotation - -**Apply:** -```bash -kubectl apply -f service_tls.yml -``` - -**Features:** - -1. **Custom SSL Certificate:** - ```yaml - apiVersion: v1 - kind: Secret - metadata: - name: host2-tls - data: - tls.crt: - tls.key: - type: kubernetes.io/tls - ``` - -2. **Ingress TLS Configuration:** - ```yaml - spec: - tls: - - hosts: - - host2.local - secretName: host2-tls # References the secret above - ``` - -3. **Certbot/Let's Encrypt:** - ```yaml - metadata: - annotations: - easyhaproxy.certbot: 'true' - easyhaproxy.redirect_ssl: 'true' - ``` - -**Test:** -```bash -# Test HTTPS (if host2.local in /etc/hosts) -curl -k https://host2.local - -# Test HTTP redirect -curl -I http://host2.local -# Should return: HTTP/1.1 301 Moved Permanently -``` - ---- - -## Kubernetes Annotations Reference - -All annotations are applied at the **Ingress** level and affect all hosts in that ingress. - -### Required Annotation - -| Annotation | Description | Example | -|-------------------------------|-----------------------|-----------------------| -| `kubernetes.io/ingress.class` | Activates EasyHAProxy | `easyhaproxy-ingress` | - -### Optional Annotations - -| Annotation | Description | Default | Example | -|----------------------------|----------------------|---------|-------------------------| -| `easyhaproxy.redirect_ssl` | Force HTTPS redirect | `false` | `'true'` | -| `easyhaproxy.certbot` | Enable Let's Encrypt | `false` | `'true'` | -| `easyhaproxy.mode` | Protocol mode | `http` | `http` or `tcp` | -| `easyhaproxy.listen_port` | Override listen port | `80` | `8080` | -| `easyhaproxy.plugins` | Enable plugins | - | `cloudflare,deny_pages` | - -See [Kubernetes Guide](../../docs/kubernetes.md#kubernetes-annotations) for complete reference. - ---- - -## Common Use Cases - -### Use Case 1: Simple HTTP Application - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - name: my-app -spec: - rules: - - host: myapp.example.com - http: - paths: - - backend: - service: - name: my-app-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -### Use Case 2: HTTPS with Let's Encrypt - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - easyhaproxy.certbot: 'true' - easyhaproxy.redirect_ssl: 'true' - name: secure-app -spec: - rules: - - host: secure.example.com - http: - paths: - - backend: - service: - name: secure-app-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -**Requirements for Let's Encrypt:** -- Cluster must be publicly accessible on ports 80 and 443 -- DNS must point to cluster IP -- Configure certbot email: - ```bash - # Via Helm: - helm upgrade ingress byjg/easyhaproxy \ - --set easyhaproxy.certbot.email=your-email@example.com - - # Or via environment variable in manifest - ``` - -### Use Case 3: Custom SSL Certificate - -```yaml ---- -apiVersion: v1 -kind: Secret -metadata: - name: my-tls-secret -type: kubernetes.io/tls -data: - tls.crt: LS0tLS1CRUdJTi... # base64 encoded certificate - tls.key: LS0tLS1CRUdJTi... # base64 encoded private key - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - name: custom-ssl-app -spec: - tls: - - hosts: - - myapp.example.com - secretName: my-tls-secret - rules: - - host: myapp.example.com - http: - paths: - - backend: - service: - name: my-app-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -### Use Case 4: Using Plugins (JWT, IP Whitelist, etc.) - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - # Enable plugins - easyhaproxy.plugins: "jwt_validator,deny_pages" - # Configure JWT validator - easyhaproxy.plugin.jwt_validator.algorithm: "RS256" - easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" - easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" - easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" - # Configure deny_pages - easyhaproxy.plugin.deny_pages.paths: "/admin,/private" - name: secure-api -spec: - rules: - - host: api.example.com - http: - paths: - - backend: - service: - name: api-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -See [Using Plugins with Kubernetes](../../docs/kubernetes.md#using-plugins-with-kubernetes) for more examples. - ---- +| File | Description | +|------------------------------------|--------------------------------------------| +| [service.yml](service.yml) | Basic HTTP ingress with multiple domains | +| [service_tls.yml](service_tls.yml) | HTTPS/TLS ingress with custom certificates | ## Plugin Examples -### JWT Validator Plugin +| File | Description | +|----------------------------------------------|-----------------------------------------------------| +| [jwt-validator.yml](jwt-validator.yml) | JWT token validation for API protection | +| [ip-whitelist.yml](ip-whitelist.yml) | IP whitelist for admin panels or sensitive services | +| [cloudflare.yml](cloudflare.yml) | Restore real client IPs when behind Cloudflare CDN | +| [plugins-combined.yml](plugins-combined.yml) | Multiple plugins combined for layered security | -Complete example with JWT validation for API protection: +## Documentation Structure -```yaml ---- -# Create ConfigMap with public key -apiVersion: v1 -kind: ConfigMap -metadata: - name: jwt-keys - namespace: default -data: - api_pubkey.pem: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY----- +Each YAML file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Command to apply the manifest +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to remove resources ---- -# Mount public key into EasyHAProxy pod -# Add this to your EasyHAProxy deployment: -# volumeMounts: -# - name: jwt-keys -# mountPath: /etc/haproxy/jwt_keys -# volumes: -# - name: jwt-keys -# configMap: -# name: jwt-keys - ---- -apiVersion: v1 -kind: Service -metadata: - name: api-service - namespace: default -spec: - ports: - - port: 8080 - selector: - app: api - type: ClusterIP - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: api - namespace: default -spec: - replicas: 3 - selector: - matchLabels: - app: api - template: - metadata: - labels: - app: api - spec: - containers: - - name: api - image: my-api:latest - ports: - - containerPort: 8080 - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - # Enable JWT validator - easyhaproxy.plugins: "jwt_validator" - easyhaproxy.plugin.jwt_validator.algorithm: "RS256" - easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" - easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" - easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" - name: api-ingress - namespace: default -spec: - rules: - - host: api.example.com - http: - paths: - - backend: - service: - name: api-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -**Test:** -```bash -# Without JWT token - should fail -curl http://api.example.com/users -# Response: Missing Authorization HTTP header - -# With valid JWT token -TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." -curl -H "Authorization: Bearer $TOKEN" http://api.example.com/users -# Response: Success -``` - ---- - -### Cloudflare IP Restoration Plugin - -Restore original visitor IPs when behind Cloudflare: - -```yaml ---- -# Create ConfigMap with Cloudflare IP ranges -apiVersion: v1 -kind: ConfigMap -metadata: - name: cloudflare-ips - namespace: easyhaproxy -data: - cloudflare_ips.lst: | - 173.245.48.0/20 - 103.21.244.0/22 - 103.22.200.0/22 - 103.31.4.0/22 - 141.101.64.0/18 - 108.162.192.0/18 - 190.93.240.0/20 - 188.114.96.0/20 - 197.234.240.0/22 - 198.41.128.0/17 - 162.158.0.0/15 - 104.16.0.0/13 - 104.24.0.0/14 - 172.64.0.0/13 - 131.0.72.0/22 - ---- -# Mount ConfigMap into EasyHAProxy pod -# Add this to your EasyHAProxy deployment: -# volumeMounts: -# - name: cloudflare-ips -# mountPath: /etc/haproxy/cloudflare_ips.lst -# subPath: cloudflare_ips.lst -# volumes: -# - name: cloudflare-ips -# configMap: -# name: cloudflare-ips - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - # Enable Cloudflare plugin - easyhaproxy.plugins: "cloudflare" - name: webapp-ingress - namespace: default -spec: - rules: - - host: myapp.example.com - http: - paths: - - backend: - service: - name: webapp-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -**Download latest Cloudflare IPs:** -```bash -curl https://www.cloudflare.com/ips-v4 -curl https://www.cloudflare.com/ips-v6 -``` - ---- - -### IP Whitelist Plugin - -Restrict admin panel to office IPs only: - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - # Enable IP whitelist - easyhaproxy.plugins: "ip_whitelist" - easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.42" - easyhaproxy.plugin.ip_whitelist.status_code: "403" - name: admin-ingress - namespace: default -spec: - rules: - - host: admin.example.com - http: - paths: - - backend: - service: - name: admin-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -**Test:** -```bash -# From allowed IP (203.0.113.50) -curl http://admin.example.com -# Response: Success - -# From blocked IP -curl http://admin.example.com -# Response: HTTP 403 Forbidden -``` - ---- - -### Multiple Plugins Combined - -Combine Cloudflare + JWT + Path Blocking for maximum security: - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: easyhaproxy-ingress - # Enable multiple plugins - easyhaproxy.plugins: "cloudflare,jwt_validator,deny_pages" - - # Cloudflare - restore real IPs - # (no config needed if using default path) - - # JWT Validator - validate tokens - easyhaproxy.plugin.jwt_validator.algorithm: "RS256" - easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" - easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" - easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" - - # Deny Pages - block sensitive paths - easyhaproxy.plugin.deny_pages.paths: "/internal,/debug,/admin" - easyhaproxy.plugin.deny_pages.status_code: "404" - name: secure-api-ingress - namespace: production -spec: - rules: - - host: api.example.com - http: - paths: - - backend: - service: - name: api-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -**Plugin execution order:** -1. Cloudflare IP restoration (sets correct visitor IP) -2. Deny Pages (blocks blacklisted paths) -3. JWT Validator (validates authentication) - ---- - -## Creating SSL Secrets - -### From Certificate Files - -```bash -kubectl create secret tls my-tls-secret \ - --cert=path/to/cert.crt \ - --key=path/to/cert.key \ - -n default -``` - -### From PEM File - -```bash -# Extract certificate and key -openssl x509 -in cert.pem -out cert.crt -openssl rsa -in cert.pem -out cert.key - -# Create secret -kubectl create secret tls my-tls-secret \ - --cert=cert.crt \ - --key=cert.key \ - -n default -``` - -### Generate Self-Signed Certificate for Testing - -```bash -openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout tls.key -out tls.crt \ - -subj "/CN=myapp.example.com" - -kubectl create secret tls my-tls-secret \ - --cert=tls.crt \ - --key=tls.key -``` - ---- - -## Troubleshooting - -### Ingress Not Detected - -**Check annotation:** -```bash -kubectl get ingress -o yaml | grep annotations -A 5 -``` - -Ensure `kubernetes.io/ingress.class: easyhaproxy-ingress` is present. - -**Check EasyHAProxy logs:** -```bash -kubectl logs -n easyhaproxy deployment/easyhaproxy -f -``` - -### SSL Certificate Not Loading - -**Verify secret exists:** -```bash -kubectl get secret -o yaml -``` - -**Check secret has correct fields:** -- `tls.crt`: base64-encoded certificate -- `tls.key`: base64-encoded private key - -**Check EasyHAProxy logs** for certificate loading errors. - -### Let's Encrypt Fails - -**Requirements:** -- Ports 80 and 443 must be publicly accessible -- DNS must resolve to cluster IP -- Certbot email must be configured - -**Check certbot logs:** -```bash -kubectl logs -n easyhaproxy deployment/easyhaproxy | grep certbot -``` - -### Changes Not Applied - -EasyHAProxy watches ingress changes automatically. If changes aren't applied: - -1. **Check discovery interval:** - ```bash - # Default is 10 seconds, increase if needed - EASYHAPROXY_REFRESH: "30" - ``` - -2. **Force reload:** - ```bash - kubectl rollout restart -n easyhaproxy deployment/easyhaproxy - ``` - ---- - -## Tips - -1. **Local Testing:** - Add entries to `/etc/hosts`: - ``` - example.org www.example.org host2.local - ``` - -2. **View HAProxy Config:** - ```bash - kubectl exec -n easyhaproxy deployment/easyhaproxy -- cat /etc/haproxy/haproxy.cfg - ``` - -3. **Access Stats Interface:** - ```bash - kubectl port-forward -n easyhaproxy deployment/easyhaproxy 1936:1936 - # Open: http://localhost:1936 - ``` - -4. **Debug Mode:** - Enable debug logging: - ```yaml - env: - - name: EASYHAPROXY_LOG_LEVEL - value: DEBUG - ``` - ---- - -## Further Reading +## Additional Documentation - [Kubernetes Installation Guide](../../docs/kubernetes.md) - [Helm Installation](../../docs/helm.md) +- [Kubernetes Annotations Reference](../../docs/kubernetes.md#kubernetes-annotations) - [Using Plugins with Kubernetes](../../docs/kubernetes.md#using-plugins-with-kubernetes) -- [ACME/Let's Encrypt](../../docs/acme.md) -- [Environment Variables](../../docs/environment-variable.md) diff --git a/examples/kubernetes/cloudflare.yml b/examples/kubernetes/cloudflare.yml index 734d228..b4e037a 100644 --- a/examples/kubernetes/cloudflare.yml +++ b/examples/kubernetes/cloudflare.yml @@ -1,34 +1,65 @@ -# Cloudflare IP Restoration Plugin Example for Kubernetes +# ============================================================================== +# EXAMPLE: Cloudflare IP Restoration Plugin for Kubernetes +# ============================================================================== # -# This example demonstrates restoring original visitor IPs when using Cloudflare CDN +# WHAT THIS DEMONSTRATES: +# - Restoring original visitor IPs when behind Cloudflare CDN +# - Using ConfigMaps to mount Cloudflare IP ranges +# - Detecting requests from Cloudflare IP ranges +# - Accurate client IP logging for applications behind Cloudflare # -# Prerequisites: -# 1. EasyHAProxy installed in your cluster +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml # -# 2. Download Cloudflare IP ranges and create ConfigMap: -# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst -# kubectl create configmap cloudflare-ips \ -# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ -# -n easyhaproxy +# # 2. Download Cloudflare IP ranges +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # -# 3. Mount the ConfigMap in EasyHAProxy deployment (add to volumeMounts and volumes): -# volumeMounts: -# - name: cloudflare-ips -# mountPath: /etc/haproxy/cloudflare_ips.lst -# subPath: cloudflare_ips.lst -# volumes: -# - name: cloudflare-ips -# configMap: -# name: cloudflare-ips +# # 3. Create ConfigMap with Cloudflare IPs +# kubectl create configmap cloudflare-ips \ +# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ +# -n easyhaproxy # -# 4. Apply this manifest: -# kubectl apply -f cloudflare.yml +# # 4. Mount the ConfigMap in EasyHAProxy deployment: +# # Edit your EasyHAProxy deployment and add: +# # volumeMounts: +# # - name: cloudflare-ips +# # mountPath: /etc/haproxy/cloudflare_ips.lst +# # subPath: cloudflare_ips.lst +# # volumes: +# # - name: cloudflare-ips +# # configMap: +# # name: cloudflare-ips +# ``` # -# 5. Test: -# curl http://myapp.example.local/ +# HOW TO START: +# ```bash +# kubectl apply -f cloudflare.yml +# ``` # -# Note: This plugin is most useful when your site is actually behind Cloudflare. +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress -l app=webapp +# +# # Test via port-forward +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: myapp.example.local" http://localhost:8080 +# # Expected: 200 OK with "App Behind Cloudflare" +# +# # In production behind Cloudflare, the plugin will restore real client IPs +# # from the CF-Connecting-IP header +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f cloudflare.yml +# ``` +# +# ============================================================================== --- apiVersion: v1 diff --git a/examples/kubernetes/ip-whitelist.yml b/examples/kubernetes/ip-whitelist.yml index 4603d43..c1d5cba 100644 --- a/examples/kubernetes/ip-whitelist.yml +++ b/examples/kubernetes/ip-whitelist.yml @@ -1,23 +1,48 @@ -# IP Whitelist Plugin Example for Kubernetes +# ============================================================================== +# EXAMPLE: IP Whitelist Plugin for Kubernetes +# ============================================================================== # -# This example demonstrates restricting access to specific IP addresses +# WHAT THIS DEMONSTRATES: +# - Restricting access to specific IP addresses or CIDR ranges +# - Using annotations for IP-based access control +# - Protecting admin panels or sensitive services in Kubernetes +# - Custom HTTP status code for blocked requests # -# Prerequisites: -# 1. EasyHAProxy installed in your cluster +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml # -# 2. Update the allowed_ips annotation with your actual IP addresses/networks +# # 2. IMPORTANT: Edit this file (line 80) and update allowed_ips +# # with your actual office/VPN IP addresses or networks +# ``` # -# 3. Apply this manifest: -# kubectl apply -f ip-whitelist.yml +# HOW TO START: +# ```bash +# kubectl apply -f ip-whitelist.yml +# ``` # -# 4. Test from allowed IP: -# curl http://admin.example.local/ -# # Response: Success (200 OK) +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress -l app=admin # -# 5. Test from non-allowed IP: -# # Response: HTTP 403 Forbidden +# # Test from allowed IP +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: admin.example.local" http://localhost:8080 +# # Expected: 200 OK with "Admin Panel - IP Restricted" (if your IP is in allowed_ips) # -# Note: Update the allowed_ips annotation with your actual office/VPN IPs +# # Test from non-allowed IP +# # Expected: HTTP 403 Forbidden +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f ip-whitelist.yml +# ``` +# +# ============================================================================== --- apiVersion: v1 diff --git a/examples/kubernetes/jwt-validator.yml b/examples/kubernetes/jwt-validator.yml index f3d7b90..1a393bd 100644 --- a/examples/kubernetes/jwt-validator.yml +++ b/examples/kubernetes/jwt-validator.yml @@ -1,41 +1,69 @@ -# JWT Validator Plugin Example for Kubernetes +# ============================================================================== +# EXAMPLE: JWT Validator Plugin for Kubernetes +# ============================================================================== # -# This example demonstrates JWT token validation for API protection in Kubernetes +# WHAT THIS DEMONSTRATES: +# - JWT token validation for API protection in Kubernetes +# - RS256 algorithm signature verification +# - Using ConfigMaps to mount JWT public keys +# - Issuer and audience validation # -# Prerequisites: -# 1. EasyHAProxy installed in your cluster -# 2. Generate RSA key pair: -# openssl genrsa -out jwt_private.pem 2048 -# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml # -# 3. Create ConfigMap with public key: -# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem +# # 2. Generate RSA key pair (idempotent - skips if exists) +# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem # -# 4. Mount the ConfigMap in EasyHAProxy deployment (add to volumeMounts and volumes): -# volumeMounts: -# - name: jwt-keys -# mountPath: /etc/haproxy/jwt_keys -# volumes: -# - name: jwt-keys -# configMap: -# name: jwt-keys +# # 3. Create ConfigMap with public key +# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem # -# 5. Apply this manifest: -# kubectl apply -f jwt-validator.yml +# # 4. Mount the ConfigMap in EasyHAProxy deployment: +# # Edit your EasyHAProxy deployment and add: +# # volumeMounts: +# # - name: jwt-keys +# # mountPath: /etc/haproxy/jwt_keys +# # volumes: +# # - name: jwt-keys +# # configMap: +# # name: jwt-keys +# ``` # -# 6. Test without token (should fail): -# curl http://api.example.local/ -# # Response: Missing Authorization HTTP header +# HOW TO START: +# ```bash +# kubectl apply -f jwt-validator.yml +# ``` # -# 7. Generate test JWT at https://jwt.io with: -# - Algorithm: RS256 -# - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} -# - Use your jwt_private.pem for signing +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress -l app=api # -# 8. Test with token: -# TOKEN="eyJhbGc..." -# curl -H "Authorization: Bearer $TOKEN" http://api.example.local/ -# # Response: Success +# # Test without token (should fail) +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: api.example.local" http://localhost:8080 +# # Expected: HTTP 403 - Missing Authorization HTTP header +# +# # Generate test JWT at https://jwt.io with: +# # - Algorithm: RS256 +# # - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# # - Paste contents of jwt_private.pem in private key field +# +# # Test with valid token +# TOKEN="eyJhbGc..." # Replace with your generated token +# curl -H "Authorization: Bearer $TOKEN" -H "Host: api.example.local" http://localhost:8080 +# # Expected: 200 OK with "Protected API - JWT Required" +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f jwt-validator.yml +# ``` +# +# ============================================================================== --- apiVersion: v1 diff --git a/examples/kubernetes/plugins-combined.yml b/examples/kubernetes/plugins-combined.yml index 321aeef..e8c76cb 100644 --- a/examples/kubernetes/plugins-combined.yml +++ b/examples/kubernetes/plugins-combined.yml @@ -1,31 +1,70 @@ -# Multiple Plugins Combined Example for Kubernetes +# ============================================================================== +# EXAMPLE: Multiple Plugins Combined for Kubernetes +# ============================================================================== # -# This example demonstrates using multiple plugins together for enhanced security +# WHAT THIS DEMONSTRATES: +# - Using multiple security plugins together +# - Different plugin combinations for different services +# - Layered security approach in Kubernetes +# - Three services with different security profiles: +# 1. Public website: Cloudflare + path blocking +# 2. Protected API: JWT validation + path blocking +# 3. Admin panel: Strict IP whitelist # -# Prerequisites: -# 1. EasyHAProxy installed in your cluster +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml # -# 2. Generate JWT keys and create ConfigMap: -# openssl genrsa -out jwt_private.pem 2048 -# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem -# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem +# # 2. Generate JWT keys (idempotent - skips if exists) +# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem # -# 3. Download Cloudflare IPs and create ConfigMap: -# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst -# kubectl create configmap cloudflare-ips \ -# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ -# -n easyhaproxy +# # 3. Download Cloudflare IPs +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# kubectl create configmap cloudflare-ips \ +# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ +# -n easyhaproxy # -# 4. Mount ConfigMaps in EasyHAProxy deployment +# # 4. Mount ConfigMaps in EasyHAProxy deployment +# # (See individual plugin examples for mount configuration) +# ``` # -# 5. Apply this manifest: -# kubectl apply -f plugins-combined.yml +# HOW TO START: +# ```bash +# kubectl apply -f plugins-combined.yml +# ``` # -# This creates three services with different security profiles: -# - Public website: Cloudflare + path blocking -# - Protected API: JWT validation + path blocking -# - Admin panel: Strict IP whitelist +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check all resources are created +# kubectl get deployment,service,ingress +# +# # Test public website (Cloudflare + path blocking) +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: website.example.local" http://localhost:8080 +# # Expected: 200 OK with "Public Website" +# curl -H "Host: website.example.local" http://localhost:8080/admin +# # Expected: HTTP 404 - Path blocked +# +# # Test protected API (JWT required) +# curl -H "Host: api.example.local" http://localhost:8080 +# # Expected: HTTP 403 - Missing Authorization header +# +# # Test admin panel (IP whitelist) +# curl -H "Host: admin.example.local" http://localhost:8080 +# # Expected: 200 OK from allowed IP, or HTTP 403 from blocked IP +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f plugins-combined.yml +# ``` +# +# ============================================================================== --- # Public website service diff --git a/examples/kubernetes/service.yml b/examples/kubernetes/service.yml index 2ac821a..36fbd31 100644 --- a/examples/kubernetes/service.yml +++ b/examples/kubernetes/service.yml @@ -1,3 +1,57 @@ +# ============================================================================== +# EXAMPLE: Basic Kubernetes Ingress +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Basic ingress configuration with EasyHAProxy +# - Multiple domains pointing to the same service +# - Complete deployment + service + ingress setup +# - HTTP ingress without TLS +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Label the node where EasyHAProxy will run +# kubectl label nodes "easyhaproxy/node=master" +# +# # 3. Add to /etc/hosts for local testing (idempotent) +# grep -q "example.org" /etc/hosts || echo " example.org www.example.org" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f service.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress container-example +# +# # Test via node IP +# curl -H "Host: example.org" http://:31080 +# # Expected: 200 OK with "My Host Example" +# +# # Or use port-forward for testing +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: example.org" http://localhost:8080 +# # Expected: 200 OK with "My Host Example" +# +# # Test second domain +# curl -H "Host: www.example.org" http://:31080 +# # Expected: Same response +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f service.yml +# ``` +# +# ============================================================================== + --- apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/examples/kubernetes/service_tls.yml b/examples/kubernetes/service_tls.yml index e351fc2..f0135cb 100644 --- a/examples/kubernetes/service_tls.yml +++ b/examples/kubernetes/service_tls.yml @@ -1,3 +1,58 @@ +# ============================================================================== +# EXAMPLE: TLS/SSL Kubernetes Ingress +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - HTTPS/TLS configuration with custom certificates +# - Kubernetes TLS secrets for SSL certificates +# - Complete deployment + service + secret + ingress with TLS +# - Using pre-generated test certificates +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Label the node where EasyHAProxy will run +# kubectl label nodes "easyhaproxy/node=master" +# +# # 3. Add to /etc/hosts for local testing (idempotent) +# grep -q "host2.local" /etc/hosts || echo " host2.local" | sudo tee -a /etc/hosts +# +# # Note: This example uses embedded test certificates +# # For production, create your own secret: +# # kubectl create secret tls host2-tls --cert=cert.crt --key=cert.key +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f service_tls.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress,secret tls-example +# kubectl get secret host2-tls +# +# # Test HTTPS (using port-forward) +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8443:443 +# curl -k -H "Host: host2.local" https://localhost:8443 +# # Expected: 200 OK with "My Host Example" +# +# # Verify certificate +# openssl s_client -showcerts -connect localhost:8443 -servername host2.local < /dev/null +# # Expected: Certificate for host2.local +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f service_tls.yml +# ``` +# +# ============================================================================== + --- apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/examples/static/README.md b/examples/static/README.md index d6fefbc..a1f6c4b 100644 --- a/examples/static/README.md +++ b/examples/static/README.md @@ -1,312 +1,77 @@ # Static Configuration Example -This directory demonstrates EasyHAProxy using **static YAML configuration** instead of dynamic service discovery. +Self-contained example for EasyHAProxy using static YAML configuration. **All documentation is in the docker-compose file as header comments.** -Static mode is useful for: -- Non-containerized backends (VMs, bare metal) -- Fixed infrastructure -- Explicit routing control +## Quick Start ---- +1. Open [docker-compose.yml](docker-compose.yml) +2. Read the header comments for complete instructions +3. Choose a configuration scenario from `conf/` directory +4. Run the commands step-by-step -## Prerequisites +## What is Static Mode? -### 1. Generate SSL Certificates +Static mode uses explicit YAML configuration files instead of dynamic service discovery. This is useful for: +- **Non-containerized backends** - VMs, bare metal servers, external APIs +- **Fixed infrastructure** - When your backend IPs/ports don't change +- **Explicit routing control** - Precise control over HAProxy configuration -```bash -# From repository root -./examples/generate-keys.sh -``` - -### 2. Add Host Entry - -```bash -echo "127.0.0.1 host1.local www.host1.local" | sudo tee -a /etc/hosts -echo "127.0.0.1 host2.local www.host2.local" | sudo tee -a /etc/hosts -``` - ---- - -## Scenario 1: Basic (HTTP → HTTPS Redirect) - -**What it does:** Simple HTTP to HTTPS redirect with SSL termination. - -### Getting Started - -```bash -cd examples/static - -# 1. Copy the basic config -cp conf/config-basic.yml conf/config.yml - -# 2. Start backend container -docker run -d --name container -p 8080:8080 byjg/static-httpserver - -# 3. Start EasyHAProxy -docker compose up -d -``` - -### Test - -```bash -# Test HTTP redirect -curl -I http://host1.local -# Expected: HTTP/1.1 301 Moved Permanently -# Expected: Location: https://host1.local - -# Test HTTPS -curl -k https://host1.local -# Expected: Hello from Static HTTP Server! - -# Test www redirect -curl -I http://www.host1.local -# Expected: HTTP/1.1 301 Moved Permanently -# Expected: Location: https://host1.local -``` - -### Stats Interface - -Open: http://localhost:1936 -- Username: `admin` -- Password: `password` - -### Clean Up - -```bash -docker compose down -docker stop container && docker rm container -``` - ---- - -## Scenario 2: Certbot (Let's Encrypt SSL) - -**What it does:** Automatic SSL certificates from Let's Encrypt using ACME HTTP-01 challenge. - -### Requirements - -- Public IP address -- Domain pointing to your IP -- Ports 80/443 publicly accessible - -### Getting Started - -```bash -cd examples/static - -# 1. Copy the certbot config -cp conf/config-certbot.yml conf/config.yml - -# 2. Edit config.yml and change: -# - Replace "example.com" with your real domain -# - Update EASYHAPROXY_CERTBOT_EMAIL in docker-compose.yml - -# 3. Start backend container -docker run -d --name container -p 8080:8080 byjg/static-httpserver - -# 4. Start EasyHAProxy -docker compose up -d - -# 5. Check logs for certificate generation -docker compose logs -f -``` - -### What to Expect - -``` -# Certbot will: -# 1. Request certificate from Let's Encrypt -# 2. Complete HTTP-01 challenge -# 3. Save certificate in /certs/certbot/ -# 4. Reload HAProxy with new certificate -``` - -### Test - -```bash -# Test HTTPS with real certificate -curl https://your-domain.com -# Expected: No certificate warnings (valid SSL) - -# Test HTTP redirect -curl -I http://your-domain.com -# Expected: HTTP/1.1 301 Moved Permanently -``` - -### Clean Up - -```bash -docker compose down -docker stop container && docker rm container -``` - -**Note:** Certificates are stored in Docker volume `certs_certbot` and persist across restarts. - ---- - -## Scenario 3: Deny Pages (Block Specific Paths) - -**What it does:** Blocks access to sensitive paths like `/admin`, `/wp-login.php`, etc. - -### Getting Started - -```bash -cd examples/static - -# 1. Copy the deny-pages config -cp conf/config-deny-pages.yml conf/config.yml - -# 2. Start backend container -docker run -d --name container -p 8080:8080 byjg/static-httpserver - -# 3. Start EasyHAProxy -docker compose up -d -``` - -### Test - -```bash -# Test normal page (should work) -curl -k https://host1.local/ -# Expected: Hello from Static HTTP Server! - -# Test blocked path (should fail) -curl -I -k https://host1.local/admin -# Expected: HTTP/1.1 404 Not Found - -curl -I -k https://host1.local/wp-login.php -# Expected: HTTP/1.1 404 Not Found - -curl -I -k https://host1.local/.env -# Expected: HTTP/1.1 404 Not Found -``` - -### What's Blocked - -The example blocks these paths: -- `/admin` -- `/wp-admin` -- `/wp-login.php` -- `/.env` -- `/config` - -### Customize Blocked Paths - -Edit `conf/config.yml`: - -```yaml -plugin_config: - deny_pages: - paths: /admin,/private,/internal - status_code: 403 # or 404 -``` - -### Clean Up - -```bash -docker compose down -docker stop container && docker rm container -``` - ---- - -## Scenario 4: JWT Validator (API Authentication) - -**What it does:** Validates JWT tokens in Authorization header before allowing access. - -### Getting Started - -```bash -cd examples/static - -# 1. Copy the JWT validator config -cp conf/config-jwt-validator.yml conf/config.yml - -# 2. JWT keys were already generated by generate-keys.sh -# Location: examples/docker/jwt_pubkey.pem and jwt_private.pem - -# 3. Start backend container -docker run -d --name container -p 8080:8080 byjg/static-httpserver - -# 4. Start EasyHAProxy -docker compose up -d -``` - -### Test Without Token (Should Fail) - -```bash -curl -k https://host1.local/ -# Expected: Missing Authorization HTTP header -``` - -### Test With Valid Token - -```bash -# 1. Generate a test JWT token using jwt_private.pem -# You can use https://jwt.io or a JWT library - -# 2. Example with valid token: -TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." -curl -k -H "Authorization: Bearer $TOKEN" https://host1.local/ -# Expected: Hello from Static HTTP Server! (if token is valid) -``` - -### Generate Test Token - -```python -# Python example using PyJWT -import jwt -from datetime import datetime, timedelta - -with open('examples/docker/jwt_private.pem', 'r') as f: - private_key = f.read() - -payload = { - 'iss': 'https://auth.example.com/', - 'aud': 'https://api.example.com', - 'exp': datetime.utcnow() + timedelta(hours=1) -} - -token = jwt.encode(payload, private_key, algorithm='RS256') -print(token) -``` - -### What's Validated - -- Authorization header must be present -- Token must be valid JWT format -- Signature must match public key (`jwt_pubkey.pem`) -- Issuer must match: `https://auth.example.com/` -- Audience must match: `https://api.example.com` -- Token must not be expired - -### Customize JWT Settings - -Edit `conf/config.yml`: - -```yaml -plugin_config: - jwt_validator: - algorithm: RS256 - issuer: https://your-auth-server.com/ - audience: https://your-api.com - pubkey_path: /certs/haproxy/jwt_pubkey.pem -``` - -### Clean Up - -```bash -docker compose down -docker stop container && docker rm container -``` - ---- - -## Configuration File Reference +## Configuration Files All scenarios use `/etc/haproxy/static/config.yml` mounted from `./conf/config.yml`. -### Basic Structure +Choose one of these pre-made configurations: + +| Configuration File | Description | +|----------------------------|-------------------------------------------------| +| `config-basic.yml` | Simple HTTP→HTTPS redirect with SSL termination | +| `config-certbot.yml` | Let's Encrypt SSL (requires public domain) | +| `config-deny-pages.yml` | Block specific paths (e.g., `/admin`, `/.env`) | +| `config-jwt-validator.yml` | JWT token validation for API authentication | + +## Prerequisites + +- SSL certificates generated (`./examples/generate-keys.sh`) +- `/etc/hosts` entry for `host1.local` +- Backend container running on port 8080 + +See header comments in [docker-compose.yml](docker-compose.yml) for detailed setup. + +## Documentation Structure + +The docker-compose.yml file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Commands to start backend and EasyHAProxy +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to stop and remove resources + +## Example Workflow + +```bash +# 1. Generate certificates +cd ../.. && ./examples/generate-keys.sh && cd examples/static + +# 2. Choose a configuration +cp conf/config-basic.yml conf/config.yml + +# 3. Start backend +docker run -d --name container -p 8080:8080 byjg/static-httpserver + +# 4. Start EasyHAProxy +docker compose up -d + +# 5. Test +curl -k https://host1.local/ + +# 6. Clean up +docker compose down +docker stop container && docker rm container +``` + +## Configuration File Reference + +Basic structure of `config.yml`: ```yaml stats: @@ -314,111 +79,19 @@ stats: password: password port: 1936 -customerrors: true - -easymapping: - - port: 80 - redirect: - host1.local: https://host1.local - - - port: 443 - ssl: true - hosts: - host1.local: - containers: - - container:8080 -``` - -### With Plugins - -```yaml easymapping: - port: 443 ssl: true hosts: host1.local: containers: - - container:8080 - plugins: - - deny_pages - plugin_config: - deny_pages: - paths: /admin,/private - status_code: 404 + - container:8080 # Can also be IP:PORT for external backends ``` ---- +See `conf/` directory for complete examples. -## Advanced: Multiple Backends - -Load balance across multiple containers: - -```yaml -hosts: - api.example.com: - containers: - - api1:8080 - - api2:8080 - - api3:8080 -``` - ---- - -## Advanced: External Backends - -Route to non-Docker backends: - -```yaml -hosts: - legacy.example.com: - containers: - - 192.168.1.100:8080 - - 192.168.1.101:8080 -``` - ---- - -## Troubleshooting - -### FileNotFoundError: config.yml - -```bash -# Make sure config.yml exists -ls conf/config.yml - -# If missing, copy from an example: -cp conf/config-basic.yml conf/config.yml -``` - -### 503 Service Unavailable - -```bash -# Check backend is running -docker ps | grep container -curl http://localhost:8080 -``` - -### SSL Certificate Not Found - -```bash -# Verify certificate exists -ls -la host1.local.pem - -# Regenerate if needed -cd ../.. && ./examples/generate-keys.sh -``` - -### Changes Not Applied - -```bash -# Restart to reload config -docker compose restart -``` - ---- - -## Further Reading +## Additional Documentation - [Static Configuration Guide](../../docs/static.md) -- [Using Plugins](../../docs/plugins.md) +- [Using Plugins](../../docs/plugins/) - [Environment Variables](../../docs/environment-variable.md) diff --git a/examples/static/docker-compose.yml b/examples/static/docker-compose.yml index 49c1dc8..92f9966 100644 --- a/examples/static/docker-compose.yml +++ b/examples/static/docker-compose.yml @@ -1,5 +1,62 @@ -# To test: +# ============================================================================== +# EXAMPLE: Static Configuration Mode +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - EasyHAProxy using static YAML configuration (no service discovery) +# - Useful for non-containerized backends, VMs, or bare metal servers +# - Configuration via /etc/haproxy/static/config.yml +# +# REQUIREMENTS (run these first): +# ```bash +# # Generate SSL certificates +# cd ../.. && ./examples/generate-keys.sh && cd examples/static +# +# # Add to /etc/hosts (idempotent) +# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local" | sudo tee -a /etc/hosts +# +# # Copy a configuration file (choose one): +# cp conf/config-basic.yml conf/config.yml # Basic HTTP→HTTPS redirect +# # OR +# cp conf/config-certbot.yml conf/config.yml # Let's Encrypt (requires public domain) +# # OR +# cp conf/config-deny-pages.yml conf/config.yml # Block specific paths +# # OR +# cp conf/config-jwt-validator.yml conf/config.yml # JWT authentication +# ``` +# +# HOW TO START: +# ```bash +# # Start backend container +# docker run -d --name container -p 8080:8080 byjg/static-httpserver +# +# # Start EasyHAProxy +# docker compose up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test HTTPS # curl -k -H "Host: host1.local" https://127.0.0.1/ +# # Expected: 200 OK with "Hello from Static HTTP Server!" +# +# # Test HTTP redirect (if using basic config) +# curl -I -H "Host: host1.local" http://127.0.0.1 +# # Expected: HTTP/1.1 301 Moved Permanently +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose down +# docker stop container && docker rm container +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/swarm/README.md b/examples/swarm/README.md index f0fce59..64d67ef 100644 --- a/examples/swarm/README.md +++ b/examples/swarm/README.md @@ -1,170 +1,52 @@ # Docker Swarm Examples -This directory contains Docker Swarm stack examples demonstrating EasyHAProxy in a Swarm cluster environment. - -## What is Docker Swarm Mode? - -Docker Swarm mode enables: -- **Service orchestration** across multiple nodes -- **Service scaling** with replicas -- **Load balancing** across service replicas -- **Rolling updates** with zero downtime -- **Service discovery** via overlay networks - -EasyHAProxy automatically discovers Swarm services and routes traffic based on service labels. - ---- - -## Prerequisites - -### 1. Initialize Docker Swarm - -```bash -# On manager node -docker swarm init - -# On worker nodes (use token from swarm init output) -docker swarm join --token :2377 -``` - -### 2. Create Overlay Network - -```bash -# Create attachable overlay network for EasyHAProxy -docker network create --driver overlay --attachable easyhaproxy -``` - -**Why attachable?** Allows both swarm services and standalone containers to connect. - ---- - -## Files in This Directory - -- `easyhaproxy.yml` - EasyHAProxy service stack -- `services.yml` - Example application services -- `portainer.yml` - Portainer management interface -- `certs/` - Directory for SSL certificates - ---- - -## Prerequisites: Generate SSL Certificates - -**IMPORTANT:** Before running any examples, you must generate the required SSL certificates: - -```bash -# From the repository root -./examples/generate-keys.sh -``` - -This script automatically generates: -- SSL certificates for host1.local and host2.local (placed in `examples/swarm/certs/`) -- JWT keys for authentication examples -- All other .pem files needed for testing - -**Note:** These are self-signed certificates for testing only. Do not use in production. - ---- +Self-contained examples for EasyHAProxy in Docker Swarm mode. **All documentation is in the YAML files as header comments.** ## Quick Start -### 1. Deploy EasyHAProxy +1. Pick an example below +2. Open the YAML file +3. Read the header comments for complete instructions +4. Run the commands step-by-step -```bash -cd examples/swarm +## Prerequisites -# Edit easyhaproxy.yml and change: -# EASYHAPROXY_CERTBOT_EMAIL: your-email@example.com +All examples require: +- Docker Swarm initialized (`docker swarm init`) +- Overlay network created (`docker network create --driver overlay --attachable easyhaproxy`) +- EasyHAProxy deployed (`docker stack deploy -c easyhaproxy.yml easyhaproxy`) -# Deploy stack -docker stack deploy -c easyhaproxy.yml easyhaproxy -``` +See header comments in each file for detailed setup instructions. -**What this creates:** -- EasyHAProxy service with 1 replica -- Exposed ports: 80, 443, 1936 -- Mounts Docker socket for service discovery -- Mounts volume for certbot certificates +## Basic Examples -### 2. Deploy Example Services +| File | Description | +|------|-------------| +| [easyhaproxy.yml](easyhaproxy.yml) | EasyHAProxy service for Swarm with stats and certbot | +| [services.yml](services.yml) | Basic services with SSL (embedded cert and file-based) | +| [portainer.yml](portainer.yml) | Portainer management UI behind EasyHAProxy | -```bash -docker stack deploy -c services.yml myapp -``` +## Plugin Examples -### 3. (Optional) Deploy Portainer +| File | Description | +|------|-------------| +| [jwt-validator.yml](jwt-validator.yml) | JWT token validation for API protection | +| [ip-whitelist.yml](ip-whitelist.yml) | IP whitelist for admin panels or sensitive services | +| [cloudflare.yml](cloudflare.yml) | Restore real client IPs when behind Cloudflare CDN | +| [plugins-combined.yml](plugins-combined.yml) | Multiple plugins combined for layered security | -```bash -docker stack deploy -c portainer.yml portainer -``` +## Documentation Structure ---- +Each YAML file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Command to deploy the stack +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to remove resources -## Example Files Explained +## Important: Service Labels -### easyhaproxy.yml - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock # Service discovery - - ./certs:/certs/haproxy # SSL certificates - - certs_certbot:/certs/certbot # Let's Encrypt certs - deploy: - replicas: 1 # Single instance - environment: - EASYHAPROXY_DISCOVER: swarm # Swarm mode! - EASYHAPROXY_SSL_MODE: "loose" - EASYHAPROXY_CERTBOT_EMAIL: changeme@example.org # Change this! - HAPROXY_CUSTOMERRORS: "true" - HAPROXY_USERNAME: admin - HAPROXY_PASSWORD: password - HAPROXY_STATS_PORT: 1936 - ports: - - "80:80/tcp" - - "443:443/tcp" - - "1936:1936/tcp" - networks: - - easyhaproxy # Overlay network - -networks: - easyhaproxy: - external: true # Created separately - -volumes: - certs_certbot: # Persistent certbot data -``` - -**Key differences from Docker Compose mode:** -- `EASYHAPROXY_DISCOVER: swarm` - Discovery mode -- `deploy.replicas: 1` - Swarm deployment config -- External overlay network - ---- - -## Service Labels in Swarm - -Service labels are similar to container labels but applied to **services**, not containers. - -### Basic Service Example - -```yaml -services: - webapp: - image: nginx:alpine - deploy: - replicas: 3 # 3 instances for load balancing - labels: - # Service labels (not container labels!) - easyhaproxy.http.host: "webapp.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "80" - networks: - - easyhaproxy -``` - -**Important:** Use `deploy.labels`, NOT top-level `labels`! +In Swarm mode, labels must be under `deploy.labels`, NOT top-level `labels`: ```yaml # ✅ CORRECT - Service labels @@ -177,760 +59,9 @@ labels: easyhaproxy.http.host: example.com ``` ---- +## Additional Documentation -## Common Use Cases - -### Use Case 1: Simple HTTP Service - -```yaml -services: - myapp: - image: my-app:latest - deploy: - replicas: 3 - labels: - easyhaproxy.http.host: "myapp.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "3000" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true -``` - -Deploy: -```bash -docker stack deploy -c myapp.yml myapp -``` - -### Use Case 2: HTTPS with Let's Encrypt - -```yaml -services: - secure-app: - image: secure-app:latest - deploy: - replicas: 2 - labels: - easyhaproxy.http.host: "secure.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - easyhaproxy.http.certbot: "true" - easyhaproxy.http.redirect_ssl: "true" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true -``` - -**Requirements:** -- Public IP with DNS pointing to swarm -- Ports 80/443 open -- Certbot email configured in `easyhaproxy.yml` - -### Use Case 3: Multiple Domains, One Service - -```yaml -services: - webapp: - image: webapp:latest - deploy: - replicas: 4 - labels: - # Primary domain - easyhaproxy.http.host: "example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - - # Additional domain (www) - easyhaproxy.http2.host: "www.example.com" - easyhaproxy.http2.port: "80" - easyhaproxy.http2.localport: "8080" - - # API subdomain - easyhaproxy.api.host: "api.example.com" - easyhaproxy.api.port: "80" - easyhaproxy.api.localport: "8080" - networks: - - easyhaproxy -``` - -### Use Case 4: Service with Plugins - -```yaml -services: - api: - image: api-server:latest - deploy: - replicas: 3 - labels: - easyhaproxy.http.host: "api.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - # Enable plugins - easyhaproxy.http.plugins: "jwt_validator,deny_pages" - # Configure JWT validator - easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" - easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" - easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api.pem" - # Configure deny_pages - easyhaproxy.http.plugin.deny_pages.paths: "/admin,/private" - easyhaproxy.http.plugin.deny_pages.status_code: "403" - networks: - - easyhaproxy -``` - -### Use Case 5: Multiple Services with Load Balancing - -```yaml -services: - frontend: - image: frontend-app:latest - deploy: - replicas: 2 - labels: - easyhaproxy.http.host: "example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "3000" - networks: - - easyhaproxy - - api: - image: api-server:latest - deploy: - replicas: 5 # More replicas for API - labels: - easyhaproxy.http.host: "api.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - networks: - - easyhaproxy - - admin: - image: admin-panel:latest - deploy: - replicas: 1 - labels: - easyhaproxy.http.host: "admin.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "4000" - # Restrict access - easyhaproxy.http.plugins: "ip_whitelist" - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true -``` - ---- - -## Plugin Examples - -### JWT Validator Plugin - -Secure your API with JWT token validation in Swarm: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - jwt_keys:/etc/haproxy/jwt_keys - deploy: - replicas: 1 - environment: - EASYHAPROXY_DISCOVER: swarm - ports: - - "80:80/tcp" - - "443:443/tcp" - networks: - - easyhaproxy - - api: - image: my-api:latest - deploy: - replicas: 5 - labels: - easyhaproxy.http.host: "api.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - # Enable JWT validation - easyhaproxy.http.plugins: "jwt_validator" - easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" - easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" - easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" - easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true - -volumes: - jwt_keys: -``` - -**Deploy public key using Docker config:** -```bash -# Create Docker config with public key -docker config create jwt_api_pubkey ./api_pubkey.pem - -# Update EasyHAProxy service to use config -docker service update \ - --config-add source=jwt_api_pubkey,target=/etc/haproxy/jwt_keys/api_pubkey.pem \ - easyhaproxy_haproxy -``` - -**Test:** -```bash -# Without token -curl http://api.example.com/users -# Response: Missing Authorization HTTP header - -# With valid token -curl -H "Authorization: Bearer eyJhbGc..." http://api.example.com/users -# Response: Success -``` - ---- - -### Cloudflare IP Restoration Plugin - -Restore original visitor IPs in Swarm environment: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - configs: - - source: cloudflare_ips - target: /etc/haproxy/cloudflare_ips.lst - deploy: - replicas: 1 - environment: - EASYHAPROXY_DISCOVER: swarm - ports: - - "80:80/tcp" - - "443:443/tcp" - networks: - - easyhaproxy - - webapp: - image: webapp:latest - deploy: - replicas: 3 - labels: - easyhaproxy.http.host: "myapp.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - # Enable Cloudflare plugin - easyhaproxy.http.plugins: "cloudflare" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true - -configs: - cloudflare_ips: - file: ./cloudflare_ips.lst -``` - -**Create Cloudflare IP list:** -```bash -# Download Cloudflare IPs -curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst - -# Deploy stack -docker stack deploy -c cloudflare-stack.yml myapp -``` - ---- - -### IP Whitelist Plugin - -Restrict admin panel to specific IPs in Swarm: - -```yaml -services: - admin: - image: admin-panel:latest - deploy: - replicas: 2 - labels: - easyhaproxy.http.host: "admin.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "4000" - # Enable IP whitelist - easyhaproxy.http.plugins: "ip_whitelist" - # Allow office network and VPN - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.0/24,10.8.0.0/16" - easyhaproxy.http.plugin.ip_whitelist.status_code: "403" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true -``` - -**Test:** -```bash -# From office IP (203.0.113.50) -curl http://admin.example.com -# Response: Success - -# From home/blocked IP -curl http://admin.example.com -# Response: HTTP 403 Forbidden -``` - ---- - -### Multiple Plugins Combined - -Production-ready setup with multiple security layers: - -```yaml -services: - haproxy: - image: byjg/easy-haproxy:4.6.0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - configs: - - source: cloudflare_ips - target: /etc/haproxy/cloudflare_ips.lst - - source: jwt_pubkey - target: /etc/haproxy/jwt_keys/api_pubkey.pem - deploy: - replicas: 1 - placement: - constraints: - - node.role == manager - environment: - EASYHAPROXY_DISCOVER: swarm - EASYHAPROXY_SSL_MODE: "loose" - EASYHAPROXY_CERTBOT_EMAIL: admin@example.com - ports: - - "80:80/tcp" - - "443:443/tcp" - - "1936:1936/tcp" - networks: - - easyhaproxy - - # Public website with Cloudflare - website: - image: website:latest - deploy: - replicas: 4 - labels: - easyhaproxy.http.host: "example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "3000" - easyhaproxy.http.certbot: "true" - easyhaproxy.http.redirect_ssl: "true" - # Cloudflare + block sensitive paths - easyhaproxy.http.plugins: "cloudflare,deny_pages" - easyhaproxy.http.plugin.deny_pages.paths: "/admin,/.env,/config" - easyhaproxy.http.plugin.deny_pages.status_code: "404" - networks: - - easyhaproxy - - # Authenticated API with JWT - api: - image: api:latest - deploy: - replicas: 6 - labels: - easyhaproxy.http.host: "api.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "8080" - easyhaproxy.http.certbot: "true" - # Cloudflare + JWT + block internal endpoints - easyhaproxy.http.plugins: "cloudflare,jwt_validator,deny_pages" - easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" - easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" - easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" - easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" - easyhaproxy.http.plugin.deny_pages.paths: "/internal,/metrics" - networks: - - easyhaproxy - - # Admin panel with strict IP restrictions - admin: - image: admin:latest - deploy: - replicas: 2 - labels: - easyhaproxy.http.host: "admin.example.com" - easyhaproxy.http.port: "80" - easyhaproxy.http.localport: "4000" - easyhaproxy.http.certbot: "true" - # IP whitelist only (no public access) - easyhaproxy.http.plugins: "ip_whitelist" - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24" - easyhaproxy.http.plugin.ip_whitelist.status_code: "403" - networks: - - easyhaproxy - -networks: - easyhaproxy: - external: true - -configs: - cloudflare_ips: - file: ./cloudflare_ips.lst - jwt_pubkey: - file: ./api_pubkey.pem -``` - -**Deploy:** -```bash -docker stack deploy -c production-stack.yml production -``` - -**Security layers:** -- **Website**: Cloudflare IP restoration + path blocking -- **API**: Cloudflare + JWT validation + internal path blocking -- **Admin**: Strict IP whitelist (office network only) - ---- - -## Scaling Services - -Scale services dynamically: - -```bash -# Scale up -docker service scale myapp_webapp=10 - -# Scale down -docker service scale myapp_webapp=2 - -# Check replicas -docker service ls -``` - -EasyHAProxy automatically detects all replicas and load balances across them. - ---- - -## Rolling Updates - -Update services with zero downtime: - -```bash -# Update service image -docker service update --image webapp:v2 myapp_webapp - -# Update with custom settings -docker service update \ - --image webapp:v2 \ - --update-parallelism 2 \ - --update-delay 10s \ - myapp_webapp -``` - -EasyHAProxy continues routing to healthy containers during rollout. - ---- - -## Management Commands - -### View Stacks - -```bash -docker stack ls -``` - -### View Services in Stack - -```bash -docker stack services myapp -``` - -### View Service Details - -```bash -docker service inspect myapp_webapp -``` - -### View Service Logs - -```bash -docker service logs -f myapp_webapp -``` - -### Update Service Labels - -```bash -docker service update \ - --label-add easyhaproxy.http.certbot=true \ - myapp_webapp -``` - -### Remove Stack - -```bash -docker stack rm myapp -``` - ---- - -## SSL Certificates in Swarm - -### Option 1: Let's Encrypt (Recommended) - -Configure in `easyhaproxy.yml`: -```yaml -environment: - EASYHAPROXY_CERTBOT_EMAIL: your-email@example.com -``` - -Enable per-service: -```yaml -deploy: - labels: - easyhaproxy.http.certbot: "true" -``` - -### Option 2: Custom Certificates - -Mount certificates directory: -```yaml -# easyhaproxy.yml -volumes: - - ./certs:/certs/haproxy -``` - -Place certificate files: -```bash -./certs/ - ├── example.com.pem - ├── api.example.com.pem - └── secure.example.com.pem -``` - -### Option 3: Docker Secrets (Production) - -```bash -# Create secret -docker secret create example_com_cert ./example.com.pem - -# Use in stack - -services: - haproxy: - secrets: - - example_com_cert - environment: - EASYHAPROXY_SSL_CERT_example_com: /run/secrets/example_com_cert - -secrets: - example_com_cert: - external: true -``` - ---- - -## Monitoring and Stats - -### HAProxy Stats Interface - -Access at: `http://:1936` -- Username: `admin` (configured in `easyhaproxy.yml`) -- Password: `password` (configured in `easyhaproxy.yml`) - -### Service Health - -```bash -# Check service health -docker service ps myapp_webapp - -# View detailed service info -docker service inspect --pretty myapp_webapp -``` - ---- - -## Troubleshooting - -### Service Not Detected - -**Check service labels:** -```bash -docker service inspect myapp_webapp | grep -A 20 Labels -``` - -Ensure labels are under `deploy.labels`, not top-level `labels`. - -**Check EasyHAProxy logs:** -```bash -docker service logs -f easyhaproxy_haproxy -``` - -### Service Unreachable (503) - -**Causes:** -- Service containers not ready yet -- Wrong network configuration -- Service crashed - -**Debug:** -```bash -# Check service is running -docker service ps myapp_webapp - -# Check network -docker network inspect easyhaproxy - -# Test service directly -docker run --rm --network easyhaproxy alpine \ - wget -O- http://myapp_webapp:8080 -``` - -### Overlay Network Issues - -**Create network if missing:** -```bash -docker network create --driver overlay --attachable easyhaproxy -``` - -**Verify service is on network:** -```bash -docker service inspect myapp_webapp | grep -A 5 Networks -``` - -### EasyHAProxy Not Starting - -**Check Docker socket permissions:** -```bash -docker service logs easyhaproxy_haproxy -``` - -**Verify socket is mounted:** -```bash -docker service inspect easyhaproxy_haproxy | grep -A 5 Mounts -``` - -### Certificate Issues - -**Certbot fails:** -- Ensure swarm is publicly accessible -- Check DNS points to swarm IP -- Verify ports 80/443 are open -- Check certbot logs: `docker service logs easyhaproxy_haproxy | grep certbot` - -**Custom cert not found:** -```bash -# Exec into service container -docker exec -it $(docker ps -q -f name=easyhaproxy) sh -ls -la /certs/haproxy/ -``` - ---- - -## High Availability Setup - -### Multiple Manager Nodes - -```bash -# On additional manager nodes -docker swarm join-token manager -# Use token on new nodes -``` - -### EasyHAProxy Constraints - -Run EasyHAProxy on specific node: - -```yaml -services: - haproxy: - deploy: - placement: - constraints: - - node.role == manager - - node.labels.haproxy == true -``` - -Label node: -```bash -docker node update --label-add haproxy=true -``` - -### Multiple EasyHAProxy Replicas - -**Not recommended** - EasyHAProxy should run as single instance because: -- Multiple instances would compete for port binding -- Use external load balancer (cloud LB, keepalived, etc.) for HA - -**Alternative HA pattern:** -``` -Internet → Cloud Load Balancer → Multiple Swarm Nodes - └→ EasyHAProxy (runs on 1 node) - └→ Services (distributed across nodes) -``` - ---- - -## Best Practices - -1. **Use Overlay Networks:** - - Create dedicated network for EasyHAProxy - - Use `--attachable` for flexibility - -2. **Service Labels:** - - Always use `deploy.labels`, never top-level `labels` - - Use clear, descriptive domain names - -3. **Replicas:** - - Start with 2-3 replicas per service - - Scale based on load monitoring - - Use odd number for consensus (3, 5, 7) - -4. **Updates:** - - Use rolling updates for zero downtime - - Set appropriate `update-delay` - - Test in staging first - -5. **Monitoring:** - - Enable HAProxy stats - - Use Portainer for visual management - - Monitor service health regularly - -6. **Security:** - - Use Docker secrets for sensitive data - - Restrict admin panel access - - Use SSL/TLS for production - - Apply IP whitelisting for admin interfaces - -7. **Persistence:** - - Use volumes for certbot certificates - - Backup certificate volumes - - Store custom certs in version control (encrypted) - ---- - -## Further Reading - -- [Docker Swarm Documentation](../../docs/swarm.md) +- [Docker Swarm Guide](../../docs/swarm.md) - [Container Labels Reference](../../docs/container-labels.md) -- [Using Plugins](../../docs/plugins.md) +- [Using Plugins](../../docs/plugins/) - [ACME/Let's Encrypt](../../docs/acme.md) -- [Environment Variables](../../docs/environment-variable.md) -- [Official Docker Swarm Docs](https://docs.docker.com/engine/swarm/) diff --git a/examples/swarm/cloudflare.yml b/examples/swarm/cloudflare.yml index f3c1520..5a0be1e 100644 --- a/examples/swarm/cloudflare.yml +++ b/examples/swarm/cloudflare.yml @@ -1,26 +1,69 @@ -# Cloudflare IP Restoration Plugin Example for Docker Swarm +# ============================================================================== +# EXAMPLE: Cloudflare IP Restoration (Swarm) +# ============================================================================== # -# This example demonstrates restoring original visitor IPs when using Cloudflare CDN +# WHAT THIS DEMONSTRATES: +# - Restoring original visitor IPs when behind Cloudflare CDN +# - Using Docker configs to manage Cloudflare IP lists +# - Service discovery in Swarm mode with plugins +# - Load balancing across multiple replicas # -# Prerequisites: -# 1. Docker Swarm initialized: -# docker swarm init +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init # -# 2. Create overlay network: -# docker network create --driver overlay --attachable easyhaproxy +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy # -# 3. Download Cloudflare IPs and create Docker config: -# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst -# docker config create cloudflare_ips cloudflare_ips.lst +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy # -# 4. Deploy the stack: -# docker stack deploy -c cloudflare.yml webapp +# # Download Cloudflare IP ranges and create Docker config +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# docker config create cloudflare_ips cloudflare_ips.lst +# rm cloudflare_ips.lst # -# 5. Test: -# curl http:/// +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "myapp.example.com" /etc/hosts || echo "127.0.0.1 myapp.example.com" | sudo tee -a /etc/hosts +# ``` # -# Note: This plugin is most useful when your site is actually behind Cloudflare. +# HOW TO START: +# ```bash +# docker stack deploy -c cloudflare.yml webapp +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep webapp +# # Expected: webapp stack listed +# +# # Check service is running +# docker service ls | grep webapp_webapp +# # Expected: webapp_webapp with 4/4 replicas +# +# # Test the application +# curl -H "Host: myapp.example.com" http://localhost/ +# # Expected: 200 OK with "App Behind Cloudflare" +# +# # Check HAProxy config includes Cloudflare IPs +# docker exec $(docker ps -q -f name=easyhaproxy_haproxy) cat /etc/haproxy/haproxy.cfg | grep -A 5 "cloudflare" +# # Expected: ACL rules for Cloudflare IP ranges +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm webapp +# # To also remove the Cloudflare IPs config: +# # docker config rm cloudflare_ips +# ``` +# +# NOTE: This plugin is most useful when your site is actually behind Cloudflare CDN. +# The plugin uses the CF-Connecting-IP header to restore the original visitor IP. +# +# ============================================================================== version: "3.7" diff --git a/examples/swarm/easyhaproxy.yml b/examples/swarm/easyhaproxy.yml index cb9e967..0265571 100644 --- a/examples/swarm/easyhaproxy.yml +++ b/examples/swarm/easyhaproxy.yml @@ -1,6 +1,55 @@ -# To Install -# docker network create --driver overlay --attachable easyhaproxy +# ============================================================================== +# EXAMPLE: EasyHAProxy for Docker Swarm +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - EasyHAProxy running in Swarm mode with service discovery +# - HAProxy stats interface +# - Let's Encrypt/Certbot support +# - Shared overlay network for services +# +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init +# +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy +# +# # Edit this file and change: +# # Line 18: EASYHAPROXY_CERTBOT_EMAIL to your email +# ``` +# +# HOW TO START: +# ```bash # docker stack deploy -c easyhaproxy.yml easyhaproxy +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls +# # Expected: easyhaproxy stack listed +# +# # Check service is running +# docker service ls +# # Expected: easyhaproxy_haproxy with 1/1 replicas +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# +# # Check logs +# docker service logs -f easyhaproxy_haproxy +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm easyhaproxy +# ``` +# +# ============================================================================== services: diff --git a/examples/swarm/ip-whitelist.yml b/examples/swarm/ip-whitelist.yml index 829925b..95c56ba 100644 --- a/examples/swarm/ip-whitelist.yml +++ b/examples/swarm/ip-whitelist.yml @@ -1,25 +1,65 @@ -# IP Whitelist Plugin Example for Docker Swarm +# ============================================================================== +# EXAMPLE: IP Whitelist Plugin (Swarm) +# ============================================================================== # -# This example demonstrates restricting access to specific IP addresses in Swarm +# WHAT THIS DEMONSTRATES: +# - Restricting access to specific IP addresses/networks +# - IP-based access control for admin panels or sensitive services +# - Returning custom status codes for blocked IPs +# - Service discovery in Swarm mode with plugins # -# Prerequisites: -# 1. Docker Swarm initialized: -# docker swarm init +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init # -# 2. Create overlay network: -# docker network create --driver overlay --attachable easyhaproxy +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy # -# 3. Update allowed_ips label with your actual IP addresses/networks +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy # -# 4. Deploy the stack: -# docker stack deploy -c ip-whitelist.yml admin +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "admin.example.com" /etc/hosts || echo "127.0.0.1 admin.example.com" | sudo tee -a /etc/hosts # -# 5. Test from allowed IP: -# curl http:/// -# # Response: Success (200 OK) +# # IMPORTANT: Edit this file (ip-whitelist.yml) line 64 to add your actual IP addresses! +# # Get your current IP: curl ifconfig.me +# ``` # -# 6. Test from non-allowed IP: -# # Response: HTTP 403 Forbidden +# HOW TO START: +# ```bash +# docker stack deploy -c ip-whitelist.yml admin +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep admin +# # Expected: admin stack listed +# +# # Check service is running +# docker service ls | grep admin_admin +# # Expected: admin_admin with 3/3 replicas +# +# # Test from allowed IP (assumes 127.0.0.1 or your IP is in the whitelist) +# curl -H "Host: admin.example.com" http://localhost/ +# # Expected: 200 OK with "Admin Panel - IP Restricted" +# +# # Test from blocked IP (using a different IP via proxy or VPN) +# # Expected: HTTP 403 Forbidden +# +# # View HAProxy stats to see IP whitelist rules +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm admin +# ``` +# +# ============================================================================== version: "3.7" diff --git a/examples/swarm/jwt-validator.yml b/examples/swarm/jwt-validator.yml index f225f52..568e89b 100644 --- a/examples/swarm/jwt-validator.yml +++ b/examples/swarm/jwt-validator.yml @@ -1,35 +1,78 @@ -# JWT Validator Plugin Example for Docker Swarm +# ============================================================================== +# EXAMPLE: JWT Validator Plugin (Swarm) +# ============================================================================== # -# This example demonstrates JWT token validation for API protection in Swarm +# WHAT THIS DEMONSTRATES: +# - JWT token validation for API authentication +# - Using Docker configs to manage JWT public keys +# - Validating issuer, audience, and expiration claims +# - Service discovery in Swarm mode with plugins # -# Prerequisites: -# 1. Docker Swarm initialized: -# docker swarm init +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init # -# 2. Create overlay network: -# docker network create --driver overlay --attachable easyhaproxy +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy # -# 3. Generate JWT keys and create Docker config: -# openssl genrsa -out jwt_private.pem 2048 -# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem -# docker config create jwt_api_pubkey jwt_pubkey.pem +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy # -# 4. Deploy the stack: -# docker stack deploy -c jwt-validator.yml api +# # Generate JWT key pair (RS256 algorithm) +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem # -# 5. Test without token (should fail): -# curl http:/// -# # Response: Missing Authorization HTTP header +# # Create Docker config with public key +# docker config create jwt_api_pubkey jwt_pubkey.pem # -# 6. Generate test JWT at https://jwt.io with: -# - Algorithm: RS256 -# - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} -# - Use your jwt_private.pem for signing +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "api.example.com" /etc/hosts || echo "127.0.0.1 api.example.com" | sudo tee -a /etc/hosts +# ``` # -# 7. Test with token: -# TOKEN="eyJhbGc..." -# curl -H "Authorization: Bearer $TOKEN" http:/// -# # Response: Success +# HOW TO START: +# ```bash +# docker stack deploy -c jwt-validator.yml api +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep api +# # Expected: api stack listed +# +# # Check service is running +# docker service ls | grep api_api +# # Expected: api_api with 5/5 replicas +# +# # Test without token (should fail) +# curl -H "Host: api.example.com" http://localhost/ +# # Expected: HTTP 401 with "Missing Authorization HTTP header" +# +# # Generate test JWT at https://jwt.io with: +# # - Algorithm: RS256 +# # - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# # - Use your jwt_private.pem content in "Verify Signature" section +# +# # Test with valid token (replace TOKEN with your JWT) +# TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +# curl -H "Host: api.example.com" -H "Authorization: Bearer $TOKEN" http://localhost/ +# # Expected: 200 OK with "Protected API - JWT Required" +# +# # Test with invalid/expired token +# curl -H "Host: api.example.com" -H "Authorization: Bearer invalid_token" http://localhost/ +# # Expected: HTTP 401 with error message +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm api +# # To also remove the JWT public key config and generated keys: +# # docker config rm jwt_api_pubkey +# # rm jwt_private.pem jwt_pubkey.pem +# ``` +# +# ============================================================================== version: "3.7" diff --git a/examples/swarm/plugins-combined.yml b/examples/swarm/plugins-combined.yml index c9c1f59..b48c493 100644 --- a/examples/swarm/plugins-combined.yml +++ b/examples/swarm/plugins-combined.yml @@ -1,31 +1,93 @@ -# Multiple Plugins Combined Example for Docker Swarm +# ============================================================================== +# EXAMPLE: Multiple Plugins Combined (Swarm) +# ============================================================================== # -# This example demonstrates using multiple plugins together for enhanced security +# WHAT THIS DEMONSTRATES: +# - Using multiple plugins together for layered security +# - Three different security profiles for different service types: +# * Public website: Cloudflare IP restoration + path blocking +# * Protected API: JWT authentication + path blocking +# * Admin panel: Strict IP whitelist +# - Complex production-ready security configuration # -# Prerequisites: -# 1. Docker Swarm initialized: -# docker swarm init +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init # -# 2. Create overlay network: -# docker network create --driver overlay --attachable easyhaproxy +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy # -# 3. Generate JWT keys and create Docker config: -# openssl genrsa -out jwt_private.pem 2048 -# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem -# docker config create jwt_api_pubkey jwt_pubkey.pem +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy # -# 4. Download Cloudflare IPs and create Docker config: -# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst -# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst -# docker config create cloudflare_ips cloudflare_ips.lst +# # Generate JWT key pair (RS256 algorithm) +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# docker config create jwt_api_pubkey jwt_pubkey.pem # -# 5. Deploy the stack: -# docker stack deploy -c plugins-combined.yml production +# # Download Cloudflare IP ranges and create Docker config +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# docker config create cloudflare_ips cloudflare_ips.lst +# rm cloudflare_ips.lst jwt_private.pem jwt_pubkey.pem # -# This creates three services with different security profiles: -# - Public website: Cloudflare + path blocking -# - Protected API: JWT validation + path blocking -# - Admin panel: Strict IP whitelist +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "website.example.com" /etc/hosts || echo "127.0.0.1 website.example.com api.example.com admin.example.com" | sudo tee -a /etc/hosts +# +# # IMPORTANT: Edit this file (plugins-combined.yml) line 124 to add your actual IP! +# ``` +# +# HOW TO START: +# ```bash +# docker stack deploy -c plugins-combined.yml production +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep production +# # Expected: production stack listed +# +# # Check all services are running +# docker service ls | grep production +# # Expected: 3 services (website, api, admin) with all replicas running +# +# # Test public website (Cloudflare + deny_pages) +# curl -H "Host: website.example.com" http://localhost/ +# # Expected: 200 OK with "Public Website" +# curl -H "Host: website.example.com" http://localhost/admin +# # Expected: HTTP 404 (blocked by deny_pages) +# +# # Test protected API (JWT + deny_pages) +# curl -H "Host: api.example.com" http://localhost/ +# # Expected: HTTP 401 with "Missing Authorization HTTP header" +# +# # Generate JWT at https://jwt.io (see jwt-validator.yml for details) +# TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +# curl -H "Host: api.example.com" -H "Authorization: Bearer $TOKEN" http://localhost/ +# # Expected: 200 OK with "Protected API" +# curl -H "Host: api.example.com" -H "Authorization: Bearer $TOKEN" http://localhost/internal +# # Expected: HTTP 403 (blocked by deny_pages) +# +# # Test admin panel (IP whitelist) +# curl -H "Host: admin.example.com" http://localhost/ +# # Expected: 200 OK if your IP is whitelisted, 403 otherwise +# +# # View HAProxy stats to see all plugin configurations +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm production +# # To also remove Docker configs: +# # docker config rm cloudflare_ips jwt_api_pubkey +# ``` +# +# ============================================================================== version: "3.7" diff --git a/examples/swarm/portainer.yml b/examples/swarm/portainer.yml index aa4f4fc..2061a02 100644 --- a/examples/swarm/portainer.yml +++ b/examples/swarm/portainer.yml @@ -1,5 +1,46 @@ -# To install: +# ============================================================================== +# EXAMPLE: Portainer Behind EasyHAProxy (Swarm) +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Running Portainer management UI in Swarm mode +# - Service discovery with EasyHAProxy +# - Using persistent volumes for Portainer data +# +# REQUIREMENTS (run these first): +# ```bash +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Add to /etc/hosts (idempotent) +# grep -q "portainer.local" /etc/hosts || echo "127.0.0.1 portainer.local" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash # docker stack deploy -c portainer.yml portainer +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check service is running +# docker service ls | grep portainer +# # Expected: portainer_portainer with 1/1 replicas +# +# # Access Portainer +# curl http://portainer.local +# # Or open in browser: http://portainer.local +# # First time: Create admin user +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm portainer +# # To also remove data volume: +# # docker volume rm portainer_portainer_data +# ``` +# +# ============================================================================== services: portainer: diff --git a/examples/swarm/services.yml b/examples/swarm/services.yml index fc9ac69..28c6b4f 100644 --- a/examples/swarm/services.yml +++ b/examples/swarm/services.yml @@ -1,22 +1,58 @@ -# To install: +# ============================================================================== +# EXAMPLE: Basic Swarm Services with SSL +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Basic Swarm services with SSL configuration +# - HTTP to HTTPS redirect +# - Two services with different SSL setups (embedded cert vs SSL file) +# - Using deploy.labels for service discovery in Swarm +# +# REQUIREMENTS (run these first): +# ```bash +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Generate SSL certificates +# cd ../.. && ./examples/generate-keys.sh && cd examples/swarm +# +# # Add to /etc/hosts (idempotent) +# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local host2.local" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash # docker stack deploy -c services.yml services +# ``` # -# To test: +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check services are running +# docker service ls | grep services +# # Expected: services_container and services_container2 with 1/1 replicas +# +# # Test HTTPS for host1.local # curl -k -H "Host: host1.local" https://127.0.0.1/ +# # Expected: 200 OK with hostname +# +# # Test HTTPS for host2.local # curl -k -H "Host: host2.local" https://127.0.0.1/ -# -# curl -I -H Host:host1.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Expected: 200 OK with hostname # -# curl -I -H Host:host2.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Test HTTP redirect +# curl -I -H "Host: host1.local" http://127.0.0.1 +# # Expected: HTTP/1.1 301 Moved Permanently, Location: https://host1.local/ # -# Test SSL: -# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local +# # Verify SSL certificate +# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local < /dev/null +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm services +# ``` +# +# ============================================================================== services: container: From e1d2cb3b59cdaa0462b6fb8dd961810e48304854 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 10:22:04 -0500 Subject: [PATCH 23/27] Update Cloudflare examples to improve IP list as part of idempotent configuration - Added `echo Are there some specific_headers missing?` - These required unlocks plugin documentation consistency --- .gitignore | 1 + examples/docker/docker-compose-cloudflare.yml | 6 ++++-- examples/swarm/cloudflare.yml | 1 + examples/swarm/plugins-combined.yml | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7cfbb8e..a32f7e6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ __pycache__ /examples/swarm/certs/host2.local.pem /examples/docker/jwt_private.pem /examples/docker/jwt_pubkey.pem +/examples/docker/cloudflare_ips.lst diff --git a/examples/docker/docker-compose-cloudflare.yml b/examples/docker/docker-compose-cloudflare.yml index 49bfe50..5325031 100644 --- a/examples/docker/docker-compose-cloudflare.yml +++ b/examples/docker/docker-compose-cloudflare.yml @@ -12,6 +12,7 @@ # ```bash # # Download Cloudflare IP ranges (idempotent - overwrites if exists) # curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# echo "" >> cloudflare_ips.lst # curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # # # Add to /etc/hosts (idempotent) @@ -74,5 +75,6 @@ services: # Enable Cloudflare plugin easyhaproxy.http.plugins: cloudflare - # Optional: Specify custom IP list path - # easyhaproxy.http.plugin.cloudflare.ip_list_path: /etc/haproxy/cloudflare_ips.lst + # Use custom IP list (disable built-in IPs) + easyhaproxy.http.plugin.cloudflare.use_builtin_ips: false + easyhaproxy.http.plugin.cloudflare.ip_list_path: /etc/haproxy/cloudflare_ips.lst diff --git a/examples/swarm/cloudflare.yml b/examples/swarm/cloudflare.yml index 5a0be1e..c00ec74 100644 --- a/examples/swarm/cloudflare.yml +++ b/examples/swarm/cloudflare.yml @@ -21,6 +21,7 @@ # # # Download Cloudflare IP ranges and create Docker config # curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# echo "" >> cloudflare_ips.lst # curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # docker config create cloudflare_ips cloudflare_ips.lst # rm cloudflare_ips.lst diff --git a/examples/swarm/plugins-combined.yml b/examples/swarm/plugins-combined.yml index b48c493..fb8843c 100644 --- a/examples/swarm/plugins-combined.yml +++ b/examples/swarm/plugins-combined.yml @@ -28,6 +28,7 @@ # # # Download Cloudflare IP ranges and create Docker config # curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# echo "" >> cloudflare_ips.lst # curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # docker config create cloudflare_ips cloudflare_ips.lst # rm cloudflare_ips.lst jwt_private.pem jwt_pubkey.pem From 0add881a87e0d08281da78eb51f9f9132ddbbcc8 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 10:33:38 -0500 Subject: [PATCH 24/27] Update JWT Validator examples and documentation with key generation steps - Updated `docker-compose-jwt-validator.yml` to reference `generate-keys.sh` for key generation. - Revised plugin documentation to include detailed steps for generating JWT keys directly. - Simplified configuration instructions to improve clarity and consistency. --- docs/Plugins/jwt-validator.md | 8 ++++++++ examples/docker/docker-compose-jwt-validator.yml | 5 ++--- examples/docker/docker-compose-plugins-combined.yml | 10 +++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/Plugins/jwt-validator.md b/docs/Plugins/jwt-validator.md index 28fb53b..4e192f1 100644 --- a/docs/Plugins/jwt-validator.md +++ b/docs/Plugins/jwt-validator.md @@ -15,6 +15,14 @@ The JWT Validator plugin validates JWT (JSON Web Token) authentication tokens us Protect APIs and services with JWT authentication without needing application-level code. +## Generating JWT Keys + +```bash +# Generate RSA key pair (idempotent - skips if exists) +[ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +[ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +``` + ## Configuration Options | Option | Description | Default | diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml index f3f49a1..9eaeff8 100644 --- a/examples/docker/docker-compose-jwt-validator.yml +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -10,9 +10,8 @@ # # REQUIREMENTS (run these first): # ```bash -# # Generate RSA key pair (idempotent - skips if exists) -# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 -# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# # Generate SSL certificates and JWT keys (from project root) +# cd ../.. && ./examples/generate-keys.sh && cd examples/docker # # # Add to /etc/hosts (idempotent) # grep -q "api.local" /etc/hosts || echo "127.0.0.1 api.local" | sudo tee -a /etc/hosts diff --git a/examples/docker/docker-compose-plugins-combined.yml b/examples/docker/docker-compose-plugins-combined.yml index a2165bf..68bcd6b 100644 --- a/examples/docker/docker-compose-plugins-combined.yml +++ b/examples/docker/docker-compose-plugins-combined.yml @@ -12,12 +12,12 @@ # # REQUIREMENTS (run these first): # ```bash -# # Generate JWT keys (idempotent - skips if exists) -# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 -# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# # Generate SSL certificates and JWT keys (from project root) +# cd ../.. && ./examples/generate-keys.sh && cd examples/docker # # # Download Cloudflare IPs (idempotent - overwrites if exists) # curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# echo "" >> cloudflare_ips.lst # curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # # # Add to /etc/hosts (idempotent) @@ -133,6 +133,6 @@ services: # IP whitelist only (strictest security) easyhaproxy.http.plugins: ip_whitelist - # Only allow local and private networks - easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 127.0.0.1,192.168.0.0/16,10.0.0.0/8 + # Only allow local and private networks (including Docker bridge) + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 easyhaproxy.http.plugin.ip_whitelist.status_code: 403 From 3c7ef2d2a8aa50a463265369bcac5bf2d3d931d3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 11:18:19 -0500 Subject: [PATCH 25/27] Update Swarm documentation to replace tags dynamically during release - Added release tag replacement logic for `docs/swarm.md` to align with Kubernetes and Helm documentation updates. - Ensured consistency in versioning across all deployment examples and documentation. --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b29454..64eb56e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -170,6 +170,7 @@ jobs: sed -i "s#easy-haproxy:[a-zA-Z0-9\.-]*#easy-haproxy:$TAG#g" deploy/kubernetes/easyhaproxy-*.yml sed -i "s#easy-haproxy/[a-zA-Z0-9\.-]*/#easy-haproxy/$TAG/#g" docs/kubernetes.md + sed -i "s#easy-haproxy:[a-zA-Z0-9\.-]*#easy-haproxy:$TAG#g" docs/swarm.md sed -i "s#appVersion: \"[a-zA-Z0-9\.-]*\"#appVersion: \"$TAG\"#g" helm/easyhaproxy/Chart.yaml find examples -type f -name '*.yml' -exec sed -i "s#\(byjg/easy-haproxy:\)[a-zA-Z0-9\.-]*#\1$TAG#g" {} \; -print From 525e1b170d682854f815452d75bd2c4e89a6908b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 11:27:18 -0500 Subject: [PATCH 26/27] Reorganize documentation sidebar positions and update HAProxy stats configuration - Updated sidebar positions across multiple documentation files for improved navigation and logical grouping. - Revised HAProxy stats configuration to require `HAPROXY_PASSWORD` for enabling statistics. - Clarified environment variable descriptions and usage in `environment-variable.md`. --- docs/Plugins/cleanup.md | 2 +- docs/Plugins/cloudflare.md | 2 +- docs/Plugins/deny-pages.md | 2 +- docs/Plugins/fastcgi.md | 4 ++-- docs/Plugins/ip-whitelist.md | 2 +- docs/Plugins/jwt-validator.md | 2 +- docs/environment-variable.md | 14 +++++++++----- docs/limitations.md | 4 ++-- docs/other.md | 6 +++--- docs/plugin-development.md | 2 +- docs/plugins.md | 2 +- 11 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/Plugins/cleanup.md b/docs/Plugins/cleanup.md index 76ac12d..d1d9ef4 100644 --- a/docs/Plugins/cleanup.md +++ b/docs/Plugins/cleanup.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 21 --- # Cleanup Plugin diff --git a/docs/Plugins/cloudflare.md b/docs/Plugins/cloudflare.md index ff6bf66..8949c1e 100644 --- a/docs/Plugins/cloudflare.md +++ b/docs/Plugins/cloudflare.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 18 --- # Cloudflare Plugin diff --git a/docs/Plugins/deny-pages.md b/docs/Plugins/deny-pages.md index 0d7cff8..9c9bf2c 100644 --- a/docs/Plugins/deny-pages.md +++ b/docs/Plugins/deny-pages.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 20 --- # Deny Pages Plugin diff --git a/docs/Plugins/fastcgi.md b/docs/Plugins/fastcgi.md index aa1316d..28db516 100644 --- a/docs/Plugins/fastcgi.md +++ b/docs/Plugins/fastcgi.md @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +sidebar_position: 17 --- # FastCGI Plugin @@ -174,4 +174,4 @@ The plugin configures: ## Related Documentation - [Plugin System Overview](../plugins.md) -- [Container Labels Reference](../container-labels.md) \ No newline at end of file +- [Container Labels Reference](../container-labels.md) diff --git a/docs/Plugins/ip-whitelist.md b/docs/Plugins/ip-whitelist.md index 0cc6132..2ae8f15 100644 --- a/docs/Plugins/ip-whitelist.md +++ b/docs/Plugins/ip-whitelist.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 19 --- # IP Whitelist Plugin diff --git a/docs/Plugins/jwt-validator.md b/docs/Plugins/jwt-validator.md index 4e192f1..1b419ea 100644 --- a/docs/Plugins/jwt-validator.md +++ b/docs/Plugins/jwt-validator.md @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 16 --- # JWT Validator Plugin diff --git a/docs/environment-variable.md b/docs/environment-variable.md index 8b52806..e591616 100644 --- a/docs/environment-variable.md +++ b/docs/environment-variable.md @@ -11,14 +11,18 @@ sidebar_position: 12 | 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 | INFO | +| 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 | DEBUG | -| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | -| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | -| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | +| 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` | +:::tip HAProxy Stats +Statistics are only configured when `HAPROXY_PASSWORD` is set. Without a password, the stats section is not generated. +::: + :::note ACME/Certbot Environment Variables For ACME/Certbot configuration (Let's Encrypt, ZeroSSL, etc.), see the [ACME documentation](acme.md#environment-variables) for the complete list of `EASYHAPROXY_CERTBOT_*` variables. ::: diff --git a/docs/limitations.md b/docs/limitations.md index 53c2549..9d3bba7 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,5 +1,5 @@ --- -sidebar_position: 15 +sidebar_position: 23 --- # Limitations and Considerations @@ -35,4 +35,4 @@ If you need to run multiple replicas for high availability, **do not activate AC ::: ---- -[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/other.md b/docs/other.md index f376e13..cc45282 100644 --- a/docs/other.md +++ b/docs/other.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 22 --- # Other configurations @@ -9,7 +9,7 @@ sidebar_position: 14 Some ports on the EasyHAProxy container and in the firewall are required to be open. However, you don't need to expose the other container ports because EasyHAProxy will handle that. - The ports `80` and `443`. -- If you enable the HAProxy statistics, you must also expose the port defined in `HAPROXY_STATS_PORT` environment variable (default 1936). Be aware that statistics are enabled by default with no password. +- If you enable the HAProxy statistics, you must also expose the port defined in `HAPROXY_STATS_PORT` environment variable (default 1936). Statistics are only generated when you set `HAPROXY_PASSWORD`. - Every port defined in `easyhaproxy.[definitions].port` also should be exposed. For example: @@ -51,4 +51,4 @@ If enabled, map the volume : `/etc/haproxy/errors-custom/` to your container and where ERROR_NUMBER is the HTTP error code (e.g., `503.http`) ---- -[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 54be8cd..f017f0c 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,5 +1,5 @@ --- -sidebar_position: 17 +sidebar_position: 15 --- # Plugin Development Guide diff --git a/docs/plugins.md b/docs/plugins.md index 3feedfd..9adda9a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,5 +1,5 @@ --- -sidebar_position: 16 +sidebar_position: 14 --- # Using Plugins From 85ba5723a01445fe49278c9697e3295ff945ef33 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Dec 2025 11:29:33 -0500 Subject: [PATCH 27/27] Bump chart version to 1.0.0 to signify stable release. --- helm/easyhaproxy/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/easyhaproxy/Chart.yaml b/helm/easyhaproxy/Chart.yaml index b5cc41c..b5ed224 100644 --- a/helm/easyhaproxy/Chart.yaml +++ b/helm/easyhaproxy/Chart.yaml @@ -15,7 +15,7 @@ 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: 0.1.9 +version: 1.0.0 # 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