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

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