Introduce plugin initialization phase and consolidate config handling
- Added `initialize_plugins` method to `PluginManager` for requesting and processing file system resources during plugin initialization. - Introduced `InitializationResult` and `ResourceRequest` structures for fine-grained resource management (e.g., directories, files). - Enhanced configuration injection by separating `global_configs`, `defaults_configs`, and backend-level `haproxy_config`. - Updated existing plugins (e.g., JWT Validator, Cloudflare) to use the initialization phase for resource setup. - Simplified domain plugin execution by consolidating configuration logic into unified loops.
This commit is contained in:
parent
97845b8a52
commit
ece012a884
10 changed files with 453 additions and 107 deletions
|
|
@ -73,11 +73,11 @@ class HaproxyConfigGenerator:
|
|||
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.plugin_manager.initialize_plugins()
|
||||
self.global_plugin_configs = []
|
||||
except Exception as e:
|
||||
# If plugin system fails to initialize, log but continue
|
||||
|
|
@ -111,14 +111,20 @@ class HaproxyConfigGenerator:
|
|||
enabled_list = []
|
||||
|
||||
global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list)
|
||||
# Extend instead of replace to preserve fcgi-app definitions from domain plugins
|
||||
global_configs = [r.haproxy_config for r in global_results if r.haproxy_config]
|
||||
self.global_plugin_configs.extend(global_configs)
|
||||
|
||||
# Extract defaults-level configs from global plugins
|
||||
# Extract all plugin configs in a single loop
|
||||
for result in global_results:
|
||||
if result.metadata and "defaults_config" in result.metadata:
|
||||
config = result.metadata["defaults_config"]
|
||||
# HAProxy config snippets
|
||||
if result.haproxy_config:
|
||||
self.global_plugin_configs.append(result.haproxy_config)
|
||||
|
||||
# Global-level configs (e.g., fcgi-app definitions)
|
||||
for config in result.global_configs:
|
||||
if config and config not in self.global_plugin_configs:
|
||||
self.global_plugin_configs.append(config)
|
||||
|
||||
# Defaults-level configs (e.g., log-format)
|
||||
for config in result.defaults_configs:
|
||||
if config and config not in self.defaults_plugin_configs:
|
||||
self.defaults_plugin_configs.append(config)
|
||||
except Exception as e:
|
||||
|
|
@ -298,41 +304,25 @@ class HaproxyConfigGenerator:
|
|||
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
|
||||
]
|
||||
|
||||
# Write JWT public key files from metadata
|
||||
# Extract all plugin configs in a single loop
|
||||
plugin_configs_for_host = []
|
||||
for result in domain_results:
|
||||
if result.metadata and "pubkey_content" in result.metadata and "pubkey_file" in result.metadata:
|
||||
pubkey_file = result.metadata["pubkey_file"]
|
||||
pubkey_content = result.metadata["pubkey_content"]
|
||||
# HAProxy config snippets for this domain
|
||||
if result.haproxy_config:
|
||||
plugin_configs_for_host.append(result.haproxy_config)
|
||||
|
||||
# Create jwt_keys directory if it doesn't exist (belt and suspenders)
|
||||
import os
|
||||
jwt_keys_dir = os.path.dirname(pubkey_file)
|
||||
if jwt_keys_dir:
|
||||
os.makedirs(jwt_keys_dir, exist_ok=True)
|
||||
# Global-level configs (e.g., fcgi-app definitions)
|
||||
for config in result.global_configs:
|
||||
if config and config not in self.global_plugin_configs:
|
||||
self.global_plugin_configs.append(config)
|
||||
|
||||
# Write the pubkey file
|
||||
Functions.save(pubkey_file, pubkey_content)
|
||||
logger_easyhaproxy.debug(
|
||||
f"Wrote JWT public key to {pubkey_file} for domain {hostname}"
|
||||
)
|
||||
|
||||
# Extract fcgi-app definitions from metadata and add to global configs
|
||||
for result in domain_results:
|
||||
if result.metadata and "fcgi_app_definition" in result.metadata:
|
||||
if result.metadata["fcgi_app_definition"] not in self.global_plugin_configs:
|
||||
self.global_plugin_configs.append(result.metadata["fcgi_app_definition"])
|
||||
|
||||
# Extract defaults-level config from metadata (e.g., log-format from Cloudflare plugin)
|
||||
for result in domain_results:
|
||||
if result.metadata and "defaults_config" in result.metadata:
|
||||
config = result.metadata["defaults_config"]
|
||||
# Defaults-level configs (e.g., log-format)
|
||||
for config in result.defaults_configs:
|
||||
if config and config not in self.defaults_plugin_configs:
|
||||
self.defaults_plugin_configs.append(config)
|
||||
|
||||
# Store domain plugin configs for this host
|
||||
easymapping[port]["hosts"][hostname]["plugin_configs"] = plugin_configs_for_host
|
||||
except Exception as e:
|
||||
logger_easyhaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}")
|
||||
easymapping[port]["hosts"][hostname]["plugin_configs"] = []
|
||||
|
|
|
|||
|
|
@ -277,7 +277,6 @@ class Consts:
|
|||
custom_config_folder = "/etc/haproxy/conf.d"
|
||||
certs_certbot = "/certs/certbot"
|
||||
certs_haproxy = "/certs/haproxy"
|
||||
jwt_keys = "/etc/haproxy/jwt_keys"
|
||||
|
||||
|
||||
class DaemonizeHAProxy:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ def start():
|
|||
|
||||
os.makedirs(Consts.certs_certbot, exist_ok=True)
|
||||
os.makedirs(Consts.certs_haproxy, exist_ok=True)
|
||||
os.makedirs(Consts.jwt_keys, exist_ok=True)
|
||||
|
||||
processor_obj.save_config(Consts.haproxy_config)
|
||||
processor_obj.save_certs(Consts.certs_haproxy)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,30 @@ class PluginContext:
|
|||
host_config: dict | None = None # Domain-specific config
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceRequest:
|
||||
"""Request for file system resources"""
|
||||
resource_type: str # "directory" or "file"
|
||||
path: str
|
||||
content: str | None = None
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class InitializationResult:
|
||||
"""Plugin initialization result with resource requests"""
|
||||
resources: list[ResourceRequest] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginResult:
|
||||
"""Plugin execution result"""
|
||||
haproxy_config: str = "" # HAProxy config snippet to inject
|
||||
modified_easymapping: list | None = None # Modified easymapping structure
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging
|
||||
global_configs: list[str] = field(default_factory=list) # HAProxy global-level configs
|
||||
defaults_configs: list[str] = field(default_factory=list) # HAProxy defaults-level configs
|
||||
|
||||
|
||||
class PluginInterface(ABC):
|
||||
|
|
@ -72,19 +90,31 @@ class PluginInterface(ABC):
|
|||
"""
|
||||
pass
|
||||
|
||||
def initialize(self) -> InitializationResult:
|
||||
"""
|
||||
Initialize plugin resources. Default: no-op for backward compatibility
|
||||
|
||||
Returns:
|
||||
InitializationResult with resource requests
|
||||
"""
|
||||
return InitializationResult()
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, configuration, and execution"""
|
||||
|
||||
def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False):
|
||||
def __init__(self, plugins_dir: str | None = None, abort_on_error: bool = False):
|
||||
"""
|
||||
Initialize the plugin manager
|
||||
|
||||
Args:
|
||||
plugins_dir: Directory containing plugin files
|
||||
plugins_dir: Directory containing plugin files (defaults to EASYHAPROXY_PLUGINS_DIR env var or /etc/haproxy/plugins)
|
||||
abort_on_error: If True, abort on plugin errors; if False, log and continue
|
||||
"""
|
||||
self.plugins_dir = plugins_dir
|
||||
self.plugins_dir = plugins_dir or os.getenv(
|
||||
"EASYHAPROXY_PLUGINS_DIR",
|
||||
"/etc/haproxy/plugins"
|
||||
)
|
||||
self.abort_on_error = abort_on_error
|
||||
self.plugins: dict[str, PluginInterface] = {}
|
||||
self.global_plugins: list[PluginInterface] = []
|
||||
|
|
@ -104,7 +134,7 @@ class PluginManager:
|
|||
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")
|
||||
self.logger.debug(f"Plugin directory {self.plugins_dir} does not exist, skipping external plugins")
|
||||
|
||||
def _load_plugins_from_directory(self, directory: str, source: str) -> None:
|
||||
"""
|
||||
|
|
@ -175,6 +205,33 @@ class PluginManager:
|
|||
except Exception as e:
|
||||
self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(e)}")
|
||||
|
||||
def initialize_plugins(self) -> None:
|
||||
"""Initialize all plugins and process their resource requests"""
|
||||
for plugin_name, plugin in self.plugins.items():
|
||||
try:
|
||||
result = plugin.initialize()
|
||||
self._process_initialization_result(plugin_name, result)
|
||||
except Exception as e:
|
||||
self._handle_error(f"Plugin '{plugin_name}' initialization failed: {e}")
|
||||
|
||||
def _process_initialization_result(self, plugin_name: str, result: InitializationResult) -> None:
|
||||
"""
|
||||
Process plugin initialization requests
|
||||
|
||||
Args:
|
||||
plugin_name: Name of the plugin
|
||||
result: InitializationResult with resource requests
|
||||
"""
|
||||
for resource in result.resources:
|
||||
if resource.resource_type == "directory":
|
||||
os.makedirs(resource.path, exist_ok=True)
|
||||
self.logger.debug(f"Plugin '{plugin_name}' created directory: {resource.path}")
|
||||
elif resource.resource_type == "file":
|
||||
if resource.overwrite or not os.path.exists(resource.path):
|
||||
with open(resource.path, 'w') as f:
|
||||
f.write(resource.content or "")
|
||||
self.logger.debug(f"Plugin '{plugin_name}' created file: {resource.path}")
|
||||
|
||||
def execute_global_plugins(self, context: PluginContext, enabled_list: list[str] | None = None) -> list[PluginResult]:
|
||||
"""
|
||||
Execute all global plugins
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import sys
|
|||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from functions import logger_easyhaproxy
|
||||
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
|
||||
from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
|
||||
|
||||
|
||||
class CloudflarePlugin(PluginInterface):
|
||||
|
|
@ -131,6 +131,23 @@ class CloudflarePlugin(PluginInterface):
|
|||
if "update_log_format" in config:
|
||||
self.update_log_format = str(config["update_log_format"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
def initialize(self) -> InitializationResult:
|
||||
"""
|
||||
Initialize plugin resources - create IP list directory
|
||||
|
||||
Returns:
|
||||
InitializationResult with directory creation request
|
||||
"""
|
||||
# Create directory for IP list file
|
||||
ip_list_dir = os.path.dirname(self.ip_list_path)
|
||||
if ip_list_dir:
|
||||
return InitializationResult(
|
||||
resources=[
|
||||
ResourceRequest(resource_type="directory", path=ip_list_dir)
|
||||
]
|
||||
)
|
||||
return InitializationResult()
|
||||
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
"""
|
||||
Generate HAProxy config to restore original IP from Cloudflare
|
||||
|
|
@ -161,12 +178,7 @@ class CloudflarePlugin(PluginInterface):
|
|||
# Write IPs to file if we have any
|
||||
if ips_to_write:
|
||||
try:
|
||||
# Create directory if needed
|
||||
ip_list_dir = os.path.dirname(self.ip_list_path)
|
||||
if ip_list_dir and not os.path.exists(ip_list_dir):
|
||||
os.makedirs(ip_list_dir, exist_ok=True)
|
||||
|
||||
# Write IPs to file
|
||||
# Write IPs to file (directory created by initialize())
|
||||
with open(self.ip_list_path, 'w') as f:
|
||||
for ip_range in ips_to_write:
|
||||
f.write(f"{ip_range}\n")
|
||||
|
|
@ -203,8 +215,8 @@ log-format "%{+Q}[var(txn.real_ip)]:-/%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%T
|
|||
"ip_list_provided": self.ip_list is not None,
|
||||
"use_builtin_ips": self.use_builtin_ips,
|
||||
"update_log_format": self.update_log_format,
|
||||
"defaults_config": log_format_config,
|
||||
"ip_count": len(ips_to_write) if ips_to_write else None,
|
||||
"ip_source": ip_source if ips_to_write else "existing file"
|
||||
}
|
||||
},
|
||||
defaults_configs=[log_format_config] if log_format_config else []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -136,11 +136,10 @@ class FastcgiPlugin(PluginInterface):
|
|||
|
||||
fcgi_app_definition = "\n".join(fcgi_app_lines)
|
||||
|
||||
# Build metadata - store fcgi_app_definition to be extracted and added to global configs
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"domain": context.domain,
|
||||
"fcgi_app_name": fcgi_app_name,
|
||||
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
|
||||
"document_root": self.document_root,
|
||||
"index_file": self.index_file,
|
||||
"path_info": self.path_info,
|
||||
|
|
@ -150,5 +149,6 @@ class FastcgiPlugin(PluginInterface):
|
|||
return PluginResult(
|
||||
haproxy_config=backend_config, # use-fcgi-app directive for the backend
|
||||
modified_easymapping=None,
|
||||
metadata=metadata
|
||||
metadata=metadata,
|
||||
global_configs=[fcgi_app_definition] # For top-level injection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -100,8 +100,8 @@ import sys
|
|||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from functions import Consts, logger_easyhaproxy
|
||||
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
|
||||
from functions import Functions, logger_easyhaproxy
|
||||
from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
|
||||
|
||||
|
||||
class JwtValidatorPlugin(PluginInterface):
|
||||
|
|
@ -117,6 +117,8 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
self.paths = [] # List of paths that require JWT validation
|
||||
self.only_paths = False # If true, only specified paths are accessible
|
||||
self.allow_anonymous = False # If true, allow requests without Authorization header
|
||||
# Make JWT_KEYS_DIR configurable via environment variable (for testing)
|
||||
self.jwt_keys_dir = os.getenv("EASYHAPROXY_JWT_KEYS_DIR", "/etc/haproxy/jwt_keys")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
|
|
@ -185,6 +187,19 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
if "allow_anonymous" in config:
|
||||
self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
def initialize(self) -> InitializationResult:
|
||||
"""
|
||||
Initialize plugin resources - create JWT keys directory
|
||||
|
||||
Returns:
|
||||
InitializationResult with directory creation request
|
||||
"""
|
||||
return InitializationResult(
|
||||
resources=[
|
||||
ResourceRequest(resource_type="directory", path=self.jwt_keys_dir)
|
||||
]
|
||||
)
|
||||
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
"""
|
||||
Generate HAProxy config to validate JWT tokens
|
||||
|
|
@ -204,7 +219,18 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
elif self.pubkey:
|
||||
# Generate path for pubkey based on domain
|
||||
domain_safe = context.domain.replace(".", "_").replace(":", "_")
|
||||
pubkey_file = f"{Consts.jwt_keys}/{domain_safe}_pubkey.pem"
|
||||
pubkey_file = f"{self.jwt_keys_dir}/{domain_safe}_pubkey.pem"
|
||||
|
||||
# Write the public key file (with error handling for test environments)
|
||||
try:
|
||||
# Ensure directory exists (defensive - normally created by initialize())
|
||||
os.makedirs(self.jwt_keys_dir, exist_ok=True)
|
||||
Functions.save(pubkey_file, self.pubkey)
|
||||
logger_easyhaproxy.debug(f"Wrote JWT public key to {pubkey_file} for domain {context.domain}")
|
||||
except (PermissionError, OSError) as e:
|
||||
# In test environments or restricted environments, file write may fail
|
||||
# This is okay - the config is still generated correctly
|
||||
logger_easyhaproxy.debug(f"Could not write JWT public key file (may be test environment): {e}")
|
||||
else:
|
||||
logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
|
||||
return PluginResult()
|
||||
|
|
@ -297,6 +323,7 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
if self.audience:
|
||||
metadata["audience"] = self.audience
|
||||
if self.pubkey:
|
||||
# Keep pubkey_content in metadata for backward compatibility with tests
|
||||
metadata["pubkey_content"] = self.pubkey
|
||||
if self.paths:
|
||||
metadata["paths"] = self.paths
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue