1
0
Fork 0

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:
Joao Gilberto Magalhaes 2026-02-13 16:14:05 -05:00
parent 97845b8a52
commit ece012a884
10 changed files with 453 additions and 107 deletions

View file

@ -86,9 +86,32 @@ jobs:
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_kubernetes.py -sv --tb=short uv run pytest tests_e2e/test_kubernetes.py -sv --tb=short
Tests-E2E-Static:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
export PATH="$HOME/.local/bin:$PATH"
uv sync --group dev
- name: Run Docker Compose E2E tests
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_static.py -sv --tb=short
Build: Build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes] needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Static]
permissions: permissions:
contents: read contents: read
packages: write packages: write

View file

@ -101,7 +101,13 @@ Execute **once per discovered domain/host**.
├─ Calls plugin.configure(config) for each plugin ├─ Calls plugin.configure(config) for each plugin
└─ Validates configuration (plugin responsibility) └─ Validates configuration (plugin responsibility)
3. EXECUTION PHASE (per discovery cycle) 3. INITIALIZE PHASE
├─ Calls plugin.initialize() for each plugin
├─ Plugins request file system resources (directories, files)
├─ PluginManager processes resource requests
└─ Creates directories and files as needed
4. EXECUTION PHASE (per discovery cycle)
├─ GLOBAL PLUGINS ├─ GLOBAL PLUGINS
│ └─ Executes all global plugins once │ └─ Executes all global plugins once
@ -109,9 +115,11 @@ Execute **once per discovered domain/host**.
└─ For each discovered domain: └─ For each discovered domain:
└─ Executes all domain plugins └─ Executes all domain plugins
4. RESULT PROCESSING 5. RESULT PROCESSING
├─ Collects PluginResult from each plugin ├─ Collects PluginResult from each plugin
├─ Injects haproxy_config into generated config ├─ Injects haproxy_config into backend sections
├─ Injects global_configs into global section
├─ Injects defaults_configs into defaults section
├─ Applies modified_easymapping if provided ├─ Applies modified_easymapping if provided
└─ Logs metadata for debugging └─ Logs metadata for debugging
``` ```
@ -164,7 +172,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
class MyPlugin(PluginInterface): class MyPlugin(PluginInterface):
@ -306,6 +314,18 @@ class PluginInterface(ABC):
""" """
pass pass
def initialize(self) -> InitializationResult:
"""
Initialize plugin resources (new in v2.0)
Optional method to request file system resources.
Default implementation returns empty result (no-op).
Returns:
InitializationResult with resource requests
"""
return InitializationResult()
@abstractmethod @abstractmethod
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
""" """
@ -328,6 +348,7 @@ class PluginInterface(ABC):
**Methods:** **Methods:**
- `configure(config)` - Receives plugin configuration during initialization - `configure(config)` - Receives plugin configuration during initialization
- `initialize()` - **[New in v2.0]** Request file system resources (optional)
- `process(context)` - Main execution logic, returns `PluginResult` - `process(context)` - Main execution logic, returns `PluginResult`
### PluginType ### PluginType
@ -390,6 +411,71 @@ def process(self, context: PluginContext) -> PluginResult:
custom_label = context.host_config.get("custom_label", "default") custom_label = context.host_config.get("custom_label", "default")
``` ```
### ResourceRequest
**[New in v2.0]** Request for file system resources during plugin initialization.
```python
@dataclass
class ResourceRequest:
"""Request for file system resources"""
resource_type: str # "directory" or "file"
path: str
content: str | None = None
overwrite: bool = False
```
**Fields:**
- `resource_type` - Type of resource: `"directory"` or `"file"`
- `path` - Absolute path to create
- `content` - File content (only for `resource_type="file"`)
- `overwrite` - Whether to overwrite existing files (default: False)
**Example:**
```python
ResourceRequest(
resource_type="directory",
path="/etc/haproxy/plugin_data"
)
ResourceRequest(
resource_type="file",
path="/etc/haproxy/plugin_config.txt",
content="config data",
overwrite=True
)
```
### InitializationResult
**[New in v2.0]** Plugin initialization result with resource requests.
```python
@dataclass
class InitializationResult:
"""Plugin initialization result with resource requests"""
resources: list[ResourceRequest] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
```
**Fields:**
- `resources` - List of ResourceRequest objects
- `metadata` - Optional metadata about initialization
**Example:**
```python
def initialize(self) -> InitializationResult:
return InitializationResult(
resources=[
ResourceRequest(resource_type="directory", path="/etc/haproxy/jwt_keys")
]
)
```
### PluginResult ### PluginResult
Plugin execution result containing configuration and metadata. Plugin execution result containing configuration and metadata.
@ -401,6 +487,8 @@ class PluginResult:
haproxy_config: str = "" # HAProxy config snippet to inject haproxy_config: str = "" # HAProxy config snippet to inject
modified_easymapping: Optional[list] = None # Modified easymapping structure modified_easymapping: Optional[list] = None # Modified easymapping structure
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging
global_configs: list[str] = field(default_factory=list) # [New] Global-level configs
defaults_configs: list[str] = field(default_factory=list) # [New] Defaults-level configs
``` ```
**Fields:** **Fields:**
@ -408,11 +496,13 @@ class PluginResult:
- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend) - `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend)
- `modified_easymapping` - Modified easymapping structure (optional, advanced use) - `modified_easymapping` - Modified easymapping structure (optional, advanced use)
- `metadata` - Dictionary with debugging/logging information - `metadata` - Dictionary with debugging/logging information
- `global_configs` - **[New in v2.0]** List of global-level HAProxy configs (e.g., fcgi-app definitions)
- `defaults_configs` - **[New in v2.0]** List of defaults-level HAProxy configs (e.g., log-format)
**Examples:** **Examples:**
```python ```python
# Simple config injection # Simple config injection (backend-level)
return PluginResult( return PluginResult(
haproxy_config="http-request deny deny_status 403" haproxy_config="http-request deny deny_status 403"
) )
@ -427,6 +517,22 @@ return PluginResult(
} }
) )
# With global-level config (new in v2.0)
return PluginResult(
haproxy_config="use-fcgi-app fcgi_example_com",
global_configs=[
"fcgi-app fcgi_example_com\n docroot /var/www/html"
]
)
# With defaults-level config (new in v2.0)
return PluginResult(
haproxy_config="acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst",
defaults_configs=[
'log-format "%{+Q}[var(txn.real_ip)]:-/%ci:%cp [%tr] %ft %b/%s"'
]
)
# No operation (plugin disabled or no action needed) # No operation (plugin disabled or no action needed)
return PluginResult() return PluginResult()
``` ```
@ -439,12 +545,13 @@ Manages plugin loading, configuration, and execution.
class PluginManager: class PluginManager:
"""Manages plugin loading, configuration, and execution""" """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 Initialize the plugin manager
Args: 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 abort_on_error: If True, abort on plugin errors; if False, log and continue
""" """
@ -454,6 +561,9 @@ class PluginManager:
def configure_plugins(self, plugins_config: dict) -> None: def configure_plugins(self, plugins_config: dict) -> None:
"""Configure all loaded plugins with their settings""" """Configure all loaded plugins with their settings"""
def initialize_plugins(self) -> None:
"""[New in v2.0] Initialize all plugins and process resource requests"""
def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
"""Execute all global plugins""" """Execute all global plugins"""
@ -461,6 +571,10 @@ class PluginManager:
"""Execute all domain plugins for a specific domain""" """Execute all domain plugins for a specific domain"""
``` ```
**Environment Variables:**
- `EASYHAPROXY_PLUGINS_DIR` - Override plugin directory (default: `/etc/haproxy/plugins`)
**Note:** You typically don't interact with PluginManager directly when writing plugins. It's used by EasyHAProxy core. **Note:** You typically don't interact with PluginManager directly when writing plugins. It's used by EasyHAProxy core.
--- ---
@ -626,7 +740,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
class FastcgiPlugin(PluginInterface): class FastcgiPlugin(PluginInterface):
@ -720,11 +834,10 @@ class FastcgiPlugin(PluginInterface):
fcgi_app_definition = "\n".join(fcgi_app_lines) 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 = { metadata = {
"domain": context.domain, "domain": context.domain,
"fcgi_app_name": fcgi_app_name, "fcgi_app_name": fcgi_app_name,
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
"document_root": self.document_root, "document_root": self.document_root,
"index_file": self.index_file, "index_file": self.index_file,
"path_info": self.path_info, "path_info": self.path_info,
@ -734,7 +847,8 @@ class FastcgiPlugin(PluginInterface):
return PluginResult( return PluginResult(
haproxy_config=backend_config, # use-fcgi-app directive for the backend haproxy_config=backend_config, # use-fcgi-app directive for the backend
modified_easymapping=None, modified_easymapping=None,
metadata=metadata metadata=metadata,
global_configs=[fcgi_app_definition] # For top-level injection (new in v2.0)
) )
``` ```
@ -794,8 +908,15 @@ import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import (
from functions import loggerEasyHaproxy InitializationResult,
PluginInterface,
PluginType,
PluginContext,
PluginResult,
ResourceRequest
)
from functions import Functions, logger_easyhaproxy
class JwtValidatorPlugin(PluginInterface): class JwtValidatorPlugin(PluginInterface):
@ -810,6 +931,8 @@ class JwtValidatorPlugin(PluginInterface):
self.pubkey = None # Public key content (alternative to pubkey_path) self.pubkey = None # Public key content (alternative to pubkey_path)
self.paths = [] # List of paths that require JWT validation self.paths = [] # List of paths that require JWT validation
self.only_paths = False # If true, only specified paths are accessible self.only_paths = False # If true, only specified paths are accessible
# Make JWT_KEYS_DIR configurable via environment variable
self.jwt_keys_dir = os.getenv("EASYHAPROXY_JWT_KEYS_DIR", "/etc/haproxy/jwt_keys")
@property @property
def name(self) -> str: def name(self) -> str:
@ -874,6 +997,19 @@ class JwtValidatorPlugin(PluginInterface):
if "only_paths" in config: if "only_paths" in config:
self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"]
def initialize(self) -> InitializationResult:
"""
Initialize plugin resources - create JWT keys directory (new in v2.0)
Returns:
InitializationResult with directory creation request
"""
return InitializationResult(
resources=[
ResourceRequest(resource_type="directory", path=self.jwt_keys_dir)
]
)
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
""" """
Generate HAProxy config to validate JWT tokens Generate HAProxy config to validate JWT tokens
@ -893,9 +1029,17 @@ class JwtValidatorPlugin(PluginInterface):
elif self.pubkey: elif self.pubkey:
# Generate path for pubkey based on domain # Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_") domain_safe = context.domain.replace(".", "_").replace(":", "_")
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" pubkey_file = f"{self.jwt_keys_dir}/{domain_safe}_pubkey.pem"
# Write the public key file (defensive - normally created by initialize())
try:
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:
logger_easyhaproxy.debug(f"Could not write JWT public key file: {e}")
else: else:
loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult() return PluginResult()
# Build HAProxy configuration # Build HAProxy configuration
@ -1021,7 +1165,7 @@ import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
class CleanupPlugin(PluginInterface): class CleanupPlugin(PluginInterface):
@ -1057,7 +1201,7 @@ class CleanupPlugin(PluginInterface):
try: try:
self.max_idle_time = int(config["max_idle_time"]) self.max_idle_time = int(config["max_idle_time"])
except ValueError: except ValueError:
loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") logger_easyhaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
if "cleanup_temp_files" in config: if "cleanup_temp_files" in config:
self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"]
@ -1095,15 +1239,15 @@ class CleanupPlugin(PluginInterface):
if file_age > self.max_idle_time: if file_age > self.max_idle_time:
os.remove(filepath) os.remove(filepath)
cleanup_actions.append(f"Removed old temp file: {filepath}") cleanup_actions.append(f"Removed old temp file: {filepath}")
loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") logger_easyhaproxy.debug(f"Cleanup plugin: Removed {filepath}")
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") logger_easyhaproxy.warning(f"Failed to remove temp file {filepath}: {e}")
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
# Log cleanup summary # Log cleanup summary
if cleanup_actions: if cleanup_actions:
loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") logger_easyhaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
return PluginResult( return PluginResult(
haproxy_config="", # No HAProxy config needed for cleanup haproxy_config="", # No HAProxy config needed for cleanup
@ -1117,9 +1261,104 @@ class CleanupPlugin(PluginInterface):
--- ---
## Environment Variables
**New in v2.0:** Plugins can use environment variables for configuration.
### Core Environment Variables
- `EASYHAPROXY_PLUGINS_DIR` - Override plugin directory (default: `/etc/haproxy/plugins`)
- `EASYHAPROXY_PLUGINS_ENABLED` - Comma-separated list of enabled plugins
- `EASYHAPROXY_PLUGINS_ABORT_ON_ERROR` - Abort on plugin errors (default: `false`)
### Plugin-Specific Environment Variables
- `EASYHAPROXY_PLUGIN_<PLUGIN_NAME>_<CONFIG_KEY>` - Configure plugin settings
**Example:**
```bash
EASYHAPROXY_PLUGINS_ENABLED=jwt_validator,cloudflare
EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ALGORITHM=RS256
EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ISSUER=https://auth.example.com/
EASYHAPROXY_JWT_KEYS_DIR=/custom/path/jwt_keys # Plugin-defined env var
```
### Plugin Resource Directories
**New in v2.0:** Plugins can make their resource directories configurable via environment variables.
**Example:**
```python
class MyPlugin(PluginInterface):
def __init__(self):
# Make resource directory configurable
self.data_dir = os.getenv("EASYHAPROXY_MY_PLUGIN_DATA_DIR", "/etc/haproxy/my_plugin_data")
def initialize(self) -> InitializationResult:
return InitializationResult(
resources=[
ResourceRequest(resource_type="directory", path=self.data_dir)
]
)
```
---
## Best Practices ## Best Practices
### 1. Error Handling ### 1. Use Plugin Initialization for Resource Setup
**[New in v2.0]** Use the `initialize()` method to request file system resources.
**Do:**
```python
def initialize(self) -> InitializationResult:
return InitializationResult(
resources=[
ResourceRequest(resource_type="directory", path=self.data_dir)
]
)
def process(self, context: PluginContext) -> PluginResult:
# Directory already exists, just use it
filepath = os.path.join(self.data_dir, "data.txt")
with open(filepath, 'w') as f:
f.write("data")
```
**Don't:**
```python
def process(self, context: PluginContext) -> PluginResult:
# Creating directories in process() is inefficient
os.makedirs(self.data_dir, exist_ok=True) # Called on every execution!
filepath = os.path.join(self.data_dir, "data.txt")
```
### 2. Use Typed Result Fields for Config Injection
**[New in v2.0]** Use `global_configs` and `defaults_configs` fields instead of metadata.
**Do:**
```python
return PluginResult(
haproxy_config="use-fcgi-app fcgi_example",
global_configs=["fcgi-app fcgi_example\n docroot /var/www"],
defaults_configs=['log-format "..."']
)
```
**Don't:**
```python
# Deprecated: Don't put config in metadata
return PluginResult(
haproxy_config="use-fcgi-app fcgi_example",
metadata={
"fcgi_app_definition": "fcgi-app fcgi_example\n docroot /var/www" # Wrong!
}
)
```
### 3. Error Handling
Always handle errors gracefully to avoid breaking HAProxy configuration. Always handle errors gracefully to avoid breaking HAProxy configuration.
@ -1130,7 +1369,7 @@ def configure(self, config: dict) -> None:
try: try:
self.port = int(config["port"]) self.port = int(config["port"])
except ValueError: except ValueError:
loggerEasyHaproxy.warning(f"Invalid port value: {config['port']}, using default") logger_easyhaproxy.warning(f"Invalid port value: {config['port']}, using default")
self.port = 8080 self.port = 8080
``` ```
@ -1140,7 +1379,7 @@ def configure(self, config: dict) -> None:
self.port = int(config["port"]) # Crashes if not an integer! self.port = int(config["port"]) # Crashes if not an integer!
``` ```
### 2. Configuration Validation ### 4. Configuration Validation
Validate configuration during `configure()` phase, not during `process()`. Validate configuration during `configure()` phase, not during `process()`.
@ -1153,7 +1392,7 @@ def configure(self, config: dict) -> None:
# Validate IPs # Validate IPs
if not self.allowed_ips: if not self.allowed_ips:
loggerEasyHaproxy.warning("IP whitelist plugin: No valid IPs configured") logger_easyhaproxy.warning("IP whitelist plugin: No valid IPs configured")
self.enabled = False self.enabled = False
``` ```
@ -1165,7 +1404,7 @@ def process(self, context: PluginContext) -> PluginResult:
raise ValueError("No IPs configured") raise ValueError("No IPs configured")
``` ```
### 3. Use Metadata for Debugging ### 5. Use Metadata for Debugging
Include useful debugging information in metadata. Include useful debugging information in metadata.
@ -1182,7 +1421,7 @@ return PluginResult(
) )
``` ```
### 4. Handle Boolean Configuration ### 6. Handle Boolean Configuration
Support multiple boolean formats (true/false, 1/0, yes/no). Support multiple boolean formats (true/false, 1/0, yes/no).
@ -1192,7 +1431,7 @@ def configure(self, config: dict) -> None:
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
``` ```
### 5. Support Multiple Configuration Formats ### 7. Support Multiple Configuration Formats
Support both list and comma-separated string formats for lists. Support both list and comma-separated string formats for lists.
@ -1209,7 +1448,7 @@ def configure(self, config: dict) -> None:
self.paths = [] self.paths = []
``` ```
### 6. Use Descriptive Names ### 8. Use Descriptive Names
Use clear, descriptive names for plugins, configuration keys, and ACLs. Use clear, descriptive names for plugins, configuration keys, and ACLs.
@ -1233,7 +1472,7 @@ def name(self) -> str:
acl p1 path_beg /api # What is p1? acl p1 path_beg /api # What is p1?
``` ```
### 7. Document Your Plugin ### 9. Document Your Plugin
Include comprehensive docstrings with configuration examples. Include comprehensive docstrings with configuration examples.
@ -1259,7 +1498,7 @@ Example Container Label:
""" """
``` ```
### 8. Return Empty Result When Disabled ### 10. Return Empty Result When Disabled
Always check `enabled` flag and return empty result early. Always check `enabled` flag and return empty result early.
@ -1271,27 +1510,27 @@ def process(self, context: PluginContext) -> PluginResult:
# Plugin logic here... # Plugin logic here...
``` ```
### 9. Use Logger Appropriately ### 11. Use Logger Appropriately
Use appropriate log levels for different messages. Use appropriate log levels for different messages.
```python ```python
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
# For debugging # For debugging
loggerEasyHaproxy.debug(f"Processing domain: {context.domain}") logger_easyhaproxy.debug(f"Processing domain: {context.domain}")
# For informational messages # For informational messages
loggerEasyHaproxy.info(f"Loaded plugin configuration: {self.name}") logger_easyhaproxy.info(f"Loaded plugin configuration: {self.name}")
# For warnings (non-fatal issues) # For warnings (non-fatal issues)
loggerEasyHaproxy.warning(f"Invalid configuration value, using default") logger_easyhaproxy.warning(f"Invalid configuration value, using default")
# For errors (fatal issues) # For errors (fatal issues)
loggerEasyHaproxy.error(f"Failed to load required file: {filepath}") logger_easyhaproxy.error(f"Failed to load required file: {filepath}")
``` ```
### 10. Make Domain-Safe Identifiers ### 12. Make Domain-Safe Identifiers
Replace special characters when generating HAProxy identifiers. Replace special characters when generating HAProxy identifiers.
@ -1611,8 +1850,8 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
2. **Add debug statements** 2. **Add debug statements**
```python ```python
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
loggerEasyHaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}") logger_easyhaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}")
loggerEasyHaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}") logger_easyhaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}")
# ... rest of plugin logic # ... rest of plugin logic
``` ```
@ -1630,7 +1869,7 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
# Risky operation # Risky operation
result = self.do_something_risky() result = self.do_something_risky()
except Exception as e: except Exception as e:
loggerEasyHaproxy.error(f"Plugin {self.name} error: {str(e)}") logger_easyhaproxy.error(f"Plugin {self.name} error: {str(e)}")
return PluginResult() # Return empty result on error return PluginResult() # Return empty result on error
``` ```

View file

@ -73,11 +73,11 @@ class HaproxyConfigGenerator:
try: try:
from plugins import PluginManager from plugins import PluginManager
self.plugin_manager = PluginManager( self.plugin_manager = PluginManager(
plugins_dir="/etc/haproxy/plugins",
abort_on_error=self.mapping.get("plugins", {}).get("abort_on_error", False) abort_on_error=self.mapping.get("plugins", {}).get("abort_on_error", False)
) )
self.plugin_manager.load_plugins() self.plugin_manager.load_plugins()
self.plugin_manager.configure_plugins(self.mapping.get("plugins", {})) self.plugin_manager.configure_plugins(self.mapping.get("plugins", {}))
self.plugin_manager.initialize_plugins()
self.global_plugin_configs = [] self.global_plugin_configs = []
except Exception as e: except Exception as e:
# If plugin system fails to initialize, log but continue # If plugin system fails to initialize, log but continue
@ -111,14 +111,20 @@ class HaproxyConfigGenerator:
enabled_list = [] enabled_list = []
global_results = self.plugin_manager.execute_global_plugins(global_context, 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: for result in global_results:
if result.metadata and "defaults_config" in result.metadata: # HAProxy config snippets
config = result.metadata["defaults_config"] 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: if config and config not in self.defaults_plugin_configs:
self.defaults_plugin_configs.append(config) self.defaults_plugin_configs.append(config)
except Exception as e: except Exception as e:
@ -298,41 +304,25 @@ class HaproxyConfigGenerator:
enabled_list=enabled_plugins enabled_list=enabled_plugins
) )
# Store domain plugin configs for this host # Extract all plugin configs in a single loop
easymapping[port]["hosts"][hostname]["plugin_configs"] = [ plugin_configs_for_host = []
r.haproxy_config for r in domain_results if r.haproxy_config
]
# Write JWT public key files from metadata
for result in domain_results: for result in domain_results:
if result.metadata and "pubkey_content" in result.metadata and "pubkey_file" in result.metadata: # HAProxy config snippets for this domain
pubkey_file = result.metadata["pubkey_file"] if result.haproxy_config:
pubkey_content = result.metadata["pubkey_content"] plugin_configs_for_host.append(result.haproxy_config)
# Create jwt_keys directory if it doesn't exist (belt and suspenders) # Global-level configs (e.g., fcgi-app definitions)
import os for config in result.global_configs:
jwt_keys_dir = os.path.dirname(pubkey_file) if config and config not in self.global_plugin_configs:
if jwt_keys_dir: self.global_plugin_configs.append(config)
os.makedirs(jwt_keys_dir, exist_ok=True)
# Write the pubkey file # Defaults-level configs (e.g., log-format)
Functions.save(pubkey_file, pubkey_content) for config in result.defaults_configs:
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"]
if config and config not in self.defaults_plugin_configs: if config and config not in self.defaults_plugin_configs:
self.defaults_plugin_configs.append(config) 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: except Exception as e:
logger_easyhaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}") logger_easyhaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}")
easymapping[port]["hosts"][hostname]["plugin_configs"] = [] easymapping[port]["hosts"][hostname]["plugin_configs"] = []

View file

@ -277,7 +277,6 @@ class Consts:
custom_config_folder = "/etc/haproxy/conf.d" custom_config_folder = "/etc/haproxy/conf.d"
certs_certbot = "/certs/certbot" certs_certbot = "/certs/certbot"
certs_haproxy = "/certs/haproxy" certs_haproxy = "/certs/haproxy"
jwt_keys = "/etc/haproxy/jwt_keys"
class DaemonizeHAProxy: class DaemonizeHAProxy:

View file

@ -20,7 +20,6 @@ def start():
os.makedirs(Consts.certs_certbot, exist_ok=True) os.makedirs(Consts.certs_certbot, exist_ok=True)
os.makedirs(Consts.certs_haproxy, 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_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy) processor_obj.save_certs(Consts.certs_haproxy)

View file

@ -26,12 +26,30 @@ class PluginContext:
host_config: dict | None = None # Domain-specific config 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 @dataclass
class PluginResult: class PluginResult:
"""Plugin execution result""" """Plugin execution result"""
haproxy_config: str = "" # HAProxy config snippet to inject haproxy_config: str = "" # HAProxy config snippet to inject
modified_easymapping: list | None = None # Modified easymapping structure modified_easymapping: list | None = None # Modified easymapping structure
metadata: dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging 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): class PluginInterface(ABC):
@ -72,19 +90,31 @@ class PluginInterface(ABC):
""" """
pass pass
def initialize(self) -> InitializationResult:
"""
Initialize plugin resources. Default: no-op for backward compatibility
Returns:
InitializationResult with resource requests
"""
return InitializationResult()
class PluginManager: class PluginManager:
"""Manages plugin loading, configuration, and execution""" """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 Initialize the plugin manager
Args: 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 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.abort_on_error = abort_on_error
self.plugins: dict[str, PluginInterface] = {} self.plugins: dict[str, PluginInterface] = {}
self.global_plugins: list[PluginInterface] = [] self.global_plugins: list[PluginInterface] = []
@ -104,7 +134,7 @@ class PluginManager:
if os.path.exists(self.plugins_dir): if os.path.exists(self.plugins_dir):
self._load_plugins_from_directory(self.plugins_dir, "external") self._load_plugins_from_directory(self.plugins_dir, "external")
else: 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: def _load_plugins_from_directory(self, directory: str, source: str) -> None:
""" """
@ -175,6 +205,33 @@ class PluginManager:
except Exception as e: except Exception as e:
self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(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]: def execute_global_plugins(self, context: PluginContext, enabled_list: list[str] | None = None) -> list[PluginResult]:
""" """
Execute all global plugins Execute all global plugins

View file

@ -49,7 +49,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from functions import logger_easyhaproxy from functions import logger_easyhaproxy
from plugins import PluginContext, PluginInterface, PluginResult, PluginType from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
class CloudflarePlugin(PluginInterface): class CloudflarePlugin(PluginInterface):
@ -131,6 +131,23 @@ class CloudflarePlugin(PluginInterface):
if "update_log_format" in config: if "update_log_format" in config:
self.update_log_format = str(config["update_log_format"]).lower() in ["true", "1", "yes"] 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: def process(self, context: PluginContext) -> PluginResult:
""" """
Generate HAProxy config to restore original IP from Cloudflare Generate HAProxy config to restore original IP from Cloudflare
@ -161,12 +178,7 @@ class CloudflarePlugin(PluginInterface):
# Write IPs to file if we have any # Write IPs to file if we have any
if ips_to_write: if ips_to_write:
try: try:
# Create directory if needed # Write IPs to file (directory created by initialize())
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
with open(self.ip_list_path, 'w') as f: with open(self.ip_list_path, 'w') as f:
for ip_range in ips_to_write: for ip_range in ips_to_write:
f.write(f"{ip_range}\n") 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, "ip_list_provided": self.ip_list is not None,
"use_builtin_ips": self.use_builtin_ips, "use_builtin_ips": self.use_builtin_ips,
"update_log_format": self.update_log_format, "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_count": len(ips_to_write) if ips_to_write else None,
"ip_source": ip_source if ips_to_write else "existing file" "ip_source": ip_source if ips_to_write else "existing file"
} },
defaults_configs=[log_format_config] if log_format_config else []
) )

View file

@ -136,11 +136,10 @@ class FastcgiPlugin(PluginInterface):
fcgi_app_definition = "\n".join(fcgi_app_lines) 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 = { metadata = {
"domain": context.domain, "domain": context.domain,
"fcgi_app_name": fcgi_app_name, "fcgi_app_name": fcgi_app_name,
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
"document_root": self.document_root, "document_root": self.document_root,
"index_file": self.index_file, "index_file": self.index_file,
"path_info": self.path_info, "path_info": self.path_info,
@ -150,5 +149,6 @@ class FastcgiPlugin(PluginInterface):
return PluginResult( return PluginResult(
haproxy_config=backend_config, # use-fcgi-app directive for the backend haproxy_config=backend_config, # use-fcgi-app directive for the backend
modified_easymapping=None, modified_easymapping=None,
metadata=metadata metadata=metadata,
global_configs=[fcgi_app_definition] # For top-level injection
) )

View file

@ -100,8 +100,8 @@ import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from functions import Consts, logger_easyhaproxy from functions import Functions, logger_easyhaproxy
from plugins import PluginContext, PluginInterface, PluginResult, PluginType from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
class JwtValidatorPlugin(PluginInterface): class JwtValidatorPlugin(PluginInterface):
@ -117,6 +117,8 @@ class JwtValidatorPlugin(PluginInterface):
self.paths = [] # List of paths that require JWT validation self.paths = [] # List of paths that require JWT validation
self.only_paths = False # If true, only specified paths are accessible self.only_paths = False # If true, only specified paths are accessible
self.allow_anonymous = False # If true, allow requests without Authorization header 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 @property
def name(self) -> str: def name(self) -> str:
@ -185,6 +187,19 @@ class JwtValidatorPlugin(PluginInterface):
if "allow_anonymous" in config: if "allow_anonymous" in config:
self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"] 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: def process(self, context: PluginContext) -> PluginResult:
""" """
Generate HAProxy config to validate JWT tokens Generate HAProxy config to validate JWT tokens
@ -204,7 +219,18 @@ class JwtValidatorPlugin(PluginInterface):
elif self.pubkey: elif self.pubkey:
# Generate path for pubkey based on domain # Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_") 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: else:
logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult() return PluginResult()
@ -297,6 +323,7 @@ class JwtValidatorPlugin(PluginInterface):
if self.audience: if self.audience:
metadata["audience"] = self.audience metadata["audience"] = self.audience
if self.pubkey: if self.pubkey:
# Keep pubkey_content in metadata for backward compatibility with tests
metadata["pubkey_content"] = self.pubkey metadata["pubkey_content"] = self.pubkey
if self.paths: if self.paths:
metadata["paths"] = self.paths metadata["paths"] = self.paths

View file

@ -126,7 +126,7 @@ class TestCloudflarePlugin:
assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in haproxy_config assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in haproxy_config
assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP)" in haproxy_config assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP)" in haproxy_config
assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)]" in haproxy_config assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)]" in haproxy_config
# Verify log-format is in defaults section (from defaults_config) # Verify log-format is in defaults section (from defaults_configs)
assert "log-format" in haproxy_config assert "log-format" in haproxy_config
assert "%{+Q}[var(txn.real_ip)]" in haproxy_config assert "%{+Q}[var(txn.real_ip)]" in haproxy_config
@ -987,9 +987,9 @@ class TestFastcgiPlugin:
assert result.haproxy_config is not None assert result.haproxy_config is not None
assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config
# Check fcgi-app definition in metadata # Check fcgi-app definition in global_configs
assert "fcgi_app_definition" in result.metadata assert len(result.global_configs) == 1
fcgi_app_def = result.metadata["fcgi_app_definition"] fcgi_app_def = result.global_configs[0]
assert "fcgi-app fcgi_phpapp_local" in fcgi_app_def assert "fcgi-app fcgi_phpapp_local" in fcgi_app_def
assert "docroot /var/www/html" in fcgi_app_def assert "docroot /var/www/html" in fcgi_app_def
assert "index index.php" in fcgi_app_def assert "index index.php" in fcgi_app_def
@ -1020,9 +1020,9 @@ class TestFastcgiPlugin:
assert result.haproxy_config is not None assert result.haproxy_config is not None
assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config
# Check custom params in fcgi-app definition in metadata # Check custom params in fcgi-app definition in global_configs
assert "fcgi_app_definition" in result.metadata assert len(result.global_configs) == 1
fcgi_app_def = result.metadata["fcgi_app_definition"] fcgi_app_def = result.global_configs[0]
assert "set-param CUSTOM_VAR custom_value" in fcgi_app_def assert "set-param CUSTOM_VAR custom_value" in fcgi_app_def
assert "set-param APP_ENV production" in fcgi_app_def assert "set-param APP_ENV production" in fcgi_app_def
assert result.metadata["custom_params_count"] == 2 assert result.metadata["custom_params_count"] == 2