mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
Finalized carve-out for legacy PHP compatibility using CGI SAPI
This commit is contained in:
@@ -55,6 +55,7 @@ dramatically faster and more scalable.
|
||||
- [Clean URL Routing](#️-clean-url-routing-optional)
|
||||
- [For PHP Developers](#-for-php-developers--the-micro-framework)
|
||||
- [Configuration](#-configuration)
|
||||
- [Running Legacy PHP](#running-legacy-php--wordpress-laravel-symfony)
|
||||
- [Three Ways to Run](#-three-ways-to-run)
|
||||
- [Building](#-building)
|
||||
- [With Qbix Platform](#-with-qbix-platform)
|
||||
@@ -1115,8 +1116,9 @@ Q_Response::setCookie('session', $id); // cookies
|
||||
Q_Response::redirect('/login'); // redirect
|
||||
```
|
||||
|
||||
For existing code that calls `header()` directly, a CGI carveout mode is coming —
|
||||
configure URL patterns to use `php-cgi` where native `header()` works (see roadmap).
|
||||
For existing code that calls `header()` directly, use CGI carveout mode —
|
||||
configure URL patterns in `server.json` under `Q.webserver.cgi.patterns` to run
|
||||
those scripts via `php-cgi` where native `header()` works (see Configuration).
|
||||
|
||||
When you upgrade to the full [Qbix Platform](https://github.com/Qbix/Platform),
|
||||
the `Q` class expands with hundreds more methods — but everything above
|
||||
@@ -1440,6 +1442,199 @@ Create `config/server.json` next to your `web/` directory, or pass `--config=pat
|
||||
| `rateLimit.enabled` | false | Enable per-IP rate limiting |
|
||||
| `rateLimit.requests` | 100 | Requests per window |
|
||||
| `rateLimit.window` | 60 | Window in seconds |
|
||||
| `webserver.fallback` | null | Catch-all: `"index.html"`, `{"handler":"app/notfound"}`, or `{"file":"404.html"}` |
|
||||
| `webserver.cgi.patterns` | [] | Regex patterns for scripts that use php-cgi (legacy compatibility) |
|
||||
| `webserver.cgi.binary` | auto | Path to php-cgi binary (auto-detected if not set) |
|
||||
|
||||
### CGI carveout mode — legacy PHP compatibility
|
||||
|
||||
Scripts matching `Q.webserver.cgi.patterns` run via `php-cgi` subprocess instead
|
||||
of fork. Native `header()`, `setcookie()`, `session_start()` all work — full
|
||||
compatibility with WordPress, Laravel, or any PHP code that calls `header()` directly.
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"webserver": {
|
||||
"cgi": {
|
||||
"patterns": [
|
||||
"#^/wp-admin/.*\\.php$#",
|
||||
"#^/wp-login\\.php$#",
|
||||
"#^/legacy/.*\\.php$#"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The tradeoff: CGI mode starts a fresh PHP interpreter per request (~50ms), so you
|
||||
don't get the preload speed benefit. Static files, caching, and everything else
|
||||
still work at full speed. Use this for third-party code you can't modify — your
|
||||
own code should use `Q::header()` and the fork path for 10x performance.
|
||||
|
||||
The server auto-detects `php-cgi` on your system. Override with `cgi.binary`:
|
||||
|
||||
```json
|
||||
{ "Q": { "webserver": { "cgi": { "binary": "/usr/bin/php-cgi8.3" } } } }
|
||||
```
|
||||
|
||||
### Running legacy PHP — WordPress, Laravel, Symfony
|
||||
|
||||
You can run existing PHP applications on Qbix Server without modifying their code.
|
||||
The key: put the framework's public directory as `web/`, and use CGI carveout
|
||||
patterns to match all PHP files.
|
||||
|
||||
**WordPress:**
|
||||
|
||||
```
|
||||
wordpress-site/
|
||||
├── qbixserver.php ← copy here
|
||||
├── src/ ← copy here
|
||||
├── config/
|
||||
│ └── server.json
|
||||
└── web/ ← symlink or copy of WordPress root
|
||||
├── wp-admin/
|
||||
├── wp-content/
|
||||
├── wp-includes/
|
||||
├── wp-login.php
|
||||
├── index.php
|
||||
└── wp-config.php
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"webserver": {
|
||||
"cgi": {
|
||||
"patterns": ["#\\.php$#"]
|
||||
},
|
||||
"fallback": "index.php"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The pattern `#\\.php$#` sends all PHP files through `php-cgi`. The fallback
|
||||
sends unmatched URLs to `index.php` (WordPress permalink routing). Static
|
||||
files (images, CSS, JS) are served directly at full speed.
|
||||
|
||||
**Laravel:**
|
||||
|
||||
```
|
||||
laravel-app/
|
||||
├── qbixserver.php
|
||||
├── src/
|
||||
├── config/
|
||||
│ └── server.json
|
||||
├── web/ ← symlink to Laravel's public/
|
||||
│ ├── index.php
|
||||
│ └── .htaccess ← ignored (no Apache)
|
||||
├── app/
|
||||
├── routes/
|
||||
├── storage/
|
||||
└── vendor/
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"webserver": {
|
||||
"cgi": {
|
||||
"patterns": ["#\\.php$#"]
|
||||
},
|
||||
"fallback": "index.php"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All requests that don't match a static file go to `index.php`. Laravel's
|
||||
router takes over from there. The `app/`, `vendor/`, and `storage/`
|
||||
directories are outside `web/` — inaccessible via URL by default.
|
||||
|
||||
**Symfony:**
|
||||
|
||||
```
|
||||
symfony-app/
|
||||
├── qbixserver.php
|
||||
├── src/
|
||||
├── config/
|
||||
│ ├── server.json
|
||||
│ └── ... ← Symfony config files
|
||||
├── web/ ← symlink to Symfony's public/
|
||||
│ └── index.php
|
||||
├── src/ ← Symfony source (separate from Qbix src/)
|
||||
├── var/
|
||||
└── vendor/
|
||||
```
|
||||
|
||||
Same config pattern. Symfony's front controller (`public/index.php`) handles
|
||||
all routing internally.
|
||||
|
||||
**Porting your own legacy code:**
|
||||
|
||||
For code you control, you have three options — from least effort to best performance:
|
||||
|
||||
**Option 1: Full CGI (zero changes, slower)**
|
||||
|
||||
```json
|
||||
{ "Q": { "webserver": { "cgi": { "patterns": ["#\\.php$#"] } } } }
|
||||
```
|
||||
|
||||
Every PHP file runs through `php-cgi`. Native `header()`, `setcookie()`,
|
||||
`session_start()` all work. No code changes. Performance is comparable
|
||||
to nginx + php-fpm (no preload benefit).
|
||||
|
||||
**Option 2: Targeted carveouts (minimal changes, mostly fast)**
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"webserver": {
|
||||
"cgi": {
|
||||
"patterns": [
|
||||
"#^/admin/.*\\.php$#",
|
||||
"#^/legacy/.*\\.php$#"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only specific paths use CGI. New code and simple scripts use fork mode
|
||||
(10x performance). Legacy code that calls `header()` directly stays
|
||||
in CGI mode.
|
||||
|
||||
**Option 3: Find-replace (one-time effort, full performance)**
|
||||
|
||||
In your PHP files, replace:
|
||||
```
|
||||
header( → Q::header(
|
||||
setcookie( → Q_Response::setCookie(
|
||||
```
|
||||
|
||||
Two find-replaces. Your code now uses fork mode everywhere — 10x concurrent
|
||||
performance, preloaded classes, shared-nothing safety.
|
||||
|
||||
### Installing php-cgi
|
||||
|
||||
CGI carveout mode requires the `php-cgi` binary:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install php-cgi
|
||||
|
||||
# macOS
|
||||
brew install php # includes php-cgi
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo yum install php-cgi
|
||||
|
||||
# Verify
|
||||
php-cgi --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1660,7 +1855,6 @@ the full 10x performance advantage, use Linux or macOS (or WSL).
|
||||
**Coming next:**
|
||||
|
||||
- **Virtual hosts** — `Q.web.hosts.$hostname` config overrides for multi-domain serving
|
||||
- **CGI carveouts** — regex URL patterns that use `php-cgi` subprocess for full `header()`/`setcookie()` compatibility with legacy code (WordPress, etc.)
|
||||
- **Hot reload** — watch `classes/`, `handlers/`, `config/` for changes, auto-restart workers
|
||||
- **Scheduler** — cron-like timed events from config, executed by the event loop
|
||||
- **Request timeout** — kill workers that exceed N seconds
|
||||
|
||||
Binary file not shown.
+263
-1
@@ -1068,6 +1068,35 @@ class Q_WebServer
|
||||
*/
|
||||
private static function handlePhp($client, $parsed, $scriptPath)
|
||||
{
|
||||
// ── CGI carveout: check if this script should use php-cgi ──
|
||||
// Scripts matching Q.webserver.cgi.patterns run via php-cgi subprocess
|
||||
// where native header(), setcookie(), headers_list() all work.
|
||||
// Use for legacy/third-party code (WordPress, etc.) that calls header() directly.
|
||||
static $cgiPatterns = null;
|
||||
static $cgiBinary = null;
|
||||
if ($cgiPatterns === null) {
|
||||
$cgiPatterns = Q_Config::get('Q', 'webserver', 'cgi', 'patterns', array());
|
||||
$cgiBinary = Q_Config::get('Q', 'webserver', 'cgi', 'binary', null);
|
||||
if (!$cgiBinary) {
|
||||
// Auto-detect php-cgi
|
||||
foreach (array('php-cgi', 'php-cgi8.3', 'php-cgi8.2', 'php-cgi8.1') as $bin) {
|
||||
$path = trim(shell_exec("which $bin 2>/dev/null") ?? '');
|
||||
if ($path && is_executable($path)) {
|
||||
$cgiBinary = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($cgiPatterns) && $cgiBinary) {
|
||||
$relPath = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen(self::$rootDir))), '/');
|
||||
foreach ($cgiPatterns as $pattern) {
|
||||
if (@preg_match($pattern, $relPath)) {
|
||||
return self::handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$pool) {
|
||||
self::$lastStatus = 200;
|
||||
self::$pool->dispatch($client, $parsed, $scriptPath);
|
||||
@@ -1272,7 +1301,240 @@ WORKER;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Static file serving ──────────────────────────────
|
||||
/**
|
||||
* Execute a PHP script via php-cgi binary for full header() compatibility.
|
||||
* Used for legacy/third-party code (WordPress, Laravel, etc.) that calls
|
||||
* header() and setcookie() directly. The php-cgi binary outputs real HTTP
|
||||
* headers followed by the body — we parse and forward them.
|
||||
*
|
||||
* Slower than fork mode (no preload benefit) but 100% compatible.
|
||||
*
|
||||
* @method handlePhpCgi
|
||||
* @static
|
||||
* @private
|
||||
*/
|
||||
private static function handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary)
|
||||
{
|
||||
$host = $parsed['headers']['host'] ?? 'localhost';
|
||||
$hostParts = explode(':', $host);
|
||||
$isHttps = !empty(self::$tlsSocket);
|
||||
$fwdProto = strtolower($parsed['headers']['x-forwarded-proto'] ?? '');
|
||||
if ($fwdProto === 'https') $isHttps = true;
|
||||
$cfVisitor = $parsed['headers']['cf-visitor'] ?? '';
|
||||
if (strpos($cfVisitor, '"https"') !== false) $isHttps = true;
|
||||
|
||||
// Compute SCRIPT_NAME and PATH_INFO (frameworks need correct PATH_INFO)
|
||||
$requestPath = parse_url($parsed['uri'], PHP_URL_PATH) ?: '/';
|
||||
$docRoot = rtrim(self::$rootDir, DS);
|
||||
$scriptRel = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen($docRoot))), '/');
|
||||
$pathInfo = '';
|
||||
if (strlen($requestPath) > strlen($scriptRel)) {
|
||||
$pathInfo = substr($requestPath, strlen($scriptRel));
|
||||
}
|
||||
|
||||
// Build CGI environment variables — full set matching nginx fastcgi_params
|
||||
$env = array(
|
||||
'REDIRECT_STATUS' => '200',
|
||||
'GATEWAY_INTERFACE' => 'CGI/1.1',
|
||||
'SERVER_SOFTWARE' => 'QbixServer/' . (defined('QBIX_SERVER_VERSION') ? QBIX_SERVER_VERSION : '1.0'),
|
||||
'SERVER_PROTOCOL' => 'HTTP/' . ($parsed['httpVersion'] ?? '1.1'),
|
||||
'SERVER_NAME' => $hostParts[0],
|
||||
'SERVER_PORT' => isset($hostParts[1]) ? $hostParts[1] : (string) self::$port,
|
||||
'SERVER_ADDR' => self::$host === '0.0.0.0' ? '127.0.0.1' : self::$host,
|
||||
'REQUEST_METHOD' => $parsed['method'],
|
||||
'REQUEST_URI' => $parsed['uri'],
|
||||
'QUERY_STRING' => $parsed['query'],
|
||||
'SCRIPT_FILENAME' => $scriptPath,
|
||||
'SCRIPT_NAME' => $scriptRel,
|
||||
'PHP_SELF' => $scriptRel . $pathInfo,
|
||||
'PATH_INFO' => $pathInfo,
|
||||
'PATH_TRANSLATED' => $pathInfo ? $docRoot . $pathInfo : '',
|
||||
'DOCUMENT_ROOT' => $docRoot,
|
||||
'DOCUMENT_URI' => $scriptRel,
|
||||
'REMOTE_ADDR' => $parsed['_remoteAddr'] ?? '127.0.0.1',
|
||||
'REMOTE_PORT' => (string) ($parsed['_remotePort'] ?? 0),
|
||||
'REQUEST_SCHEME' => $isHttps ? 'https' : 'http',
|
||||
'HTTPS' => $isHttps ? 'on' : '',
|
||||
'REQUEST_TIME' => (string) time(),
|
||||
'REQUEST_TIME_FLOAT' => (string) microtime(true),
|
||||
);
|
||||
|
||||
// Forward ALL request headers as HTTP_* env vars
|
||||
foreach ($parsed['headers'] as $k => $v) {
|
||||
$envKey = 'HTTP_' . strtoupper(str_replace('-', '_', $k));
|
||||
$env[$envKey] = $v;
|
||||
}
|
||||
// Content-Type and Content-Length are special (no HTTP_ prefix per CGI spec)
|
||||
if (isset($parsed['headers']['content-type'])) {
|
||||
$env['CONTENT_TYPE'] = $parsed['headers']['content-type'];
|
||||
}
|
||||
if (isset($parsed['headers']['content-length'])) {
|
||||
$env['CONTENT_LENGTH'] = $parsed['headers']['content-length'];
|
||||
}
|
||||
// Basic auth
|
||||
$auth = $parsed['headers']['authorization'] ?? '';
|
||||
if (stripos($auth, 'Basic ') === 0) {
|
||||
$decoded = base64_decode(substr($auth, 6));
|
||||
if ($decoded && strpos($decoded, ':') !== false) {
|
||||
list($user, $pass) = explode(':', $decoded, 2);
|
||||
$env['PHP_AUTH_USER'] = $user;
|
||||
$env['PHP_AUTH_PW'] = $pass;
|
||||
$env['AUTH_TYPE'] = 'Basic';
|
||||
}
|
||||
}
|
||||
// Inherit essential system env vars
|
||||
foreach (array('PATH', 'HOME', 'TEMP', 'TMP', 'TMPDIR', 'SYSTEMROOT') as $sysVar) {
|
||||
if (isset($_ENV[$sysVar])) $env[$sysVar] = $_ENV[$sysVar];
|
||||
elseif (($v = getenv($sysVar)) !== false) $env[$sysVar] = $v;
|
||||
}
|
||||
|
||||
// Launch php-cgi
|
||||
$descriptors = array(
|
||||
0 => array('pipe', 'r'), // stdin (request body)
|
||||
1 => array('pipe', 'w'), // stdout (CGI response)
|
||||
2 => array('pipe', 'w'), // stderr (errors)
|
||||
);
|
||||
|
||||
$cwd = dirname($scriptPath); // run in the script's directory
|
||||
$process = proc_open($cgiBinary, $descriptors, $pipes, $cwd, $env);
|
||||
if (!is_resource($process)) {
|
||||
self::sendResponse($client, 502, 'CGI process failed to start');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-blocking reads for timeout support
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
// Send request body to stdin
|
||||
if (!empty($parsed['body'])) {
|
||||
@fwrite($pipes[0], $parsed['body']);
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Read CGI response with timeout
|
||||
$timeout = Q_Config::get('Q', 'webserver', 'cgi', 'timeout', 30);
|
||||
$deadline = microtime(true) + $timeout;
|
||||
$stdout = '';
|
||||
$stderr = '';
|
||||
while (true) {
|
||||
$read = array($pipes[1], $pipes[2]);
|
||||
$write = $except = null;
|
||||
$remaining = max(0.1, $deadline - microtime(true));
|
||||
if ($remaining <= 0) break; // timeout
|
||||
$ready = @stream_select($read, $write, $except, (int) $remaining, (int) (($remaining - (int) $remaining) * 1000000));
|
||||
if ($ready === false) break;
|
||||
if ($ready === 0) continue;
|
||||
foreach ($read as $pipe) {
|
||||
$chunk = @fread($pipe, 65536);
|
||||
if ($chunk === false || $chunk === '') {
|
||||
if ($pipe === $pipes[1] && feof($pipes[1])) break 2;
|
||||
continue;
|
||||
}
|
||||
if ($pipe === $pipes[1]) $stdout .= $chunk;
|
||||
else $stderr .= $chunk;
|
||||
}
|
||||
}
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
// Log stderr if non-empty
|
||||
if ($stderr !== '') {
|
||||
Q_WebServer_Log::error("CGI stderr ($scriptPath): " . trim($stderr));
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (microtime(true) >= $deadline && $stdout === '') {
|
||||
self::sendResponse($client, 504, 'CGI process timed out');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if ($stdout === '') {
|
||||
$status = ($exitCode !== 0) ? 500 : 200;
|
||||
$response = array('status' => $status, 'body' => '', 'headers' => array());
|
||||
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
|
||||
self::$lastStatus = $status;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse CGI output: headers separated by blank line from body
|
||||
$headerEnd = strpos($stdout, "\r\n\r\n");
|
||||
$sep = 4;
|
||||
if ($headerEnd === false) {
|
||||
$headerEnd = strpos($stdout, "\n\n");
|
||||
$sep = 2;
|
||||
}
|
||||
|
||||
if ($headerEnd === false) {
|
||||
$body = $stdout;
|
||||
$headers = array();
|
||||
$status = 200;
|
||||
$extraHeaders = array();
|
||||
} else {
|
||||
$headerBlock = substr($stdout, 0, $headerEnd);
|
||||
$body = substr($stdout, $headerEnd + $sep);
|
||||
$headers = array();
|
||||
$extraHeaders = array(); // for multiple Set-Cookie headers
|
||||
$status = 200;
|
||||
|
||||
foreach (explode("\n", $headerBlock) as $line) {
|
||||
$line = rtrim($line, "\r");
|
||||
if ($line === '') continue;
|
||||
|
||||
// Status line: "Status: 404 Not Found"
|
||||
if (stripos($line, 'Status:') === 0) {
|
||||
$status = (int) trim(substr($line, 7));
|
||||
continue;
|
||||
}
|
||||
// Location header implies redirect status
|
||||
$colonPos = strpos($line, ':');
|
||||
if ($colonPos === false) continue;
|
||||
|
||||
$name = trim(substr($line, 0, $colonPos));
|
||||
$value = trim(substr($line, $colonPos + 1));
|
||||
|
||||
// Multiple Set-Cookie headers must all be forwarded
|
||||
if (strtolower($name) === 'set-cookie') {
|
||||
$extraHeaders[] = array($name, $value);
|
||||
} else {
|
||||
$headers[$name] = $value;
|
||||
}
|
||||
|
||||
if (strtolower($name) === 'location' && $status === 200) {
|
||||
$status = 302; // implicit redirect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build and send response
|
||||
// We bypass processResponse for Set-Cookie to handle multiples
|
||||
$headers['Content-Length'] = strlen($body);
|
||||
$headers['Connection'] = 'close';
|
||||
|
||||
static $reasons = array(
|
||||
200=>'OK', 201=>'Created', 204=>'No Content',
|
||||
301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified',
|
||||
400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden',
|
||||
404=>'Not Found', 405=>'Method Not Allowed',
|
||||
413=>'Payload Too Large', 500=>'Internal Server Error',
|
||||
502=>'Bad Gateway', 503=>'Service Unavailable', 504=>'Gateway Timeout',
|
||||
);
|
||||
$reason = $reasons[$status] ?? 'OK';
|
||||
$out = "HTTP/1.1 $status $reason\r\n";
|
||||
foreach ($headers as $k => $v) {
|
||||
$out .= "$k: $v\r\n";
|
||||
}
|
||||
// Append all Set-Cookie headers (can't use associative array)
|
||||
foreach ($extraHeaders as $pair) {
|
||||
$out .= $pair[0] . ': ' . $pair[1] . "\r\n";
|
||||
}
|
||||
@fwrite($client, $out . "\r\n" . $body);
|
||||
|
||||
self::$lastStatus = $status;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function serveStaticFile($client, $fsPath, $method, $reqHeaders, $keepAlive = false)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user