PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / feedback / FeedbackEnvironmentExtras_PlatformFingerprint.php

FeedbackEnvironmentExtras_PlatformFingerprint.php in 404 Solution trunk, at includes/feedback/FeedbackEnvironmentExtras_PlatformFingerprint.php

429 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Static-identity probes that fingerprint the hosting platform: WHICH
9 * managed host (WP Engine, Kinsta, Pantheon, ...), WHICH control panel
10 * (cPanel, Plesk, RunCloud, ...), WHICH PHP execution stack (LSWS,
11 * mod_lsapi, FPM, mod_php, CGI), and WHICH CloudLinux markers are present.
12 *
13 * WHICH cache implementation owns the request caches is a different subject
14 * with a different marker vocabulary, and it lives in
15 * ABJ_404_Solution_FeedbackEnvironmentExtras_CacheFingerprint.
16 *
17 * Distinct in kind from FeedbackEnvironmentExtras_HostProbes, which
18 * answers dynamic runtime questions (how much disk is left, what is
19 * the open_basedir RIGHT NOW). Platform fingerprints answer "what is
20 * this site permanently sitting on" -- the values rarely change for
21 * the life of the install and group reports the same way over time.
22 *
23 * Every detector here follows the same pattern: scan a table of
24 * distinctive markers (constants, env vars, paths, container and
25 * database-server identity), return the first match. Keeping those tables
26 * together lets them evolve as a single editorial concern instead of
27 * being scattered.
28 *
29 * No PII: only matched marker keys are returned. SERVER_SOFTWARE is
30 * NOT echoed wholesale; it may include a hostname.
31 *
32 * Owned by ABJ_404_Solution_FeedbackEnvironmentExtras via composition;
33 * see that class's collect() method for the keyed probe registry that
34 * wraps each call below in recordProbe() for failure isolation.
35 */
36 class ABJ_404_Solution_FeedbackEnvironmentExtras_PlatformFingerprint {
37
38 /**
39 * Best-effort hosting-class hint. Parses well-known markers from
40 * server_software + per-host environment vars + per-host PHP
41 * constants. Returns a small object so the server side can
42 * distinguish "WP Engine" from "Kinsta" without re-parsing strings.
43 *
44 * No PII: only matched markers are returned. server_software is NOT
45 * echoed wholesale; it may include a hostname.
46 *
47 * `$runtime` is an optional already-observed runtime snapshot. Production
48 * callers normally omit it; diagnostic tests and offline collectors can
49 * supply stable values without mutating process-wide PHP state.
50 *
51 * @param array{php_sapi?: mixed, loaded_extensions?: mixed, cloudlinux_alt_php_present?: mixed,
52 * env?: mixed, document_root?: mixed, abspath?: mixed, cgroup?: mixed,
53 * db_server_version?: mixed} $runtime
54 * @return array<string, mixed>
55 */
56 public function probeHostingClass(array $runtime = array()): array {
57 $out = array(
58 'host' => 'unknown',
59 'panel' => 'unknown',
60 'php_execution_stack' => 'unknown',
61 'cloudlinux_markers' => array(),
62 'matched_marker' => '',
63 );
64 $sw = '';
65 if (isset($_SERVER['SERVER_SOFTWARE']) && is_scalar($_SERVER['SERVER_SOFTWARE'])) {
66 $sw = strtolower((string)$_SERVER['SERVER_SOFTWARE']);
67 }
68 $out['server_class'] = $this->classifyServerClass($sw);
69
70 $phpSapi = isset($runtime['php_sapi']) && is_scalar($runtime['php_sapi'])
71 ? strtolower(trim((string)$runtime['php_sapi']))
72 : strtolower(PHP_SAPI);
73 $out['php_execution_stack'] = $this->classifyPhpExecutionStack(
74 (string)$out['server_class'],
75 $phpSapi
76 );
77
78 $out['cloudlinux_markers'] = $this->collectCloudLinuxMarkers($runtime);
79
80 $managedHost = $this->detectManagedHost($runtime);
81 if ($managedHost['host'] !== '') {
82 $out['host'] = $managedHost['host'];
83 $out['matched_marker'] = $managedHost['matched_marker'];
84 }
85 if ($out['host'] === 'unknown') {
86 $infrastructure = $this->detectInfrastructureHost($runtime);
87 if ($infrastructure['host'] !== '') {
88 $out['host'] = $infrastructure['host'];
89 $out['matched_marker'] = $infrastructure['matched_marker'];
90 }
91 }
92 if ($out['host'] === 'unknown' && $out['cloudlinux_markers'] !== array()) {
93 $out['host'] = 'cloudlinux';
94 $out['matched_marker'] = (string)$out['cloudlinux_markers'][0];
95 }
96
97 $panel = $this->detectControlPanel($runtime);
98 if ($panel['panel'] !== '') {
99 $out['panel'] = $panel['panel'];
100 if ($out['matched_marker'] === '') {
101 $out['matched_marker'] = $panel['matched_marker'];
102 }
103 }
104
105 return $out;
106 }
107
108 /**
109 * Managed hosts that identify themselves outright, each through a
110 * distinctive PHP constant or environment variable. First match wins, so
111 * declaration order is precedence order.
112 *
113 * Azure App Service is in this table for the environment route
114 * (WEBSITE_SITE_NAME and friends, which App Service always injects into the
115 * container); the filesystem/cgroup/database routes that survive an FPM
116 * pool built with `clear_env = yes` are in INFRASTRUCTURE_HOST_MARKERS.
117 *
118 * @param array<string, mixed> $runtime
119 * @return array{host: string, matched_marker: string} Empty host when no marker matched.
120 */
121 private function detectManagedHost(array $runtime): array {
122 $managedHostChecks = array(
123 'wp_engine' => array('const' => array('WPE_APIKEY', 'WPE_PLUGIN_DIR'), 'env' => array('IS_WPE')),
124 'kinsta' => array('const' => array('KINSTA_CACHE_ZONE'), 'env' => array('KINSTA_SERVICE_NAME')),
125 'pantheon' => array('const' => array('PANTHEON_ENVIRONMENT'), 'env' => array('PANTHEON_ENVIRONMENT')),
126 'flywheel' => array('const' => array('FLYWHEEL_CONFIG_DIR', 'FLYWHEEL_PLUGIN_DIR'), 'env' => array()),
127 'pressable' => array('const' => array('PRESSABLE_VERSION'), 'env' => array()),
128 'siteground' => array('const' => array('SG_OPTIMIZER_VERSION'), 'env' => array()),
129 'wordpress_com' => array('const' => array('IS_ATOMIC', 'IS_WPCOM'), 'env' => array()),
130 'cloudways' => array('const' => array(), 'env' => array('cw_allowed_ip')),
131 'azure_app_service' => array('const' => array(), 'env' => array(
132 'WEBSITE_SITE_NAME', 'WEBSITE_INSTANCE_ID', 'APPSETTING_WEBSITE_SITE_NAME')),
133 );
134 foreach ($managedHostChecks as $hostKey => $checks) {
135 foreach ((array)$checks['const'] as $c) {
136 if (defined($c)) {
137 return array('host' => $hostKey, 'matched_marker' => 'const:' . $c);
138 }
139 }
140 foreach ((array)$checks['env'] as $e) {
141 if ($this->environmentValue($runtime, $e) !== null) {
142 return array('host' => $hostKey, 'matched_marker' => 'env:' . $e);
143 }
144 }
145 }
146 return array('host' => '', 'matched_marker' => '');
147 }
148
149 /**
150 * Control panels: cPanel / hPanel / Plesk / DirectAdmin / RunCloud /
151 * CloudPanel. Independent of the managed-host class: a cPanel site might
152 * also be on SiteGround.
153 *
154 * @param array<string, mixed> $runtime
155 * @return array{panel: string, matched_marker: string} Empty panel when no marker matched.
156 */
157 private function detectControlPanel(array $runtime): array {
158 $panelChecks = array(
159 'cpanel' => array('env' => array('CPANEL'), 'path' => array('/usr/local/cpanel')),
160 'hpanel' => array('env' => array('HOSTINGER'), 'path' => array('/usr/local/hostinger')),
161 'plesk' => array('env' => array('PLESK_ADMIN_PASSWORD'), 'path' => array('/usr/local/psa', '/opt/psa')),
162 'directadmin' => array('env' => array(), 'path' => array('/usr/local/directadmin')),
163 'runcloud' => array('env' => array(), 'path' => array('/etc/runcloud')),
164 'cloudpanel' => array('env' => array(), 'path' => array('/home/clp')),
165 );
166 foreach ($panelChecks as $panelKey => $checks) {
167 foreach ((array)$checks['env'] as $e) {
168 if ($this->environmentValue($runtime, $e) !== null) {
169 return array('panel' => $panelKey, 'matched_marker' => 'env:' . $e);
170 }
171 }
172 foreach ((array)$checks['path'] as $p) {
173 // The panel-detection paths (/home/clp, /usr/local/cpanel, /opt/psa,
174 // /etc/runcloud, etc.) sit outside the open_basedir of most managed
175 // shared-hosting environments. is_dir() raises E_WARNING on every
176 // miss. The plugin's NormalErrorHandler reports those warnings
177 // (errfile is THIS file, which lives under the plugin folder), so
178 // the diagnostic that was supposed to be silent ends up in the
179 // admin error inbox as "ABJ404-SOLUTION Normal error handler error:
180 // errno: 2, errstr: is_dir(): open_basedir restriction in effect.".
181 // Suppress with @ since the probe is intentionally best-effort and
182 // a denial here means "not on this host", not a logic bug.
183 if (@is_dir($p)) { // allow-silent-error: open_basedir restriction surface; absence here is the answer, not a fault. See production reports 22-39 (4.1.18-4.1.19) flooding the inbox with "is_dir(): open_basedir restriction in effect" for /home/clp probes on p2p-game.com and similar CloudLinux-hosted sites.
184 return array('panel' => $panelKey, 'matched_marker' => 'path:' . $p);
185 }
186 }
187 }
188 return array('panel' => '', 'matched_marker' => '');
189 }
190
191 /**
192 * Platforms that publish no constant and cannot be relied on to publish an
193 * environment variable either, but that DO stamp themselves on three fixed,
194 * non-identifying facts about the runtime: where the site is served from,
195 * which container hierarchy the worker sits in, and how the database server
196 * suffixes its own version string.
197 *
198 * Azure App Service for Linux is the case this table was built for. Support
199 * report 2026-08-27 (plugin 4.3.4) carried `/home/site/wwwroot`, an Antares
200 * cgroup path and `8.0.45-azure`, and still reported host="unknown",
201 * because an FPM pool with `clear_env = yes` (the App Service default for
202 * some images) strips WEBSITE_SITE_NAME before PHP ever sees it. Any ONE of
203 * the three is sufficient, which is the point: this platform is only
204 * reliably named when the markers are checked independently.
205 *
206 * Marker KEYS only ever leave the site. The cgroup line and the App Service
207 * environment both embed the customer's own site name, so the matched
208 * needle is reported and the matched text never is.
209 *
210 * @var array<string, array{paths: array<int, string>, cgroup: array<int, string>, db_version: array<int, string>}>
211 */
212 private const INFRASTRUCTURE_HOST_MARKERS = array(
213 'azure_app_service' => array(
214 'paths' => array('/home/site/wwwroot'),
215 'cgroup' => array('antares'),
216 'db_version' => array('azure'),
217 ),
218 );
219
220 /** Ceiling on the cgroup read. The membership lines we match are in the first few. */
221 private const MAX_CGROUP_BYTES = 4096;
222
223 /**
224 * Name a platform from the fixed runtime markers in
225 * INFRASTRUCTURE_HOST_MARKERS, or report no match.
226 *
227 * Runs only after the constant/environment table above has come back
228 * unknown, so a host that identifies itself explicitly keeps its own,
229 * more specific marker.
230 *
231 * @param array<string, mixed> $runtime
232 * @return array{host: string, matched_marker: string}
233 */
234 private function detectInfrastructureHost(array $runtime): array {
235 $documentRoot = $this->normalizedDirectory(
236 array_key_exists('document_root', $runtime)
237 ? $runtime['document_root']
238 : ($_SERVER['DOCUMENT_ROOT'] ?? '')
239 );
240 $installPath = $this->normalizedDirectory(
241 array_key_exists('abspath', $runtime)
242 ? $runtime['abspath']
243 : (defined('ABSPATH') ? ABSPATH : '')
244 );
245 $cgroup = array_key_exists('cgroup', $runtime)
246 ? (is_scalar($runtime['cgroup']) ? (string)$runtime['cgroup'] : '')
247 : $this->readProcSelfCgroup();
248 $cgroup = strtolower($cgroup);
249 $databaseVersion = strtolower(
250 array_key_exists('db_server_version', $runtime) && is_scalar($runtime['db_server_version'])
251 ? (string)$runtime['db_server_version'] : ''
252 );
253
254 foreach (self::INFRASTRUCTURE_HOST_MARKERS as $hostKey => $markers) {
255 foreach ($markers['paths'] as $markerPath) {
256 if ($this->pathIsWithin(array('candidate' => $documentRoot, 'marker' => $markerPath))
257 || $this->pathIsWithin(array('candidate' => $installPath, 'marker' => $markerPath))) {
258 return array('host' => $hostKey, 'matched_marker' => 'path:' . $markerPath);
259 }
260 }
261 foreach ($markers['cgroup'] as $needle) {
262 // Bounded by the path separators on both sides so a customer
263 // site literally named "antares" cannot match as the platform.
264 if ($cgroup !== '' && strpos($cgroup, '/' . $needle . '/') !== false) {
265 return array('host' => $hostKey, 'matched_marker' => 'cgroup:' . $needle);
266 }
267 }
268 foreach ($markers['db_version'] as $needle) {
269 // The vendor suffix, not a substring: "8.0.45-azure" matches and
270 // a server hosted at azure.example.com does not.
271 if ($databaseVersion !== '' && strpos($databaseVersion, '-' . $needle) !== false) {
272 return array('host' => $hostKey, 'matched_marker' => 'db_version:' . $needle);
273 }
274 }
275 }
276 return array('host' => '', 'matched_marker' => '');
277 }
278
279 /**
280 * One environment variable, read from the injected snapshot when the caller
281 * supplied one and from the process otherwise. Null means "not set".
282 *
283 * An injected `env` array is authoritative even when empty: a test or an
284 * offline collector that declares the environment has to be able to declare
285 * it EMPTY, or the negative control silently reads the developer's own shell.
286 *
287 * @param array<string, mixed> $runtime
288 */
289 private function environmentValue(array $runtime, string $name): ?string {
290 if (array_key_exists('env', $runtime)) {
291 $environment = is_array($runtime['env']) ? $runtime['env'] : array();
292 return array_key_exists($name, $environment) && is_scalar($environment[$name])
293 ? (string)$environment[$name] : null;
294 }
295 $value = getenv($name);
296 return $value === false ? null : (string)$value;
297 }
298
299 /**
300 * The worker's cgroup membership text, or '' when this platform has no
301 * procfs (macOS, Windows, a hardened open_basedir).
302 */
303 private function readProcSelfCgroup(): string {
304 $path = '/proc/self/cgroup';
305 if (!@is_readable($path)) { // allow-silent-error: procfs is absent on non-Linux hosts and outside many open_basedir roots; absence is the answer, not a fault.
306 return '';
307 }
308 $raw = @file_get_contents($path, false, null, 0, self::MAX_CGROUP_BYTES); // allow-silent-error: a readable-but-unreadable procfs entry means the marker is unavailable, which is the same finding as absence.
309 return is_string($raw) ? $raw : '';
310 }
311
312 /**
313 * A directory path with any trailing separator removed, so
314 * `/home/site/wwwroot/` and `/home/site/wwwroot` compare equal.
315 *
316 * @param mixed $value
317 */
318 private function normalizedDirectory($value): string {
319 $path = is_scalar($value) ? (string)$value : '';
320 return $path === '' ? '' : rtrim($path, '/\\');
321 }
322
323 /**
324 * Is the site's directory the platform marker directory itself, or
325 * something inside it?
326 *
327 * Keyed rather than positional: `candidate` and `marker` are both
328 * directory strings, so transposing them is type-correct and silently
329 * inverts the question into "does the platform's marker directory live
330 * inside this site?", which is false on every real host. Platform
331 * detection would simply stop matching, with nothing logged and no test
332 * failing unless one happened to cover this exact pair. Shipped code has
333 * a PHP 7.4 floor, so named arguments are not available here; the keyed
334 * bag is what makes the swap unwriteable.
335 *
336 * @param array{candidate: string, marker: string} $probe
337 */
338 private function pathIsWithin(array $probe): bool {
339 $candidate = $probe['candidate'];
340 $marker = $probe['marker'];
341 return $candidate !== '' && ($candidate === $marker || strpos($candidate, $marker . '/') === 0);
342 }
343
344 /**
345 * Reduce SERVER_SOFTWARE to a bounded server product without returning
346 * versions or hostnames that may appear in the raw value.
347 *
348 * @param string $serverSoftware Lowercased SERVER_SOFTWARE value.
349 * @return string
350 */
351 private function classifyServerClass(string $serverSoftware): string {
352 if (strpos($serverSoftware, 'apache') !== false) {
353 return 'apache';
354 }
355 if (strpos($serverSoftware, 'nginx') !== false) {
356 return 'nginx';
357 }
358 if (strpos($serverSoftware, 'litespeed') !== false) {
359 return 'litespeed';
360 }
361 if (strpos($serverSoftware, 'iis') !== false) {
362 return 'iis';
363 }
364 return $serverSoftware === '' ? 'unknown' : 'other';
365 }
366
367 /**
368 * Collect only the fixed, non-identifying CloudLinux markers used by
369 * support diagnostics.
370 *
371 * @param array{loaded_extensions?: mixed, cloudlinux_alt_php_present?: mixed} $runtime
372 * @return array<int, string>
373 */
374 private function collectCloudLinuxMarkers(array $runtime): array {
375 $loadedExtensions = array_key_exists('loaded_extensions', $runtime)
376 ? $runtime['loaded_extensions']
377 : get_loaded_extensions();
378 $extensionNames = array();
379 if (is_array($loadedExtensions)) {
380 foreach ($loadedExtensions as $extensionName) {
381 if (is_scalar($extensionName)) {
382 $extensionNames[strtolower((string)$extensionName)] = true;
383 }
384 }
385 }
386
387 $markers = array();
388 foreach (array('xray', 'clos_ssa') as $cloudLinuxExtension) {
389 if (isset($extensionNames[$cloudLinuxExtension])) {
390 $markers[] = 'extension:' . $cloudLinuxExtension;
391 }
392 }
393 $altPhpPresent = array_key_exists('cloudlinux_alt_php_present', $runtime)
394 ? $runtime['cloudlinux_alt_php_present'] === true
395 : @is_dir('/opt/alt/php'); // allow-silent-error: CloudLinux's alt-PHP directory is outside many open_basedir roots; denial means the marker is unavailable.
396 if ($altPhpPresent) {
397 // Report the marker name, never the absolute path that was probed.
398 $markers[] = 'alt_php';
399 }
400
401 return $markers;
402 }
403
404 /**
405 * Identify the request's PHP execution product from the webserver and
406 * SAPI pair. In particular, Apache + the `litespeed` SAPI is mod_lsapi,
407 * not LiteSpeed Web Server.
408 *
409 * @param string $serverClass
410 * @param string $phpSapi
411 * @return string
412 */
413 private function classifyPhpExecutionStack(string $serverClass, string $phpSapi): string {
414 if ($phpSapi === 'litespeed') {
415 return $serverClass === 'apache' ? 'mod_lsapi' : 'lsws';
416 }
417 if (strpos($phpSapi, 'fpm') !== false) {
418 return 'fpm';
419 }
420 if ($phpSapi === 'apache2handler' || $phpSapi === 'apache') {
421 return 'mod_php';
422 }
423 if ($phpSapi === 'cgi' || $phpSapi === 'cgi-fcgi') {
424 return 'cgi';
425 }
426 return 'unknown';
427 }
428 }
429