Add FastCGI Plugin for PHP-FPM support with examples, tests, and documentation
- Introduced `FastcgiPlugin` for automatic HAProxy `fcgi-app` configuration generation. - Added example Docker Compose setup for PHP-FPM with FastCGI. - Updated documentation with detailed examples and usage instructions for FastCGI. - Included test cases to validate plugin behavior and generated configurations. - Enhanced `easymapping` to support `fcgi-app` definitions in global configs.
This commit is contained in:
parent
a993025718
commit
883521e7e1
12 changed files with 1029 additions and 2 deletions
|
|
@ -98,7 +98,9 @@ class HaproxyConfigGenerator:
|
|||
enabled_list = []
|
||||
|
||||
global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list)
|
||||
self.global_plugin_configs = [r.haproxy_config for r in global_results if r.haproxy_config]
|
||||
# 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)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to execute global plugins: {e}")
|
||||
|
|
@ -265,6 +267,12 @@ class HaproxyConfigGenerator:
|
|||
easymapping[port]["hosts"][hostname]["plugin_configs"] = [
|
||||
r.haproxy_config for r in domain_results if r.haproxy_config
|
||||
]
|
||||
|
||||
# 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"])
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to execute domain plugins for {hostname}: {e}")
|
||||
|
|
|
|||
155
src/plugins/builtin/fastcgi.py
Normal file
155
src/plugins/builtin/fastcgi.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""
|
||||
FastCGI Plugin for EasyHAProxy
|
||||
|
||||
This plugin generates HAProxy fcgi-app configuration for PHP-FPM and other FastCGI applications.
|
||||
It runs as a DOMAIN plugin (once per domain).
|
||||
|
||||
The plugin creates:
|
||||
1. A top-level fcgi-app section with CGI parameter definitions
|
||||
2. A use-fcgi-app directive in the backend
|
||||
|
||||
Configuration:
|
||||
- enabled: Enable/disable the plugin (default: true)
|
||||
- document_root: Document root path (default: /var/www/html)
|
||||
- script_filename: Pattern for SCRIPT_FILENAME (default: %[path])
|
||||
- index_file: Default index file (default: index.php)
|
||||
- path_info: Enable PATH_INFO support (default: true)
|
||||
- custom_params: Dictionary of custom FastCGI parameters (optional)
|
||||
|
||||
Example YAML config:
|
||||
plugins:
|
||||
fastcgi:
|
||||
enabled: true
|
||||
document_root: /var/www/html
|
||||
index_file: index.php
|
||||
path_info: true
|
||||
|
||||
Example Container Label:
|
||||
easyhaproxy.http.plugins: "fastcgi"
|
||||
easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp
|
||||
easyhaproxy.http.plugin.fastcgi.index_file: index.php
|
||||
easyhaproxy.http.plugin.fastcgi.path_info: true
|
||||
|
||||
Example Kubernetes Annotation:
|
||||
easyhaproxy.plugins: "fastcgi"
|
||||
easyhaproxy.plugin.fastcgi.document_root: /var/www/myapp
|
||||
easyhaproxy.plugin.fastcgi.index_file: index.php
|
||||
"""
|
||||
|
||||
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
|
||||
from functions import loggerEasyHaproxy
|
||||
|
||||
|
||||
class FastcgiPlugin(PluginInterface):
|
||||
"""Plugin to configure FastCGI parameters for PHP-FPM"""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = True
|
||||
self.document_root = "/var/www/html"
|
||||
self.script_filename = "%[path]"
|
||||
self.index_file = "index.php"
|
||||
self.path_info = True
|
||||
self.custom_params = {}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fastcgi"
|
||||
|
||||
@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
|
||||
- document_root: Document root path
|
||||
- script_filename: Pattern for SCRIPT_FILENAME
|
||||
- index_file: Default index file
|
||||
- path_info: Enable PATH_INFO support
|
||||
- custom_params: Dictionary of custom FastCGI parameters
|
||||
"""
|
||||
if "enabled" in config:
|
||||
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
if "document_root" in config:
|
||||
self.document_root = config["document_root"]
|
||||
|
||||
if "script_filename" in config:
|
||||
self.script_filename = config["script_filename"]
|
||||
|
||||
if "index_file" in config:
|
||||
self.index_file = config["index_file"]
|
||||
|
||||
if "path_info" in config:
|
||||
self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
if "custom_params" in config:
|
||||
self.custom_params = config["custom_params"]
|
||||
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
"""
|
||||
Process the plugin and generate FastCGI configuration
|
||||
|
||||
Args:
|
||||
context: Plugin execution context
|
||||
|
||||
Returns:
|
||||
PluginResult with HAProxy FastCGI configuration
|
||||
"""
|
||||
if not self.enabled:
|
||||
return PluginResult()
|
||||
|
||||
# Generate a unique fcgi-app name based on the domain
|
||||
# Replace dots and colons with underscores for valid HAProxy identifier
|
||||
domain_safe = context.domain.replace(".", "_").replace(":", "_")
|
||||
fcgi_app_name = f"fcgi_{domain_safe}"
|
||||
|
||||
# Generate the use-fcgi-app directive for the backend
|
||||
backend_config = f"use-fcgi-app {fcgi_app_name}"
|
||||
|
||||
# Generate the fcgi-app section (to be inserted at top level)
|
||||
fcgi_app_lines = [f"fcgi-app {fcgi_app_name}"]
|
||||
fcgi_app_lines.append(f" docroot {self.document_root}")
|
||||
fcgi_app_lines.append(f" index {self.index_file}")
|
||||
|
||||
# PATH_INFO support
|
||||
if self.path_info:
|
||||
fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$")
|
||||
|
||||
# Set SCRIPT_FILENAME if customized
|
||||
if self.script_filename and self.script_filename != "%[path]":
|
||||
fcgi_app_lines.append(f" set-param SCRIPT_FILENAME {self.script_filename}")
|
||||
|
||||
# Custom parameters
|
||||
if self.custom_params:
|
||||
for param_name, param_value in self.custom_params.items():
|
||||
fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}")
|
||||
|
||||
fcgi_app_definition = "\n".join(fcgi_app_lines)
|
||||
|
||||
# Build metadata - store fcgi_app_definition to be extracted and added to global configs
|
||||
metadata = {
|
||||
"domain": context.domain,
|
||||
"fcgi_app_name": fcgi_app_name,
|
||||
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
|
||||
"document_root": self.document_root,
|
||||
"index_file": self.index_file,
|
||||
"path_info": self.path_info,
|
||||
"custom_params_count": len(self.custom_params)
|
||||
}
|
||||
|
||||
return PluginResult(
|
||||
haproxy_config=backend_config, # use-fcgi-app directive for the backend
|
||||
modified_easymapping=None,
|
||||
metadata=metadata
|
||||
)
|
||||
56
src/tests/expected/services-fcgi.txt
Normal file
56
src/tests/expected/services-fcgi.txt
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
global
|
||||
log stdout format raw local0 info
|
||||
maxconn 2000
|
||||
tune.ssl.default-dh-param 2048
|
||||
|
||||
# intermediate configuration
|
||||
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
|
||||
|
||||
ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
|
||||
|
||||
ssl-dh-param-file /etc/haproxy/dhparam
|
||||
|
||||
defaults
|
||||
log global
|
||||
option httplog
|
||||
|
||||
timeout connect 3s
|
||||
timeout client 10s
|
||||
timeout server 10m
|
||||
|
||||
|
||||
|
||||
frontend http_in_80
|
||||
bind *:80
|
||||
mode http
|
||||
|
||||
acl is_rule_phpapp_local_80_1 hdr(host) -i phpapp.local
|
||||
acl is_rule_phpapp_local_80_2 hdr(host) -i phpapp.local:80
|
||||
use_backend srv_phpapp_local_80 if is_rule_phpapp_local_80_1 OR is_rule_phpapp_local_80_2
|
||||
|
||||
acl is_rule_phpapp-tcp_local_80_1 hdr(host) -i phpapp-tcp.local
|
||||
acl is_rule_phpapp-tcp_local_80_2 hdr(host) -i phpapp-tcp.local:80
|
||||
use_backend srv_phpapp-tcp_local_80 if is_rule_phpapp-tcp_local_80_1 OR is_rule_phpapp-tcp_local_80_2
|
||||
|
||||
backend srv_phpapp_local_80
|
||||
balance roundrobin
|
||||
mode http
|
||||
option forwardfor
|
||||
http-request set-header X-Forwarded-Port %[dst_port]
|
||||
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
||||
server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi
|
||||
backend srv_phpapp-tcp_local_80
|
||||
balance roundrobin
|
||||
mode http
|
||||
option forwardfor
|
||||
http-request set-header X-Forwarded-Port %[dst_port]
|
||||
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
||||
server srv-0 172.17.0.3:9000 check weight 1 proto fcgi
|
||||
|
||||
backend certbot_backend
|
||||
mode http
|
||||
server certbot 127.0.0.1:2080
|
||||
16
src/tests/fixtures/services-fcgi
vendored
Normal file
16
src/tests/fixtures/services-fcgi
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"172.17.0.2": {
|
||||
"easyhaproxy.definitions": "fcgi",
|
||||
"easyhaproxy.fcgi.host": "phpapp.local",
|
||||
"easyhaproxy.fcgi.port": "80",
|
||||
"easyhaproxy.fcgi.socket": "/run/php/php-fpm.sock",
|
||||
"easyhaproxy.fcgi.proto": "fcgi"
|
||||
},
|
||||
"172.17.0.3": {
|
||||
"easyhaproxy.definitions": "fcgi-tcp",
|
||||
"easyhaproxy.fcgi-tcp.host": "phpapp-tcp.local",
|
||||
"easyhaproxy.fcgi-tcp.port": "80",
|
||||
"easyhaproxy.fcgi-tcp.localport": "9000",
|
||||
"easyhaproxy.fcgi-tcp.proto": "fcgi"
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ from plugins.builtin.cleanup import CleanupPlugin
|
|||
from plugins.builtin.deny_pages import DenyPagesPlugin
|
||||
from plugins.builtin.ip_whitelist import IpWhitelistPlugin
|
||||
from plugins.builtin.jwt_validator import JwtValidatorPlugin
|
||||
from plugins.builtin.fastcgi import FastcgiPlugin
|
||||
import easymapping
|
||||
|
||||
|
||||
|
|
@ -736,6 +737,114 @@ class TestJwtValidatorPlugin:
|
|||
assert result.metadata["path_validation"] is False
|
||||
|
||||
|
||||
class TestFastcgiPlugin:
|
||||
"""Test cases for FastcgiPlugin"""
|
||||
|
||||
def test_fastcgi_plugin_initialization(self):
|
||||
"""Test plugin initializes with correct defaults"""
|
||||
plugin = FastcgiPlugin()
|
||||
|
||||
assert plugin.name == "fastcgi"
|
||||
assert plugin.enabled is True
|
||||
assert plugin.document_root == "/var/www/html"
|
||||
assert plugin.index_file == "index.php"
|
||||
assert plugin.path_info is True
|
||||
assert plugin.custom_params == {}
|
||||
|
||||
def test_fastcgi_plugin_configuration(self):
|
||||
"""Test plugin configuration"""
|
||||
plugin = FastcgiPlugin()
|
||||
plugin.configure({
|
||||
"document_root": "/var/www/myapp",
|
||||
"index_file": "app.php",
|
||||
"path_info": "false"
|
||||
})
|
||||
|
||||
assert plugin.document_root == "/var/www/myapp"
|
||||
assert plugin.index_file == "app.php"
|
||||
assert plugin.path_info is False
|
||||
|
||||
def test_fastcgi_plugin_generates_config(self):
|
||||
"""Test plugin generates correct HAProxy config"""
|
||||
plugin = FastcgiPlugin()
|
||||
plugin.configure({
|
||||
"document_root": "/var/www/html",
|
||||
"index_file": "index.php"
|
||||
})
|
||||
|
||||
context = PluginContext(
|
||||
parsed_object={},
|
||||
easymapping=[],
|
||||
container_env={},
|
||||
domain="phpapp.local",
|
||||
port="80",
|
||||
host_config={}
|
||||
)
|
||||
|
||||
result = plugin.process(context)
|
||||
|
||||
assert result.haproxy_config is not None
|
||||
assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config
|
||||
|
||||
# Check fcgi-app definition in metadata
|
||||
assert "fcgi_app_definition" in result.metadata
|
||||
fcgi_app_def = result.metadata["fcgi_app_definition"]
|
||||
assert "fcgi-app fcgi_phpapp_local" in fcgi_app_def
|
||||
assert "docroot /var/www/html" in fcgi_app_def
|
||||
assert "index index.php" in fcgi_app_def
|
||||
assert result.metadata["document_root"] == "/var/www/html"
|
||||
assert result.metadata["index_file"] == "index.php"
|
||||
|
||||
def test_fastcgi_plugin_custom_params(self):
|
||||
"""Test plugin with custom FastCGI parameters"""
|
||||
plugin = FastcgiPlugin()
|
||||
plugin.configure({
|
||||
"custom_params": {
|
||||
"CUSTOM_VAR": "custom_value",
|
||||
"APP_ENV": "production"
|
||||
}
|
||||
})
|
||||
|
||||
context = PluginContext(
|
||||
parsed_object={},
|
||||
easymapping=[],
|
||||
container_env={},
|
||||
domain="phpapp.local",
|
||||
port="80",
|
||||
host_config={}
|
||||
)
|
||||
|
||||
result = plugin.process(context)
|
||||
|
||||
assert result.haproxy_config is not None
|
||||
assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config
|
||||
|
||||
# Check custom params in fcgi-app definition in metadata
|
||||
assert "fcgi_app_definition" in result.metadata
|
||||
fcgi_app_def = result.metadata["fcgi_app_definition"]
|
||||
assert "set-param CUSTOM_VAR custom_value" in fcgi_app_def
|
||||
assert "set-param APP_ENV production" in fcgi_app_def
|
||||
assert result.metadata["custom_params_count"] == 2
|
||||
|
||||
def test_fastcgi_plugin_disabled(self):
|
||||
"""Test plugin returns empty config when disabled"""
|
||||
plugin = FastcgiPlugin()
|
||||
plugin.configure({"enabled": "false"})
|
||||
|
||||
context = PluginContext(
|
||||
parsed_object={},
|
||||
easymapping=[],
|
||||
container_env={},
|
||||
domain="phpapp.local",
|
||||
port="80",
|
||||
host_config={}
|
||||
)
|
||||
|
||||
result = plugin.process(context)
|
||||
|
||||
assert result.haproxy_config is None or result.haproxy_config == ""
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test cases for PluginManager"""
|
||||
|
||||
|
|
@ -750,10 +859,11 @@ class TestPluginManager:
|
|||
assert "deny_pages" in manager.plugins
|
||||
assert "ip_whitelist" in manager.plugins
|
||||
assert "jwt_validator" in manager.plugins
|
||||
assert "fastcgi" in manager.plugins
|
||||
|
||||
# Verify plugin types
|
||||
assert len(manager.global_plugins) == 1 # cleanup
|
||||
assert len(manager.domain_plugins) == 4 # cloudflare, deny_pages, ip_whitelist, jwt_validator
|
||||
assert len(manager.domain_plugins) == 5 # cloudflare, deny_pages, ip_whitelist, jwt_validator, fastcgi
|
||||
|
||||
# Verify plugin instances
|
||||
assert manager.plugins["cloudflare"].name == "cloudflare"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue