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_HostProbes.php

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

409 lines 17.1 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 require_once __DIR__ . '/FeedbackTransportLog.php';
8 require_once dirname(__DIR__) . '/services/PostResponseWorkerBudget.php';
9
10 /**
11 * Host / runtime environment probes for the feedback payload's
12 * `environment_extras` field.
13 *
14 * Every method in this class reads dynamic PHP, OS, or WordPress
15 * runtime state with no SQL: filesystem headroom on the uploads dir /
16 * system temp, opcache settings, open_basedir, timezone identity,
17 * multisite role, htaccess writability, install + upgrade lifecycle.
18 *
19 * Static-identity platform fingerprinting (hosting class, control
20 * panel, object-cache backend) lives in the sibling class
21 * FeedbackEnvironmentExtras_PlatformFingerprint: those values rarely
22 * change for the life of the install and are kept together so the
23 * marker tables evolve as a single editorial concern.
24 *
25 * Owned by ABJ_404_Solution_FeedbackEnvironmentExtras via composition;
26 * see that class's collect() method for the keyed probe registry that
27 * wraps each call below in recordProbe() for failure isolation.
28 */
29 class ABJ_404_Solution_FeedbackEnvironmentExtras_HostProbes {
30
31 /**
32 * Free bytes available on the WP uploads directory's filesystem. Used
33 * to triage "Table is full" reports (the logical table-full condition
34 * is rare, disk quota is common). Throws when disk_free_space() is
35 * disabled (open_basedir, hardened hosts) so the caller's tryInt
36 * wrapper records null rather than a misleading zero.
37 *
38 * @return int
39 */
40 public function diskFreeBytesOrThrow(): int {
41 if (!ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('disk_free_space')) {
42 throw new \RuntimeException('disk_free_space unavailable');
43 }
44 $dir = $this->supportDiagnosticsDirectory();
45 $v = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::diskFreeSpace($dir);
46 if ($v === false) {
47 throw new \RuntimeException('disk_free_space returned false for ' . $dir);
48 }
49 return (int)$v;
50 }
51
52 /**
53 * Total bytes on the same filesystem. Combined with disk_free_bytes,
54 * lets the server-side report show "8% free" rather than a raw byte
55 * count that is hard to interpret across hosts.
56 *
57 * @return int
58 */
59 public function diskTotalBytesOrThrow(): int {
60 if (!ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('disk_total_space')) {
61 throw new \RuntimeException('disk_total_space unavailable');
62 }
63 $dir = $this->supportDiagnosticsDirectory();
64 $v = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::diskTotalSpace($dir);
65 if ($v === false) {
66 throw new \RuntimeException('disk_total_space returned false for ' . $dir);
67 }
68 return (int)$v;
69 }
70
71 /**
72 * Best directory to probe for the plugin's filesystem headroom. The
73 * uploads dir is the most useful target (the debug log and any
74 * cron-scratch files land there), but it may not be writable in
75 * locked-down installs. Falls back to ABSPATH and finally __DIR__.
76 *
77 * @return string
78 */
79 private function supportDiagnosticsDirectory(): string {
80 if (function_exists('wp_upload_dir')) {
81 $info = wp_upload_dir(null, false);
82 if (is_array($info) && isset($info['basedir']) && is_string($info['basedir']) && $info['basedir'] !== '') {
83 return $info['basedir'];
84 }
85 }
86 if (defined('ABSPATH') && is_string(ABSPATH) && ABSPATH !== '') {
87 return ABSPATH;
88 }
89 return __DIR__;
90 }
91
92 /** @return bool */
93 public function opcacheEnabled(): bool {
94 if (ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('opcache_get_status')) {
95 $st = ABJ_404_Solution_OpcacheAdapter::status(false);
96 if (is_array($st) && isset($st['opcache_enabled'])) {
97 return (bool)$st['opcache_enabled'];
98 }
99 }
100 if (function_exists('ini_get')) {
101 $v = ini_get('opcache.enable');
102 if ($v === false) {
103 return false;
104 }
105 return ((int)$v === 1 || strtolower((string)$v) === 'on');
106 }
107 return false;
108 }
109
110 /**
111 * Timezone identity for the WP install, PHP runtime, and OS. The
112 * canonical "off-by-N-hours" cron-window bug class is when WP thinks
113 * it is in pt_BR while PHP is in UTC; capturing all three lets us
114 * detect drift retroactively.
115 *
116 * @return array<string, mixed>
117 */
118 public function probeTimezone(): array {
119 $out = array(
120 'wp_timezone' => '',
121 'wp_gmt_offset' => 0,
122 'php_timezone' => '',
123 'server_utc_offset_seconds' => 0,
124 );
125 if (function_exists('get_option')) {
126 $tz = get_option('timezone_string', '');
127 if (is_scalar($tz)) { $out['wp_timezone'] = (string)$tz; }
128 $off = get_option('gmt_offset', 0);
129 if (is_scalar($off)) { $out['wp_gmt_offset'] = (int)round((float)$off * 3600); }
130 }
131 if (function_exists('date_default_timezone_get')) {
132 $out['php_timezone'] = (string)date_default_timezone_get();
133 }
134 try {
135 $tz = new \DateTimeZone($out['php_timezone'] !== '' ? $out['php_timezone'] : 'UTC');
136 $dt = new \DateTime('@' . abj_clock()->now());
137 $dt->setTimezone($tz);
138 $out['server_utc_offset_seconds'] = (int)$tz->getOffset($dt);
139 } catch (\Throwable $e) {
140 // allow-silent-catch: server_utc_offset is best-effort; an invalid tz string leaves the default zero in place
141 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'probeTimezone offset probe failed: ' . $e->getMessage());
142 }
143 return $out;
144 }
145
146 /**
147 * Install + upgrade timeline. The single most useful bifurcator for
148 * "started after upgrade Tuesday" vs "always broken since install."
149 * Read-only from plugin options the upgrade path already writes;
150 * no new SQL, no new options.
151 *
152 * Fields:
153 * installed_at: int|null unix seconds, from abj404_installed_time
154 * current_version: string ABJ404_VERSION (live)
155 * db_version_option string|null abj404_settings['DB_VERSION'] (the value
156 * stamped at the last upgrade; equals
157 * current_version after the upgrade path
158 * ran, mismatches between upgrade tick
159 * and DB_VERSION write on lock contention)
160 *
161 * @return array<string, mixed>
162 */
163 public function probePluginLifecycle(): array {
164 $out = array(
165 'installed_at' => null,
166 'current_version' => defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : '',
167 'db_version_option' => null,
168 );
169 if (function_exists('get_option')) {
170 $t = get_option('abj404_installed_time', null);
171 if (is_scalar($t) && is_numeric($t)) {
172 $out['installed_at'] = (int)$t;
173 }
174 }
175 $optionsRepository = function_exists('abj_service_optional') ? abj_service_optional('options_repository') : null;
176 if (is_object($optionsRepository) && method_exists($optionsRepository, 'getOptions')) {
177 try {
178 $settings = $optionsRepository->getOptions(true);
179 if (isset($settings['DB_VERSION']) && is_scalar($settings['DB_VERSION'])) {
180 $out['db_version_option'] = (string)$settings['DB_VERSION'];
181 }
182 } catch (\Throwable $e) {
183 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'probePluginLifecycle options_repository probe failed: ' . $e->getMessage());
184 }
185 }
186 return $out;
187 }
188
189 /**
190 * opcache detail fields beyond the on/off enum. Each value is
191 * explicitly nullable: ini_get() returns false when the directive
192 * is unknown, and "we couldn't read it" is materially different
193 * from a stamped 0/false the host configured deliberately.
194 *
195 * Shape:
196 * { revalidate_freq: int|null,
197 * validate_timestamps: bool|null,
198 * enable_cli: bool|null }
199 *
200 * @return array<string, mixed>
201 */
202 public function probeOpcacheSettings(): array {
203 $out = array(
204 'revalidate_freq' => null,
205 'validate_timestamps' => null,
206 'enable_cli' => null,
207 );
208 if (!function_exists('ini_get')) {
209 return $out;
210 }
211 $rf = ini_get('opcache.revalidate_freq');
212 if ($rf !== false) {
213 $out['revalidate_freq'] = (int)$rf;
214 }
215 $vt = ini_get('opcache.validate_timestamps');
216 if ($vt !== false) {
217 $out['validate_timestamps'] = ((int)$vt === 1 || strtolower((string)$vt) === 'on');
218 }
219 $ec = ini_get('opcache.enable_cli');
220 if ($ec !== false) {
221 $out['enable_cli'] = ((int)$ec === 1 || strtolower((string)$ec) === 'on');
222 }
223 return $out;
224 }
225
226 /**
227 * open_basedir restriction string, or null when not configured.
228 * Returned wholesale (path list) so the server side can match it
229 * against the plugin's known write targets; the value is not PII
230 * and the per-host shapes vary enough that any normalization here
231 * would lose signal.
232 *
233 * @return string|null
234 */
235 public function probeOpenBasedir(): ?string {
236 if (!function_exists('ini_get')) {
237 return null;
238 }
239 $v = ini_get('open_basedir');
240 if (!is_string($v) || $v === '') {
241 return null;
242 }
243 return $v;
244 }
245
246 /**
247 * Multisite identity for the request the report originates from.
248 * When `is_multisite()` is false the rest of the shape is omitted
249 * rather than emitted as nulls per probe (a single-site install
250 * has no blog_id/network_id and the keys would be misleading).
251 *
252 * Shape (multisite):
253 * { is_multisite: true,
254 * is_main_site: bool|null,
255 * blog_id: int|null,
256 * network_id: int|null,
257 * network_activated: bool|null }
258 *
259 * Shape (single-site):
260 * { is_multisite: false }
261 *
262 * @return array<string, mixed>
263 */
264 public function probeMultisiteRole(): array {
265 $isMultisite = function_exists('is_multisite') && (bool)is_multisite();
266 $out = array('is_multisite' => $isMultisite);
267 if (!$isMultisite) {
268 return $out;
269 }
270 $out['is_main_site'] = function_exists('is_main_site') ? (bool)is_main_site() : null;
271 $out['blog_id'] = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : null;
272 $out['network_id'] = function_exists('get_current_network_id') ? (int)get_current_network_id() : null;
273
274 $networkActivated = null;
275 if (function_exists('is_plugin_active_for_network') && function_exists('plugin_basename') && defined('ABJ404_FILE')) {
276 try {
277 $networkActivated = (bool) is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
278 } catch (\Throwable $e) {
279 // allow-silent-catch: best-effort multisite probe; is_plugin_active_for_network requires wp-admin context that may not be loaded on front-end / cron paths, leave null
280 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'probeMultisiteRole network-activated check failed: ' . $e->getMessage());
281 $networkActivated = null;
282 }
283 }
284 $out['network_activated'] = $networkActivated;
285 return $out;
286 }
287
288 /**
289 * Whether the .htaccess at the WP home path is writable by the
290 * plugin. Differentiates "Apache rule install will succeed" from
291 * "must use the DB-only redirect handler". Falls back to ABSPATH
292 * when get_home_path() is unavailable (front-end / cron context
293 * loads it on demand from wp-admin/includes/file.php).
294 *
295 * @return bool
296 */
297 public function probeHtaccessWritable(): bool {
298 $path = $this->resolveHtaccessPath();
299 if ($path === '') {
300 return false;
301 }
302 // is_writable() returns false on a non-existent file too,
303 // which matches the install-method intent: if the file does
304 // not yet exist and we cannot write the directory either, the
305 // Apache-rule path cannot succeed.
306 return @is_writable($path);
307 }
308
309 /**
310 * Best path to test for .htaccess writability. Prefers
311 * get_home_path() (which honors WordPress in-subdir installs);
312 * falls back to ABSPATH for early-boot / front-end contexts where
313 * wp-admin/includes/file.php has not been loaded.
314 *
315 * @return string
316 */
317 private function resolveHtaccessPath(): string {
318 if (function_exists('get_home_path')) {
319 $home = (string) get_home_path();
320 if ($home !== '') {
321 return rtrim($home, "/\\") . '/.htaccess';
322 }
323 }
324 if (defined('ABSPATH') && ABSPATH !== '') {
325 return rtrim(ABSPATH, "/\\") . '/.htaccess';
326 }
327 return '';
328 }
329
330 /**
331 * Free bytes on the system temp directory's filesystem. Some
332 * shared hosts mount /tmp as a separate quota from the WP install
333 * path; the disk_free_bytes probe (which targets the uploads dir)
334 * cannot see /tmp exhaustion. Throws when disk_free_space is
335 * disabled so the caller's tryInt wrapper records null rather
336 * than a misleading zero.
337 *
338 * @return int
339 */
340 public function probeTmpFreeBytesOrThrow(): int {
341 if (!ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('disk_free_space')) {
342 throw new \RuntimeException('disk_free_space unavailable');
343 }
344 $tmp = function_exists('sys_get_temp_dir') ? sys_get_temp_dir() : '';
345 if ($tmp === '') {
346 throw new \RuntimeException('sys_get_temp_dir returned empty');
347 }
348 $v = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::diskFreeSpace($tmp);
349 if ($v === false) {
350 throw new \RuntimeException('disk_free_space returned false for ' . $tmp);
351 }
352 return (int)$v;
353 }
354
355 /**
356 * The PHP runtime's own self-report: which SAPI is executing, what the
357 * hardening and buffering ini settings are, how much memory this request
358 * peaked at, and which output-buffer handlers own the response right now.
359 *
360 * A flat map rather than a set of registered probes, because none of it can
361 * fail: every value is a constant, an `ini_get()`, or a function guarded by
362 * `function_exists()`, so there is no error to isolate and nothing for a
363 * `<probe>_error` marker to say. Keeping it here instead of inline in
364 * FeedbackEnvironmentExtras::collect() leaves that method a uniform probe
365 * registry with no special case in the middle of it.
366 *
367 * PHP_SAPI, not php_sapi_name(): the constant is defined by the engine on
368 * every SAPI and cannot be removed, while the function is on the
369 * disable_functions hardening lists some shared/CloudLinux hosts ship,
370 * where the guarded call silently degrades to ''. This is the field that
371 * identified Bruno's litespeed SAPI (and with it the FPM-only
372 * fastcgi_finish_request() no-op), so losing it loses the diagnosis. Same
373 * accessor the flight recorder uses (RequestEnvironmentFingerprint).
374 *
375 * @return array<string, mixed>
376 */
377 public function collectPhpRuntimeIdentity(): array {
378 $obHandlerNames = array();
379 foreach (function_exists('ob_get_status') ? ob_get_status(true) : array() as $obStatus) {
380 if (is_array($obStatus) && isset($obStatus['name']) && is_string($obStatus['name'])) {
381 $obHandlerNames[] = $obStatus['name'];
382 }
383 }
384 return array(
385 'php_sapi' => PHP_SAPI,
386 'php_disable_functions' => function_exists('ini_get')
387 ? (string)ini_get('disable_functions') : '',
388 'php_post_response_budget_armable' =>
389 ABJ_404_Solution_PostResponseWorkerBudget::isSupported(),
390 'php_memory_peak_bytes' => function_exists('memory_get_peak_usage')
391 ? (int)memory_get_peak_usage(true) : 0,
392 'php_opcache_enabled' => $this->opcacheEnabled(),
393 'php_max_input_vars' => function_exists('ini_get') ? (int)ini_get('max_input_vars') : 0,
394 'php_output_buffering' => function_exists('ini_get')
395 ? (string)ini_get('output_buffering') : '',
396 'php_zlib_output_compression' => function_exists('ini_get')
397 ? (string)ini_get('zlib.output_compression') : '',
398 'php_ob_level_at_collect' => array(
399 'level' => function_exists('ob_get_level') ? (int)ob_get_level() : 0,
400 // Handler names identify stack ownership. Other status fields,
401 // especially byte counts, are unnecessary diagnostic surface.
402 'handlers' => $obHandlerNames,
403 ),
404 'php_realpath_cache_size_bytes' => function_exists('realpath_cache_size')
405 ? (int)realpath_cache_size() : 0,
406 );
407 }
408 }
409