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:
parent
fdb6ddf3b9
commit
90e3df2b75
12 changed files with 1341 additions and 1 deletions
|
|
@ -88,6 +88,7 @@ Detailed configuration guides for advanced setups:
|
||||||
- [Container Labels](docs/container-labels.md) - Configure Docker/Swarm containers with labels
|
- [Container Labels](docs/container-labels.md) - Configure Docker/Swarm containers with labels
|
||||||
- [Environment Variables](docs/environment-variable.md) - Configure EasyHAProxy behavior
|
- [Environment Variables](docs/environment-variable.md) - Configure EasyHAProxy behavior
|
||||||
- [Volumes](docs/volumes.md) - Map volumes for certificates, config, and custom files
|
- [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.)
|
- [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.)
|
||||||
- [Limitations](docs/limitations.md) - Important limitations and considerations
|
- [Limitations](docs/limitations.md) - Important limitations and considerations
|
||||||
|
|
||||||
|
|
|
||||||
294
docs/plugin-development.md
Normal file
294
docs/plugin-development.md
Normal 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
341
docs/plugins.md
Normal 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)
|
||||||
|
|
@ -54,19 +54,60 @@ class HaproxyConfigGenerator:
|
||||||
self.serving_hosts = []
|
self.serving_hosts = []
|
||||||
self.certs = {}
|
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={}):
|
def generate(self, container_metadata={}):
|
||||||
self.mapping.setdefault("easymapping", [])
|
self.mapping.setdefault("easymapping", [])
|
||||||
|
|
||||||
if container_metadata != {}:
|
if container_metadata != {}:
|
||||||
self.mapping["easymapping"] = self.parse(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')
|
file_loader = FileSystemLoader('templates')
|
||||||
env = Environment(loader=file_loader)
|
env = Environment(loader=file_loader)
|
||||||
env.trim_blocks = True
|
env.trim_blocks = True
|
||||||
env.lstrip_blocks = True
|
env.lstrip_blocks = True
|
||||||
env.rstrip_blocks = True
|
env.rstrip_blocks = True
|
||||||
template = env.get_template('haproxy.cfg.j2')
|
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):
|
def parse(self, container_metadata):
|
||||||
easymapping = dict()
|
easymapping = dict()
|
||||||
|
|
@ -151,6 +192,45 @@ class HaproxyConfigGenerator:
|
||||||
self.label.create([definition, "redirect"])
|
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 certbot or clone_to_ssl:
|
||||||
if "443" not in easymapping:
|
if "443" not in easymapping:
|
||||||
easymapping["443"] = {
|
easymapping["443"] = {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,23 @@ class ContainerEnv:
|
||||||
|
|
||||||
os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"]
|
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
|
return env_vars
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
250
src/plugins/__init__.py
Normal file
250
src/plugins/__init__.py
Normal 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)
|
||||||
1
src/plugins/builtin/__init__.py
Normal file
1
src/plugins/builtin/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Built-in plugins for EasyHAProxy
|
||||||
124
src/plugins/builtin/cleanup.py
Normal file
124
src/plugins/builtin/cleanup.py
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
89
src/plugins/builtin/cloudflare.py
Normal file
89
src/plugins/builtin/cloudflare.py
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
106
src/plugins/builtin/deny_pages.py
Normal file
106
src/plugins/builtin/deny_pages.py
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -116,6 +116,30 @@ class Static(ProcessorInterface):
|
||||||
|
|
||||||
def parse(self):
|
def parse(self):
|
||||||
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
|
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
|
||||||
|
|
||||||
|
# Merge plugin config from YAML with env vars
|
||||||
|
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)
|
self.cfg = HaproxyConfigGenerator(self.static_content)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ defaults
|
||||||
errorfile 504 /etc/haproxy/errors-custom/504.http
|
errorfile 504 /etc/haproxy/errors-custom/504.http
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if global_plugin_configs %}
|
||||||
|
# Global Plugin Configurations
|
||||||
|
{% for config in global_plugin_configs %}
|
||||||
|
{{ config }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% set data_stats = data["stats"] | default({}) %}
|
{% set data_stats = data["stats"] | default({}) %}
|
||||||
{% if data_stats["port"] | default(1936) | int > 0 %}
|
{% if data_stats["port"] | default(1936) | int > 0 %}
|
||||||
frontend stats
|
frontend stats
|
||||||
|
|
@ -75,6 +82,12 @@ frontend {{ mode }}_in_{{ o["port"] }}
|
||||||
backend srv_{{ host }}
|
backend srv_{{ host }}
|
||||||
balance {{ o["balance"] | default("roundrobin") }}
|
balance {{ o["balance"] | default("roundrobin") }}
|
||||||
mode {{ mode }}
|
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" %}
|
{% if mode == "http" %}
|
||||||
option forwardfor
|
option forwardfor
|
||||||
http-request set-header X-Forwarded-Port %[dst_port]
|
http-request set-header X-Forwarded-Port %[dst_port]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue