300 lines
14 KiB
Markdown
300 lines
14 KiB
Markdown
# PHP-FPM / FastCGI for the Web Server
|
|
|
|
RustProxy 0.25 can execute configured PHP extensions through a long-running PHP-FPM pool while preserving the reusable Web Service architecture.
|
|
|
|
## Architecture
|
|
|
|
```text
|
|
Request
|
|
-> existing listener / vhost / location / ACL / auth
|
|
-> Web Server -> Web Service
|
|
|- static file
|
|
`- configured PHP extension -> FastCGI -> PHP-FPM
|
|
-> existing error/header/logging/metrics/transport pipeline
|
|
```
|
|
|
|
Vhosts and locations still reference only a Web Service. Each Web Service may
|
|
use local PHP settings or reference a shared `php_fpm_profiles` entry from the
|
|
normal revisioned configuration; its local settings are retained on detach.
|
|
|
|
### Shared PHP-FPM profiles (0.29.4)
|
|
|
|
In **Web Server → Edit Web Service → PHP-FPM**, select **Local configuration**
|
|
or a named profile. **Manage PHP-FPM profiles** opens a modal with an expandable
|
|
editor for every profile and an **Add profile** action. A profile contains the
|
|
same PHP execution, FastCGI and PHP_VALUE/PHP_ADMIN_VALUE settings as local
|
|
configuration. Changes to a shared profile affect all attached services after
|
|
validation and apply. In-use profiles cannot be deleted.
|
|
|
|
Configuration example:
|
|
|
|
```json
|
|
{
|
|
"php_fpm_profiles": [{
|
|
"id": "php82", "name": "PHP 8.2 FPM",
|
|
"php": {"enabled": true, "endpoint": "unix:/run/php/php8.2-fpm.sock"}
|
|
}],
|
|
"web_services": [{
|
|
"id": "site", "name": "Site", "document_root": "/var/www/site",
|
|
"php_profile_id": "php82"
|
|
}]
|
|
}
|
|
```
|
|
|
|
Unspecified PHP fields inherit the safe serde defaults. A service without
|
|
`php_profile_id` uses its own `php` object as in earlier versions.
|
|
|
|
## Runtime model
|
|
|
|
RustProxy implements the FastCGI protocol directly. It does not spawn `php`, `php-cgi` or shell processes for individual requests.
|
|
|
|
Supported endpoint syntax:
|
|
|
|
```text
|
|
unix:/run/php/php-fpm.sock
|
|
tcp:127.0.0.1:9000
|
|
tcp:[::1]:9000
|
|
```
|
|
|
|
Use the `unix:` prefix for a Unix-domain socket. Use `tcp:` followed by a literal loopback IP address and port for TCP. Hostnames and non-loopback addresses are rejected. Unix sockets are preferred for a local PHP-FPM installation; exposing FastCGI directly to an untrusted network is not supported by this configuration.
|
|
|
|
## Web Service configuration
|
|
|
|
```json
|
|
{
|
|
"index": ["index.html", "index.php"],
|
|
"try_files": ["$uri", "$uri/", "/index.php?$query_string"],
|
|
"php": {
|
|
"enabled": true,
|
|
"extensions": ["php"],
|
|
"endpoint": "unix:/run/php/php-fpm.sock",
|
|
"connect_timeout_ms": 1000,
|
|
"request_timeout_ms": 30000,
|
|
"max_concurrent_requests": 64,
|
|
"pass_authorization": true,
|
|
"max_request_body_bytes": 0,
|
|
"max_response_header_bytes": 65536,
|
|
"stderr_log_bytes": 4096,
|
|
"custom_params": {
|
|
"APP_ENV": "production"
|
|
},
|
|
"php_values": {
|
|
"memory_limit": "256M",
|
|
"upload_max_filesize": "64M",
|
|
"post_max_size": "64M"
|
|
}
|
|
},
|
|
"symlink_policy": "deny"
|
|
}
|
|
```
|
|
|
|
The normal ordered `index` list handles `index.php`; there is no separate PHP-index option.
|
|
|
|
### Additional PHP/FastCGI options
|
|
|
|
- `path_info`: default-off secure script/path splitting. When enabled, `/index.php/foo/bar` may resolve the existing `index.php` script and pass `/foo/bar` as `PATH_INFO`.
|
|
- `pass_authorization`: forwards the inbound `Authorization` header as `HTTP_AUTHORIZATION`. Disable it when the application does not need that header.
|
|
- `max_request_body_bytes`: streaming request-body limit enforced before/during FastCGI forwarding. `0` means no additional RustProxy-side limit. A request exceeding the configured value receives `413 Payload Too Large`.
|
|
- `max_response_header_bytes`: maximum bytes accepted before the CGI response-header terminator. The allowed range is 4096..1048576 bytes.
|
|
- `stderr_log_bytes`: maximum PHP-FPM stderr bytes retained for one diagnostic request entry. `0` disables stderr capture; maximum 65536.
|
|
- `custom_params`: trusted static FastCGI parameters supplied by the operator.
|
|
- `php_values`: php.ini-style overrides encoded into one validated FastCGI `PHP_VALUE` parameter.
|
|
|
|
### Custom FastCGI parameter syntax
|
|
|
|
In the administration UI use one entry per line:
|
|
|
|
```text
|
|
APP_ENV = production
|
|
APP_DEBUG = 0
|
|
TENANT = main
|
|
```
|
|
|
|
Names are normalized to uppercase and are limited to letters, digits and `_` (the first character must be a letter). Values are static: there is no request-variable interpolation in this field. RustProxy rejects core CGI parameters, `HTTP_*`, `PHP_VALUE` and `PHP_ADMIN_VALUE`, so custom values cannot replace `SCRIPT_FILENAME`, routing data, HTTP-derived fields or PHP control parameters. Up to 64 custom FastCGI parameters and 64 PHP INI overrides are accepted, with at most 128 KiB of combined custom parameter/value data.
|
|
|
|
### PHP INI (`PHP_VALUE`) syntax
|
|
|
|
Use one `directive = value` entry per line:
|
|
|
|
```text
|
|
memory_limit = 256M
|
|
upload_max_filesize = 64M
|
|
post_max_size = 64M
|
|
max_execution_time = 30
|
|
```
|
|
|
|
RustProxy validates the lines and combines them with newline separators into the FastCGI `PHP_VALUE` parameter. PHP-FPM/PHP still decides whether a directive is changeable at this scope. `PHP_ADMIN_VALUE` is intentionally not exposed. Control characters/newlines inside names or values are rejected.
|
|
|
|
These custom parameters are stored as normal Web Service configuration. Do not put secrets there unless normal configuration storage is an acceptable location for them.
|
|
|
|
## `try_files` front controller
|
|
|
|
A typical application can use:
|
|
|
|
```text
|
|
$uri
|
|
$uri/
|
|
/index.php?$query_string
|
|
```
|
|
|
|
Existing static files/directories are used first. The final path becomes the fallback and can therefore reach `index.php`. This resolution remains inside the already-selected Web Service and does not introduce a second vhost router.
|
|
|
|
## Bounded execution
|
|
|
|
Each compiled Web Service owns a bounded PHP concurrency semaphore. `max_concurrent_requests` limits simultaneous FastCGI requests. RustProxy does not create an unbounded wait queue: if no execution slot is immediately available it returns `503 Service Unavailable` with `Retry-After`.
|
|
|
|
Connection establishment and complete FastCGI request execution have independent configured time limits. A connect failure produces a gateway error; a request timeout produces a gateway timeout. The outer normal RustProxy error-page processing remains able to replace these responses.
|
|
|
|
Request bodies are split into bounded FastCGI records rather than collected into one large buffer. When `max_request_body_bytes` is non-zero, the limit is checked both from `Content-Length` when available and while streaming chunked/unknown-length bodies. PHP-FPM stdout is parsed and delivered through a bounded response channel so application output can stream to the client.
|
|
|
|
## Secure script selection
|
|
|
|
Only configured extensions are executable. Before PHP is selected, RustProxy resolves the request/`try_files`/index path using the Web Server filesystem confinement rules and verifies that it is a regular file below the document root.
|
|
|
|
When PHP handling was selected:
|
|
|
|
- PHP source is never returned as a fallback;
|
|
- an unavailable PHP-FPM endpoint produces 502/504 behavior rather than source disclosure;
|
|
- encoded traversal/separators and `..` remain rejected;
|
|
- the current implementation requires `symlink_policy=deny`.
|
|
|
|
The last restriction is deliberate: PHP-FPM opens `SCRIPT_FILENAME` independently, so RustProxy does not claim an `inside_root` symlink guarantee that the external process cannot enforce atomically.
|
|
|
|
## FastCGI parameters
|
|
|
|
RustProxy supplies a controlled CGI/FastCGI parameter set including:
|
|
|
|
- `REQUEST_METHOD`
|
|
- `SCRIPT_FILENAME`
|
|
- `SCRIPT_NAME`
|
|
- `PATH_INFO` when `path_info` is enabled and a trailing path was securely split from an existing PHP script
|
|
- `QUERY_STRING`
|
|
- `REQUEST_URI`
|
|
- `DOCUMENT_ROOT`
|
|
- `SERVER_PROTOCOL`
|
|
- `SERVER_NAME`
|
|
- `SERVER_PORT`
|
|
- `REMOTE_ADDR`
|
|
- `REMOTE_PORT` when available
|
|
- `HTTPS`
|
|
- `GATEWAY_INTERFACE`
|
|
- `SERVER_SOFTWARE`
|
|
- `REDIRECT_STATUS`
|
|
- `CONTENT_TYPE`
|
|
- `CONTENT_LENGTH`
|
|
- selected request headers as `HTTP_*`
|
|
|
|
Request headers cannot become FastCGI `PHP_VALUE` or `PHP_ADMIN_VALUE` parameters. Operator-defined `custom_params` are allowed only through the validated Web Service configuration and cannot override core CGI/HTTP-derived fields. Operator-defined `php_values` are the only path that creates `PHP_VALUE`; `PHP_ADMIN_VALUE` is never generated.
|
|
|
|
An inbound `Proxy` header is **never** translated into FastCGI `HTTP_PROXY` (httpoxy mitigation), regardless of the optional Web Service hardening switch. The **Block common attack probes** option additionally blocks high-confidence sensitive/probe paths before PHP-FPM dispatch. FastCGI output cannot control downstream `Content-Length`, transfer framing, connection headers or `Proxy-*` transport headers; RustProxy owns downstream response framing.
|
|
|
|
`PATH_INFO` splitting is optional and disabled by default. When enabled, RustProxy searches for an existing configured PHP script prefix inside the document root, keeps `SCRIPT_FILENAME`/`SCRIPT_NAME` on that script, and supplies only the remaining suffix as `PATH_INFO`. `REQUEST_URI` remains the original request target.
|
|
|
|
## Response parsing
|
|
|
|
The FastCGI stdout parser understands standard CGI response headers, including `Status`. Hop-by-hop transport headers are stripped before returning the response through RustProxy.
|
|
|
|
For `HEAD`, PHP-FPM can still execute normally but the client response body is empty. GET/POST/PUT and other methods can reach a PHP script once routing resolves to a configured executable extension.
|
|
|
|
PHP stderr is bounded by `stderr_log_bytes` and written to the RustProxy diagnostic log; it is not sent to the client as page content. CGI response headers are separately bounded by `max_response_header_bytes`.
|
|
|
|
## Caching
|
|
|
|
PHP output does not use static file validators or the static-file OS representation path. Application response headers and the normal vhost/location response-header pipeline control client/intermediary caching.
|
|
|
|
The Web Service static `cache_rules` apply to local static file responses, not
|
|
to PHP stdout. When global/vhost caching is enabled, explicitly cacheable
|
|
PHP-FPM responses may enter the shared RustProxy cache; responses with
|
|
`Set-Cookie`, `Cache-Control: private` or `no-store` cannot be stored. In
|
|
particular, do not enable forced caching of authenticated WordPress pages
|
|
without auditing the origin response policy. HTTP/2 and HTTP/3 `:authority`
|
|
requests populate the `HTTP_HOST` FastCGI parameter, retaining the original
|
|
host/port from the incoming request when provided.
|
|
|
|
## PHP installation
|
|
|
|
RustProxy does not bundle PHP. Install and maintain PHP-FPM using the operating system/package ecosystem and enable only the extensions required by the application.
|
|
|
|
Operational requirements:
|
|
|
|
- a running PHP-FPM 8.x service supported by the operator's platform;
|
|
- a dedicated Unix socket or loopback TCP listener reachable by the RustProxy service account;
|
|
- document-root permissions usable by both RustProxy and the PHP-FPM worker identity as intended;
|
|
- correct socket owner/group/mode when using Unix sockets;
|
|
- PHP-FPM limits such as `pm.max_children`, application execution timeout and upload/body limits sized consistently with the RustProxy concurrency limit.
|
|
|
|
Extensions such as PDO drivers, mbstring, intl, gd, curl or opcache are application dependencies, not RustProxy dependencies.
|
|
|
|
## Example PHP-FPM pool concept
|
|
|
|
The exact paths/users depend on the operating system. For production, use a **separate PHP-FPM worker identity per site/application**. Never run PHP-FPM as `root` or as the `rustproxy` service account. A compromised CMS/plugin then must not inherit access to RustProxy's SQLite database, Secret Store, certificate private keys or backups.
|
|
|
|
A local pool can expose a socket such as:
|
|
|
|
```ini
|
|
user = site_example
|
|
group = site_example
|
|
listen = /run/php/rustproxy-example.sock
|
|
listen.owner = www-data
|
|
listen.group = rustproxy
|
|
listen.mode = 0660
|
|
```
|
|
|
|
The exact socket owner may differ by distribution; the important properties are that the PHP worker user is **not** `rustproxy`, and only RustProxy plus the pool manager can reach the socket. Configure the Web Service endpoint as:
|
|
|
|
```text
|
|
unix:/run/php/rustproxy-example.sock
|
|
```
|
|
|
|
Keep `/var/lib/rustproxy` mode/private ownership inaccessible to every PHP pool. Prefer separate writable upload/data directories from executable PHP source, and prevent the application from creating executable PHP files in upload areas. A CMS-level arbitrary file upload or vulnerable plugin can otherwise become PHP code execution even when the reverse proxy itself is correct.
|
|
|
|
Do not expose a PHP-FPM TCP listener to the public network.
|
|
|
|
## Verification targets
|
|
|
|
The implementation includes source/unit coverage for:
|
|
|
|
- PHP front-controller resolution through `try_files`;
|
|
- request body delivery to a FastCGI fixture;
|
|
- FastCGI response parsing;
|
|
- connection failure returning a gateway error without PHP source disclosure;
|
|
- bounded endpoint/time/concurrency configuration validation.
|
|
|
|
Release-host acceptance should additionally exercise a real PHP-FPM runtime over Unix socket and loopback TCP and verify ACL/auth/custom errors/headers plus H1/H2/H3 client transports.
|
|
|
|
|
|
## Per-Web-Service PHP administrative INI overrides (0.29.2)
|
|
|
|
`php.php_admin_values` is a validated operator-managed string map sent in a single
|
|
`PHP_ADMIN_VALUE` FastCGI parameter. Example:
|
|
|
|
```json
|
|
"php": {
|
|
"enabled": true,
|
|
"path_info": true,
|
|
"endpoint": "unix:/run/php/php-fpm-mail.sock",
|
|
"php_admin_values": {
|
|
"open_basedir": "/var/www/webmail/:/tmp/",
|
|
"display_errors": "Off"
|
|
}
|
|
}
|
|
```
|
|
|
|
Use separate Web Services for distinct per-location PHP settings, then assign those
|
|
services to the corresponding Custom Locations. Each Web Service can refer to the
|
|
same trusted PHP-FPM socket. The setting does **not** isolate worker process
|
|
identities or file permissions. Never expose PHP-FPM FastCGI to untrusted clients.
|
|
HTTP headers cannot supply `PHP_ADMIN_VALUE`, and `php_values` must not repeat
|
|
an admin directive. Control characters and excessive values are rejected.
|
|
|
|
Prometheus metrics use only configured Web Service IDs, never script paths,
|
|
URLs, PHP values, or dynamic user labels. `rust_proxy_php_requests_total` and
|
|
`rust_proxy_php_responses_total{class=...}` count responses after headers have
|
|
been received (including proxy-generated failures); the cumulative header
|
|
latency metric intentionally excludes streamed body time.
|
|
|
|
The same snapshot also exports `rust_proxy_web_server_requests_total`,
|
|
`rust_proxy_web_server_responses_total{class=...}` and
|
|
`rust_proxy_web_server_response_header_duration_microseconds_total` for all
|
|
Web Server requests (PHP and non-PHP).
|