PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-server.php

class-server.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.3, at includes/class-server.php

464 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server / SAPI detection.
4 *
5 * Used by Gzip and the UI to decide which optimizations are server-applied
6 * (Apache / LiteSpeed via .htaccess) vs. require manual config (nginx).
7 *
8 * @package XSpeed
9 */
10
11 namespace XSpeed;
12
13 defined( 'ABSPATH' ) || exit;
14
15 class Server {
16
17 const APACHE = 'apache';
18 const LITESPEED = 'litespeed';
19 const NGINX = 'nginx';
20 const IIS = 'iis';
21 const UNKNOWN = 'unknown';
22
23 const OPT_CACHED_TYPE = 'xspeed_server_type';
24
25 public static function type() {
26 $detected = self::detect();
27 if ( self::UNKNOWN !== $detected ) {
28 // Persist whenever we have a real answer so future CLI /
29 // cron / REST calls (where SERVER_SOFTWARE may be empty)
30 // inherit it. Non-autoloaded — only read when needed.
31 $cached = get_option( self::OPT_CACHED_TYPE, null );
32 if ( $cached !== $detected ) {
33 update_option( self::OPT_CACHED_TYPE, $detected, false );
34 }
35 return $detected;
36 }
37
38 // No definitive signal this request (typically WP-CLI, where
39 // SERVER_SOFTWARE is empty). Read whatever was cached the last
40 // time we ran from a real HTTP request.
41 $cached = get_option( self::OPT_CACHED_TYPE, null );
42 if ( is_string( $cached ) && '' !== $cached ) {
43 return $cached;
44 }
45
46 return self::UNKNOWN;
47 }
48
49 /**
50 * Live detection — never reads the cache. Used by type() and by
51 * any caller that explicitly wants the current-request answer
52 * (e.g. diagnostic UI showing "detected this request").
53 *
54 * We DO NOT fall back to "if .htaccess exists assume Apache" here:
55 * Cache::install_rewrite() writes .htaccess itself, so on nginx
56 * hosts the file appears after first cache toggle and a presence
57 * check then flips us to APACHE forever. Cached HTTP detection
58 * is the cleaner backstop.
59 */
60 public static function detect(): string {
61 global $is_apache, $is_nginx, $is_IIS, $is_iis7;
62
63 $signature = self::server_signature();
64
65 if ( false !== stripos( $signature, 'litespeed' ) ) {
66 return self::LITESPEED;
67 }
68 // apache_get_modules() exists only with mod_php (not FPM), so
69 // gate it behind SERVER_SOFTWARE first. Otherwise an
70 // "apache_get_modules exists" check would false-positive on a
71 // few PHP-builtin-server / mod_php-on-localhost dev edge cases.
72 if ( false !== stripos( $signature, 'apache' ) || ! empty( $is_apache ) ) {
73 return self::APACHE;
74 }
75 if ( false !== stripos( $signature, 'nginx' ) || ! empty( $is_nginx ) ) {
76 return self::NGINX;
77 }
78 if ( false !== stripos( $signature, 'microsoft-iis' ) || ! empty( $is_IIS ) || ! empty( $is_iis7 ) ) {
79 return self::IIS;
80 }
81 return self::UNKNOWN;
82 }
83
84 /**
85 * Whether the server respects .htaccess / web.config-style file-based config.
86 */
87 public static function supports_htaccess() {
88 $t = self::type();
89 return self::APACHE === $t || self::LITESPEED === $t;
90 }
91
92 /**
93 * Whether Apache can stamp a response header from `.htaccess`
94 * (i.e. mod_headers is loaded).
95 *
96 * This decides whether the static-rewrite fast path can be used at
97 * all. A statically-served file bypasses PHP entirely, so the ONLY
98 * way to mark it as a cache HIT is a `Header` directive in the
99 * rewrite block. Without mod_headers that directive is silently
100 * swallowed by its `<IfModule>` guard, and the site serves fast but
101 * completely invisible cache hits — no `X-XSpeed-Cache` header for
102 * the user, nothing for the hit counter. That is exactly the
103 * "cache works, dashboard says 0%" report this check exists to
104 * prevent. (Cache::static_rewrite_allowed() consumes it.)
105 *
106 * Detection is best-effort by necessity:
107 * - mod_php exposes apache_get_modules() — authoritative.
108 * - Under PHP-FPM that function doesn't exist. Assume the module
109 * IS present, matching Apache's own default build (mod_headers
110 * ships enabled in every mainstream distro package). Guessing
111 * "absent" there would push every FPM site onto the slower
112 * drop-in path over a detection limitation rather than a real
113 * capability gap; the loopback probe in
114 * Cache::probe_static_rewrite() is what catches a genuinely
115 * header-less FPM host.
116 *
117 * @return bool
118 */
119 public static function apache_has_mod_headers(): bool {
120 if ( function_exists( 'apache_get_modules' ) ) {
121 $has = in_array( 'mod_headers', apache_get_modules(), true );
122 } else {
123 $has = true; // FPM: undetectable, assume the distro default.
124 }
125
126 /**
127 * Filter: xspeed_apache_has_mod_headers
128 *
129 * Override mod_headers detection. Return false on a host where
130 * `.htaccess` Header directives are stripped (some managed
131 * stacks do this) to force cache hits through the PHP drop-in,
132 * where they are stamped and counted.
133 *
134 * @param bool $has Whether mod_headers appears to be available.
135 */
136 return (bool) apply_filters( 'xspeed_apache_has_mod_headers', $has );
137 }
138
139 /**
140 * Accept a candidate access-log path only if it can actually be
141 * tail-scanned, else ''.
142 *
143 * `is_readable()` alone is not enough. The official WordPress and
144 * Apache Docker images symlink `access.log -> /dev/stdout`, i.e. a
145 * PIPE: `is_file()` is false, `filesize()` is 0, and `is_readable()`
146 * is false for the PHP user. Hit_Counter::collect_server_log_hits()
147 * fseek()s to a stored byte offset and reads forward, which a pipe
148 * or character device cannot support at all — it would either fail
149 * or block. Requiring a REGULAR file makes that contract explicit
150 * instead of relying on the filesize>0 test to reject pipes as a
151 * side effect. Containerised Apache is the standard layout, not an
152 * edge case, so this path is common. (Field report: hit ratio stuck
153 * at 0% on Dockerised Apache while the cache served correctly.)
154 *
155 * @param string $path Candidate path.
156 * @return string The path when usable, '' otherwise.
157 */
158 private static function usable_access_log( string $path ): string {
159 if ( '' === $path ) {
160 return '';
161 }
162 // is_file() resolves symlinks, so access.log -> /var/log/real.log
163 // is still accepted; only the pipe/device targets are rejected.
164 if ( ! @is_file( $path ) || ! is_readable( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat on an external log; treated as "unusable".
165 return '';
166 }
167 return $path;
168 }
169
170 /**
171 * Best-effort path to the web server's access log, used to count
172 * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those
173 * requests are served straight from disk and never reach our
174 * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()).
175 *
176 * Resolution order:
177 * 1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override
178 * for hosts where the log lives somewhere non-standard).
179 * 2. The `xspeed_access_log_path` filter (programmatic override).
180 * 3. Auto-detection: a short list of the standard Apache/LiteSpeed
181 * access-log locations, returning the first that exists AND is
182 * readable by the PHP user.
183 *
184 * Every route is funnelled through usable_access_log(), so an
185 * override can no more hand us a pipe than auto-detection can.
186 *
187 * Returns '' when nothing usable is found — a very common case on
188 * managed/cPanel hosts where the PHP user can't read the server log,
189 * and on containers where it's a symlink to stdout. Callers MUST
190 * treat '' as "can't count static hits here" and fall back
191 * gracefully (the drop-in path still counts its own HITs).
192 *
193 * @return string Absolute path, or '' if none is usable.
194 */
195 public static function access_log_path(): string {
196 if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) {
197 return self::usable_access_log( XSPEED_ACCESS_LOG );
198 }
199
200 /**
201 * Filter: xspeed_access_log_path
202 *
203 * Override the auto-detected access-log path. Return '' to disable
204 * server-log hit counting entirely.
205 *
206 * @param string|null $path Null = use auto-detection below.
207 */
208 $filtered = apply_filters( 'xspeed_access_log_path', null );
209 if ( is_string( $filtered ) ) {
210 return self::usable_access_log( $filtered );
211 }
212
213 // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed
214 // Enterprise access-log locations. First readable, NON-EMPTY file
215 // wins — an empty global access.log (common on LiteSpeed, which
216 // logs per-vhost instead) must not shadow the real per-vhost log we
217 // discover below.
218 $candidates = array(
219 '/var/log/apache2/access.log', // Debian/Ubuntu Apache
220 '/var/log/httpd/access_log', // RHEL/CentOS Apache
221 '/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost
222 '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global
223 '/var/log/lshttpd/access.log', // LiteSpeed Enterprise
224 );
225 foreach ( $candidates as $path ) {
226 // usable_access_log() enforces "regular file + readable"; the
227 // non-empty test stays here so an empty global log doesn't
228 // shadow the real per-vhost one found further below.
229 if ( '' !== self::usable_access_log( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip".
230 return $path;
231 }
232 }
233
234 // LiteSpeed (and some Apache vhost setups) write a per-vhost
235 // `<vhost>.access.log` rather than a single global file. Scan the
236 // known log dirs for the most-recently-written, readable, non-empty
237 // *.access.log and use that. Auto-tracks whichever vhost is serving
238 // this site without the admin having to set a path.
239 $dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' );
240 $best = '';
241 $best_mtime = 0;
242 foreach ( $dirs as $dir ) {
243 if ( ! is_dir( $dir ) ) {
244 continue;
245 }
246 $globbed = glob( $dir . '/*access*log*' );
247 if ( ! is_array( $globbed ) ) {
248 continue;
249 }
250 foreach ( $globbed as $path ) {
251 // Same regular-file contract as the fixed candidates: a
252 // glob can just as easily turn up a symlink to stdout.
253 if ( '' === self::usable_access_log( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
254 continue;
255 }
256 $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
257 if ( $mtime > $best_mtime ) {
258 $best_mtime = $mtime;
259 $best = $path;
260 }
261 }
262 }
263 return $best;
264 }
265
266 /**
267 * GZIP support category for the UI:
268 * 'auto' — toggling writes server config (Apache / LiteSpeed)
269 * 'manual' — must be configured outside the plugin (nginx, IIS, unknown)
270 */
271 public static function gzip_mode() {
272 return self::supports_htaccess() ? 'auto' : 'manual';
273 }
274
275 /**
276 * Whether the server can serve Brotli-compressed responses.
277 *
278 * Brotli is an optional server module (mod_brotli on Apache,
279 * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike
280 * GZIP it is NOT guaranteed present. We report availability so the UI
281 * and any add-on (xspeed-pro Brotli module) can decide whether to
282 * emit Brotli rules or fall back to GZIP only.
283 *
284 * Detection, cheapest signal first:
285 * 1. LiteSpeed — Brotli is part of the core server, always available.
286 * 2. Apache mod_php — apache_get_modules() lists 'mod_brotli'.
287 * 3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least
288 * pre-compress static files even when the web server can't.
289 * Anything else (nginx/FPM, IIS, unknown) is reported as not detected;
290 * the user can still wire ngx_brotli manually and the UI surfaces a
291 * snippet, mirroring how GZIP behaves on nginx.
292 *
293 * Result is filterable so a host with a known-good but undetectable
294 * setup (e.g. nginx + ngx_brotli) can force-enable.
295 */
296 public static function brotli_available(): bool {
297 $available = false;
298
299 if ( self::LITESPEED === self::type() ) {
300 $available = true;
301 } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) {
302 $available = true;
303 } elseif ( function_exists( 'brotli_compress' ) ) {
304 $available = true;
305 }
306
307 /**
308 * Filter detected Brotli availability.
309 *
310 * @param bool $available Whether Brotli serving was detected.
311 */
312 return (bool) apply_filters( 'xspeed_brotli_available', $available );
313 }
314
315 /**
316 * Is WordPress running inside a container (Docker / Podman / k8s)?
317 *
318 * Three signals checked in cheapness order, OR'd together:
319 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
320 * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
321 * containerd/podman storage drivers — works under cgroup v2.
322 * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
323 * — the cgroup v1 signal, still present on older Docker installs.
324 *
325 * Any single positive returns true. On non-Linux hosts (Windows /
326 * macOS / WSL host process), all three quietly return false and we
327 * fall back to "not containerized."
328 */
329 public static function is_containerized(): bool {
330 // 1. Docker marker file — cheap to stat, almost always present.
331 if ( file_exists( '/.dockerenv' ) ) {
332 return true;
333 }
334 // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
335 // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or
336 // hosts that hide /proc (open_basedir, hardened Apache), the file is
337 // absent and reading it would emit a warning. Query Monitor surfaces
338 // even @-suppressed warnings, so guard rather than silence. (FBS-83114)
339 if ( is_readable( '/proc/self/mountinfo' ) ) {
340 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
341 $mounts = file_get_contents( '/proc/self/mountinfo' );
342 if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
343 return true;
344 }
345 }
346 // 3. cgroup v1 fallback.
347 if ( is_readable( '/proc/1/cgroup' ) ) {
348 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
349 $cgroup = file_get_contents( '/proc/1/cgroup' );
350 if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
351 return true;
352 }
353 }
354 return false;
355 }
356
357 /**
358 * Is WordPress likely behind a reverse proxy (host nginx → container
359 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
360 * built from the headers WordPress hands to PHP: when a proxy forwards
361 * the request it almost always sets X-Forwarded-* or X-Real-IP.
362 *
363 * False positives (CDN-only forwarding without a local reverse proxy)
364 * are acceptable — the caller uses this signal only to soften messaging
365 * that would otherwise mislead container-host customers. False negatives
366 * (proxy that strips headers) just mean we keep showing the snippet
367 * paste UX, which is the safe default.
368 */
369 public static function is_behind_proxy(): bool {
370 $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
371 foreach ( $proxy_headers as $h ) {
372 if ( ! empty( $_SERVER[ $h ] ) ) {
373 return true;
374 }
375 }
376 return false;
377 }
378
379 /**
380 * High-level topology classifier driving the rewrite-alert UX.
381 *
382 * Decides WHERE the user's nginx snippet needs to be installed
383 * (or whether automatic install via .htaccess covers it). The
384 * dashboard banner uses the return value to render the right
385 * "paste this here" message — there is no topology that can't
386 * benefit from xSpeed's static-rewrite; the question is only
387 * which nginx is in the cache file's filesystem.
388 *
389 * Returns one of:
390 * 'htaccess' — Apache / LiteSpeed; .htaccess block is
391 * installed automatically, no user action.
392 * 'nginx-host' — self-managed nginx on the host (no
393 * container in the request path). User
394 * pastes the snippet into their vhost
395 * (typically /etc/nginx/sites-enabled/<site>).
396 * 'nginx-container' — nginx running inside the same container
397 * as PHP. User pastes the snippet into the
398 * container's nginx config (typically
399 * docker/nginx.conf in the site's
400 * docker-compose dir). Host nginx (if any)
401 * is a reverse-proxy that just forwards
402 * bytes — snippet does NOT go there.
403 * 'unknown' — IIS or undetected; treat as manual.
404 */
405 public static function rewrite_topology(): string {
406 $type = self::type();
407 if ( self::APACHE === $type || self::LITESPEED === $type ) {
408 return 'htaccess';
409 }
410 if ( self::NGINX === $type ) {
411 return self::is_containerized() ? 'nginx-container' : 'nginx-host';
412 }
413 return 'unknown';
414 }
415
416 private static function server_signature() {
417 return isset( $_SERVER['SERVER_SOFTWARE'] )
418 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
419 : '';
420 }
421
422 /**
423 * Detect active caching plugins that would conflict with xSpeed. Returns
424 * a list of human-readable labels for any conflicting plugin currently
425 * active; empty array means the field is clear. Used by the onboarding
426 * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
427 * Health card.
428 *
429 * The detection key is the plugin's main file path relative to the
430 * plugins directory — the same value WordPress uses internally in
431 * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
432 * would false-positive on disabled plugins still on disk.
433 */
434 public static function conflicts() {
435 if ( ! function_exists( 'is_plugin_active' ) ) {
436 require_once ABSPATH . 'wp-admin/includes/plugin.php';
437 }
438
439 $known = array(
440 'wp-rocket/wp-rocket.php' => 'WP Rocket',
441 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
442 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
443 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
444 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
445 'cache-enabler/cache-enabler.php' => 'Cache Enabler',
446 'comet-cache/comet-cache.php' => 'Comet Cache',
447 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
448 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
449 'breeze/breeze.php' => 'Breeze',
450 'autoptimize/autoptimize.php' => 'Autoptimize',
451 'flying-press/flying-press.php' => 'FlyingPress',
452 'nitropack/main.php' => 'NitroPack',
453 );
454
455 $active = array();
456 foreach ( $known as $file => $label ) {
457 if ( is_plugin_active( $file ) ) {
458 $active[] = $label;
459 }
460 }
461 return $active;
462 }
463 }
464