1
0
Fork 0

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`.
This commit is contained in:
Joao Gilberto Magalhaes 2025-11-27 17:43:59 -05:00
parent fdb6ddf3b9
commit 90e3df2b75
12 changed files with 1341 additions and 1 deletions

View file

@ -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

294
docs/plugin-development.md Normal file
View file

@ -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

341
docs/plugins.md Normal file
View file

@ -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)

View file

@ -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"] = {

View file

@ -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

250
src/plugins/__init__.py Normal file
View file

@ -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)

View file

@ -0,0 +1 @@
# Built-in plugins for EasyHAProxy

View file

@ -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
}
)

View file

@ -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
}
)

View file

@ -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
}
)

View file

@ -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)

View file

@ -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]