Install Client Harbor
Checking this hosting account
- = $c['level'] === 'ok' ? '✓' : ($c['level'] === 'fail' ? '✗' : '!') ?> = h($c['label']) ?>= $c['detail'] !== '' ? ' — ' . $c['detail'] : '' ?>
Fix the items marked ✗, then reload this page.
'Downloading Client Harbor', 'extract' => 'Unpacking the files', 'database' => 'Creating the database', 'settings' => 'Writing the settings', 'python' => 'Installing the Python packages (a few minutes)', 'setup' => 'Creating the tables and your administrator', 'cron' => 'Scheduling the background tasks', 'golive' => 'Turning on the website', ]; // ── Requests ─────────────────────────────────────────────────────────────────────────────────────────────── $action = $_GET['action'] ?? ''; if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action !== '') { try { if ($action === 'start') { json_out(start_install($_POST)); } elseif ($action === 'step') { json_out(run_step((string) ($_POST['step'] ?? ''), (string) ($_POST['token'] ?? ''))); } throw new InstallError('Unknown action.'); } catch (InstallError $e) { json_out(['status' => 'error', 'message' => $e->getMessage()]); } catch (Throwable $e) { json_out(['status' => 'error', 'message' => 'Unexpected error: ' . $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')']); } } render_page(); exit; // ── Starting ─────────────────────────────────────────────────────────────────────────────────────────────── function start_install(array $in): array { if (!installer_stamped()) { throw new InstallError('This installer was not prepared for download.'); } $state = load_state(); foreach (array_keys($state['jobs'] ?? []) as $name) { if (job_status($name)['state'] === 'running') { throw new InstallError('An install is already running here. Wait a few minutes and try again.'); } } $token = trim((string) ($in['api_token'] ?? '')); if ($token !== '') { $state['api_token'] = $token; } $GLOBALS['api_token'] = $state['api_token'] ?? ''; foreach (preflight() as $c) { if ($c['level'] === 'fail' || $c['level'] === 'token') { throw new InstallError($c['label'] . ': ' . strip_tags($c['detail'])); } } if (!empty(site_files()) && empty($in['replace_site'])) { throw new InstallError('Tick the box to confirm Client Harbor should replace the website at this address.'); } $email = trim((string) ($in['email'] ?? '')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new InstallError('Enter a valid email address for the administrator.'); } $company = trim((string) ($in['company'] ?? '')); if ($company === '' || strlen($company) > 200) { throw new InstallError('Enter your company name.'); } $tz = (string) ($in['timezone'] ?? ''); if (!in_array($tz, DateTimeZone::listIdentifiers(), true)) { throw new InstallError('Choose a time zone.'); } $host = request_host(); $domains = account_domains(); $base = base_domain($host, $domains); if ($base === '') { throw new InstallError("$host isn't a domain in this cPanel account."); } $portal = ''; if (!empty($in['client_area'])) { $portal = strtolower(trim((string) ($in['portal_host'] ?? ''))); if (!preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/', $portal) || $portal === $host) { throw new InstallError('Enter the client area address, like clients.' . $base . '.'); } if (base_domain($portal, $domains) === '') { throw new InstallError("$portal isn't on a domain in this cPanel account."); } } $license = strtoupper((string) preg_replace('/\s+/', '', (string) ($in['license'] ?? ''))); if ($license === '') { throw new InstallError('Enter your license number.'); } activate_license($license, $host, $portal); $python = find_python(); $state = array_merge($state, [ 'license' => $license, 'token' => bin2hex(random_bytes(16)), 'started' => time(), 'done' => [], 'host' => $host, 'base' => $base, 'portal' => $portal, 'app' => $state['app'] ?? free_app_dir(), 'python' => $python['path'], 'admin' => [ 'email' => $email, 'first_name' => mb_substr(trim((string) ($in['first_name'] ?? '')), 0, 150), 'last_name' => mb_substr(trim((string) ($in['last_name'] ?? '')), 0, 150), 'company' => $company, 'timezone' => $tz, ], ]); unset($state['jobs']); save_state($state); return ['status' => 'done', 'token' => $state['token'], 'steps' => STEPS]; } function run_step(string $step, string $token): array { $state = load_state(); if (empty($state['token']) || !hash_equals($state['token'], $token)) { throw new InstallError('This install session has ended. Reload the page to start again.'); } if (!isset(STEPS[$step])) { throw new InstallError('Unknown step.'); } foreach (array_keys(STEPS) as $earlier) { if ($earlier === $step) { break; } if (empty($state['done'][$earlier])) { throw new InstallError('"' . STEPS[$earlier] . '" has to finish first.'); } } $GLOBALS['api_token'] = $state['api_token'] ?? ''; try { $result = call_user_func_array('step_' . $step, [&$state]); } catch (InstallError $e) { save_state($state); // keep what the step did before failing (a started job, a created database) throw $e; } if ($result['status'] === 'done') { $state['done'][$step] = true; } if ($step !== 'golive' || $result['status'] !== 'done') { // golive deletes the state when it's done save_state($state); } return $result; } // ── The steps ────────────────────────────────────────────────────────────────────────────────────────────── function step_download(array &$s): array { $local = glob(__DIR__ . '/clientharbor-*.zip') ?: []; if ($local) { // uploaded next to the installer: no download needed usort($local, function ($a, $b) { return version_compare(zip_version($b), zip_version($a)); }); $s['zip'] = $local[0]; $s['version'] = zip_version($local[0]); return done('Using ' . basename($local[0]) . '.'); } $manifest = json_decode(http_get(RELEASE_URL), true); if (!is_array($manifest) || empty($manifest['url']) || empty($manifest['sha256'])) { throw new InstallError('The release information at ' . RELEASE_URL . " can't be read."); } $zip = state_dir() . '/release.zip'; http_download($manifest['url'], $zip); if (!hash_equals(strtolower($manifest['sha256']), (string) hash_file('sha256', $zip))) { @unlink($zip); throw new InstallError('The download was damaged (its checksum is wrong). Try again.'); } $s['zip'] = $zip; $s['version'] = (string) ($manifest['version'] ?? ''); return done('Downloaded version ' . $s['version'] . '.'); } function step_extract(array &$s): array { $app = $s['app']; if (!is_dir($app) && !@mkdir($app, 0755, true)) { throw new InstallError("Can't create the folder $app."); } if (class_exists('ZipArchive')) { $zip = new ZipArchive(); if ($zip->open($s['zip']) !== true || !$zip->extractTo($app)) { throw new InstallError("Can't unpack " . basename($s['zip']) . '.'); } $zip->close(); } else { [$code, $out] = run('unzip -oq ' . escapeshellarg($s['zip']) . ' -d ' . escapeshellarg($app)); if ($code !== 0) { throw new InstallError("Can't unpack " . basename($s['zip']) . ": $out"); } } if (!is_file("$app/manage.py") || !is_file("$app/passenger_wsgi.py")) { throw new InstallError(basename($s['zip']) . " isn't a Client Harbor release."); } foreach (['logs', 'media', 'private', 'tmp'] as $dir) { if (!is_dir("$app/$dir")) { @mkdir("$app/$dir", 0755); } } @chmod("$app/private", 0700); return done("Unpacked into $app."); } function step_database(array &$s): array { if (!empty($s['db']['ready'])) { return done('Using the database ' . $s['db']['name'] . '.'); } $limits = cpanel('Mysql', 'get_restrictions'); $prefix = (string) ($limits['prefix'] ?? ''); if (empty($s['db']['name'])) { check_database_server(); $existing = array_column(cpanel('Mysql', 'list_databases') ?: [], 'database'); $name = $prefix . 'clientharbor'; for ($i = 2; in_array($name, $existing, true); $i++) { $name = $prefix . 'clientharbor' . $i; } $name = substr($name, 0, (int) ($limits['max_database_name_length'] ?? 64)); cpanel('Mysql', 'create_database', ['name' => $name]); $s['db'] = ['name' => $name]; // saved even if a later call fails, so trying again doesn't make another } if (empty($s['db']['user'])) { $room = (int) ($limits['max_username_length'] ?? 16) - strlen($prefix); if ($room < 4) { throw new InstallError("cPanel's database user names are too short to add one here."); } $user = $prefix . substr('ch' . strtolower(random_text(6)), 0, min($room, 8)); $password = random_text(32); cpanel('Mysql', 'create_user', ['name' => $user, 'password' => $password]); $s['db'] += ['user' => $user, 'password' => $password, 'host' => database_host()]; } cpanel('Mysql', 'set_privileges_on_database', ['user' => $s['db']['user'], 'database' => $s['db']['name'], 'privileges' => 'ALL PRIVILEGES']); $s['db']['ready'] = true; return done('Created the database ' . $s['db']['name'] . ' and its user ' . $s['db']['user'] . '.'); } function step_settings(array &$s): array { if (empty($s['secrets'])) { // kept across retries: the encryption key must never change once used $s['secrets'] = [ 'secret_key' => random_text(64), 'encryption_key' => strtr(base64_encode(random_bytes(32)), '+/', '-_'), ]; } $db = $s['db']; $hosts = [$s['host']]; if ($s['host'] === $s['base']) { $hosts[] = 'www.' . $s['base']; } $portal = $s['portal'] ?: 'clients.' . $s['base']; $values = [ 'DJANGO_SETTINGS_MODULE' => 'config.settings.cpanel', 'DEBUG' => 'False', 'SECRET_KEY' => $s['secrets']['secret_key'], 'ALLOWED_HOSTS' => implode(',', $hosts), 'SITE_URL' => 'https://' . $s['host'], 'PORTAL_HOST' => $portal, 'PORTAL_URL' => 'https://' . $portal, 'DATABASE_URL' => sprintf('mysql://%s:%s@%s/%s', $db['user'], $db['password'], rawurlencode($db['host']), $db['name']), 'FIELD_ENCRYPTION_KEY' => $s['secrets']['encryption_key'], 'LICENSE_KEY' => $s['license'], 'LICENSE_URL' => LICENSE_URL, 'DEFAULT_FROM_EMAIL' => 'noreply@' . $s['base'], 'EMAIL_HOST' => 'localhost', 'EMAIL_PORT' => '25', 'EMAIL_USE_TLS' => 'False', 'SENTRY_DSN' => '', ]; $env = '# Client Harbor settings, written by the installer on ' . date('Y-m-d') . ". Keep this file private.\n" . "# Back up FIELD_ENCRYPTION_KEY: stored email passwords can't be read without it.\n" . "# After changing anything here, restart the app: touch tmp/restart.txt\n"; foreach ($values as $k => $v) { $env .= "$k=$v\n"; } write_private($s['app'] . '/.env', $env); return done('Wrote ' . $s['app'] . '/.env.'); } function step_python(array &$s): array { $app = escapeshellarg($s['app']); $python = escapeshellarg($s['python']); $pip = 'venv/bin/python -m pip install --no-cache-dir --disable-pip-version-check'; return job_step($s, 'python', "cd $app\n" . "echo \"Using \$($python --version) at $python\"\n" . "$python -m venv venv\n" . "$pip --upgrade pip\n" . "$pip -r requirements/cpanel.txt\n", 'The Python packages are installed.'); } function step_setup(array &$s): array { $a = $s['admin']; $script = 'cd ' . escapeshellarg($s['app']) . "\n" . <<<'SH' echo "== Preparing the database" venv/bin/python manage.py shell <<'PY' from django.db import connection with connection.cursor() as cursor: # cPanel makes databases in the server's default character set cursor.execute("ALTER DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" % connection.settings_dict["NAME"]) PY echo "== Creating the tables" venv/bin/python manage.py migrate --no-input echo "== Collecting the static files" venv/bin/python manage.py collectstatic --no-input -v 0 SH; if (empty($s['admin_password'])) { $script .= "echo \"== Creating your administrator\"\n" . 'venv/bin/python manage.py setup_instance --no-input' . ' --email ' . escapeshellarg($a['email']) . ' --company ' . escapeshellarg($a['company']) . ' --timezone ' . escapeshellarg($a['timezone']) . ($a['first_name'] !== '' ? ' --first-name ' . escapeshellarg($a['first_name']) : '') . ($a['last_name'] !== '' ? ' --last-name ' . escapeshellarg($a['last_name']) : '') . "\n"; } $result = job_step($s, 'setup', $script, 'The database is ready.'); if ($result['status'] === 'done' && empty($s['admin_password'])) { $log = (string) @file_get_contents(state_dir() . '/setup.log'); if (!preg_match('/Temporary password: (\S+)/', $log, $m)) { throw new InstallError("The administrator was created, but its password couldn't be read from the log."); } $s['admin_password'] = $m[1]; } return $result; } function step_cron(array &$s): array { $command = 'cd ' . escapeshellarg($s['app']) . ' && venv/bin/python manage.py run_scheduled >> logs/scheduled.log 2>&1'; [$code, $current] = run('crontab -l'); if ($code !== 0 && stripos($current, 'no crontab') === false) { return cron_by_api($command); } if ($code !== 0) { $current = ''; } if (strpos($current, $command) !== false) { return done('The cron job is already there.'); } $file = state_dir() . '/crontab'; file_put_contents($file, ($current === '' ? '' : rtrim($current) . "\n") . "# Client Harbor: scheduled tasks (reminders, mailbox checks, recurring invoices)\n" . "* * * * * $command\n"); [$code] = run('crontab ' . escapeshellarg($file)); @unlink($file); if ($code !== 0) { return cron_by_api($command); } return done('Added a cron job that runs every minute.'); } function cron_by_api(string $command): array { try { foreach (cpanel('Cron', 'listcron', [], 2) ?: [] as $job) { if (($job['command'] ?? '') === $command) { return done('The cron job is already there.'); } } } catch (InstallError $e) { // can't list them: add it anyway } cpanel('Cron', 'add_line', ['command' => $command, 'minute' => '*', 'hour' => '*', 'day' => '*', 'month' => '*', 'weekday' => '*'], 2); return done('Added a cron job that runs every minute.'); } function step_golive(array &$s): array { $app = $s['app']; $notes = []; // Send this address's requests to the Python app $htaccess = __DIR__ . '/.htaccess'; $current = is_file($htaccess) ? (string) file_get_contents($htaccess) : ''; if ($current !== '' && !is_file("$htaccess.before-clientharbor")) { copy($htaccess, "$htaccess.before-clientharbor"); } $current = trim((string) preg_replace('/' . preg_quote(HTACCESS_BEGIN, '/') . '.*?' . preg_quote(HTACCESS_END, '/') . '\R?/s', '', $current)); $block = HTACCESS_BEGIN . " (added by the installer: this site is the Python app in $app)\n" . "PassengerAppRoot \"$app\"\n" . "PassengerBaseURI \"/\"\n" . "PassengerPython \"$app/venv/bin/python\"\n" . HTACCESS_END . "\n"; if (file_put_contents($htaccess, $block . ($current === '' ? '' : "\n$current\n")) === false) { throw new InstallError("Can't write $htaccess."); } foreach (['index.html', 'index.htm', 'index.php'] as $index) { // they'd be served instead of the app if (is_file(__DIR__ . "/$index")) { rename(__DIR__ . "/$index", __DIR__ . "/$index.before-clientharbor"); } } // The client area: a subdomain with the same folder, so the same app answers it if ($s['portal'] !== '') { try { $notes[] = add_portal_subdomain($s['portal']); } catch (InstallError $e) { $notes[] = 'The client area subdomain could not be added (' . $e->getMessage() . '). In cPanel, under ' . 'Domains, add ' . $s['portal'] . ' with the folder ' . home_relative(__DIR__) . '.'; } } try { cpanel('SSL', 'start_autossl_check'); } catch (InstallError $e) { // AutoSSL runs on its own schedule anyway } @touch("$app/tmp/restart.txt"); file_put_contents("$app/logs/install.log", sprintf("Installed Client Harbor %s on %s for %s\nPython: %s\n" . "Database: %s (user %s)\nSite folder: %s\n", $s['version'], date('c'), $s['host'], $s['python'], $s['db']['name'], $s['db']['user'], __DIR__)); $result = done('Client Harbor is installed.'); $result['summary'] = [ 'url' => 'https://' . $s['host'] . '/login/', 'email' => $s['admin']['email'], 'password' => $s['admin_password'], 'encryption_key' => $s['secrets']['encryption_key'], 'app' => $app, 'portal' => $s['portal'] !== '' ? 'https://' . $s['portal'] . '/' : '', 'notes' => $notes, 'api_token_used' => !empty($s['api_token']), ]; // Clean up: the state (with its passwords), the download, and this installer foreach (array_merge(glob(state_dir() . '/*') ?: [], glob(state_dir() . '/.*') ?: []) as $file) { if (is_file($file)) { @unlink($file); } } @rmdir(state_dir()); foreach (glob(__DIR__ . '/clientharbor-*.zip') ?: [] as $zip) { @unlink($zip); } @unlink(__FILE__); return $result; } function add_portal_subdomain(string $portal): string { $domains = account_domains(); if (in_array($portal, $domains['all'], true)) { return "$portal was already in cPanel: make sure its folder is " . home_relative(__DIR__) . '.'; } $root = base_domain($portal, $domains); cpanel('SubDomain', 'addsubdomain', [ 'domain' => substr($portal, 0, -strlen($root) - 1), 'rootdomain' => $root, 'dir' => home_relative(__DIR__), 'disallowdot' => 0, ]); return "Added the client area at $portal. Its SSL certificate can take a few minutes to arrive."; } // ── Background jobs (steps that take longer than a web request may) ──────────────────────────────────────── /** Start the job on the first call, then report on it each time the page polls. */ function job_step(array &$s, string $name, string $script, string $doneMessage): array { if (empty($s['jobs'][$name])) { start_job($name, $script); $s['jobs'][$name] = ['started' => time()]; return ['status' => 'running', 'message' => 'Started.', 'log' => '']; } $job = job_status($name); if ($job['state'] === 'done') { unset($s['jobs'][$name]); return done($doneMessage); } if ($job['state'] !== 'running' || time() - $s['jobs'][$name]['started'] > JOB_TIMEOUT) { unset($s['jobs'][$name]); // so trying again starts it afresh throw new InstallError(($job['state'] === 'failed' ? 'This step failed.' : 'This step stopped responding.') . ($job['log'] !== '' ? "\n\n" . $job['log'] : '')); } return ['status' => 'running', 'message' => '', 'log' => $job['log']]; } function start_job(string $name, string $script): void { $dir = state_dir(); @unlink("$dir/$name.exit"); @unlink("$dir/$name.log"); file_put_contents("$dir/$name.sh", "set -e\nexport LANG=C.UTF-8 PYTHONIOENCODING=utf-8 PYTHONUNBUFFERED=1\n" . $script); $inner = escapeshellarg("sh $name.sh > $name.log 2>&1; echo \$? > $name.exit"); // Detached, so it carries on after this request ends run('cd ' . escapeshellarg($dir) . ' && if command -v setsid >/dev/null 2>&1; then setsid sh -c ' . $inner . ' /dev/null 2>&1 & else nohup sh -c ' . $inner . ' /dev/null 2>&1 & fi'); } /** ['state' => running|done|failed|none, 'log' => the log's last lines] */ function job_status(string $name): array { $dir = state_dir(); $log = is_file("$dir/$name.log") ? tail((string) file_get_contents("$dir/$name.log"), 15) : ''; if (!is_file("$dir/$name.exit")) { return ['state' => is_file("$dir/$name.sh") ? 'running' : 'none', 'log' => $log]; } $code = (int) trim((string) file_get_contents("$dir/$name.exit")); return ['state' => $code === 0 ? 'done' : 'failed', 'log' => $log]; } // ── Checks shown before installing ───────────────────────────────────────────────────────────────────────── /** Each: ['level' => ok|warn|token|fail, 'label' => ..., 'detail' => HTML]. "token": needs a cPanel API token. */ function preflight(): array { $checks = []; $add = function ($level, $label, $detail = '') use (&$checks) { $checks[] = ['level' => $level, 'label' => $label, 'detail' => $detail]; }; if (!installer_stamped()) { $add('fail', 'Installer', 'This copy was not prepared for download. Build it with deploy/cpanel/build_release.py.'); } if (PHP_VERSION_ID < 70400) { $add('fail', 'PHP', 'PHP 7.4 or later is needed (this is ' . PHP_VERSION . '). Choose a newer one in MultiPHP Manager.'); } if (!exec_available()) { $add('fail', 'Running commands', 'PHP is not allowed to run commands here (proc_open is disabled). Ask your host to allow it for this account.'); return $checks; } $add('ok', 'Running commands'); if (!is_https()) { $https = 'https://' . request_host() . strtok((string) $_SERVER['REQUEST_URI'], '?'); $add('fail', 'HTTPS', 'Open the installer over HTTPS: ' . h($https) . '. ' . "If that doesn't load, the address has no SSL certificate yet: in cPanel, open SSL/TLS Status and click Run AutoSSL."); } else { $add('ok', 'HTTPS'); } $docroot = realpath((string) ($_SERVER['DOCUMENT_ROOT'] ?? '')); if ($docroot && $docroot !== realpath(__DIR__)) { $add('fail', 'Folder', 'The installer has to be in the top folder of the address it runs on, but it is in a subfolder. ' . 'In cPanel, under Domains, create a subdomain such as crm.' . h(request_host()) . ' with a folder of its own, ' . 'and upload the installer there.'); } elseif ($files = site_files()) { $add('warn', 'Folder', 'This folder already has a website (' . h(implode(', ', array_slice($files, 0, 5))) . (count($files) > 5 ? ', ...' : '') . '). Client Harbor will take over this address.'); } else { $add('ok', 'Folder', h(home_relative(__DIR__))); } try { account_domains(); $add('ok', 'cPanel API', api_mode() === 'cli' ? '' : 'Using your API token.'); } catch (InstallError $e) { if (!empty($GLOBALS['api_token'])) { $add('fail', 'cPanel API', 'Your API token was not accepted: ' . h($e->getMessage())); } else { $add('token', 'cPanel API', 'The installer needs a cPanel API token to create the database. ' . 'In cPanel, open Manage API Tokens, create one, and paste it below.'); } } try { $python = find_python(); $add('ok', 'Python', 'Python ' . h($python['version'])); } catch (InstallError $e) { $add('fail', 'Python', h($e->getMessage())); } if (!glob(__DIR__ . '/clientharbor-*.zip') && !function_exists('curl_init') && !ini_get('allow_url_fopen') && !command_exists('curl')) { $add('fail', 'Downloading', "PHP can't download files here. Upload the Client Harbor zip next to the installer instead."); } if (!class_exists('ZipArchive') && !command_exists('unzip')) { $add('fail', 'Unpacking', "Neither PHP's zip extension nor the unzip command is available. Turn on the zip extension in Select PHP Version."); } if (!is_writable(__DIR__) || !is_writable(home_dir())) { $add('fail', 'Permissions', "The installer can't write to your home folder or this folder."); } return $checks; } function check_database_server(): void { try { $info = cpanel('Mysql', 'get_server_information'); } catch (InstallError $e) { return; // older cPanel: Django checks the version itself when it connects } $version = (string) ($info['version'] ?? ''); if (!preg_match('/^(\d+)\.(\d+)(?:\.(\d+))?/', $version, $m)) { return; } $mariadb = stripos($version . ' ' . ($info['type'] ?? ''), 'mariadb') !== false; $needed = $mariadb ? '10.5' : '8.0.11'; if (version_compare("$m[1].$m[2]." . ($m[3] ?? '0'), $needed, '<')) { throw new InstallError(sprintf('Client Harbor needs %s %s or later, and this server has %s. Ask your host to upgrade it.', $mariadb ? 'MariaDB' : 'MySQL', $needed, $version)); } } function find_python(): array { static $found = null; if ($found !== null) { return $found; } $candidates = []; foreach (['3.12', '3.13', '3.14'] as $v) { $short = str_replace('.', '', $v); $candidates[] = "/opt/alt/python$short/bin/python$v"; // CloudLinux's Python Selector $candidates[] = "/usr/local/bin/python$v"; $candidates[] = "/usr/bin/python$v"; $candidates[] = "python$v"; } $candidates[] = 'python3'; $check = escapeshellarg('import sys, venv, ensurepip; print("%d.%d" % sys.version_info[:2])'); $seen = []; foreach ($candidates as $python) { [$code, $out] = run(escapeshellarg($python) . " -c $check"); $version = trim($out); if ($code !== 0 || !preg_match('/^\d+\.\d+$/', $version)) { continue; } $seen[$version] = true; if (version_compare($version, MIN_PYTHON, '>=')) { if (strpos($python, '/') === false) { [, $where] = run('command -v ' . escapeshellarg($python)); $python = trim($where) ?: $python; } return $found = ['path' => $python, 'version' => $version]; } } throw new InstallError('Python ' . MIN_PYTHON . ' or later is needed but was not found' . ($seen ? ' (found ' . implode(', ', array_keys($seen)) . ')' : '') . '. Ask your host for Python ' . MIN_PYTHON . ' (CloudLinux hosts provide it through Setup Python App).'); } /** Files in this folder other than the installer and what cPanel puts in every site folder. */ function site_files(): array { $ignore = ['.', '..', basename(__FILE__), '.htaccess', 'cgi-bin', '.well-known', 'error_log', '.user.ini', 'php.ini']; $files = []; foreach (scandir(__DIR__) ?: [] as $f) { if (!in_array($f, $ignore, true) && !preg_match('/^clientharbor-.*\.zip$|\.before-clientharbor$/', $f)) { $files[] = $f; } } return $files; } // ── cPanel ───────────────────────────────────────────────────────────────────────────────────────────────── /** 'cli' when this account can run cPanel's uapi command, else 'token' (an API token over HTTPS). */ function api_mode(): string { static $mode = null; if ($mode === null) { $mode = 'token'; if (empty($GLOBALS['api_token']) && exec_available()) { [$code, $out] = run(CPANEL_BIN . 'uapi --output=json Variables get_user_information'); $json = json_decode(json_part($out), true); if ($code === 0 && !empty($json['result']['status'])) { $mode = 'cli'; } } } return $mode; } /** Call cPanel's API (UAPI, or API 2 for cron jobs) and return its data; InstallError with cPanel's message. */ function cpanel(string $module, string $function, array $args = [], int $version = 3) { if (api_mode() === 'cli') { $command = CPANEL_BIN . ($version === 3 ? 'uapi' : 'cpapi2') . ' --output=json ' . escapeshellarg($module) . ' ' . escapeshellarg($function); foreach ($args as $k => $v) { $command .= ' ' . escapeshellarg($k . '=' . rawurlencode((string) $v)); } [, $out] = run($command); $json = json_decode(json_part($out), true); $result = $version === 3 ? ($json['result'] ?? null) : ($json['cpanelresult'] ?? null); } else { $token = $GLOBALS['api_token'] ?? ''; if ($token === '') { throw new InstallError('A cPanel API token is needed.'); } $url = 'https://127.0.0.1:2083/' . ($version === 3 ? "execute/$module/$function?" . http_build_query($args) : 'json-api/cpanel?' . http_build_query(['cpanel_jsonapi_user' => account_user(), 'cpanel_jsonapi_apiversion' => 2, 'cpanel_jsonapi_module' => $module, 'cpanel_jsonapi_func' => $function] + $args)); // cPanel's own port on this server: its certificate is for the hostname, not 127.0.0.1 $json = json_decode(http_get($url, ['Authorization: cpanel ' . account_user() . ':' . $token], false), true); $result = $version === 3 ? $json : ($json['cpanelresult'] ?? null); } if (!is_array($result)) { throw new InstallError("cPanel didn't answer $module::$function."); } if ($version === 3 && empty($result['status'])) { throw new InstallError(implode(' ', (array) ($result['errors'] ?? ["$module::$function failed."]))); } if ($version === 2 && (!empty($result['error']) || empty($result['event']['result']))) { throw new InstallError((string) ($result['error'] ?? "$module::$function failed.")); } return $result['data'] ?? null; } /** The account's domains: ['roots' => main, addon and parked domains, 'all' => the roots and subdomains] */ function account_domains(): array { static $domains = null; if ($domains === null) { $d = cpanel('DomainInfo', 'list_domains'); $roots = array_merge([(string) $d['main_domain']], (array) ($d['addon_domains'] ?? []), (array) ($d['parked_domains'] ?? [])); $domains = ['roots' => $roots, 'all' => array_merge($roots, (array) ($d['sub_domains'] ?? []))]; } return $domains; } /** The account domain that host is, or is under ('' when none). */ function base_domain(string $host, array $domains): string { $best = ''; foreach ($domains['roots'] as $root) { if (($host === $root || substr($host, -strlen($root) - 1) === ".$root") && strlen($root) > strlen($best)) { $best = $root; } } return $best; } function database_host(): string { foreach (['/var/lib/mysql/mysql.sock', '/var/run/mysqld/mysqld.sock', '/tmp/mysql.sock'] as $socket) { if (@file_exists($socket)) { return $socket; // cPanel's database users are for "localhost", which a TCP connection may not match } } return 'localhost'; } // ── Helpers ──────────────────────────────────────────────────────────────────────────────────────────────── function exec_available(): bool { $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); return function_exists('proc_open') && !in_array('proc_open', $disabled, true); } /** Run a shell command; returns [exit code, its output (stdout and stderr)]. */ function run(string $command): array { $env = ['HOME' => home_dir(), 'USER' => account_user(), 'LANG' => 'C.UTF-8', 'PATH' => '/usr/local/bin:/usr/bin:/bin:' . rtrim(CPANEL_BIN, '/')]; $proc = @proc_open(['/bin/sh', '-c', "$command 2>&1"], [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w']], $pipes, home_dir(), $env); if (!is_resource($proc)) { return [127, 'Could not run the command.']; } $out = (string) stream_get_contents($pipes[1]); fclose($pipes[1]); return [proc_close($proc), trim($out)]; } function command_exists(string $name): bool { return exec_available() && run('command -v ' . escapeshellarg($name))[0] === 0; } function http_get(string $url, array $headers = [], bool $verify = true): string { if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => $verify, CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($body === false || $status >= 400) { throw new InstallError("Couldn't fetch $url: " . ($error ?: "HTTP $status")); } return (string) $body; } $context = stream_context_create(['http' => ['header' => implode("\r\n", $headers), 'timeout' => 60], 'ssl' => ['verify_peer' => $verify, 'verify_peer_name' => $verify]]); $body = @file_get_contents($url, false, $context); if ($body === false) { throw new InstallError("Couldn't fetch $url."); } return $body; } /** * Register this install with the license server, before anything is installed. Sends the address Client Harbor will * run at, this server's address and the address of whoever is running the installer. */ function activate_license(string $license, string $host, string $portal): void { [$status, $body] = http_post(LICENSE_URL, [ 'action' => 'activate', 'license' => $license, 'domain' => $host, 'portal' => $portal, 'server_ip' => (string) ($_SERVER['SERVER_ADDR'] ?? ''), 'installer_ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? ''), ]); $answer = json_decode($body, true); if (!is_array($answer) || !isset($answer['status'])) { throw new InstallError("The license server didn't answer (HTTP $status). Try again in a few minutes."); } if ($answer['status'] !== 'active') { throw new InstallError((string) ($answer['message'] ?? 'The license server refused this license.')); } } /** POST a form; returns [HTTP status, body] whatever the status (0 when the server can't be reached). */ function http_post(string $url, array $fields): array { $data = http_build_query($fields); if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $data, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => ['Accept: application/json']]); $body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [$status, $body === false ? '' : (string) $body]; } $context = stream_context_create(['http' => ['method' => 'POST', 'timeout' => 30, 'ignore_errors' => true, 'header' => "Content-Type: application/x-www-form-urlencoded\r\nAccept: application/json", 'content' => $data]]); $body = @file_get_contents($url, false, $context); $status = isset($http_response_header[0]) && preg_match('/\s(\d{3})\s?/', $http_response_header[0], $m) ? (int) $m[1] : 0; return [$status, $body === false ? '' : $body]; } function http_download(string $url, string $dest): void { if (function_exists('curl_init')) { $fp = fopen($dest, 'wb'); $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 240]); $ok = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); fclose($fp); if (!$ok || $status >= 400) { throw new InstallError("Couldn't download $url: " . ($error ?: "HTTP $status")); } return; } [$code, $out] = run('curl -fsSL -o ' . escapeshellarg($dest) . ' ' . escapeshellarg($url)); if ($code !== 0 && !@copy($url, $dest)) { throw new InstallError("Couldn't download $url: $out"); } } function home_dir(): string { static $home = null; if ($home === null) { $home = (string) getenv('HOME'); if (($home === '' || $home === '/') && function_exists('posix_getpwuid')) { $home = (string) (posix_getpwuid(posix_geteuid())['dir'] ?? ''); } if (($home === '' || $home === '/') && preg_match('#^(/home\d*/[^/]+)/#', __DIR__ . '/', $m)) { $home = $m[1]; } $home = rtrim($home, '/'); } return $home; } function account_user(): string { if (function_exists('posix_getpwuid')) { return (string) (posix_getpwuid(posix_geteuid())['name'] ?? get_current_user()); } return get_current_user(); } function home_relative(string $path): string { $home = home_dir() . '/'; return strpos($path, $home) === 0 ? substr($path, strlen($home)) : $path; } /** ~/clientharbor, or ~/clientharbor2... when another install is already there. */ function free_app_dir(): string { for ($i = 1; ; $i++) { $dir = home_dir() . '/clientharbor' . ($i > 1 ? $i : ''); if (!file_exists($dir) || (is_dir($dir) && count(scandir($dir) ?: []) <= 2)) { return $dir; } } } function state_dir(): string { $dir = home_dir() . '/.clientharbor-install'; if (!is_dir($dir)) { @mkdir($dir, 0700); } return $dir; } function load_state(): array { $file = state_dir() . '/state.json'; return is_file($file) ? (json_decode((string) file_get_contents($file), true) ?: []) : []; } function save_state(array $state): void { write_private(state_dir() . '/state.json', (string) json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } function write_private(string $path, string $content): void { $tmp = $path . '.tmp'; if (file_put_contents($tmp, $content) === false) { throw new InstallError("Can't write $path."); } chmod($tmp, 0600); rename($tmp, $path); } function installer_stamped(): bool { return strpos(LICENSE_URL, '{{') !== 0 && strpos(RELEASE_URL, '{{') !== 0; } function zip_version(string $path): string { return preg_match('/clientharbor-(.+)\.zip$/', $path, $m) ? $m[1] : ''; } function request_host(): string { return strtolower((string) preg_replace('/:\d+$/', '', (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''))); } function is_https(): bool { return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443 || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'; } function random_text(int $length): string { $chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $text = ''; for ($i = 0; $i < $length; $i++) { $text .= $chars[random_int(0, strlen($chars) - 1)]; } return $text; } function json_part(string $out): string { $start = strpos($out, '{'); return $start === false ? '' : substr($out, $start); } function tail(string $text, int $lines): string { return implode("\n", array_slice(preg_split('/\R/', rtrim($text)) ?: [], -$lines)); } function done(string $message): array { return ['status' => 'done', 'message' => $message]; } function json_out(array $data): void { header('Content-Type: application/json'); header('Cache-Control: no-store'); echo json_encode($data); exit; } function h($text): string { return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); } // ── The page ─────────────────────────────────────────────────────────────────────────────────────────────── function render_page(): void { header('Cache-Control: no-store'); header('X-Frame-Options: DENY'); $state = load_state(); $GLOBALS['api_token'] = $state['api_token'] ?? ''; $checks = preflight(); $blocked = (bool) array_filter($checks, function ($c) { return $c['level'] === 'fail'; }); $needs_token = (bool) array_filter($checks, function ($c) { return $c['level'] === 'token'; }); $host = request_host(); try { $base = base_domain($host, account_domains()) ?: $host; } catch (InstallError $e) { $base = (string) preg_replace('/^(www|crm)\./', '', $host); } ?>
Fix the items marked ✗, then reload this page.