diff --git a/README.md b/README.md index 1efd8e8..8ab1475 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/qbixserver.phar b/qbixserver.phar index 2ac9869..6daf007 100644 Binary files a/qbixserver.phar and b/qbixserver.phar differ diff --git a/src/Q/WebServer.php b/src/Q/WebServer.php index 8943d5c..b9ce3c9 100644 --- a/src/Q/WebServer.php +++ b/src/Q/WebServer.php @@ -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) {