diff --git a/docs/reference/plugins/fastcgi.md b/docs/reference/plugins/fastcgi.md index 87a08ef..068576c 100644 --- a/docs/reference/plugins/fastcgi.md +++ b/docs/reference/plugins/fastcgi.md @@ -24,10 +24,28 @@ Automatically generates HAProxy `fcgi-app` configuration that defines required C | `document_root` | Document root path | `/var/www/html` | | `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | | `index_file` | Default index file | `index.php` | -| `path_info` | Enable PATH_INFO support | `true` | +| `path_info` | PATH_INFO preset or custom regex | `php` | +| `log_stderr` | Forward FastCGI stderr to HAProxy logs | `false` | +| `keep_conn` | Reuse FastCGI connections between requests | `true` | | `custom_params` | Dictionary of custom FastCGI parameters | (optional) | | `pass_headers` | HTTP headers to forward to the FastCGI app | (optional) | +### `path_info` — PATH_INFO Presets + +The `path_info` option accepts a preset name or a custom regex. Built-in presets: + +| Preset | Language | Regex | +|----------|----------------|---------------------------------| +| `php` | PHP (default) | `^(/.+\.php)(/.*)?$` | +| `python` | Python FastCGI | `^(/.+\.py)(/.*)?$` | +| `perl` | Perl/CGI | `^(/.+\.(pl\|cgi))(/.*)?$` | +| `ruby` | Ruby FastCGI | `^(/.+\.rb)(/.*)?$` | +| `any` | Generic | `^(.+?)(/.*)?$` | + +Pass `false` to disable PATH_INFO entirely, or any other string to use it as a custom regex directly. + +Setting `path_info: true` maps to `php` for backward compatibility. + ### `pass_headers` — Forwarding HTTP Headers :::important @@ -154,6 +172,7 @@ easymapping: fcgi-app fcgi_phpapp_local docroot /var/www/html index index.php + option keep-conn path-info ^(/.+\.php)(/.*)?$ pass-header Authorization pass-header Proxy-Authorization diff --git a/src/plugins/builtin/fastcgi.py b/src/plugins/builtin/fastcgi.py index e6e7f27..b2720cb 100644 --- a/src/plugins/builtin/fastcgi.py +++ b/src/plugins/builtin/fastcgi.py @@ -51,12 +51,23 @@ from plugins import PluginContext, PluginInterface, PluginResult, PluginType class FastcgiPlugin(PluginInterface): """Plugin to configure FastCGI parameters for PHP-FPM""" + # Built-in path-info regex presets + PATH_INFO_PRESETS = { + "php": r"^(/.+\.php)(/.*)?$", + "python": r"^(/.+\.py)(/.*)?$", + "perl": r"^(/.+\.(?:pl|cgi))(/.*)?$", + "ruby": r"^(/.+\.rb)(/.*)?$", + "any": r"^(.+?)(/.*)?$", + } + 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.path_info = "php" # False | preset name | custom regex string + self.log_stderr = False + self.keep_conn = True self.custom_params = {} self.pass_headers = [] # list of {"name": str, "condition": str|None} @@ -80,6 +91,16 @@ class FastcgiPlugin(PluginInterface): - index_file: Default index file - path_info: Enable PATH_INFO support - custom_params: Dictionary of custom FastCGI parameters + - path_info: Enable PATH_INFO splitting. Accepts: + - False / "false" / "no" / "0": disabled + - "php" (default): ^(/.+[.]php)(/.*)?$ + - "python": ^(/.+[.]py)(/.*)?$ + - "perl": ^(/.+[.](pl|cgi))(/.*)?$ + - "ruby": ^(/.+[.]rb)(/.*)?$ + - "any": ^(.+?)(/.*)?$ + - any other string: used as-is as the regex + - log_stderr: Forward FastCGI app's stderr to HAProxy logs (default: false) + - keep_conn: Reuse FastCGI connections between requests (default: true) - pass_headers: Headers to forward to the FastCGI application. HAProxy omits Authorization, Proxy-Authorization, and hop-by-hop headers by default — this directive is required to pass them through. @@ -107,7 +128,19 @@ class FastcgiPlugin(PluginInterface): self.index_file = config["index_file"] if "path_info" in config: - self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"] + val = str(config["path_info"]).lower() + if val in ["false", "0", "no"]: + self.path_info = False + elif val in ["true", "1", "yes"]: + self.path_info = "php" # backward-compatible: true → php preset + else: + self.path_info = str(config["path_info"]) # preset name or custom regex + + if "log_stderr" in config: + self.log_stderr = str(config["log_stderr"]).lower() in ["true", "1", "yes"] + + if "keep_conn" in config: + self.keep_conn = str(config["keep_conn"]).lower() in ["true", "1", "yes"] if "custom_params" in config: self.custom_params = config["custom_params"] @@ -155,9 +188,16 @@ class FastcgiPlugin(PluginInterface): fcgi_app_lines.append(f" docroot {self.document_root}") fcgi_app_lines.append(f" index {self.index_file}") + if self.log_stderr: + fcgi_app_lines.append(" log-stderr") + + if self.keep_conn: + fcgi_app_lines.append(" option keep-conn") + # PATH_INFO support if self.path_info: - fcgi_app_lines.append(" path-info ^(/.+\\.php)(/.*)?$") + regex = self.PATH_INFO_PRESETS.get(self.path_info, self.path_info) + fcgi_app_lines.append(f" path-info {regex}") # Set SCRIPT_FILENAME if customized if self.script_filename and self.script_filename != "%[path]": @@ -184,6 +224,8 @@ class FastcgiPlugin(PluginInterface): "document_root": self.document_root, "index_file": self.index_file, "path_info": self.path_info, + "log_stderr": self.log_stderr, + "keep_conn": self.keep_conn, "custom_params_count": len(self.custom_params), "pass_headers_count": len(self.pass_headers) } diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 47bcf16..e6a7086 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -950,7 +950,9 @@ class TestFastcgiPlugin: 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.path_info == "php" + assert plugin.log_stderr is False + assert plugin.keep_conn is True assert plugin.custom_params == {} assert plugin.pass_headers == [] @@ -1094,6 +1096,81 @@ class TestFastcgiPlugin: assert "pass-header X-Custom-Header if { ssl_fc }" in fcgi_app_def assert result.metadata["pass_headers_count"] == 2 + def test_fastcgi_plugin_log_stderr(self): + """Test log-stderr directive is emitted when enabled""" + plugin = FastcgiPlugin() + plugin.configure({"log_stderr": "true"}) + assert plugin.log_stderr is True + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert "log-stderr" in result.global_configs[0] + assert result.metadata["log_stderr"] is True + + def test_fastcgi_plugin_keep_conn_disabled(self): + """Test option keep-conn is omitted when disabled""" + plugin = FastcgiPlugin() + plugin.configure({"keep_conn": "false"}) + assert plugin.keep_conn is False + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert "option keep-conn" not in result.global_configs[0] + assert result.metadata["keep_conn"] is False + + def test_fastcgi_plugin_path_info_presets(self): + """Test path_info presets resolve to correct regexes""" + presets = { + "php": r"^(/.+\.php)(/.*)?$", + "python": r"^(/.+\.py)(/.*)?$", + "perl": r"^(/.+\.(?:pl|cgi))(/.*)?$", + "ruby": r"^(/.+\.rb)(/.*)?$", + "any": r"^(.+?)(/.*)?$", + } + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + for preset, expected_regex in presets.items(): + plugin = FastcgiPlugin() + plugin.configure({"path_info": preset}) + assert plugin.path_info == preset + result = plugin.process(context) + assert f"path-info {expected_regex}" in result.global_configs[0], \ + f"Preset '{preset}' did not emit expected regex" + + def test_fastcgi_plugin_path_info_custom_regex(self): + """Test path_info accepts a custom regex""" + plugin = FastcgiPlugin() + plugin.configure({"path_info": r"^(/.+\.fcgi)(/.*)?$"}) + assert plugin.path_info == r"^(/.+\.fcgi)(/.*)?$" + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert r"path-info ^(/.+\.fcgi)(/.*)?$" in result.global_configs[0] + + def test_fastcgi_plugin_path_info_backward_compat(self): + """Test path_info: true maps to php preset (backward compatibility)""" + plugin = FastcgiPlugin() + plugin.configure({"path_info": "true"}) + assert plugin.path_info == "php" + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + assert r"path-info ^(/.+\.php)(/.*)?$" in result.global_configs[0] + class TestPluginManager: """Test cases for PluginManager"""