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
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
|
||||
}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue