README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.
Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.
Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.
For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.
Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
205 lines
6.5 KiB
PHP
205 lines
6.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Health Check Endpoint
|
|
*
|
|
* Returns system health status for monitoring tools.
|
|
* Does not require authentication - suitable for load balancer health checks.
|
|
*
|
|
* Returns:
|
|
* - 200 OK: System is healthy
|
|
* - 503 Service Unavailable: System has issues
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|
ErrorHandler::init();
|
|
|
|
// Don't apply rate limiting to health checks - they should always respond
|
|
header('Content-Type: application/json');
|
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
|
|
|
$startTime = microtime(true);
|
|
$checks = [];
|
|
$healthy = true;
|
|
|
|
// Check 1: Database connectivity
|
|
try {
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
|
|
$conn = Database::getConnection();
|
|
|
|
// Quick query to verify connection is actually working
|
|
$result = $conn->query('SELECT 1');
|
|
if ($result && $result->fetch_row()) {
|
|
$checks['database'] = [
|
|
'status' => 'ok',
|
|
'message' => 'Connected'
|
|
];
|
|
} else {
|
|
$checks['database'] = [
|
|
'status' => 'error',
|
|
'message' => 'Query failed'
|
|
];
|
|
$healthy = false;
|
|
}
|
|
} catch (Exception $e) {
|
|
$checks['database'] = [
|
|
'status' => 'error',
|
|
'message' => 'Connection failed'
|
|
];
|
|
$healthy = false;
|
|
}
|
|
|
|
// Check 2: File system (uploads directory writable)
|
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
|
if (is_dir($uploadDir) && is_writable($uploadDir)) {
|
|
$checks['filesystem'] = [
|
|
'status' => 'ok',
|
|
'message' => 'Writable'
|
|
];
|
|
} else {
|
|
$checks['filesystem'] = [
|
|
'status' => 'warning',
|
|
'message' => 'Upload directory not writable'
|
|
];
|
|
// Don't mark as unhealthy - this might be intentional
|
|
}
|
|
|
|
// Check 3: Session storage
|
|
$sessionPath = session_save_path() ?: sys_get_temp_dir();
|
|
if (is_dir($sessionPath) && is_writable($sessionPath)) {
|
|
$checks['sessions'] = [
|
|
'status' => 'ok',
|
|
'message' => 'Writable'
|
|
];
|
|
} else {
|
|
$checks['sessions'] = [
|
|
'status' => 'error',
|
|
'message' => 'Session storage not writable'
|
|
];
|
|
$healthy = false;
|
|
}
|
|
|
|
// Check 4: Rate limit storage
|
|
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
|
if (!is_dir($rateLimitDir)) {
|
|
@mkdir($rateLimitDir, 0755, true);
|
|
}
|
|
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
|
$checks['rate_limit'] = [
|
|
'status' => 'ok',
|
|
'message' => 'Writable'
|
|
];
|
|
} else {
|
|
$checks['rate_limit'] = [
|
|
'status' => 'warning',
|
|
'message' => 'Rate limit storage not writable'
|
|
];
|
|
}
|
|
|
|
// Check 5: Required PHP extensions (catches e.g. a PHP upgrade silently
|
|
// dropping php-ldap, which breaks avatars with no other visible error).
|
|
$requirements = require dirname(__DIR__) . '/config/requirements.php';
|
|
$missingExt = array_values(array_filter(
|
|
$requirements['required_extensions'],
|
|
fn($ext) => !extension_loaded($ext)
|
|
));
|
|
if (empty($missingExt)) {
|
|
$checks['php_extensions'] = [
|
|
'status' => 'ok',
|
|
'message' => 'All required extensions loaded'
|
|
];
|
|
} else {
|
|
$checks['php_extensions'] = [
|
|
'status' => 'error',
|
|
'message' => 'Missing extensions: ' . implode(', ', $missingExt)
|
|
];
|
|
$healthy = false;
|
|
}
|
|
|
|
// Check 6: PHP version meets the declared minimum
|
|
if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
|
|
$checks['php_version'] = [
|
|
'status' => 'ok',
|
|
'message' => PHP_VERSION
|
|
];
|
|
} else {
|
|
$checks['php_version'] = [
|
|
'status' => 'error',
|
|
'message' => sprintf('PHP %s < required %s', PHP_VERSION, $requirements['min_php_version'])
|
|
];
|
|
$healthy = false;
|
|
}
|
|
|
|
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
|
|
// low default doesn't fail requests until something large actually runs, so
|
|
// surface it here rather than waiting for a mysterious failure under load).
|
|
$memLimitIni = ini_get('memory_limit');
|
|
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
|
|
$memLimitBytes = $memLimitIni === '-1'
|
|
? -1
|
|
: (int)$memLimitIni * match ($memLimitUnit) {
|
|
'g' => 1024 * 1024 * 1024,
|
|
'm' => 1024 * 1024,
|
|
'k' => 1024,
|
|
default => 1,
|
|
};
|
|
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
|
|
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
|
|
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
|
|
} else {
|
|
$checks['memory_limit'] = [
|
|
'status' => 'warning',
|
|
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
|
|
];
|
|
}
|
|
|
|
$maxExecTime = (int)ini_get('max_execution_time');
|
|
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
|
|
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
|
|
} else {
|
|
$checks['max_execution_time'] = [
|
|
'status' => 'warning',
|
|
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
|
|
];
|
|
}
|
|
|
|
// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s
|
|
// allowlist entirely, meaning anything that can reach this app directly can
|
|
// spoof the Authelia forward-auth Remote-* headers and impersonate any user,
|
|
// including an admin. Not fatal (a fresh/dev install may not sit behind a
|
|
// proxy yet), but should never go unnoticed on a real deployment.
|
|
if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) {
|
|
$checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured'];
|
|
} else {
|
|
$checks['trusted_proxies'] = [
|
|
'status' => 'warning',
|
|
'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; '
|
|
. 'anything that can reach this app directly can impersonate any user'
|
|
];
|
|
}
|
|
|
|
// Calculate response time
|
|
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
|
|
|
// Set status code
|
|
http_response_code($healthy ? 200 : 503);
|
|
|
|
// This endpoint is unauthenticated, so expose only a coarse per-component status
|
|
// and never the diagnostic messages (they leak PHP_VERSION, exact missing
|
|
// extension names, and filesystem paths to anonymous callers).
|
|
$publicChecks = [];
|
|
foreach ($checks as $name => $check) {
|
|
$publicChecks[$name] = ['status' => $check['status']];
|
|
}
|
|
|
|
// Return response
|
|
echo json_encode([
|
|
'status' => $healthy ? 'healthy' : 'unhealthy',
|
|
'timestamp' => date('c'),
|
|
'response_time_ms' => $responseTime,
|
|
'checks' => $publicChecks,
|
|
'version' => '1.0.0'
|
|
], JSON_PRETTY_PRINT);
|