PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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 1.2.0 All 28 releases
xspeed / includes / class-server.php

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

701 lines 28.2 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 /**
26 * Last authoritative mod_headers answer, captured under mod_php where
27 * apache_get_modules() actually exists. Read by SAPIs that cannot
28 * detect (WP-CLI, FPM) so one host gives one answer. See
29 * apache_has_mod_headers().
30 */
31 const OPT_CACHED_MOD_HEADERS = 'xspeed_apache_mod_headers';
32
33 public static function type() {
34 $detected = self::detect();
35 if ( self::UNKNOWN !== $detected ) {
36 // Persist whenever we have a real answer so future CLI /
37 // cron / REST calls (where SERVER_SOFTWARE may be empty)
38 // inherit it. Non-autoloaded — only read when needed.
39 $cached = get_option( self::OPT_CACHED_TYPE, null );
40 if ( $cached !== $detected ) {
41 update_option( self::OPT_CACHED_TYPE, $detected, false );
42 }
43 return $detected;
44 }
45
46 // No definitive signal this request (typically WP-CLI, where
47 // SERVER_SOFTWARE is empty). Read whatever was cached the last
48 // time we ran from a real HTTP request.
49 $cached = get_option( self::OPT_CACHED_TYPE, null );
50 if ( is_string( $cached ) && '' !== $cached ) {
51 return $cached;
52 }
53
54 return self::UNKNOWN;
55 }
56
57 /**
58 * Live detection — never reads the cache. Used by type() and by
59 * any caller that explicitly wants the current-request answer
60 * (e.g. diagnostic UI showing "detected this request").
61 *
62 * We DO NOT fall back to "if .htaccess exists assume Apache" here:
63 * Cache::install_rewrite() writes .htaccess itself, so on nginx
64 * hosts the file appears after first cache toggle and a presence
65 * check then flips us to APACHE forever. Cached HTTP detection
66 * is the cleaner backstop.
67 */
68 public static function detect(): string {
69 global $is_apache, $is_nginx, $is_IIS, $is_iis7;
70
71 $signature = self::server_signature();
72
73 if ( false !== stripos( $signature, 'litespeed' ) ) {
74 return self::LITESPEED;
75 }
76 // apache_get_modules() exists only with mod_php (not FPM), so
77 // gate it behind SERVER_SOFTWARE first. Otherwise an
78 // "apache_get_modules exists" check would false-positive on a
79 // few PHP-builtin-server / mod_php-on-localhost dev edge cases.
80 if ( false !== stripos( $signature, 'apache' ) || ! empty( $is_apache ) ) {
81 return self::APACHE;
82 }
83 if ( false !== stripos( $signature, 'nginx' ) || ! empty( $is_nginx ) ) {
84 return self::NGINX;
85 }
86 if ( false !== stripos( $signature, 'microsoft-iis' ) || ! empty( $is_IIS ) || ! empty( $is_iis7 ) ) {
87 return self::IIS;
88 }
89 return self::UNKNOWN;
90 }
91
92 /**
93 * Whether the server respects .htaccess / web.config-style file-based config.
94 */
95 public static function supports_htaccess() {
96 $t = self::type();
97 return self::APACHE === $t || self::LITESPEED === $t;
98 }
99
100 /**
101 * Whether Apache can stamp a response header from `.htaccess`
102 * (i.e. mod_headers is loaded).
103 *
104 * This decides whether the static-rewrite fast path can be used at
105 * all. A statically-served file bypasses PHP entirely, so the ONLY
106 * way to mark it as a cache HIT is a `Header` directive in the
107 * rewrite block. Without mod_headers that directive is silently
108 * swallowed by its `<IfModule>` guard, and the site serves fast but
109 * completely invisible cache hits — no `X-XSpeed-Cache` header for
110 * the user, nothing for the hit counter. That is exactly the
111 * "cache works, dashboard says 0%" report this check exists to
112 * prevent. (Cache::static_rewrite_allowed() consumes it.)
113 *
114 * Detection is best-effort by necessity, and MUST NOT vary by SAPI:
115 * - mod_php exposes apache_get_modules() — authoritative. Persist
116 * that answer so other SAPIs can inherit it.
117 * - Under PHP-FPM / WP-CLI the function doesn't exist. Read the
118 * stored mod_php answer; only when nothing was ever stored do we
119 * assume the module IS present, matching Apache's own default
120 * build (mod_headers ships enabled in every mainstream distro
121 * package). Guessing "absent" there would push every FPM site
122 * onto the slower drop-in path over a detection limitation
123 * rather than a real capability gap; the loopback probe in
124 * Cache::probe_static_rewrite() is what catches a genuinely
125 * header-less FPM host.
126 *
127 * Returning a different answer per SAPI is not merely inaccurate: it
128 * makes static_rewrite_allowed() disagree with the on-disk .htaccess,
129 * so every WP-CLI bootstrap "corrects" what the last web request
130 * wrote and vice versa — an endless rewrite/purge ping-pong that
131 * keeps the hit ratio pinned near zero. (#138)
132 *
133 * @return bool
134 */
135 public static function apache_has_mod_headers(): bool {
136 if ( function_exists( 'apache_get_modules' ) ) {
137 $has = in_array( 'mod_headers', apache_get_modules(), true );
138
139 // Authoritative — persist so CLI/FPM inherit it instead of
140 // guessing. Non-autoloaded; only read when needed.
141 $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null );
142 $want = $has ? '1' : '0';
143 if ( (string) $cached !== $want ) {
144 update_option( self::OPT_CACHED_MOD_HEADERS, $want, false );
145 }
146 } else {
147 // Cannot detect here. Prefer the last known real answer over
148 // an optimistic guess that would flip static_rewrite_allowed().
149 $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null );
150 $has = ( null === $cached || '' === $cached )
151 ? true // never detected: assume the distro default.
152 : (bool) (int) $cached;
153 }
154
155 /**
156 * Filter: xspeed_apache_has_mod_headers
157 *
158 * Override mod_headers detection. Return false on a host where
159 * `.htaccess` Header directives are stripped (some managed
160 * stacks do this) to force cache hits through the PHP drop-in,
161 * where they are stamped and counted.
162 *
163 * @param bool $has Whether mod_headers appears to be available.
164 */
165 return (bool) apply_filters( 'xspeed_apache_has_mod_headers', $has );
166 }
167
168 /**
169 * Accept a candidate access-log path only if it can actually be
170 * tail-scanned, else ''.
171 *
172 * `is_readable()` alone is not enough. The official WordPress and
173 * Apache Docker images symlink `access.log -> /dev/stdout`, i.e. a
174 * PIPE: `is_file()` is false, `filesize()` is 0, and `is_readable()`
175 * is false for the PHP user. Hit_Counter::collect_server_log_hits()
176 * fseek()s to a stored byte offset and reads forward, which a pipe
177 * or character device cannot support at all — it would either fail
178 * or block. Requiring a REGULAR file makes that contract explicit
179 * instead of relying on the filesize>0 test to reject pipes as a
180 * side effect. Containerised Apache is the standard layout, not an
181 * edge case, so this path is common. (Field report: hit ratio stuck
182 * at 0% on Dockerised Apache while the cache served correctly.)
183 *
184 * @param string $path Candidate path.
185 * @return string The path when usable, '' otherwise.
186 */
187 private static function usable_access_log( string $path ): string {
188 if ( '' === $path ) {
189 return '';
190 }
191 // is_file() resolves symlinks, so access.log -> /var/log/real.log
192 // is still accepted; only the pipe/device targets are rejected.
193 if ( ! @is_file( $path ) || ! is_readable( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat on an external log; treated as "unusable".
194 return '';
195 }
196 return $path;
197 }
198
199 /**
200 * Best-effort path to the web server's access log, used to count
201 * static-rewrite HITs that bypass PHP on Apache/LiteSpeed (those
202 * requests are served straight from disk and never reach our
203 * Hit_Counter inline — see Hit_Counter::collect_server_log_hits()).
204 *
205 * Resolution order:
206 * 1. The `XSPEED_ACCESS_LOG` constant, if defined (explicit override
207 * for hosts where the log lives somewhere non-standard).
208 * 2. The `xspeed_access_log_path` filter (programmatic override).
209 * 3. Auto-detection: a short list of the standard Apache/LiteSpeed
210 * access-log locations, returning the first that exists AND is
211 * readable by the PHP user.
212 *
213 * Every route is funnelled through usable_access_log(), so an
214 * override can no more hand us a pipe than auto-detection can.
215 *
216 * Returns '' when nothing usable is found — a very common case on
217 * managed/cPanel hosts where the PHP user can't read the server log,
218 * and on containers where it's a symlink to stdout. Callers MUST
219 * treat '' as "can't count static hits here" and fall back
220 * gracefully (the drop-in path still counts its own HITs).
221 *
222 * @return string Absolute path, or '' if none is usable.
223 */
224 public static function access_log_path(): string {
225 if ( defined( 'XSPEED_ACCESS_LOG' ) && is_string( XSPEED_ACCESS_LOG ) && '' !== XSPEED_ACCESS_LOG ) {
226 return self::usable_access_log( XSPEED_ACCESS_LOG );
227 }
228
229 /**
230 * Filter: xspeed_access_log_path
231 *
232 * Override the auto-detected access-log path. Return '' to disable
233 * server-log hit counting entirely.
234 *
235 * @param string|null $path Null = use auto-detection below.
236 */
237 $filtered = apply_filters( 'xspeed_access_log_path', null );
238 if ( is_string( $filtered ) ) {
239 return self::usable_access_log( $filtered );
240 }
241
242 // Auto-detect: the standard Apache + OpenLiteSpeed/LiteSpeed
243 // Enterprise access-log locations. First readable, NON-EMPTY file
244 // wins — an empty global access.log (common on LiteSpeed, which
245 // logs per-vhost instead) must not shadow the real per-vhost log we
246 // discover below.
247 $candidates = array(
248 '/var/log/apache2/access.log', // Debian/Ubuntu Apache
249 '/var/log/httpd/access_log', // RHEL/CentOS Apache
250 '/var/log/apache2/other_vhosts_access.log', // Debian multi-vhost
251 '/usr/local/lsws/logs/access.log', // OpenLiteSpeed global
252 '/var/log/lshttpd/access.log', // LiteSpeed Enterprise
253 );
254 foreach ( $candidates as $path ) {
255 // usable_access_log() enforces "regular file + readable"; the
256 // non-empty test stays here so an empty global log doesn't
257 // shadow the real per-vhost one found further below.
258 if ( '' !== self::usable_access_log( $path ) && (int) @filesize( $path ) > 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- racey stat, treated as "skip".
259 return $path;
260 }
261 }
262
263 // LiteSpeed (and some Apache vhost setups) write a per-vhost
264 // `<vhost>.access.log` rather than a single global file. Scan the
265 // known log dirs for the most-recently-written, readable, non-empty
266 // *.access.log and use that. Auto-tracks whichever vhost is serving
267 // this site without the admin having to set a path.
268 $dirs = array( '/usr/local/lsws/logs', '/var/log/lshttpd', '/var/log/apache2', '/var/log/httpd' );
269 $best = '';
270 $best_mtime = 0;
271 foreach ( $dirs as $dir ) {
272 if ( ! is_dir( $dir ) ) {
273 continue;
274 }
275 $globbed = glob( $dir . '/*access*log*' );
276 if ( ! is_array( $globbed ) ) {
277 continue;
278 }
279 foreach ( $globbed as $path ) {
280 // Same regular-file contract as the fixed candidates: a
281 // glob can just as easily turn up a symlink to stdout.
282 if ( '' === self::usable_access_log( $path ) || (int) @filesize( $path ) === 0 ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
283 continue;
284 }
285 $mtime = (int) @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
286 if ( $mtime > $best_mtime ) {
287 $best_mtime = $mtime;
288 $best = $path;
289 }
290 }
291 }
292 return $best;
293 }
294
295 /**
296 * GZIP support category for the UI:
297 * 'auto' — toggling writes server config (Apache / LiteSpeed)
298 * 'manual' — must be configured outside the plugin (nginx, IIS, unknown)
299 */
300 public static function gzip_mode() {
301 return self::supports_htaccess() ? 'auto' : 'manual';
302 }
303
304 /**
305 * Whether the server can serve Brotli-compressed responses.
306 *
307 * Brotli is an optional server module (mod_brotli on Apache,
308 * ngx_brotli on nginx, built in on LiteSpeed/OpenLiteSpeed) — unlike
309 * GZIP it is NOT guaranteed present. We report availability so the UI
310 * and any add-on (xspeed-pro Brotli module) can decide whether to
311 * emit Brotli rules or fall back to GZIP only.
312 *
313 * Detection, cheapest signal first:
314 * 1. LiteSpeed — Brotli is part of the core server, always available.
315 * 2. Apache mod_php — apache_get_modules() lists 'mod_brotli'.
316 * 3. PHP `brotli` extension (kjdev/php-ext-brotli) — lets us at least
317 * pre-compress static files even when the web server can't.
318 * Anything else (nginx/FPM, IIS, unknown) is reported as not detected;
319 * the user can still wire ngx_brotli manually and the UI surfaces a
320 * snippet, mirroring how GZIP behaves on nginx.
321 *
322 * Result is filterable so a host with a known-good but undetectable
323 * setup (e.g. nginx + ngx_brotli) can force-enable.
324 */
325 public static function brotli_available(): bool {
326 $available = false;
327
328 if ( self::LITESPEED === self::type() ) {
329 $available = true;
330 } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) {
331 $available = true;
332 } elseif ( function_exists( 'brotli_compress' ) ) {
333 $available = true;
334 } elseif ( self::NGINX === self::type() ) {
335 // nginx modules are not introspectable from PHP, so none of the
336 // branches above can ever be true on the very common nginx +
337 // php-fpm setup — even while ngx_brotli is actively serving
338 // `Content-Encoding: br` on every request. Reporting "unavailable"
339 // there told users who had done everything right to go install a
340 // module they already had.
341 //
342 // So ask the server instead of asking PHP: one cached loopback
343 // request with `Accept-Encoding: br`, and read what comes back.
344 $available = self::brotli_probe();
345 }
346
347 /**
348 * Filter detected Brotli availability.
349 *
350 * @param bool $available Whether Brotli serving was detected.
351 */
352 return (bool) apply_filters( 'xspeed_brotli_available', $available );
353 }
354
355 /**
356 * Ask the web server whether it serves Brotli, by requesting our own home
357 * URL with `Accept-Encoding: br` and reading the response encoding.
358 *
359 * The only way to answer this on nginx: the module list isn't visible to
360 * PHP, so introspection can't work and the request itself is the evidence.
361 *
362 * Cached in a transient — a positive result for a day (server modules
363 * don't come and go), a negative for an hour so someone who has just
364 * installed ngx_brotli isn't told "no" until tomorrow. Failures cache
365 * briefly too, so a host that hangs on loopback self-requests can't turn
366 * every dashboard load into a timeout.
367 *
368 * @param bool $force Skip the cache and re-probe.
369 */
370 public static function brotli_probe( bool $force = false ): bool {
371 return 'yes' === self::brotli_probe_state( $force );
372 }
373
374 /**
375 * The probe's three-way answer: 'yes', 'no', or 'unknown'.
376 *
377 * brotli_probe() collapses this to a bool because every consumer wants
378 * one, but the distinction matters for what we TELL the user.
379 * "unknown" — a blocked or failing loopback — is not evidence that the
380 * server lacks Brotli, and reporting it as "no" would repeat the original
381 * bug in a new place: telling someone whose setup is fine that it isn't.
382 *
383 * @param bool $force Skip the cache and re-probe.
384 * @return string 'yes' | 'no' | 'unknown'
385 */
386 public static function brotli_probe_state( bool $force = false ): string {
387 $key = 'xspeed_brotli_probe';
388
389 if ( ! $force ) {
390 $cached = get_transient( $key );
391 if ( false !== $cached ) {
392 $cached = (string) $cached;
393 // Legacy '1'/'0' values from an earlier cache format.
394 if ( '1' === $cached ) {
395 return 'yes';
396 }
397 if ( '0' === $cached ) {
398 return 'no';
399 }
400 return in_array( $cached, array( 'yes', 'no', 'unknown' ), true ) ? $cached : 'unknown';
401 }
402 }
403
404 $url = home_url( '/' );
405 if ( ! function_exists( 'wp_remote_get' ) || '' === $url ) {
406 return 'unknown';
407 }
408
409 // Stampede guard. On a cold transient every concurrent dashboard load
410 // would otherwise fire its own 3s loopback request, because nothing
411 // was written until the response came back. Claim the slot BEFORE the
412 // request so the other callers answer 'unknown' (accurate — they
413 // genuinely don't know yet) rather than piling on.
414 $inflight = $key . '_inflight';
415 if ( ! $force && false !== get_transient( $inflight ) ) {
416 return 'unknown';
417 }
418 set_transient( $inflight, 1, 30 );
419
420 // Mirror Cache::probe_static_rewrite()'s posture: short timeout so a
421 // blocked loopback can't stall the caller, and relax cert verification
422 // only in local/dev where self-signed certs are normal.
423 $is_local = function_exists( 'wp_get_environment_type' )
424 && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
425
426 $resp = wp_remote_get(
427 $url,
428 array(
429 'timeout' => 3,
430 'sslverify' => ! $is_local,
431 'redirection' => 0,
432 'headers' => array(
433 // `br` ONLY. Offering gzip as well would let a server that
434 // prefers gzip answer with it and look like a brotli
435 // failure, which is exactly the false negative this method
436 // exists to remove.
437 'Accept-Encoding' => 'br',
438 'Cache-Control' => 'no-cache',
439 ),
440 )
441 );
442
443 delete_transient( $inflight );
444
445 if ( is_wp_error( $resp ) ) {
446 // Can't reach ourselves. This is NOT evidence the server lacks
447 // Brotli — reporting it as "no" would repeat the original bug in a
448 // new place. Cache briefly so a hanging host doesn't cost 3s on
449 // every call, but re-check soon.
450 set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
451 return 'unknown';
452 }
453
454 // A non-2xx answer tells us nothing about compression: basic auth
455 // (401), maintenance mode (503) and WAF challenge pages are all
456 // "couldn't check", not "no module". Caching 'no' for an hour on the
457 // strength of one is the same category error this method fixes.
458 $code = (int) wp_remote_retrieve_response_code( $resp );
459 if ( $code < 200 || $code >= 300 ) {
460 set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
461 return 'unknown';
462 }
463
464 // A CDN or reverse proxy in front of the origin compresses on its own
465 // behalf, so `content-encoding: br` would describe the EDGE, not this
466 // server. On Apache/LiteSpeed that is harmless (brotli_available()
467 // short-circuits before consulting the probe), but on nginx the probe
468 // IS the answer — and a large share of nginx sites sit behind
469 // Cloudflare, Fastly or a load balancer. Asserting 'yes' there is the
470 // mirror image of the false negative this method exists to remove, so
471 // we answer 'unknown': we genuinely could not observe the origin.
472 if ( self::response_came_through_proxy( $resp ) ) {
473 set_transient( $key, 'unknown', HOUR_IN_SECONDS );
474 return 'unknown';
475 }
476
477 $encoding = wp_remote_retrieve_header( $resp, 'content-encoding' );
478 if ( is_array( $encoding ) ) {
479 $encoding = implode( ',', $encoding );
480 }
481 $serves_brotli = false !== stripos( (string) $encoding, 'br' );
482
483 // A positive is durable (server modules don't come and go); a negative
484 // expires sooner so someone who has just installed ngx_brotli isn't
485 // told "no" until tomorrow.
486 $state = $serves_brotli ? 'yes' : 'no';
487 set_transient( $key, $state, $serves_brotli ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
488
489 return $state;
490 }
491
492 /**
493 * Did this response come back through a CDN / reverse proxy rather than
494 * straight from our own web server?
495 *
496 * home_url() resolves through public DNS, so the request can leave the
497 * box entirely and be answered at an edge. These headers are the evidence
498 * the edge leaves behind; none of them are set by a plain origin.
499 *
500 * Deliberately conservative — a false "there's a proxy" costs a user the
501 * capability assertion and shows the 'unknown' copy, while a false "no
502 * proxy" tells an nginx user Brotli is on when their origin cannot serve
503 * it. Cache::probe_static_rewrite() shares this blind spot, which is why
504 * this is a public helper rather than inline.
505 *
506 * @param array|\WP_Error $resp Response from wp_remote_get().
507 */
508 public static function response_came_through_proxy( $resp ): bool {
509 if ( is_wp_error( $resp ) ) {
510 return false;
511 }
512
513 // Headers whose mere presence means an intermediary handled this.
514 foreach ( array( 'cf-ray', 'x-served-by', 'x-cache', 'via', 'x-varnish', 'fastly-io-info', 'x-amz-cf-id', 'x-akamai-transformed', 'x-sucuri-id' ) as $header ) {
515 $value = wp_remote_retrieve_header( $resp, $header );
516 if ( is_array( $value ) ) {
517 $value = implode( ',', $value );
518 }
519 if ( '' !== (string) $value ) {
520 return true;
521 }
522 }
523
524 // `server:` naming a known edge. Checked by substring because these
525 // arrive as `cloudflare`, `Sucuri/Cloudproxy`, `AkamaiGHost`, etc.
526 $server = wp_remote_retrieve_header( $resp, 'server' );
527 if ( is_array( $server ) ) {
528 $server = implode( ',', $server );
529 }
530 $server = strtolower( (string) $server );
531 foreach ( array( 'cloudflare', 'cloudfront', 'akamai', 'fastly', 'sucuri', 'incapsula', 'stackpath', 'bunnycdn', 'keycdn' ) as $needle ) {
532 if ( false !== strpos( $server, $needle ) ) {
533 return true;
534 }
535 }
536
537 return (bool) apply_filters( 'xspeed_response_came_through_proxy', false, $resp );
538 }
539
540 /**
541 * Drop the cached Brotli probe result so the next call re-checks.
542 *
543 * Without this a user who installs ngx_brotli has no way to make the
544 * dashboard notice before the transient expires — the same gap
545 * Cache::recheck_static_rewrite() exists to close.
546 */
547 public static function recheck_brotli(): bool {
548 delete_transient( 'xspeed_brotli_probe' );
549 return self::brotli_probe( true );
550 }
551
552 /**
553 * Is WordPress running inside a container (Docker / Podman / k8s)?
554 *
555 * Three signals checked in cheapness order, OR'd together:
556 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
557 * 2. /proc/self/mountinfo references /var/lib/docker/overlay2 or
558 * containerd/podman storage drivers — works under cgroup v2.
559 * 3. /proc/1/cgroup names docker / kubepods / containerd / podman / lxc
560 * — the cgroup v1 signal, still present on older Docker installs.
561 *
562 * Any single positive returns true. On non-Linux hosts (Windows /
563 * macOS / WSL host process), all three quietly return false and we
564 * fall back to "not containerized."
565 */
566 public static function is_containerized(): bool {
567 // 1. Docker marker file — cheap to stat, almost always present.
568 if ( file_exists( '/.dockerenv' ) ) {
569 return true;
570 }
571 // 2. mountinfo overlay2 / containerd footprint — works under cgroup v2.
572 // Gate on is_readable() first: on non-Linux hosts (macOS/Windows) or
573 // hosts that hide /proc (open_basedir, hardened Apache), the file is
574 // absent and reading it would emit a warning. Query Monitor surfaces
575 // even @-suppressed warnings, so guard rather than silence. (FBS-83114)
576 if ( is_readable( '/proc/self/mountinfo' ) ) {
577 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- /proc/self/mountinfo is a virtual file; WP_Filesystem doesn't model /proc.
578 $mounts = file_get_contents( '/proc/self/mountinfo' );
579 if ( is_string( $mounts ) && '' !== $mounts && preg_match( '#(docker/overlay2|/var/lib/containerd|/var/lib/podman)#i', $mounts ) ) {
580 return true;
581 }
582 }
583 // 3. cgroup v1 fallback.
584 if ( is_readable( '/proc/1/cgroup' ) ) {
585 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above.
586 $cgroup = file_get_contents( '/proc/1/cgroup' );
587 if ( is_string( $cgroup ) && '' !== $cgroup && preg_match( '#(docker|kubepods|containerd|podman|lxc)#i', $cgroup ) ) {
588 return true;
589 }
590 }
591 return false;
592 }
593
594 /**
595 * Is WordPress likely behind a reverse proxy (host nginx → container
596 * php-fpm, host nginx → docker nginx, etc.)? Detection is a heuristic
597 * built from the headers WordPress hands to PHP: when a proxy forwards
598 * the request it almost always sets X-Forwarded-* or X-Real-IP.
599 *
600 * False positives (CDN-only forwarding without a local reverse proxy)
601 * are acceptable — the caller uses this signal only to soften messaging
602 * that would otherwise mislead container-host customers. False negatives
603 * (proxy that strips headers) just mean we keep showing the snippet
604 * paste UX, which is the safe default.
605 */
606 public static function is_behind_proxy(): bool {
607 $proxy_headers = array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_HOST', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_SERVER' );
608 foreach ( $proxy_headers as $h ) {
609 if ( ! empty( $_SERVER[ $h ] ) ) {
610 return true;
611 }
612 }
613 return false;
614 }
615
616 /**
617 * High-level topology classifier driving the rewrite-alert UX.
618 *
619 * Decides WHERE the user's nginx snippet needs to be installed
620 * (or whether automatic install via .htaccess covers it). The
621 * dashboard banner uses the return value to render the right
622 * "paste this here" message — there is no topology that can't
623 * benefit from xSpeed's static-rewrite; the question is only
624 * which nginx is in the cache file's filesystem.
625 *
626 * Returns one of:
627 * 'htaccess' — Apache / LiteSpeed; .htaccess block is
628 * installed automatically, no user action.
629 * 'nginx-host' — self-managed nginx on the host (no
630 * container in the request path). User
631 * pastes the snippet into their vhost
632 * (typically /etc/nginx/sites-enabled/<site>).
633 * 'nginx-container' — nginx running inside the same container
634 * as PHP. User pastes the snippet into the
635 * container's nginx config (typically
636 * docker/nginx.conf in the site's
637 * docker-compose dir). Host nginx (if any)
638 * is a reverse-proxy that just forwards
639 * bytes — snippet does NOT go there.
640 * 'unknown' — IIS or undetected; treat as manual.
641 */
642 public static function rewrite_topology(): string {
643 $type = self::type();
644 if ( self::APACHE === $type || self::LITESPEED === $type ) {
645 return 'htaccess';
646 }
647 if ( self::NGINX === $type ) {
648 return self::is_containerized() ? 'nginx-container' : 'nginx-host';
649 }
650 return 'unknown';
651 }
652
653 private static function server_signature() {
654 return isset( $_SERVER['SERVER_SOFTWARE'] )
655 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
656 : '';
657 }
658
659 /**
660 * Detect active caching plugins that would conflict with xSpeed. Returns
661 * a list of human-readable labels for any conflicting plugin currently
662 * active; empty array means the field is clear. Used by the onboarding
663 * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
664 * Health card.
665 *
666 * The detection key is the plugin's main file path relative to the
667 * plugins directory — the same value WordPress uses internally in
668 * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
669 * would false-positive on disabled plugins still on disk.
670 */
671 public static function conflicts() {
672 if ( ! function_exists( 'is_plugin_active' ) ) {
673 require_once ABSPATH . 'wp-admin/includes/plugin.php';
674 }
675
676 $known = array(
677 'wp-rocket/wp-rocket.php' => 'WP Rocket',
678 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
679 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
680 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
681 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
682 'cache-enabler/cache-enabler.php' => 'Cache Enabler',
683 'comet-cache/comet-cache.php' => 'Comet Cache',
684 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
685 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
686 'breeze/breeze.php' => 'Breeze',
687 'autoptimize/autoptimize.php' => 'Autoptimize',
688 'flying-press/flying-press.php' => 'FlyingPress',
689 'nitropack/main.php' => 'NitroPack',
690 );
691
692 $active = array();
693 foreach ( $known as $file => $label ) {
694 if ( is_plugin_active( $file ) ) {
695 $active[] = $label;
696 }
697 }
698 return $active;
699 }
700 }
701