From 57387f3e3222a9f1d4dff5d035c1fce43fbbdad3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:35:37 -0500 Subject: [PATCH] Add IP Whitelist Plugin and corresponding tests - Introduced `IpWhitelistPlugin` (DOMAIN): Restricts access to specific IP addresses or CIDR ranges. - Added configuration options: `enabled`, `allowed_ips`, and `status_code`. - Updated `plugins.md` and `plugin-development.md` to document `IpWhitelistPlugin`. - Created fixtures for `services-with-ip-whitelist`. - Added extensive test cases for `IpWhitelistPlugin` to validate configuration and HAProxy output generation. - Ensured seamless integration into the plugin framework alongside other domain plugins. --- docs/plugin-development.md | 1217 ++++++++++++++--- docs/plugins.md | 534 ++++---- src/plugins/builtin/ip_whitelist.py | 107 ++ src/tests/fixtures/services-with-ip-whitelist | 10 + src/tests/test_plugins.py | 109 +- 5 files changed, 1549 insertions(+), 428 deletions(-) create mode 100644 src/plugins/builtin/ip_whitelist.py create mode 100644 src/tests/fixtures/services-with-ip-whitelist diff --git a/docs/plugin-development.md b/docs/plugin-development.md index f9f3a67..79b0141 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -2,29 +2,583 @@ sidebar_position: 17 --- -# EasyHAProxy Plugin System - Developer Guide +# Plugin Developer Guide -## Overview +This guide explains how to create custom plugins for EasyHAProxy. For information on using existing plugins, see [Using Plugins](plugins.md). -The EasyHAProxy plugin system allows you to extend HAProxy configuration generation with custom functionality. Plugins are invoked during the discovery cycle and can inject HAProxy configuration snippets or modify the discovery data. +## Architecture Overview -## Plugin Types +### Plugin Lifecycle -### GLOBAL Plugins -- **Execution**: Once per discovery cycle -- **Use Cases**: Cleanup tasks, global configuration, monitoring, DNS updates -- **Example**: `CleanupPlugin` +``` +1. Discovery Cycle Starts + │ +2. PluginManager loads plugins + ├─ Load builtin plugins from src/plugins/builtin/ + └─ Load external plugins from /etc/haproxy/plugins/ + │ +3. PluginManager configures plugins + ├─ Read global config (YAML/env vars) + └─ Call plugin.configure(config) + │ +4. Parse container metadata → easymapping + │ +5. Execute GLOBAL plugins (once) + ├─ Create PluginContext (no domain info) + ├─ Call plugin.process(context) + └─ Collect PluginResult + │ +6. For each discovered domain: + ├─ Extract plugin list from labels + ├─ Extract plugin configs from labels + ├─ Call plugin.configure(label_config) + ├─ Execute DOMAIN plugins + │ ├─ Create PluginContext (with domain info) + │ ├─ Call plugin.process(context) + │ └─ Collect PluginResult + └─ Store plugin HAProxy configs + │ +7. Render Jinja2 template + ├─ Inject global plugin configs + └─ Inject domain plugin configs per backend + │ +8. Generate final HAProxy config +``` -### DOMAIN Plugins -- **Execution**: Once for each discovered domain/host -- **Use Cases**: Domain-specific configuration, IP restoration, path blocking, custom headers -- **Example**: `CloudflarePlugin`, `DenyPagesPlugin` +## Built-in Plugins Reference -## Creating a Custom Plugin +EasyHAProxy includes four built-in plugins that serve as both functional tools and reference implementations for plugin development. -### 1. Plugin Structure +### CloudflarePlugin (DOMAIN) -Create a Python file in `/etc/haproxy/plugins/` or `/scripts/plugins/builtin/`: +**Purpose:** Restore original visitor IP addresses when using Cloudflare CDN + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/cloudflare.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `ip_list_path` (str) - Path to Cloudflare IP list file (default: `/etc/haproxy/cloudflare_ips.lst`) + +**What it does:** +- Reads Cloudflare IP ranges from a file +- Checks if request comes from Cloudflare IP +- Restores original visitor IP from `CF-Connecting-IP` header + +**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 +``` + +**Usage:** See [Cloudflare Plugin documentation](plugins.md#cloudflare-plugin-domain) + +### CleanupPlugin (GLOBAL) + +**Purpose:** Perform cleanup tasks during discovery cycle + +**Type:** GLOBAL - Executes once per discovery cycle + +**Source:** `src/plugins/builtin/cleanup.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `max_idle_time` (int) - Max file age in seconds before deletion (default: `300`) +- `cleanup_temp_files` (bool) - Enable temp file cleanup (default: `true`) + +**What it does:** +- Scans `/tmp` for files prefixed with `easyhaproxy_` +- Removes files older than `max_idle_time` seconds +- Logs cleanup actions to metadata + +**HAProxy config generated:** None (performs cleanup only) + +**Usage:** See [Cleanup Plugin documentation](plugins.md#cleanup-plugin-global) + +### DenyPagesPlugin (DOMAIN) + +**Purpose:** Block access to specific paths for a domain + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/deny_pages.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `paths` (str) - Comma-separated list of paths to block (e.g., `/admin,/private`) +- `status_code` (int) - HTTP status code to return (default: `403`) + +**What it does:** +- Parses comma-separated list of paths +- Creates HAProxy ACL matching those paths +- Returns specified HTTP status code for matching requests + +**HAProxy config generated:** +``` +# Deny Pages - Block specific paths +acl denied_path path_beg /admin /private +http-request deny deny_status 403 if denied_path +``` + +**Usage:** See [Deny Pages Plugin documentation](plugins.md#deny-pages-plugin-domain) + +### IpWhitelistPlugin (DOMAIN) + +**Purpose:** Restrict domain access to specific IP addresses or CIDR ranges + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/ip_whitelist.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `allowed_ips` (str) - Comma-separated list of IPs/CIDR ranges to allow (e.g., `192.168.1.0/24,10.0.0.1`) +- `status_code` (int) - HTTP status code to return for blocked IPs (default: `403`) + +**What it does:** +- Parses comma-separated list of IPs and CIDR ranges +- Creates HAProxy ACL matching whitelisted IPs +- Denies all requests NOT from whitelisted IPs + +**HAProxy config generated:** +``` +# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 +http-request deny deny_status 403 if !whitelisted_ip +``` + +**Usage:** See [IP Whitelist Plugin documentation](plugins.md#ip-whitelist-plugin-domain) + +### Summary Table + +| Plugin | Type | Purpose | Config Generated | +|----------------|--------|------------------------------|------------------| +| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | +| `cleanup` | GLOBAL | Clean up temp files | ❌ No | +| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | +| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | + +### Learning from Built-in Plugins + +**Best practices demonstrated:** + +1. **CloudflarePlugin** shows: + - Reading external files (IP list) + - Conditional HAProxy ACLs + - Header manipulation + +2. **CleanupPlugin** shows: + - GLOBAL plugin pattern + - File system operations + - Metadata-only results (no HAProxy config) + +3. **DenyPagesPlugin** shows: + - Parsing comma-separated config values + - Configurable status codes + - Path-based ACLs + +4. **IpWhitelistPlugin** shows: + - IP-based access control + - Negated ACLs (`if !whitelisted_ip`) + - CIDR range support + +**View the source code:** +- [cloudflare.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cloudflare.py) +- [cleanup.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cleanup.py) +- [deny_pages.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/deny_pages.py) +- [ip_whitelist.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/ip_whitelist.py) + +## Plugin API Reference + +### PluginInterface (Abstract Base Class) + +All plugins must inherit from `PluginInterface` and implement these abstract methods: + +```python +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + +class MyPlugin(PluginInterface): + @property + @abstractmethod + def name(self) -> str: + """ + Return unique plugin identifier. + + Used for: + - Configuration lookups + - Enable/disable via labels + - Logging + + Must be: + - Unique across all plugins + - Lowercase with underscores + - Match filename (e.g., my_plugin.py → "my_plugin") + """ + pass + + @property + @abstractmethod + def plugin_type(self) -> PluginType: + """ + Return plugin execution type. + + Returns: + PluginType.GLOBAL - Execute once per discovery cycle + PluginType.DOMAIN - Execute once per domain/host + """ + pass + + @abstractmethod + def configure(self, config: dict) -> None: + """ + Configure plugin with settings from YAML/env/labels. + + Called: + - Once at startup with global config + - Before each execution with label-specific config (domain plugins) + + Args: + config: Dictionary with plugin configuration + Keys are configuration option names + Values are strings from YAML/env/labels + + Common pattern: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + if "my_option" in config: + self.my_option = config["my_option"] + """ + pass + + @abstractmethod + def process(self, context: PluginContext) -> PluginResult: + """ + Execute plugin logic. + + Args: + context: PluginContext with all execution data + + Returns: + PluginResult with HAProxy config snippets and/or metadata + + Common pattern: + if not self.enabled: + return PluginResult() # Empty result when disabled + + # Generate config + haproxy_config = "..." + + return PluginResult( + haproxy_config=haproxy_config, + metadata={"info": "value"} + ) + """ + pass +``` + +### PluginType (Enum) + +Defines when plugins execute: + +```python +class PluginType(Enum): + GLOBAL = "global" # Execute once per discovery cycle + DOMAIN = "domain" # Execute once per domain/host +``` + +**GLOBAL plugins:** +- Execute once regardless of how many domains exist +- Receive empty domain/port/host_config in context +- Use cases: cleanup, monitoring, DNS updates + +**DOMAIN plugins:** +- Execute for each discovered domain +- Receive domain-specific data in context +- Use cases: per-domain config, headers, path blocking + +### PluginContext (Data Class) + +Contains all data available to plugins during execution: + +```python +@dataclass +class PluginContext: + parsed_object: dict # Discovery data: {IP: labels} + easymapping: list # HAProxy mapping structure + container_env: dict # Global environment config + domain: Optional[str] = None # Domain name (DOMAIN plugins only) + port: Optional[str] = None # Port (DOMAIN plugins only) + host_config: Optional[dict] = None # Host config (DOMAIN plugins only) +``` + +**Fields explained:** + +#### `parsed_object: dict` + +Container discovery data mapping IP addresses to labels. + +**Example:** +```python +{ + "192.168.1.10": { + "easyhaproxy.http.host": "example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "cloudflare,deny_pages", + "easyhaproxy.http.plugin.deny_pages.paths": "/admin" + }, + "192.168.1.20": { + "easyhaproxy.http.host": "other.com", + "easyhaproxy.http.port": "80" + } +} +``` + +**Use cases:** +- Iterate over all discovered containers +- Access container labels directly +- Global plugins analyzing all containers + +#### `easymapping: list` + +Parsed HAProxy configuration structure before template rendering. + +**Example:** +```python +[ + { + "port": "80", + "mode": "http", + "ssl-check": "", + "hosts": { + "example.com": { + "containers": ["192.168.1.10:8080"], + "balance": "roundrobin", + "certbot": False, + "redirect_ssl": False, + "plugin_configs": [] + } + }, + "redirect": {} + } +] +``` + +**Use cases:** +- Analyze discovered hosts +- Modify discovery structure (advanced) + +#### `container_env: dict` + +Global configuration from YAML and environment variables. + +**Example:** +```python +{ + "customerrors": False, + "ssl_mode": "default", + "certbot": { + "email": "admin@example.com" + }, + "stats": { + "port": 1936, + "username": "admin" + }, + "plugins": { + "enabled": ["cleanup"], + "abort_on_error": False, + "config": { + "cleanup": { + "max_idle_time": "300" + } + } + } +} +``` + +**Use cases:** +- Access global settings +- Check certbot configuration +- Read global plugin config + +#### `domain: Optional[str]` + +Domain name for DOMAIN plugins. `None` for GLOBAL plugins. + +**Example:** `"example.com"` + +#### `port: Optional[str]` + +Port for DOMAIN plugins. `None` for GLOBAL plugins. + +**Example:** `"80"`, `"443"` + +#### `host_config: Optional[dict]` + +Host-specific configuration for DOMAIN plugins. `None` for GLOBAL plugins. + +**Example:** +```python +{ + "containers": ["192.168.1.10:8080"], + "balance": "roundrobin", + "certbot": False, + "redirect_ssl": False, + "plugin_configs": [] +} +``` + +### PluginResult (Data Class) + +``` +Plugin.process(context) → PluginResult + ├─ haproxy_config (what to add to HAProxy config) + ├─ modified_easymapping (optional: modify discovery data) + └─ metadata (optional: debug/logging info) +``` + +**What is PluginResult?** + +`PluginResult` is a container object that your plugin returns from its `process()` method. It holds everything the plugin produced during execution. + +**Why does it exist?** + +Plugins need to return multiple pieces of information: +- HAProxy configuration snippets to inject +- Optional modifications to the discovery data +- Metadata for logging and debugging + +Instead of returning multiple values, plugins return one `PluginResult` object containing all this information. + +**How do you use it?** + +Every `process()` method must return a `PluginResult`: + +```python +def process(self, context: PluginContext) -> PluginResult: + # Plugin is disabled - return empty result + if not self.enabled: + return PluginResult() + + # Plugin is enabled - return config + return PluginResult( + haproxy_config="http-request set-header X-Custom-Header 'value'", + metadata={"info": "some debug info"} + ) +``` + +**PluginResult structure:** + +```python +@dataclass +class PluginResult: + haproxy_config: str = "" # HAProxy config to inject + modified_easymapping: Optional[list] = None # Modified discovery data (advanced) + metadata: Dict[str, Any] = field(default_factory=dict) # Debug/logging info +``` + +**Common usage patterns:** + +```python +# Empty result (plugin disabled or nothing to do) +return PluginResult() + +# Config only (most common) +return PluginResult( + haproxy_config="# My config\n directive value" +) + +# Config + metadata (recommended) +return PluginResult( + haproxy_config="# My config\n directive value", + metadata={"domain": context.domain, "option": self.my_option} +) + +# All fields (advanced) +return PluginResult( + haproxy_config="# My config\n directive value", + modified_easymapping=modified_data, + metadata={"info": "value"} +) +``` + +**Fields explained:** + +#### `haproxy_config: str` + +HAProxy configuration snippet to inject into the generated config. + +**For DOMAIN plugins:** Injected into the backend section for that domain. + +**Example:** +```python +haproxy_config = """# Cloudflare IP Restoration + 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""" +``` + +**Important:** +- Include leading spaces/tabs for proper indentation +- Add comment describing what the config does +- Use HAProxy directives that make sense for backend context (domain plugins) +- Return empty string `""` when plugin is disabled or has nothing to add + +#### `modified_easymapping: Optional[list]` + +Modified easymapping structure. **Advanced feature, rarely used.** + +**When to use:** +- Modify discovery data before template rendering +- Add/remove hosts dynamically +- Change port mappings + +**Default:** `None` (don't modify easymapping) + +**Example:** +```python +# Add a new host to port 80 +modified = context.easymapping.copy() +modified[0]["hosts"]["new-host.com"] = { + "containers": ["192.168.1.30:8080"], + "balance": "roundrobin", + "certbot": False, + "redirect_ssl": False, + "plugin_configs": [] +} +return PluginResult(modified_easymapping=modified) +``` + +#### `metadata: Dict[str, Any]` + +Metadata for logging and debugging. Not used in HAProxy config. + +**Example:** +```python +metadata = { + "domain": context.domain, + "blocked_paths": ["/admin", "/private"], + "status_code": 403, + "files_cleaned": 5 +} +``` + +**Use cases:** +- Debug information +- Statistics +- Audit trail + +**Logged at DEBUG level:** +``` +DEBUG: Plugin deny_pages metadata: {'domain': 'example.com', 'blocked_paths': ['/admin'], 'status_code': 403} +``` + +## Creating a Plugin: Step-by-Step + +### Step 1: Create Plugin File + +Create `/etc/haproxy/plugins/my_plugin.py`: ```python import os @@ -34,216 +588,117 @@ import sys 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 # Optional: for logging +``` +### Step 2: Define Plugin Class -class MyCustomPlugin(PluginInterface): - """Your plugin description""" +```python +class MyPlugin(PluginInterface): + """ + Brief description of what your plugin does. + + Configuration: + - enabled: Enable/disable plugin (default: true) + - my_option: Description of option + + Example: + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_option: value + """ def __init__(self): - # Initialize plugin state + """Initialize plugin with default values""" self.enabled = True - self.my_config_value = "default" + self.my_option = "default_value" +``` +### Step 3: Implement Required Methods + +```python @property def name(self) -> str: - """Return unique plugin name""" - return "my_custom_plugin" + return "my_plugin" # Must match filename @property def plugin_type(self) -> PluginType: - """Return GLOBAL or DOMAIN""" - return PluginType.DOMAIN + return PluginType.DOMAIN # or PluginType.GLOBAL def configure(self, config: dict) -> None: - """ - Configure plugin from YAML/env/labels - - Args: - config: Configuration dictionary - """ + """Parse configuration from YAML/env/labels""" if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "my_config_value" in config: - self.my_config_value = config["my_config_value"] + if "my_option" in config: + self.my_option = config["my_option"] + + loggerEasyHaproxy.debug(f"Configured {self.name}: enabled={self.enabled}, my_option={self.my_option}") def process(self, context: PluginContext) -> PluginResult: - """ - Process plugin logic - - Args: - context: PluginContext with execution data - - Returns: - PluginResult with HAProxy config and/or metadata - """ + """Execute plugin logic""" + # Return empty result if disabled if not self.enabled: return PluginResult() - # Generate HAProxy configuration snippet - haproxy_config = """# My Custom Plugin - # Add your HAProxy directives here - http-request set-header X-Custom-Header "value" - """ + # Generate HAProxy config snippet + haproxy_config = f"""# My Plugin - Description + http-request set-header X-My-Header "{self.my_option}\"""" + # Return result return PluginResult( haproxy_config=haproxy_config, - modified_easymapping=None, # Optional: modify discovery data - metadata={"info": "my metadata"} # Optional: logging info + metadata={ + "domain": context.domain, + "my_option": self.my_option + } ) ``` -### 2. Plugin Context Data - -The `PluginContext` object provides: - -```python -@dataclass -class PluginContext: - parsed_object: dict # {IP: labels} from container discovery - easymapping: list # Current HAProxy mapping structure - container_env: dict # Environment configuration - domain: Optional[str] # Domain name (DOMAIN plugins only) - port: Optional[str] # Port (DOMAIN plugins only) - host_config: Optional[dict] # Domain config (DOMAIN plugins only) -``` - -**Example parsed_object:** -```python -{ - "192.168.1.10": { - "easyhaproxy.http.host": "example.com", - "easyhaproxy.http.port": "80", - "easyhaproxy.http.localport": "8080" - } -} -``` - -**Example easymapping:** -```python -[ - { - "port": "80", - "mode": "http", - "hosts": { - "example.com": { - "containers": ["192.168.1.10:8080"], - "certbot": False, - "balance": "roundrobin" - } - } - } -] -``` - -### 3. Plugin Configuration - -Plugins can be configured via: - -#### YAML (`/etc/haproxy/static/config.yaml`): -```yaml -plugins: - my_custom_plugin: - enabled: true - my_config_value: "custom" -``` - -#### Environment Variables: -```bash -EASYHAPROXY_PLUGINS_ENABLED=my_custom_plugin -EASYHAPROXY_PLUGIN_MY_CUSTOM_PLUGIN_MY_CONFIG_VALUE=custom -``` - -#### Container Labels: -```yaml -labels: - easyhaproxy.http.plugins: "my_custom_plugin" - easyhaproxy.http.plugin.my_custom_plugin.my_config_value: "custom" -``` - -## Built-in Plugins - -### CloudflarePlugin (DOMAIN) -- **Purpose**: Restore original visitor IP from Cloudflare headers -- **Config**: `ip_list_path` - Path to Cloudflare IP list -- **Location**: `/scripts/plugins/builtin/cloudflare.py` - -### CleanupPlugin (GLOBAL) -- **Purpose**: Cleanup temporary files during discovery -- **Config**: `max_idle_time`, `cleanup_temp_files` -- **Location**: `/scripts/plugins/builtin/cleanup.py` - -### DenyPagesPlugin (DOMAIN) -- **Purpose**: Block access to specific paths -- **Config**: `paths`, `status_code` -- **Location**: `/scripts/plugins/builtin/deny_pages.py` - -## Plugin Lifecycle - -``` -Discovery Cycle - ├─ Container/Service Discovery - ├─ Parse Metadata → easymapping - ├─ Execute GLOBAL plugins (once) - ├─ For each domain: - │ ├─ Execute DOMAIN plugins - │ └─ Store plugin configs - ├─ Render Jinja2 template with plugin snippets - └─ Generate final HAProxy config -``` - -## Error Handling - -### Default Behavior (Log and Continue) -```yaml -plugins: - abort_on_error: false # Default -``` -- Plugin errors are logged as warnings -- Discovery cycle continues -- HAProxy config generation proceeds - -### Abort on Error -```yaml -plugins: - abort_on_error: true -``` -- Plugin errors stop the discovery cycle -- Previous HAProxy config remains active -- Useful for critical plugins - -## Best Practices - -1. **Keep It Simple**: Plugins should do one thing well -2. **Error Handling**: Use try/except and return empty PluginResult on errors -3. **Logging**: Use `from functions import loggerEasyHaproxy` for logging -4. **Configuration**: Provide sensible defaults -5. **Documentation**: Add docstrings explaining configuration options -6. **Testing**: Test your plugin with various configurations - -## Debugging +### Step 4: Test Your Plugin Enable debug logging: ```bash EASYHAPROXY_LOG_LEVEL=DEBUG ``` -Check plugin loading: -``` -# Look for log messages: -Loaded builtin plugin: cloudflare (domain) -Loaded external plugin: my_plugin (global) +Enable plugin via label: +```yaml +services: + test: + labels: + easyhaproxy.http.host: test.example.com + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_option: custom_value ``` -## Examples +Check logs for: +``` +INFO: Loaded external plugin: my_plugin (domain) +DEBUG: Configured my_plugin: enabled=True, my_option=custom_value +DEBUG: Executing domain plugin: my_plugin for domain: test.example.com +DEBUG: Plugin my_plugin metadata: {'domain': 'test.example.com', 'my_option': 'custom_value'} +``` -### Example 1: Add Custom Header (DOMAIN) +## Complete Examples + +### Example 1: Custom Header Plugin (DOMAIN) + +Add custom headers to specific domains: ```python +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + class CustomHeaderPlugin(PluginInterface): + """Add custom HTTP headers to requests""" + def __init__(self): - self.header_name = "X-Custom" - self.header_value = "value" + self.enabled = True + self.headers = {} # {header_name: header_value} @property def name(self) -> str: @@ -254,41 +709,407 @@ class CustomHeaderPlugin(PluginInterface): return PluginType.DOMAIN def configure(self, config: dict) -> None: - self.header_name = config.get("header_name", self.header_name) - self.header_value = config.get("header_value", self.header_value) + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + # Parse headers from config + # Format: header1:value1,header2:value2 + if "headers" in config: + for header_pair in config["headers"].split(","): + if ":" in header_pair: + name, value = header_pair.split(":", 1) + self.headers[name.strip()] = value.strip() def process(self, context: PluginContext) -> PluginResult: + if not self.enabled or not self.headers: + return PluginResult() + + # Generate HAProxy directives for each header + lines = ["# Custom Headers"] + for name, value in self.headers.items(): + lines.append(f' http-request set-header {name} "{value}"') + return PluginResult( - haproxy_config=f'http-request set-header {self.header_name} "{self.header_value}"' + haproxy_config="\n".join(lines), + metadata={"domain": context.domain, "headers": self.headers} ) ``` -### Example 2: DNS Update (GLOBAL) +**Usage:** +```yaml +labels: + easyhaproxy.http.plugins: custom_header + easyhaproxy.http.plugin.custom_header.headers: X-App-Name:MyApp,X-Environment:Production +``` + +### Example 2: Rate Limiting Plugin (DOMAIN) + +Add HAProxy rate limiting per domain: ```python -class DNSUpdatePlugin(PluginInterface): +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class RateLimitPlugin(PluginInterface): + """Rate limit requests per domain""" + + def __init__(self): + self.enabled = True + self.requests_per_second = 100 + self.burst = 200 + @property def name(self) -> str: - return "dns_update" + return "rate_limit" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "requests_per_second" in config: + try: + self.requests_per_second = int(config["requests_per_second"]) + except ValueError: + pass + + if "burst" in config: + try: + self.burst = int(config["burst"]) + except ValueError: + pass + + def process(self, context: PluginContext) -> PluginResult: + if not self.enabled: + return PluginResult() + + # Use HAProxy stick tables for rate limiting + domain_safe = context.domain.replace(".", "_") + + haproxy_config = f"""# Rate Limiting - {self.requests_per_second} req/s + stick-table type ip size 100k expire 30s store http_req_rate({self.requests_per_second}s) + http-request track-sc0 src + http-request deny deny_status 429 if {{ sc_http_req_rate(0) gt {self.burst} }}""" + + return PluginResult( + haproxy_config=haproxy_config, + metadata={ + "domain": context.domain, + "rate_limit": self.requests_per_second, + "burst": self.burst + } + ) +``` + +**Usage:** +```yaml +labels: + easyhaproxy.http.plugins: rate_limit + easyhaproxy.http.plugin.rate_limit.requests_per_second: 50 + easyhaproxy.http.plugin.rate_limit.burst: 100 +``` + +### Example 3: Maintenance Mode Plugin (GLOBAL) + +Put all sites in maintenance mode: + +```python +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class MaintenanceModePlugin(PluginInterface): + """Enable/disable maintenance mode globally""" + + def __init__(self): + self.enabled = False # Disabled by default + self.message = "Site is under maintenance" + + @property + def name(self) -> str: + return "maintenance_mode" @property def plugin_type(self) -> PluginType: return PluginType.GLOBAL def configure(self, config: dict) -> None: - self.dns_server = config.get("dns_server", "8.8.8.8") + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "message" in config: + self.message = config["message"] def process(self, context: PluginContext) -> PluginResult: - # Update DNS records based on discovered hosts - for ip, labels in context.parsed_object.items(): - # Your DNS update logic here - pass + if not self.enabled: + return PluginResult() - return PluginResult(metadata={"dns_updates": "completed"}) + # When enabled, return maintenance page for all requests + # This would need custom error pages configured in HAProxy + return PluginResult( + metadata={ + "maintenance_mode": True, + "message": self.message + } + ) ``` +## Best Practices + +### 1. Error Handling + +Always handle errors gracefully: + +```python +def process(self, context: PluginContext) -> PluginResult: + try: + # Your plugin logic + result = do_something() + return PluginResult(haproxy_config=result) + except Exception as e: + loggerEasyHaproxy.error(f"Plugin {self.name} failed: {e}") + return PluginResult() # Return empty result on error +``` + +### 2. Logging + +Use structured logging: + +```python +from functions import loggerEasyHaproxy + +# Info level for important events +loggerEasyHaproxy.info(f"Plugin {self.name} executed successfully") + +# Debug level for detailed info +loggerEasyHaproxy.debug(f"Plugin {self.name} config: {self.my_option}") + +# Warning for non-critical issues +loggerEasyHaproxy.warning(f"Plugin {self.name}: config missing, using default") + +# Error for failures +loggerEasyHaproxy.error(f"Plugin {self.name} failed: {error}") +``` + +### 3. Configuration Validation + +Validate configuration values: + +```python +def configure(self, config: dict) -> None: + if "timeout" in config: + try: + timeout = int(config["timeout"]) + if timeout < 0: + loggerEasyHaproxy.warning(f"{self.name}: timeout must be positive, using default") + self.timeout = 30 + else: + self.timeout = timeout + except ValueError: + loggerEasyHaproxy.warning(f"{self.name}: invalid timeout value, using default") + self.timeout = 30 +``` + +### 4. Documentation + +Document your plugin thoroughly: + +```python +class MyPlugin(PluginInterface): + """ + One-line description. + + Detailed description of what the plugin does and why you'd use it. + + Configuration Options: + enabled (bool): Enable/disable plugin (default: true) + option1 (str): Description of option1 (default: "value") + option2 (int): Description of option2 (default: 100) + + Example YAML: + plugins: + my_plugin: + enabled: true + option1: custom_value + option2: 200 + + Example Container Label: + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.option1: custom_value + easyhaproxy.http.plugin.my_plugin.option2: 200 + + HAProxy Config Generated: + # My Plugin - Description + directive1 value + directive2 value + """ +``` + +### 5. Testing + +Test your plugin with various configurations: + +```python +# Test 1: Plugin disabled +config = {"enabled": "false"} +plugin.configure(config) +result = plugin.process(context) +assert result.haproxy_config == "" + +# Test 2: Plugin with default config +plugin = MyPlugin() +result = plugin.process(context) +assert "expected output" in result.haproxy_config + +# Test 3: Plugin with custom config +config = {"my_option": "custom"} +plugin.configure(config) +result = plugin.process(context) +assert "custom" in result.haproxy_config +``` + +### 6. Performance + +Keep plugins lightweight: + +```python +# DON'T: Make external API calls in domain plugins +def process(self, context: PluginContext) -> PluginResult: + # This runs for EVERY domain! + data = requests.get("https://api.example.com/data") # BAD + +# DO: Cache data or use global plugins for external calls +def process(self, context: PluginContext) -> PluginResult: + # Use cached data + data = self.cached_data +``` + +## Testing Plugins + +### Unit Testing + +Create tests in `src/tests/test_my_plugin.py`: + +```python +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginContext +from plugins.my_plugin import MyPlugin + + +def test_plugin_initialization(): + plugin = MyPlugin() + assert plugin.name == "my_plugin" + assert plugin.enabled is True + + +def test_plugin_configuration(): + plugin = MyPlugin() + plugin.configure({"enabled": "false", "my_option": "custom"}) + assert plugin.enabled is False + assert plugin.my_option == "custom" + + +def test_plugin_generates_config(): + plugin = MyPlugin() + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + assert "X-My-Header" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + + +def test_plugin_disabled(): + plugin = MyPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" +``` + +Run tests: +```bash +pytest src/tests/test_my_plugin.py -v +``` + +## Troubleshooting + +### Plugin Not Loading + +**Check logs:** +``` +ERROR: Failed to load plugin from /etc/haproxy/plugins/my_plugin.py: +``` + +**Common causes:** +- Syntax errors in Python code +- Missing imports +- Class doesn't inherit from `PluginInterface` +- `__init__.py` in plugins directory (remove it) + +### Plugin Not Executing + +**Check logs:** +``` +DEBUG: Executing domain plugin: my_plugin for domain: example.com +``` + +**If missing:** +- Plugin not enabled in labels/YAML/env +- Plugin name mismatch +- Plugin returned by `name` property doesn't match + +### Configuration Not Working + +**Enable debug logging:** +```bash +EASYHAPROXY_LOG_LEVEL=DEBUG +``` + +**Check:** +``` +DEBUG: Configured my_plugin: enabled=True, option=value +``` + +**Verify precedence:** +1. Container labels (highest) +2. YAML config +3. Environment variables (lowest) + +## Further Reading + +- [Using Plugins](plugins.md) - How to use existing plugins +- [Built-in Plugin Source Code](https://github.com/byjg/docker-easy-haproxy/tree/master/src/plugins/builtin) - Examples to learn from +- [HAProxy Configuration Manual](http://docs.haproxy.org/2.8/configuration.html) - HAProxy directive reference + ## Support For issues or questions: -- GitHub: https://github.com/byjg/docker-easy-haproxy +- GitHub Issues: https://github.com/byjg/docker-easy-haproxy/issues - Documentation: https://byjg.github.io/docker-easy-haproxy diff --git a/docs/plugins.md b/docs/plugins.md index d1d0ec2..6920ccd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,42 +2,57 @@ sidebar_position: 16 --- -# Plugins +# Using Plugins -EasyHAProxy supports a flexible plugin system that allows you to extend HAProxy configuration with custom functionality. Plugins are automatically invoked during the discovery cycle and can inject HAProxy configuration directives or modify discovery data. +EasyHAProxy supports a plugin system that extends HAProxy configuration with custom functionality. This guide explains how to use and configure plugins. + +## What are Plugins? + +Plugins automatically run during the discovery cycle and can: +- Add HAProxy configuration directives +- Perform maintenance tasks +- Modify discovery data +- Integrate with external services ## Plugin Types ### Global Plugins -Global plugins execute **once per discovery cycle**, regardless of how many domains are discovered. They're ideal for: + +Execute **once per discovery cycle** regardless of how many domains are discovered. + +**Use cases:** - Cleanup tasks - Global monitoring - DNS updates - Log management +**Example:** `cleanup` plugin + ### Domain Plugins -Domain plugins execute **once for each discovered domain/host**. They're ideal for: + +Execute **once for each discovered domain/host**. + +**Use cases:** - Domain-specific configuration -- IP restoration (e.g., Cloudflare) +- IP restoration (Cloudflare) - Path blocking - Custom headers per domain +**Examples:** `cloudflare`, `deny_pages` + ## Built-in Plugins -### Cloudflare (Domain Plugin) +### Cloudflare Plugin (Domain) -Restores the original visitor IP address from Cloudflare's `CF-Connecting-IP` header when requests come through Cloudflare's CDN. +Restores the original visitor IP address when requests come through Cloudflare's CDN. -**Configuration:** -```yaml -# /etc/haproxy/static/config.yaml -plugins: - cloudflare: - enabled: true - ip_list_path: /etc/haproxy/cloudflare_ips.lst -``` +**Why use it:** Cloudflare replaces the visitor's IP with its own. This plugin restores the original IP from the `CF-Connecting-IP` header. -**Container Label:** +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `ip_list_path` - Path to Cloudflare IP list (default: `/etc/haproxy/cloudflare_ips.lst`) + +**Enable via container label:** ```yaml services: myapp: @@ -46,101 +61,117 @@ services: easyhaproxy.http.plugins: cloudflare ``` -**Environment Variable:** -```bash -EASYHAPROXY_PLUGINS_ENABLED=cloudflare -EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst +**Custom IP list path:** +```yaml +labels: + easyhaproxy.http.plugins: cloudflare + easyhaproxy.http.plugin.cloudflare.ip_list_path: /custom/path/cf_ips.lst ``` -### Cleanup (Global Plugin) +**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 +``` + +**Required:** Download Cloudflare IP list from [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786). + +### Cleanup Plugin (Global) Performs cleanup tasks during each discovery cycle, such as removing old temporary files. -**Configuration:** +**Why use it:** Prevents disk space issues by automatically cleaning up temporary files created by EasyHAProxy. + +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `max_idle_time` - Maximum age in seconds before deleting files (default: `300`) +- `cleanup_temp_files` - Enable temp file cleanup (default: `true`) + +**Enable via YAML:** ```yaml # /etc/haproxy/static/config.yaml plugins: - cleanup: - enabled: true - max_idle_time: 300 # seconds - cleanup_temp_files: true + enabled: [cleanup] + config: + cleanup: + max_idle_time: 600 + cleanup_temp_files: true ``` -**Environment Variable:** +**Enable via environment variable:** ```bash EASYHAPROXY_PLUGINS_ENABLED=cleanup EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 ``` -### Deny Pages (Domain Plugin) +### Deny Pages Plugin (Domain) -Blocks access to specific paths for a domain. +Blocks access to specific paths for a domain, returning a configurable HTTP status code. -**Configuration:** -```yaml -# /etc/haproxy/static/config.yaml -plugins: - deny_pages: - enabled: true - paths: /admin,/private,/internal - status_code: 403 -``` +**Why use it:** Protect admin panels, internal APIs, or debugging endpoints from public access. -**Container Label:** +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `paths` - Comma-separated list of paths to block (e.g., `/admin,/private`) +- `status_code` - HTTP status code to return (default: `403`) + +**Enable via container label:** ```yaml services: - myapp: + webapp: labels: easyhaproxy.http.host: example.com easyhaproxy.http.plugins: deny_pages - easyhaproxy.http.plugin.deny_pages.paths: /admin,/private - easyhaproxy.http.plugin.deny_pages.status_code: 403 + easyhaproxy.http.plugin.deny_pages.paths: /admin,/private,/debug + easyhaproxy.http.plugin.deny_pages.status_code: 404 ``` +**HAProxy config generated:** +``` +# Deny Pages - Block specific paths +acl denied_path path_beg /admin /private /debug +http-request deny deny_status 404 if denied_path +``` + +### IP Whitelist Plugin (Domain) + +Restricts access to a domain to only specific IP addresses or CIDR ranges. + +**Why use it:** Restrict access to internal tools, admin panels, or staging environments to only trusted IP addresses. + +**Configuration options:** +- `enabled` - Enable/disable plugin (default: `true`) +- `allowed_ips` - Comma-separated list of IPs/CIDR ranges to allow (e.g., `192.168.1.0/24,10.0.0.1`) +- `status_code` - HTTP status code to return for blocked IPs (default: `403`) + +**Enable via container label:** +```yaml +services: + admin: + labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 +``` + +**HAProxy config generated:** +``` +# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 +http-request deny deny_status 403 if !whitelisted_ip +``` + +**Important:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! + ## Configuration Methods -Plugins can be configured using three methods. Configuration from YAML takes precedence over environment variables. +Plugins can be configured using three methods, listed in order of precedence (highest to lowest): -### 1. Static YAML Configuration +### 1. Container Labels (Domain Plugins Only) -Configure plugins in `/etc/haproxy/static/config.yaml`: - -```yaml -plugins: - # Global settings - abort_on_error: false # Log and continue on plugin errors (default) - - # Plugin-specific configuration - cloudflare: - enabled: true - ip_list_path: /etc/haproxy/cloudflare_ips.lst - - cleanup: - enabled: true - max_idle_time: 300 - - deny_pages: - enabled: false # Disable globally, can still be enabled per domain -``` - -### 2. Environment Variables - -Configure plugins via environment variables: - -```bash -# Global settings -EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false -EASYHAPROXY_PLUGINS_ENABLED=cloudflare,cleanup - -# Plugin-specific configuration -EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED=true -EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst -EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 -``` - -### 3. Container Labels (Domain Plugins Only) - -Enable domain plugins for specific containers: +Enable and configure domain plugins for specific containers: ```yaml services: @@ -148,194 +179,239 @@ services: image: myapp:latest labels: easyhaproxy.http.host: example.com + easyhaproxy.http.port: 80 + # Enable multiple plugins easyhaproxy.http.plugins: cloudflare,deny_pages + # Configure deny_pages plugin easyhaproxy.http.plugin.deny_pages.paths: /admin,/api/internal + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +**Label format:** +- Enable plugins: `easyhaproxy..plugins: plugin1,plugin2` +- Configure plugin: `easyhaproxy..plugin..: value` + +**Where `` is:** `http`, `https`, `tcp`, etc. + +### 2. Static YAML Configuration + +Configure plugins globally in `/etc/haproxy/static/config.yaml`: + +```yaml +plugins: + # Global settings + abort_on_error: false # Log and continue on errors (recommended) + + # Enable global plugins + enabled: [cleanup] + + # Configure individual plugins + config: + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst + + cleanup: + enabled: true + max_idle_time: 600 + + deny_pages: + enabled: false # Disable globally, enable per-container via labels +``` + +### 3. Environment Variables + +Configure plugins via environment variables: + +```bash +# Global settings +EASYHAPROXY_PLUGINS_ENABLED=cleanup +EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false + +# Plugin-specific configuration +EASYHAPROXY_PLUGIN_CLEANUP_ENABLED=true +EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst +``` + +**Variable format:** +- Enable plugins: `EASYHAPROXY_PLUGINS_ENABLED=plugin1,plugin2` +- Configure plugin: `EASYHAPROXY_PLUGIN__=value` + +## Common Use Cases + +### Restrict Admin Panel to Office IPs + +Protect admin panel by only allowing access from office network: + +```yaml +labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 203.0.113.0/24,198.51.100.42 +``` + +### Protect Admin Paths + +Block access to WordPress admin and other sensitive paths: + +```yaml +labels: + easyhaproxy.http.host: wordpress.example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /wp-admin,/wp-login.php,/.env + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### Cloudflare IP Restoration + +Restore original visitor IPs for applications behind Cloudflare: + +```yaml +labels: + easyhaproxy.http.host: myapp.com + easyhaproxy.http.plugins: cloudflare +``` + +### Multiple Plugins Together + +Combine multiple plugins for one domain: + +```yaml +labels: + easyhaproxy.http.host: secure-app.com + easyhaproxy.http.plugins: cloudflare,deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/config + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +### Automatic Cleanup + +Keep your system clean with automatic temp file removal: + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + enabled: true + max_idle_time: 3600 # 1 hour ``` ## Error Handling -### Log and Continue (Default) +### Log and Continue (Recommended) -By default, plugin errors are logged as warnings and the discovery cycle continues: +By default, plugin errors are logged as warnings and discovery continues: ```yaml plugins: abort_on_error: false # Default ``` -This ensures that a failing plugin doesn't prevent HAProxy configuration updates. +**When to use:** Most situations. Ensures a failing plugin doesn't prevent HAProxy updates. + +**Behavior:** +- Plugin errors logged as warnings +- Discovery cycle continues +- Other plugins still execute +- HAProxy config is generated without the failed plugin ### Abort on Error -For critical plugins, you can stop the discovery cycle on errors: +Stop discovery cycle if any plugin fails: ```yaml plugins: abort_on_error: true ``` -With this setting, any plugin error will halt configuration generation and the previous HAProxy config remains active until the issue is resolved. +**When to use:** Critical plugins where failure should halt deployment. -## Custom Plugins +**Behavior:** +- Plugin error stops discovery +- Previous HAProxy config remains active +- No configuration changes until issue is resolved -You can create custom plugins by placing Python files in `/etc/haproxy/plugins/`. +## Troubleshooting -### Simple Custom Plugin Example +### Enable Debug Logging -Create `/etc/haproxy/plugins/custom_header.py`: - -```python -import os -import sys -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from plugins import PluginInterface, PluginType, PluginContext, PluginResult - - -class CustomHeaderPlugin(PluginInterface): - def __init__(self): - self.enabled = True - self.header_name = "X-Custom-App" - self.header_value = "EasyHAProxy" - - @property - def name(self) -> str: - return "custom_header" - - @property - def plugin_type(self) -> PluginType: - return PluginType.DOMAIN - - def configure(self, config: dict) -> None: - if "enabled" in config: - self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] - if "header_name" in config: - self.header_name = config["header_name"] - if "header_value" in config: - self.header_value = config["header_value"] - - def process(self, context: PluginContext) -> PluginResult: - if not self.enabled: - return PluginResult() - - haproxy_config = f'http-request set-header {self.header_name} "{self.header_value}"' - - return PluginResult( - haproxy_config=haproxy_config, - metadata={"domain": context.domain} - ) -``` - -### Enable Your Custom Plugin - -**YAML:** -```yaml -plugins: - custom_header: - enabled: true - header_name: X-My-Header - header_value: MyValue -``` - -**Container Label:** -```yaml -labels: - easyhaproxy.http.plugins: custom_header - easyhaproxy.http.plugin.custom_header.header_value: CustomValue -``` - -## Plugin Development - -For detailed information on developing plugins, see the [Plugin Developer Guide](plugin-development.md). - -Key points: -- Plugins must inherit from `PluginInterface` -- Implement required methods: `name`, `plugin_type`, `configure`, `process` -- Return `PluginResult` with HAProxy config snippets -- Use `loggerEasyHaproxy` for logging -- Handle errors gracefully - -## Use Cases - -### 1. Cloudflare IP Restoration - -Restore original visitor IPs when using Cloudflare: - -```yaml -plugins: - cloudflare: - enabled: true -``` - -Requires Cloudflare IP list at `/etc/haproxy/cloudflare_ips.lst`. See [Cloudflare documentation](https://support.cloudflare.com/hc/en-us/articles/200170786-Restoring-original-visitor-IPs). - -### 2. Path Protection - -Block sensitive paths from public access: - -```yaml -labels: - easyhaproxy.http.host: myapp.com - easyhaproxy.http.plugins: deny_pages - easyhaproxy.http.plugin.deny_pages.paths: /admin,/config,/debug -``` - -### 3. Automatic Cleanup - -Keep your system clean: - -```yaml -plugins: - cleanup: - enabled: true - max_idle_time: 3600 # 1 hour -``` - -### 4. Custom Authentication - -Create a plugin to add basic auth: - -```python -def process(self, context: PluginContext) -> PluginResult: - return PluginResult( - haproxy_config="""http-request auth realm MyApp unless { http_auth(user_list) }""" - ) -``` - -## Debugging - -Enable debug logging to see plugin execution: +See detailed plugin execution information: ```bash EASYHAPROXY_LOG_LEVEL=DEBUG ``` -Look for log messages: +**Look for:** ``` INFO: Loaded builtin plugin: cloudflare (domain) +INFO: Loaded builtin plugin: cleanup (global) DEBUG: Executing domain plugin: cloudflare for domain: example.com -DEBUG: Plugin cloudflare metadata: {'domain': 'example.com'} +DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/haproxy/cloudflare_ips.lst'} ``` +### Plugin Not Loading + +**Check:** +1. Plugin file exists in `/etc/haproxy/plugins/` or builtin directory +2. Python syntax is valid +3. Plugin class inherits from `PluginInterface` +4. Check logs for load errors + +### Plugin Not Executing + +**For domain plugins:** +1. Check container has label: `easyhaproxy.http.plugins: plugin_name` +2. Verify plugin name is correct (case-sensitive) +3. Enable debug logging + +**For global plugins:** +1. Check YAML config: `plugins.enabled: [plugin_name]` +2. Or env var: `EASYHAPROXY_PLUGINS_ENABLED=plugin_name` +3. Enable debug logging + +### Configuration Not Applied + +**Check precedence order:** +1. Container labels (highest) +2. YAML configuration +3. Environment variables (lowest) + +Container labels override YAML and env vars. + +### Plugin Output Missing + +**Verify:** +1. Plugin is enabled (`enabled: true`) +2. Plugin configuration is correct +3. Plugin's `process()` method returns valid `PluginResult` +4. Check debug logs for plugin execution + ## Best Practices -1. **Test plugins independently** before deploying to production -2. **Use log-and-continue mode** unless a plugin is critical -3. **Keep plugin logic simple** - one plugin should do one thing well -4. **Document configuration options** in plugin docstrings -5. **Handle errors gracefully** - return empty PluginResult on errors -6. **Use sensible defaults** so plugins work out of the box +1. **Start with log-and-continue mode** - Use `abort_on_error: false` until you're confident plugins are stable +2. **Use container labels for domain-specific config** - Easier to manage per-service +3. **Use YAML/env for global config** - Better for global plugins and defaults +4. **Enable debug logging during testing** - Helps identify configuration issues +5. **Test plugin changes in staging first** - Avoid production surprises +6. **Keep plugin configurations simple** - Use defaults when possible ## Limitations -- Plugins are Python-only (no shell scripts) -- Plugins cannot modify the HAProxy template structure -- Domain plugins are executed for each domain, so keep them lightweight -- Plugin errors in abort mode will halt configuration updates +- Plugins must be written in Python +- Domain plugins execute for each domain, so keep them lightweight +- Plugins cannot modify the Jinja2 template structure directly +- Plugin errors in abort mode prevent all configuration updates + +## Creating Custom Plugins + +To create your own plugins, see the [Plugin Developer Guide](plugin-development.md). ## Further Reading -- [Plugin Developer Guide](plugin-development.md) -- [Container Labels](container-labels.md) -- [Environment Variables](environment-variable.md) -- [Static Configuration](static.md) +- [Plugin Developer Guide](plugin-development.md) - Create custom plugins +- [Container Labels](container-labels.md) - Label configuration reference +- [Environment Variables](environment-variable.md) - Environment variable reference +- [Static Configuration](static.md) - YAML configuration reference diff --git a/src/plugins/builtin/ip_whitelist.py b/src/plugins/builtin/ip_whitelist.py new file mode 100644 index 0000000..8a67222 --- /dev/null +++ b/src/plugins/builtin/ip_whitelist.py @@ -0,0 +1,107 @@ +""" +IP Whitelist Plugin for EasyHAProxy + +This plugin restricts access to a domain to only specific IP addresses or CIDR ranges. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - allowed_ips: Comma-separated list of IPs/CIDR ranges to allow + - status_code: HTTP status code to return for blocked IPs (default: 403) + +Example YAML config: + plugins: + ip_whitelist: + enabled: true + allowed_ips: "192.168.1.0/24,10.0.0.1,172.16.0.0/16" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.1" + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 + +HAProxy Config Generated: + # IP Whitelist - Only allow specific IPs + acl whitelisted_ip src 192.168.1.0/24 10.0.0.1 + http-request deny deny_status 403 if !whitelisted_ip +""" + +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 IpWhitelistPlugin(PluginInterface): + """Plugin to restrict access to specific IP addresses""" + + def __init__(self): + self.enabled = True + self.allowed_ips = [] + self.status_code = 403 + + @property + def name(self) -> str: + return "ip_whitelist" + + @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 + - allowed_ips: Comma-separated list of IPs/CIDR ranges + - status_code: HTTP status code to return for denied requests + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.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 whitelist specific IPs + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.allowed_ips: + return PluginResult() + + # Create space-separated list of IPs for ACL + ips_str = " ".join(self.allowed_ips) + + # Generate HAProxy config snippet + haproxy_config = f"""# IP Whitelist - Only allow specific IPs + acl whitelisted_ip src {ips_str} + http-request deny deny_status {self.status_code} if !whitelisted_ip""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "allowed_ips": self.allowed_ips, + "status_code": self.status_code + } + ) diff --git a/src/tests/fixtures/services-with-ip-whitelist b/src/tests/fixtures/services-with-ip-whitelist new file mode 100644 index 0000000..0e21abf --- /dev/null +++ b/src/tests/fixtures/services-with-ip-whitelist @@ -0,0 +1,10 @@ +{ + "192.168.1.40": { + "easyhaproxy.http.host": "secure.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "ip_whitelist", + "easyhaproxy.http.plugin.ip_whitelist.allowed_ips": "192.168.1.0/24,10.0.0.5", + "easyhaproxy.http.plugin.ip_whitelist.status_code": "403" + } +} diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index 8defa6e..0d15b7c 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -20,6 +20,7 @@ from plugins import PluginManager, PluginContext from plugins.builtin.cloudflare import CloudflarePlugin from plugins.builtin.cleanup import CleanupPlugin from plugins.builtin.deny_pages import DenyPagesPlugin +from plugins.builtin.ip_whitelist import IpWhitelistPlugin import easymapping @@ -320,6 +321,110 @@ class TestDenyPagesPlugin: assert "http-request deny deny_status 404 if denied_path" in haproxy_config +class TestIpWhitelistPlugin: + """Test cases for IpWhitelistPlugin (DOMAIN plugin)""" + + def test_ip_whitelist_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = IpWhitelistPlugin() + assert plugin.name == "ip_whitelist" + assert plugin.enabled is True + assert plugin.allowed_ips == [] + assert plugin.status_code == 403 + + def test_ip_whitelist_plugin_configuration(self): + """Test plugin configuration""" + plugin = IpWhitelistPlugin() + + # Test allowed IPs + plugin.configure({"allowed_ips": "192.168.1.0/24,10.0.0.1,172.16.0.0/16"}) + assert plugin.allowed_ips == ["192.168.1.0/24", "10.0.0.1", "172.16.0.0/16"] + + # Test status code + plugin.configure({"status_code": "404"}) + assert plugin.status_code == 404 + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_ip_whitelist_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = IpWhitelistPlugin() + plugin.configure({ + "allowed_ips": "192.168.1.0/24,10.0.0.1", + "status_code": "403" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "IP Whitelist" in result.haproxy_config + assert "acl whitelisted_ip src 192.168.1.0/24 10.0.0.1" in result.haproxy_config + assert "http-request deny deny_status 403 if !whitelisted_ip" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["allowed_ips"] == ["192.168.1.0/24", "10.0.0.1"] + assert result.metadata["status_code"] == 403 + + def test_ip_whitelist_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = IpWhitelistPlugin() + plugin.configure({"enabled": "false", "allowed_ips": "192.168.1.0/24"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_ip_whitelist_plugin_no_ips(self): + """Test plugin returns empty config when no IPs configured""" + plugin = IpWhitelistPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_ip_whitelist_plugin_in_haproxy_config(self): + """Test IP Whitelist plugin integration in full HAProxy config generation""" + # Use fixture with ip_whitelist plugin enabled via labels + line_list = load_fixture("services-with-ip-whitelist") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify IP Whitelist config is in the output + assert "IP Whitelist - Only allow specific IPs" in haproxy_config + assert "acl whitelisted_ip src 192.168.1.0/24 10.0.0.5" in haproxy_config + assert "http-request deny deny_status 403 if !whitelisted_ip" in haproxy_config + + class TestPluginManager: """Test cases for PluginManager""" @@ -332,15 +437,17 @@ class TestPluginManager: assert "cloudflare" in manager.plugins assert "cleanup" in manager.plugins assert "deny_pages" in manager.plugins + assert "ip_whitelist" in manager.plugins # Verify plugin types assert len(manager.global_plugins) == 1 # cleanup - assert len(manager.domain_plugins) == 2 # cloudflare, deny_pages + assert len(manager.domain_plugins) == 3 # cloudflare, deny_pages, ip_whitelist # Verify plugin instances assert manager.plugins["cloudflare"].name == "cloudflare" assert manager.plugins["cleanup"].name == "cleanup" assert manager.plugins["deny_pages"].name == "deny_pages" + assert manager.plugins["ip_whitelist"].name == "ip_whitelist" def test_plugin_manager_executes_global_plugins(self): """Test plugin manager executes global plugins correctly"""