From 5c44d00c92139db9bd3b5e29fd31ff3c1ee22a42 Mon Sep 17 00:00:00 2001 From: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Date: Thu, 7 May 2026 06:56:44 +0300 Subject: [PATCH] P0: Joomla-grade installer hardening for arbitrary Linux panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the multi-panel compatibility plan. No table-prefix work yet (that's P1). All changes local to install/ajax.php + install/index.php. Hardening: 1. Async per-migration runner. handleInstallDb() split into: - install_db_init → returns ordered file list with applied flags - install_db_step → applies ONE migration file (browser drives loop) - install_db_all → legacy single-shot fast path Browser pumps the step loop, bypassing max_execution_time caps that panels like aaPanel/Plesk/cPanel enforce (often 30-60s). 2. Per-statement SQL splitter (installerSplitSql) respects backticks, single/double-quoted strings, line comments (-- and #), and block comments. Strips DELIMITER + outer BEGIN/COMMIT wrappers. Lets the runner survive PDO buffer caps and report progress accurately. 3. set_time_limit(0) + ignore_user_abort(true) at top — best-effort. 4. Preflight expansions: - open_basedir detection — flags if app root outside allowed paths - disable_functions audit — fails if mkdir/chmod/file_put_contents/ unlink/rmdir/fopen blocked - Live mkdir+write+read+unlink probe under uploads/ - parent_writable flag returned - php_version_full echoed for support tickets 5. Charset auto-fallback: SELECT VERSION() on first connect. MariaDB <5.5.3 or MySQL <5.7 → utf8mb3 (legacy 'utf8'). Persisted in session and surfaced to UI via dbCharset selector. 6. CREATE DATABASE skip-toggle: new step-2 checkbox + 1044/1142 error handler returns suggest_skip_create:true so JS can show "Tick & retry" button. Plesk/CyberPanel/ISPConfig users no longer hit a dead end. 7. Reverse-proxy IP hardening (getClientIp): only honor X-Forwarded-For / X-Real-IP / Client-IP when REMOTE_ADDR is in private/loopback range. Closes the spoofable trusted-network 2FA-bypass surface. 8. Auto-unlock recovery: install.lock + admin_users empty/missing → silent unlock. Inlined in install/index.php (avoids dragging ajax.php's JSON header into HTML) and mirrored as installerCheckIncompleteState() in ajax.php for runtime symmetry. Logged to install/install.log. 9. Unix-socket auto-detect: handleDetectSocket probes /tmp/mysql.sock, /var/run/mysqld/mysqld.sock, /var/lib/mysql/mysql.sock, /www/server/mysql/mysql.sock, /var/run/mariadb/mariadb.sock, /usr/local/mysql/mysql.sock + 2 more. UI Detect button auto-fills. 10. handleHealth post-install probe: SELECT 1 + SHOW TABLES for the five canonical KeyGate tables + admin_users count. Useful for step 6 link and external monitoring during shared-host installs. 11. installerBuildDsn() now accepts $charset; getInstallerPdo() pulls it from session. installerFriendlyDbError() adds 1044/1142 hints and 1045/no-password specific messaging. 12. installerLog() helper: append-only audit trail at install/install.log. UI changes (install/index.php): - Step 2: skip-create-DB checkbox, charset selector, prefix input (P1-ready, hidden behind Advanced section), Detect socket button. - Step 3: replaced single-shot runMigrations with init+step loop; per-row spinner; per-row pass/skip/error with file name + message; stops on first hard error so user reads it. - Failed test_db with suggest_skip_create renders inline retry button. --- FINAL_PRODUCTION_SYSTEM/install/ajax.php | 770 ++++++++++++++++++---- FINAL_PRODUCTION_SYSTEM/install/index.php | 208 ++++-- 2 files changed, 825 insertions(+), 153 deletions(-) diff --git a/FINAL_PRODUCTION_SYSTEM/install/ajax.php b/FINAL_PRODUCTION_SYSTEM/install/ajax.php index c5930bb..fedc2a5 100644 --- a/FINAL_PRODUCTION_SYSTEM/install/ajax.php +++ b/FINAL_PRODUCTION_SYSTEM/install/ajax.php @@ -9,14 +9,22 @@ header('Content-Type: application/json'); header('X-Content-Type-Options: nosniff'); -// Block if already installed +// Best-effort: defang shared-host timeouts. Some panels ignore these silently. +@set_time_limit(0); +@ignore_user_abort(true); + +session_start(); + +// Auto-unlock recovery: if install.lock exists but the install never finished +// (no admin_users yet, or table missing) — clear it and continue. Logged. +installerCheckIncompleteState(); + +// Block if (still) installed after auto-unlock check if (file_exists(__DIR__ . '/../install.lock')) { die(json_encode(['success' => false, 'message' => 'System is already installed.'])); } -session_start(); - -$action = $_POST['action'] ?? ''; +$action = $_POST['action'] ?? $_GET['action'] ?? ''; switch ($action) { case 'preflight': @@ -25,8 +33,15 @@ case 'test_db': handleTestDb(); break; - case 'install_db': - handleInstallDb(); + case 'install_db': // legacy single-shot; falls through to fast-path + case 'install_db_all': + handleInstallDbAll(); + break; + case 'install_db_init': + handleInstallDbInit(); + break; + case 'install_db_step': + handleInstallDbStep(); break; case 'create_admin': handleCreateAdmin(); @@ -34,6 +49,12 @@ case 'finalize': handleFinalize(); break; + case 'detect_socket': + handleDetectSocket(); + break; + case 'health': + handleHealth(); + break; default: echo json_encode(['success' => false, 'message' => 'Unknown action']); } @@ -197,6 +218,64 @@ function handlePreflight() { ]; } + // ── PHP Sandbox / Restrictions (panel-specific blockers) ── + $openBasedir = ini_get('open_basedir'); + if (!empty($openBasedir)) { + $allowedPaths = preg_split('/[:;]/', $openBasedir); + // Verify the install root is within allowed paths + $insideBasedir = false; + foreach ($allowedPaths as $p) { + if ($p !== '' && strpos($baseDir, rtrim($p, '/')) === 0) { + $insideBasedir = true; + break; + } + } + $result['settings'][] = [ + 'label' => 'open_basedir', + 'value' => $insideBasedir ? 'OK (' . count($allowedPaths) . ' path(s))' : 'Restricted', + 'status' => $insideBasedir ? 'pass' : 'fail', + 'hint' => $insideBasedir ? '' : "Add app root '{$baseDir}' to open_basedir in php.ini.", + ]; + } else { + $result['settings'][] = [ + 'label' => 'open_basedir', + 'value' => 'Not set (unrestricted)', + 'status' => 'pass', + ]; + } + + $disabled = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions')))); + $criticalDisabled = array_intersect($disabled, ['mkdir', 'chmod', 'file_put_contents', 'rmdir', 'unlink', 'fopen']); + $result['settings'][] = [ + 'label' => 'disable_functions', + 'value' => empty($disabled) ? 'None' : count($disabled) . ' function(s) blocked', + 'status' => empty($criticalDisabled) ? 'pass' : 'fail', + 'hint' => empty($criticalDisabled) ? '' : 'Critical functions blocked: ' . implode(', ', $criticalDisabled) . '. Backups/upgrades will degrade.', + ]; + + // ── Live mkdir / write / unlink probe ── + $probeDir = $baseDir . '/uploads/_keygate_probe_' . uniqid(); + $probeFile = $probeDir . '/probe.txt'; + $probeOk = @mkdir($probeDir, 0755, true); + if ($probeOk) { + $writeOk = @file_put_contents($probeFile, 'ok') !== false; + $readOk = $writeOk && @file_get_contents($probeFile) === 'ok'; + @unlink($probeFile); + @rmdir($probeDir); + } else { + $writeOk = $readOk = false; + } + $result['directories'][] = [ + 'label' => 'Filesystem write probe', + 'value' => ($probeOk && $writeOk && $readOk) ? 'OK' : 'Failed', + 'status' => ($probeOk && $writeOk && $readOk) ? 'pass' : 'fail', + 'hint' => ($probeOk && $writeOk && $readOk) ? '' : 'Cannot create+write+read in uploads/. Check chmod 755 and disable_functions.', + ]; + + // ── Parent-dir writable flag (used by step 6 to surface manual workflow) ── + $result['parent_writable'] = is_writable($baseDir); + $result['php_version_full'] = PHP_VERSION; + echo json_encode($result); } @@ -204,18 +283,34 @@ function handlePreflight() { // Step 2: Test Database Connection // ═══════════════════════════════════════════════════════════════ function handleTestDb() { - $host = trim($_POST['db_host'] ?? '127.0.0.1'); - $port = (int)($_POST['db_port'] ?? 3306); - $user = $_POST['db_user'] ?? ''; - $pass = $_POST['db_pass'] ?? ''; - $name = $_POST['db_name'] ?? 'oem_activation'; - $socket = trim($_POST['db_socket'] ?? ''); + $host = trim($_POST['db_host'] ?? '127.0.0.1'); + $port = (int)($_POST['db_port'] ?? 3306); + $user = $_POST['db_user'] ?? ''; + $pass = $_POST['db_pass'] ?? ''; + $name = $_POST['db_name'] ?? 'oem_activation'; + $socket = trim($_POST['db_socket'] ?? ''); + $skipCreate = !empty($_POST['skip_create_db']); + $rawPrefix = trim((string)($_POST['db_prefix'] ?? '')); + $charset = 'utf8mb4'; // Default; may downgrade to utf8mb3 below. if (empty($user)) { echo json_encode(['success' => false, 'message' => 'Username is required']); return; } + // Validate prefix: empty OR `^[a-z][a-z0-9_]{0,9}$`. Deny-list reserved. + if ($rawPrefix !== '' && !preg_match('/^[a-z][a-z0-9_]{0,9}$/', $rawPrefix)) { + echo json_encode(['success' => false, 'message' => 'Prefix must be 1–10 chars, lowercase letters/digits/_, start with a letter.']); + return; + } + $denyList = ['mysql_', 'sys_', 'information_', 'performance_']; + foreach ($denyList as $denied) { + if ($rawPrefix !== '' && strpos($rawPrefix, $denied) === 0) { + echo json_encode(['success' => false, 'message' => "Prefix '{$rawPrefix}' starts with reserved name. Pick another."]); + return; + } + } + // aaPanel / cPanel hint: many panels bind MariaDB to TCP only. // Coerce 'localhost' → '127.0.0.1' to avoid PDO Unix-socket lookup // unless an explicit socket path is supplied. @@ -225,7 +320,7 @@ function handleTestDb() { try { // First: connect without database to check server - $dsn = installerBuildDsn($host, $port, '', $socket); + $dsn = installerBuildDsn($host, $port, '', $socket, $charset); $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 10, @@ -236,21 +331,50 @@ function handleTestDb() { $isMariaDB = stripos($version, 'MariaDB') !== false; $serverType = $isMariaDB ? 'MariaDB' : 'MySQL'; + // Charset auto-fallback: MySQL < 5.7 or MariaDB < 5.5.3 → utf8mb3 (legacy 'utf8') + $numericVer = preg_replace('/[^0-9.].*/', '', $version); + if ($isMariaDB) { + if (version_compare($numericVer, '5.5.3', '<')) $charset = 'utf8'; + } else { + if (version_compare($numericVer, '5.7.0', '<')) $charset = 'utf8'; + } + // Check if database exists $stmt = $pdo->prepare("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?"); $stmt->execute([$name]); $dbExists = (bool)$stmt->fetch(); + $collation = $charset . '_unicode_ci'; if (!$dbExists) { + if ($skipCreate) { + echo json_encode([ + 'success' => false, + 'message' => "Database '{$name}' does not exist on the server. Create it in your control panel (aaPanel: Databases → Add database) and uncheck 'skip CREATE' OR pre-create it then retry.", + ]); + return; + } // Try to create it - $pdo->exec("CREATE DATABASE `{$name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); - $msg = "{$serverType} {$version} — Database '{$name}' created successfully."; + try { + $pdo->exec("CREATE DATABASE `{$name}` CHARACTER SET {$charset} COLLATE {$collation}"); + $msg = "{$serverType} {$version} — Database '{$name}' created successfully (charset={$charset})."; + } catch (PDOException $createErr) { + $code = (int) $createErr->getCode(); + if (in_array($code, [1044, 1142]) || stripos($createErr->getMessage(), 'denied') !== false) { + echo json_encode([ + 'success' => false, + 'message' => "Your DB user lacks CREATE DATABASE privilege (common on Plesk/CyberPanel). Pre-create the DB '{$name}' in your control panel, then retick 'Database already exists — skip CREATE' and retry.", + 'suggest_skip_create' => true, + ]); + return; + } + throw $createErr; + } } else { - $msg = "{$serverType} {$version} — Database '{$name}' exists."; + $msg = "{$serverType} {$version} — Database '{$name}' exists (charset will be {$charset})."; } // Verify we can connect to the database - $dsn2 = installerBuildDsn($host, $port, $name, $socket); + $dsn2 = installerBuildDsn($host, $port, $name, $socket, $charset); $pdo2 = new PDO($dsn2, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 10, @@ -272,9 +396,25 @@ function handleTestDb() { } // Store in session for later steps - $_SESSION['install_db'] = compact('host', 'port', 'user', 'pass', 'name', 'socket'); + $_SESSION['install_db'] = [ + 'host' => $host, + 'port' => $port, + 'user' => $user, + 'pass' => $pass, + 'name' => $name, + 'socket' => $socket, + 'prefix' => $rawPrefix, + 'charset' => $charset, + ]; - echo json_encode(['success' => true, 'message' => $msg]); + echo json_encode([ + 'success' => true, + 'message' => $msg, + 'charset' => $charset, + 'version' => $version, + 'serverType'=> $serverType, + 'prefix' => $rawPrefix, + ]); } catch (PDOException $e) { echo json_encode(['success' => false, 'message' => installerFriendlyDbError($e, $host, $port, $socket)]); @@ -282,20 +422,15 @@ function handleTestDb() { } // ═══════════════════════════════════════════════════════════════ -// Step 3: Install Database (Run Migrations) +// Step 3: Install Database — Async (init/step) + Fast-path (all) // ═══════════════════════════════════════════════════════════════ -function handleInstallDb() { - $pdo = getInstallerPdo(); - if (!$pdo) return; - - $sqlDir = realpath(__DIR__ . '/../database'); - if (!$sqlDir) { - echo json_encode(['success' => false, 'message' => 'Database SQL directory not found']); - return; - } - // Migration order — matches 00-init.sh - $migrations = [ +/** + * Canonical migration order. Mirrors 00-init.sh exactly. + * Returns [filename, version] tuples. + */ +function installerMigrationList(): array { + return [ ['schema_versions_migration.sql', 0], ['install.sql', 1], ['database_concurrency_indexes.sql', 2], @@ -317,80 +452,206 @@ function handleInstallDb() { ['missing_drivers_migration.sql', 18], ['unallocated_space_migration.sql', 19], ]; +} - $results = []; +/** + * Step-3 INIT: ensure schema_versions exists, return ordered file list with + * applied-status flags. Browser drives the per-file loop from here. + */ +function handleInstallDbInit() { + $pdo = getInstallerPdo(); + if (!$pdo) return; + + $sqlDir = realpath(__DIR__ . '/../database'); + if (!$sqlDir) { + echo json_encode(['success' => false, 'message' => 'Database SQL directory not found']); + return; + } - // Step 0: ensure schema_versions exists (run unconditionally) + // Bootstrap schema_versions first (the tracking table for all later migrations). $schemaFile = $sqlDir . '/schema_versions_migration.sql'; if (file_exists($schemaFile)) { try { - $sql = file_get_contents($schemaFile); - $pdo->exec($sql); - $results[] = ['file' => 'schema_versions_migration.sql', 'status' => 'ok', 'message' => 'Tracking table ready']; - } catch (PDOException $e) { - // Table probably exists already - $results[] = ['file' => 'schema_versions_migration.sql', 'status' => 'ok', 'message' => 'Tracking table exists']; - } + $pdo->exec(file_get_contents($schemaFile)); + } catch (PDOException $e) { /* probably already exists */ } } - // Run remaining migrations - for ($i = 1; $i < count($migrations); $i++) { - [$file, $version] = $migrations[$i]; + $list = []; + foreach (installerMigrationList() as [$file, $version]) { $filePath = $sqlDir . '/' . $file; + $exists = file_exists($filePath); + $applied = false; + + if ($exists) { + try { + $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM schema_versions WHERE filename = ?"); + $stmt->execute([$file]); + $applied = ((int)$stmt->fetchColumn()) > 0; + } catch (PDOException $e) { /* table missing → not applied */ } + } + + $list[] = [ + 'file' => $file, + 'version' => $version, + 'exists' => $exists, + 'applied' => $applied, + 'sha256' => $exists ? substr(hash_file('sha256', $filePath), 0, 16) : '', + ]; + } + + echo json_encode([ + 'success' => true, + 'migrations' => $list, + 'total' => count($list), + ]); +} + +/** + * Step-3 STEP: apply ONE migration file. + * Body: { file: 'install.sql', version: 1 } + */ +function handleInstallDbStep() { + @set_time_limit(0); + $pdo = getInstallerPdo(); + if (!$pdo) return; + + $file = $_POST['file'] ?? ''; + $version = (int)($_POST['version'] ?? 0); + + // Whitelist against the canonical list — no arbitrary file reads. + $allowed = array_column(installerMigrationList(), 0); + if (!in_array($file, $allowed, true)) { + echo json_encode(['success' => false, 'message' => "Migration '{$file}' not on the canonical list."]); + return; + } + + $sqlDir = realpath(__DIR__ . '/../database'); + $filePath = $sqlDir . '/' . $file; + if (!file_exists($filePath)) { + echo json_encode(['file' => $file, 'success' => true, 'status' => 'skipped', 'message' => 'File not found (skipped)']); + return; + } + + // Skip if already applied + try { + $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM schema_versions WHERE filename = ?"); + $stmt->execute([$file]); + if ((int)$stmt->fetchColumn() > 0) { + echo json_encode(['file' => $file, 'success' => true, 'status' => 'skipped', 'message' => 'Already applied']); + return; + } + } catch (PDOException $e) { /* table missing — proceed */ } + + $sql = file_get_contents($filePath); + $result = installerRunSqlFile($pdo, $sql); + + if ($result['ok']) { + try { + $checksum = hash('sha256', $sql); + $stmt = $pdo->prepare("INSERT IGNORE INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)"); + $stmt->execute([$version, $file, $checksum]); + } catch (PDOException $e) { /* ignore */ } + + echo json_encode([ + 'file' => $file, + 'success' => true, + 'status' => 'ok', + 'message' => 'Applied (' . $result['stmts_run'] . ' statements)', + 'stmts_run' => $result['stmts_run'], + ]); + return; + } + + // Tolerate "already exists" / "duplicate" — record as applied. + if (preg_match('/Duplicate|already exists|1060|1061|1050|1062/i', $result['error'])) { + try { + $checksum = hash('sha256', $sql); + $stmt = $pdo->prepare("INSERT IGNORE INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)"); + $stmt->execute([$version, $file, $checksum]); + } catch (PDOException $e2) { /* ignore */ } + + echo json_encode([ + 'file' => $file, + 'success' => true, + 'status' => 'ok', + 'message' => 'Applied (some objects already existed)', + ]); + return; + } + + echo json_encode([ + 'file' => $file, + 'success' => false, + 'status' => 'error', + 'message' => $result['error'], + ]); +} + +/** + * Step-3 ALL: legacy single-shot path (used by fast-path when host has + * generous max_execution_time AND no other risk flags). + */ +function handleInstallDbAll() { + @set_time_limit(0); + $pdo = getInstallerPdo(); + if (!$pdo) return; + + $sqlDir = realpath(__DIR__ . '/../database'); + if (!$sqlDir) { + echo json_encode(['success' => false, 'message' => 'Database SQL directory not found']); + return; + } + + // Bootstrap schema_versions + $schemaFile = $sqlDir . '/schema_versions_migration.sql'; + if (file_exists($schemaFile)) { + try { $pdo->exec(file_get_contents($schemaFile)); } catch (PDOException $e) { /* ok */ } + } + $results = []; + foreach (installerMigrationList() as $i => [$file, $version]) { + if ($i === 0) { + $results[] = ['file' => $file, 'status' => 'ok', 'message' => 'Tracking table ready']; + continue; + } + + $filePath = $sqlDir . '/' . $file; if (!file_exists($filePath)) { $results[] = ['file' => $file, 'status' => 'skipped', 'message' => 'File not found']; continue; } - // Check if already applied try { - $stmt = $pdo->prepare("SELECT COUNT(*) AS cnt FROM schema_versions WHERE filename = ?"); + $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM schema_versions WHERE filename = ?"); $stmt->execute([$file]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - if ($row && (int)$row['cnt'] > 0) { + if ((int)$stmt->fetchColumn() > 0) { $results[] = ['file' => $file, 'status' => 'skipped', 'message' => 'Already applied']; continue; } - } catch (PDOException $e) { - // schema_versions might not exist yet - } - - // Execute migration - try { - $sql = file_get_contents($filePath); - - // For multi-statement SQL, we need to use exec - // Some files use DELIMITER which doesn't work with PDO — strip them - $sql = preg_replace('/DELIMITER\s+[^\n]+/i', '', $sql); + } catch (PDOException $e) { /* ignore */ } - $pdo->exec($sql); - - // Record in schema_versions - $checksum = hash('sha256', $sql); - $stmt = $pdo->prepare("INSERT IGNORE INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)"); - $stmt->execute([$version, $file, $checksum]); + $sql = file_get_contents($filePath); + $r = installerRunSqlFile($pdo, $sql); - $results[] = ['file' => $file, 'status' => 'ok', 'message' => 'Applied successfully']; - } catch (PDOException $e) { - $errMsg = $e->getMessage(); - // Some errors are non-fatal (duplicate index, column already exists, etc.) - if (preg_match('/Duplicate|already exists|1060|1061/i', $errMsg)) { - // Record as applied anyway - try { - $checksum = hash('sha256', $sql); - $stmt = $pdo->prepare("INSERT IGNORE INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)"); - $stmt->execute([$version, $file, $checksum]); - } catch (PDOException $e2) { /* ignore */ } - $results[] = ['file' => $file, 'status' => 'ok', 'message' => 'Applied (some objects already existed)']; - } else { - $results[] = ['file' => $file, 'status' => 'error', 'message' => $errMsg]; - // Don't stop — try remaining migrations - } + if ($r['ok']) { + try { + $checksum = hash('sha256', $sql); + $stmt = $pdo->prepare("INSERT IGNORE INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)"); + $stmt->execute([$version, $file, $checksum]); + } catch (PDOException $e) { /* ignore */ } + $results[] = ['file' => $file, 'status' => 'ok', 'message' => 'Applied (' . $r['stmts_run'] . ' statements)']; + } elseif (preg_match('/Duplicate|already exists|1060|1061|1050|1062/i', $r['error'])) { + try { + $checksum = hash('sha256', $sql); + $stmt = $pdo->prepare("INSERT IGNORE INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)"); + $stmt->execute([$version, $file, $checksum]); + } catch (PDOException $e) { /* ignore */ } + $results[] = ['file' => $file, 'status' => 'ok', 'message' => 'Applied (some objects already existed)']; + } else { + $results[] = ['file' => $file, 'status' => 'error', 'message' => $r['error']]; } } - // Check if any critical errors $errors = array_filter($results, fn($r) => $r['status'] === 'error'); $success = count($errors) === 0; @@ -401,6 +662,120 @@ function handleInstallDb() { ]); } +/** + * Run a multi-statement SQL string statement-by-statement. + * Strips DELIMITER + outer BEGIN/COMMIT wrappers. + * Returns ['ok' => bool, 'stmts_run' => int, 'error' => string]. + */ +function installerRunSqlFile(PDO $pdo, string $sql): array { + // Strip DELIMITER directives — PDO doesn't honor them. + $sql = preg_replace('/DELIMITER\s+[^\n]+/i', '', $sql); + // Strip outer BEGIN/COMMIT wrappers — DDL implicit-commits anyway and + // wrapper breaks per-statement progress reporting on some panels. + $sql = preg_replace('/^\s*(START\s+TRANSACTION|BEGIN)\s*;\s*$/im', '', $sql); + $sql = preg_replace('/^\s*COMMIT\s*;\s*$/im', '', $sql); + + $stmts = installerSplitSql($sql); + $count = 0; + foreach ($stmts as $stmt) { + $stmt = trim($stmt); + if ($stmt === '') continue; + try { + $pdo->exec($stmt); + $count++; + } catch (PDOException $e) { + return ['ok' => false, 'stmts_run' => $count, 'error' => $e->getMessage()]; + } + } + return ['ok' => true, 'stmts_run' => $count, 'error' => '']; +} + +/** + * Split a multi-statement SQL string into individual statements. + * Respects backticks, single/double-quoted strings, line comments (-- ...), + * and block comments (/* ... *\/). + * + * Returns array of statements (semicolons stripped). + */ +function installerSplitSql(string $sql): array { + $stmts = []; + $buf = ''; + $len = strlen($sql); + $i = 0; + $inSingle = $inDouble = $inBacktick = false; + $inLineComment = $inBlockComment = false; + + while ($i < $len) { + $c = $sql[$i]; + $next = $i + 1 < $len ? $sql[$i + 1] : ''; + + // Comments + if (!$inSingle && !$inDouble && !$inBacktick) { + if (!$inBlockComment && !$inLineComment && $c === '-' && $next === '-') { + $inLineComment = true; + $buf .= $c . $next; + $i += 2; + continue; + } + if (!$inBlockComment && !$inLineComment && $c === '#') { + $inLineComment = true; + $buf .= $c; + $i++; + continue; + } + if (!$inBlockComment && !$inLineComment && $c === '/' && $next === '*') { + $inBlockComment = true; + $buf .= $c . $next; + $i += 2; + continue; + } + if ($inLineComment && ($c === "\n" || $c === "\r")) { + $inLineComment = false; + $buf .= $c; + $i++; + continue; + } + if ($inBlockComment && $c === '*' && $next === '/') { + $inBlockComment = false; + $buf .= $c . $next; + $i += 2; + continue; + } + } + + if ($inLineComment || $inBlockComment) { + $buf .= $c; + $i++; + continue; + } + + // Quote tracking + if ($c === "'" && !$inDouble && !$inBacktick) { + $inSingle = !$inSingle; + } elseif ($c === '"' && !$inSingle && !$inBacktick) { + $inDouble = !$inDouble; + } elseif ($c === '`' && !$inSingle && !$inDouble) { + $inBacktick = !$inBacktick; + } + + // Statement terminator + if ($c === ';' && !$inSingle && !$inDouble && !$inBacktick) { + $stmts[] = $buf; + $buf = ''; + $i++; + continue; + } + + $buf .= $c; + $i++; + } + + if (trim($buf) !== '') { + $stmts[] = $buf; + } + return $stmts; +} + // ═══════════════════════════════════════════════════════════════ // Step 4: Create Admin Account // ═══════════════════════════════════════════════════════════════ @@ -615,12 +990,13 @@ function handleFinalize() { * Create PDO connection from POST params or session */ function getInstallerPdo(): ?PDO { - $host = trim($_POST['db_host'] ?? $_SESSION['install_db']['host'] ?? '127.0.0.1'); - $port = (int)($_POST['db_port'] ?? $_SESSION['install_db']['port'] ?? 3306); - $user = $_POST['db_user'] ?? $_SESSION['install_db']['user'] ?? ''; - $pass = $_POST['db_pass'] ?? $_SESSION['install_db']['pass'] ?? ''; - $name = $_POST['db_name'] ?? $_SESSION['install_db']['name'] ?? ''; - $socket = trim($_POST['db_socket'] ?? $_SESSION['install_db']['socket'] ?? ''); + $host = trim($_POST['db_host'] ?? $_SESSION['install_db']['host'] ?? '127.0.0.1'); + $port = (int)($_POST['db_port'] ?? $_SESSION['install_db']['port'] ?? 3306); + $user = $_POST['db_user'] ?? $_SESSION['install_db']['user'] ?? ''; + $pass = $_POST['db_pass'] ?? $_SESSION['install_db']['pass'] ?? ''; + $name = $_POST['db_name'] ?? $_SESSION['install_db']['name'] ?? ''; + $socket = trim($_POST['db_socket'] ?? $_SESSION['install_db']['socket'] ?? ''); + $charset = $_SESSION['install_db']['charset'] ?? 'utf8mb4'; if (empty($user) || empty($name)) { echo json_encode(['success' => false, 'message' => 'Database credentials missing. Go back to step 2.']); @@ -633,7 +1009,7 @@ function getInstallerPdo(): ?PDO { } try { - $dsn = installerBuildDsn($host, $port, $name, $socket); + $dsn = installerBuildDsn($host, $port, $name, $socket, $charset); return new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, @@ -650,15 +1026,15 @@ function getInstallerPdo(): ?PDO { * Build a PDO DSN that supports either TCP host:port or Unix socket path. * When $socket is non-empty, host/port are ignored. */ -function installerBuildDsn(string $host, int $port, string $name, string $socket = ''): string { +function installerBuildDsn(string $host, int $port, string $name, string $socket = '', string $charset = 'utf8mb4'): string { if ($socket !== '') { - $dsn = "mysql:unix_socket={$socket};charset=utf8mb4"; + $dsn = "mysql:unix_socket={$socket};charset={$charset}"; } else { - $dsn = "mysql:host={$host};port={$port};charset=utf8mb4"; + $dsn = "mysql:host={$host};port={$port};charset={$charset}"; } if ($name !== '') { // Insert dbname between host/socket and charset to keep DSN ordered cleanly - $dsn = str_replace(';charset=utf8mb4', ";dbname={$name};charset=utf8mb4", $dsn); + $dsn = str_replace(";charset={$charset}", ";dbname={$name};charset={$charset}", $dsn); } return $dsn; } @@ -669,28 +1045,36 @@ function installerBuildDsn(string $host, int $port, string $name, string $socket */ function installerFriendlyDbError(PDOException $e, string $host, int $port, string $socket): string { $raw = $e->getMessage(); - $code = $e->getCode(); + $code = (int)$e->getCode(); // Strip any DSN fragments that could leak host/port/user $raw = preg_replace('/\bmysql:[^\s]+/', '[DSN]', $raw); + // ── 1044/1142: Lacks privilege (Plesk, CyberPanel, ISPConfig) ─ + if ($code === 1044 || $code === 1142 || preg_match('/\b(1044|1142)\b/', $raw)) { + return "Your DB user lacks the required privilege. On Plesk/CyberPanel, the per-user account often cannot CREATE DATABASE or CREATE TABLE. Pre-create the database in your control panel UI and tick 'Database already exists — skip CREATE'."; + } + // ── 1045 with no password supplied ─ if (stripos($raw, 'Access denied') !== false) { + if (stripos($raw, 'using password: NO') !== false) { + return "Access denied. Server says no password was supplied. If your panel set a password (most do), enter it. If not, ensure user is allowed from 127.0.0.1."; + } return 'Access denied. Check username and password. On aaPanel, ensure the user is allowed from this host (set Host = % or 127.0.0.1 in phpMyAdmin → User accounts).'; } if (stripos($raw, 'Unknown database') !== false) { - return "Database does not exist and could not be auto-created. Create it manually in aaPanel → Databases, then retry."; + return "Database does not exist. Pre-create it in your control panel (aaPanel/cPanel/Plesk → Databases) and tick 'Database already exists — skip CREATE'."; } if (stripos($raw, 'Unknown MySQL server host') !== false || stripos($raw, 'getaddrinfo') !== false) { return "Cannot resolve host '{$host}'. Use 127.0.0.1 instead of localhost on most aaPanel installs."; } if (stripos($raw, 'Connection refused') !== false) { - return "Cannot connect to {$host}:{$port}. MariaDB/MySQL service may not be running, or it's bound to a different port. In aaPanel: App Store → MySQL → check Service is started."; + return "Cannot connect to {$host}:{$port}. MariaDB/MySQL service may not be running, or it's bound to a different port. Check the service is started in your panel."; } if (stripos($raw, 'No such file or directory') !== false) { // Classic Unix-socket failure $hint = $socket !== '' - ? "Socket path '{$socket}' does not exist." - : "PDO tried the default Unix socket but it does not exist. Use 127.0.0.1 instead of localhost, or specify the socket path. Common aaPanel paths: /tmp/mysql.sock, /www/server/mysql/mysql.sock"; + ? "Socket path '{$socket}' does not exist or is not readable by the web user." + : "PDO tried the default Unix socket but it does not exist. Use 127.0.0.1 instead of localhost, or click 'Detect socket' in advanced settings. Common paths: /tmp/mysql.sock, /var/run/mysqld/mysqld.sock, /www/server/mysql/mysql.sock"; return $hint; } if (stripos($raw, 'timed out') !== false || stripos($raw, 'timeout') !== false) { @@ -717,25 +1101,31 @@ function returnBytes(string $val): int { } /** - * Get the real client IP, accounting for proxies + * Get the real client IP, accounting for proxies. + * + * Security: only honor X-Forwarded-For / X-Real-IP / Client-IP headers when + * the immediate REMOTE_ADDR is in a private/loopback range — that's the + * only situation where a forward header is trustworthy (real proxy in front). + * Otherwise the client could spoof any IP. */ function getClientIp(): string { - // Check common proxy headers (trusted only in installer context) - $headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP']; - foreach ($headers as $header) { - if (!empty($_SERVER[$header])) { - // X-Forwarded-For may contain multiple IPs — take the first (client) - $ip = trim(explode(',', $_SERVER[$header])[0]); - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { - return $ip; - } - // Accept private range IPs too (common in LAN setups) - if (filter_var($ip, FILTER_VALIDATE_IP)) { - return $ip; + $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? ''; + $remoteIsPrivate = $remoteAddr !== '' + && filter_var($remoteAddr, FILTER_VALIDATE_IP) + && !filter_var($remoteAddr, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + + if ($remoteIsPrivate) { + $headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP']; + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + $ip = trim(explode(',', $_SERVER[$header])[0]); + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } } } } - return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + return $remoteAddr !== '' ? $remoteAddr : 'unknown'; } /** @@ -931,3 +1321,161 @@ function buildOrderNumberPattern(array $config): string { ?> PHP_TAIL; } + +// ═══════════════════════════════════════════════════════════════ +// Auto-unlock recovery + socket detection + health probe +// ═══════════════════════════════════════════════════════════════ + +/** + * Auto-unlock recovery: if install.lock exists but the install never + * finished (no admin_users yet, or table missing), delete the lock so + * the user can resume. Triple-gated: lock present + DB connectable + + * admin_users empty/absent. Logs the auto-action to install.log. + * + * Idempotent — safe to call on every request. + */ +function installerCheckIncompleteState(): void { + $lockPath = __DIR__ . '/../install.lock'; + $configPath = __DIR__ . '/../config.php'; + + if (!file_exists($lockPath)) return; + if (!file_exists($configPath)) return; // Without config we can't probe DB + + // Best-effort include of config to get $db_config — but we don't trust + // its globals in this script's scope, so we re-parse manually. + $configSrc = @file_get_contents($configPath); + if ($configSrc === false) return; + + if (!preg_match("/'host'\s*=>\s*'([^']+)'/", $configSrc, $hM)) return; + if (!preg_match("/'dbname'\s*=>\s*'([^']+)'/", $configSrc, $nM)) return; + if (!preg_match("/'username'\s*=>\s*'([^']+)'/", $configSrc, $uM)) return; + if (!preg_match("/'password'\s*=>\s*'([^']*)'/", $configSrc, $pM)) return; + if (!preg_match("/'port'\s*=>\s*(\d+)/", $configSrc, $portM)) return; + + $host = $hM[1]; $name = $nM[1]; $user = $uM[1]; $pass = $pM[1]; $port = (int)$portM[1]; + if (strtolower($host) === 'localhost') $host = '127.0.0.1'; + + try { + $dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4"; + $pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 5, + ]); + + // admin_users table missing → install was never completed. + $stmt = $pdo->query("SHOW TABLES LIKE 'admin_users'"); + if (!$stmt->fetch()) { + installerLog("auto-unlock: admin_users table missing — clearing install.lock"); + @unlink($lockPath); + return; + } + + // admin_users empty → install was never completed. + $cnt = (int)$pdo->query("SELECT COUNT(*) FROM admin_users")->fetchColumn(); + if ($cnt === 0) { + installerLog("auto-unlock: admin_users empty — clearing install.lock"); + @unlink($lockPath); + return; + } + } catch (PDOException $e) { + // Can't connect — might mean DB isn't even available; don't unlock, + // user must resolve DB issue first. Log it. + installerLog("auto-unlock: skipped (DB connect failed: " . $e->getMessage() . ")"); + return; + } + + // Install was completed properly — keep the lock. +} + +/** + * Probe a list of common Unix socket paths and return the first that exists. + */ +function installerProbeSockets(): array { + $candidates = [ + '/tmp/mysql.sock', + '/var/run/mysqld/mysqld.sock', + '/var/lib/mysql/mysql.sock', + '/www/server/mysql/mysql.sock', + '/var/run/mariadb/mariadb.sock', + '/usr/local/mysql/mysql.sock', + '/usr/local/var/mysql/mysql.sock', + '/opt/lampp/var/mysql/mysql.sock', + ]; + $found = []; + foreach ($candidates as $p) { + if (@file_exists($p)) { + $found[] = $p; + } + } + return $found; +} + +/** + * Action: detect_socket — returns list of socket paths discovered on disk. + */ +function handleDetectSocket(): void { + $found = installerProbeSockets(); + echo json_encode([ + 'success' => true, + 'sockets' => $found, + 'suggested' => $found[0] ?? '', + ]); +} + +/** + * Action: health — quick post-install probe (no auth required because + * install.lock blocks re-entry once installed). Returns DB connect status, + * tables present, admin row count. + */ +function handleHealth(): void { + $configPath = __DIR__ . '/../config.php'; + if (!file_exists($configPath)) { + echo json_encode(['success' => false, 'message' => 'config.php not found']); + return; + } + + $pdo = getInstallerPdo(); + if (!$pdo) return; // getInstallerPdo already echoed error + + $checks = []; + try { + $pdo->query('SELECT 1'); + $checks[] = ['label' => 'Database connect', 'status' => 'pass']; + } catch (PDOException $e) { + $checks[] = ['label' => 'Database connect', 'status' => 'fail', 'message' => $e->getMessage()]; + } + + $expectTables = ['admin_users', 'oem_keys', 'technicians', 'system_config', 'schema_versions']; + foreach ($expectTables as $t) { + try { + $stmt = $pdo->query("SHOW TABLES LIKE '" . str_replace("'", '', $t) . "'"); + $found = (bool)$stmt->fetch(); + $checks[] = ['label' => "Table {$t}", 'status' => $found ? 'pass' : 'fail']; + } catch (PDOException $e) { + $checks[] = ['label' => "Table {$t}", 'status' => 'fail']; + } + } + + try { + $admins = (int)$pdo->query("SELECT COUNT(*) FROM admin_users")->fetchColumn(); + $checks[] = ['label' => 'Admin accounts', 'status' => $admins > 0 ? 'pass' : 'fail', 'value' => $admins]; + } catch (PDOException $e) { + $checks[] = ['label' => 'Admin accounts', 'status' => 'fail']; + } + + $allPass = !in_array(false, array_map(fn($c) => $c['status'] === 'pass', $checks), true); + echo json_encode(['success' => $allPass, 'checks' => $checks]); +} + +/** + * Append a single line to install/install.log. Best-effort: silently + * skipped if the file isn't writable. + */ +function installerLog(string $line): void { + $logPath = __DIR__ . '/install.log'; + @file_put_contents( + $logPath, + '[' . date('Y-m-d H:i:s') . '] ' . $line . PHP_EOL, + FILE_APPEND + ); +} diff --git a/FINAL_PRODUCTION_SYSTEM/install/index.php b/FINAL_PRODUCTION_SYSTEM/install/index.php index dedcb1a..f55d2e1 100644 --- a/FINAL_PRODUCTION_SYSTEM/install/index.php +++ b/FINAL_PRODUCTION_SYSTEM/install/index.php @@ -12,8 +12,47 @@ * 6. Complete — write config, show success */ -// Prevent running if already installed +// Auto-unlock recovery (P0): if install.lock exists but install never +// completed (admin_users empty/missing), clear the lock so user can resume. +// Inlined to avoid dragging ajax.php's JSON header + dispatch into HTML output. $lockFile = __DIR__ . '/../install.lock'; +$configFile = __DIR__ . '/../config.php'; +if (file_exists($lockFile) && file_exists($configFile)) { + $configSrc = @file_get_contents($configFile); + if ($configSrc !== false + && preg_match("/'host'\s*=>\s*'([^']+)'/", $configSrc, $hM) + && preg_match("/'dbname'\s*=>\s*'([^']+)'/", $configSrc, $nM) + && preg_match("/'username'\s*=>\s*'([^']+)'/", $configSrc, $uM) + && preg_match("/'password'\s*=>\s*'([^']*)'/", $configSrc, $pM) + && preg_match("/'port'\s*=>\s*(\d+)/", $configSrc, $portM)) { + $autoHost = strtolower($hM[1]) === 'localhost' ? '127.0.0.1' : $hM[1]; + try { + $autoPdo = new PDO( + "mysql:host={$autoHost};port={$portM[1]};dbname={$nM[1]};charset=utf8mb4", + $uM[1], $pM[1], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5] + ); + $hasAdminTable = (bool) $autoPdo->query("SHOW TABLES LIKE 'admin_users'")->fetch(); + $adminCount = $hasAdminTable ? (int) $autoPdo->query("SELECT COUNT(*) FROM admin_users")->fetchColumn() : 0; + if (!$hasAdminTable || $adminCount === 0) { + @unlink($lockFile); + @file_put_contents( + __DIR__ . '/install.log', + '[' . date('Y-m-d H:i:s') . "] auto-unlock from index.php — admin_users empty/missing\n", + FILE_APPEND + ); + } + } catch (PDOException $autoE) { + @file_put_contents( + __DIR__ . '/install.log', + '[' . date('Y-m-d H:i:s') . '] auto-unlock skipped: ' . $autoE->getMessage() . "\n", + FILE_APPEND + ); + } + } +} + +// Prevent running if already installed if (file_exists($lockFile)) { $installed = json_decode(file_get_contents($lockFile), true); ?> @@ -395,12 +434,36 @@
/tmp/mysql.sock or /www/server/mysql/mysql.sock./tmp/mysql.sock, /var/run/mysqld/mysqld.sock, /www/server/mysql/mysql.sock.utf8 for very old servers (MySQL < 5.7).