provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost'; } private function shouldUseLocalExecution(HostingNode $node): bool { return $this->isLocal($node) && blank($node->ssh_private_key); } public function connect(HostingNode $node): ?SSH2 { $this->connectedNode = $node; if ($this->shouldUseLocalExecution($node)) { $this->isLocalNode = true; return null; } $this->isLocalNode = false; if ($this->ssh && $this->ssh->isConnected()) { return $this->ssh; } // For local nodes with SSH keys, connect to 127.0.0.1 $host = $this->isLocal($node) ? '127.0.0.1' : $node->ip_address; $this->ssh = new SSH2($host, (int) ($node->ssh_port ?: 22)); $this->ssh->setTimeout(300); // Load the private key properly for phpseclib3 $credential = $node->ssh_private_key; if ($this->looksLikeSshKey($credential)) { try { $credential = PublicKeyLoader::load($credential); } catch (\Throwable $e) { throw new \RuntimeException("Invalid SSH key for node {$node->hostname}: " . $e->getMessage()); } } if (!$this->ssh->login('root', $credential)) { throw new \RuntimeException("Failed to connect to hosting node: {$node->hostname}"); } return $this->ssh; } private function disconnectRemoteSession(): void { if ($this->ssh instanceof SSH2) { try { $this->ssh->disconnect(); } catch (\Throwable) { // Ignore teardown failures and force a clean reconnect path. } } $this->ssh = null; } private function looksLikeSshKey(string $value): bool { return str_contains($value, '-----BEGIN') || str_contains($value, 'PRIVATE KEY'); } private function exec(?SSH2 &$ssh, string $command): string { if ($this->isLocalNode) { return $this->runLocalProcess($command)['output']; } // Ensure connection is still alive. Some long administrative flows, like SSL issuance, // can leave phpseclib holding a stale channel between individual remote commands. if (!$ssh instanceof SSH2 || !$ssh->isConnected()) { if ($this->ssh instanceof SSH2 && $this->ssh->isConnected()) { $ssh = $this->ssh; } else { Log::warning('SharedNodeProvider detected a lost SSH connection; reconnecting.', [ 'node_id' => $this->connectedNode?->id, 'node_name' => $this->connectedNode?->name, 'command' => $command, ]); if (! $this->connectedNode) { throw new \RuntimeException('SSH connection lost'); } $this->disconnectRemoteSession(); $ssh = $this->connect($this->connectedNode); } } try { $output = $ssh->exec($command); } catch (\RuntimeException $e) { if (! str_contains($e->getMessage(), 'Please close the channel')) { throw $e; } Log::warning('SharedNodeProvider detected a stale SSH exec channel; reconnecting and retrying command.', [ 'node_id' => $this->connectedNode?->id, 'node_name' => $this->connectedNode?->name, 'command' => $command, ]); $this->disconnectRemoteSession(); if (! $this->connectedNode) { throw $e; } $ssh = $this->connect($this->connectedNode); $output = $ssh?->exec($command); } // Allow the SSH library a moment to fully settle channel bookkeeping // before the next exec on the shared connection. usleep(10000); return (string) $output; } private function getExitStatus(?SSH2 $ssh): int { if ($this->isLocalNode) { return $this->lastLocalExitCode; } return $ssh->getExitStatus() ?? 0; } private function runLocalProcess(string $command, int $timeout = 600): array { // Use forever() for long-running commands, with explicit timeout as fallback $result = Process::timeout($timeout)->idleTimeout($timeout)->run($command); $this->lastLocalExitCode = $result->exitCode(); return [ 'output' => $result->output() . $result->errorOutput(), 'exit_code' => $this->lastLocalExitCode, ]; } private function runLocalAccountCommand(string $username, string $command, int $timeout = 600): array { $helperResult = $this->runLocalUserHelper($username, $command, $timeout); if ($helperResult['exit_code'] === 0 || ! $this->isLocalHelperUnavailable($helperResult['output'])) { if ($this->isLocalUserSwitchFailure('sudo', $helperResult['output'])) { return [ 'output' => $this->localUserSwitchUnavailableMessage(), 'exit_code' => $helperResult['exit_code'], ]; } return $helperResult; } $sudoCommand = "sudo -n -u {$username} -- bash -lc " . escapeshellarg($command); $result = $this->runLocalProcess($sudoCommand, $timeout); if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('sudo', $result['output'])) { return $result; } $runUserCommand = "runuser -u {$username} -- bash -lc " . escapeshellarg($command); $result = $this->runLocalProcess($runUserCommand, $timeout); if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('runuser', $result['output'])) { return $result; } if ($this->isCurrentProcessUser($username)) { return $this->runLocalProcess($command, $timeout); } return [ 'output' => $this->localUserSwitchUnavailableMessage(), 'exit_code' => 1, ]; } private function localUserSwitchUnavailableMessage(): string { return 'Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.'; } private function localAdministrativeAccessUnavailableMessage(): string { return 'Local hosting administration requires passwordless sudo or a valid root SSH key on the hosting node.'; } private function isCurrentProcessUser(string $username): bool { if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) { return false; } $currentUser = posix_getpwuid(posix_geteuid()); return isset($currentUser['name']) && $currentUser['name'] === $username; } private function runLocalUserHelper(string $username, string $command, int $timeout = 600): array { $payload = base64_encode($command); $parts = ['sudo', '-n', self::LOCAL_USER_HELPER, $username, $payload]; return $this->runLocalProcess(collect($parts) ->map(fn (string $part): string => escapeshellarg($part)) ->implode(' '), $timeout); } private function runLocalAdminHelper(string $operation, array $arguments = []): array { $parts = ['sudo', '-n', self::LOCAL_ADMIN_HELPER, $operation]; foreach ($arguments as $argument) { $parts[] = $argument; } return $this->runLocalProcess(collect($parts) ->map(fn (string $part): string => escapeshellarg($part)) ->implode(' ')); } private function isLocalHelperUnavailable(string $output): bool { $normalized = Str::lower($output); foreach ([ 'no such file or directory', 'command not found', 'unable to execute', ] as $needle) { if (str_contains($normalized, $needle)) { return true; } } return false; } private function isCurrentProcessRoot(): bool { return function_exists('posix_geteuid') && posix_geteuid() === 0; } private function isLocalUserSwitchFailure(string $method, string $output): bool { $normalized = Str::lower($output); $needles = match ($method) { 'sudo' => [ 'sudo: a password is required', 'sudo: sorry, you must have a tty', 'sudo: command not found', 'not in the sudoers', 'not allowed to execute', 'unknown user', ], 'runuser' => [ 'runuser: may not be used by non-root users', 'runuser: failed to execute', 'runuser: user', 'permission denied', ], default => [], }; foreach ($needles as $needle) { if (str_contains($normalized, $needle)) { return true; } } return false; } private function shouldRetryLocalAdminCommandWithSudo(string $output): bool { $normalized = Str::lower($output); foreach ([ 'permission denied', 'operation not permitted', 'not owner', 'must be run as root', 'run this program as root', 'superuser', 'authentication is required', ] as $needle) { if (str_contains($normalized, $needle)) { return true; } } return false; } private function executeAdministrativeCommand(?SSH2 $ssh, string $command): array { if ($this->isLocalNode) { $result = $this->runLocalProcess($command); if ($result['exit_code'] !== 0 && $this->shouldRetryLocalAdminCommandWithSudo($result['output'])) { $sudoResult = $this->runLocalProcess("sudo -n bash -lc " . escapeshellarg($command)); if ($sudoResult['exit_code'] === 0) { return $sudoResult; } if ($this->isLocalUserSwitchFailure('sudo', $sudoResult['output'])) { $message = trim((string) $result['output']); $message = $message !== '' ? $message . PHP_EOL : ''; $message .= $this->localAdministrativeAccessUnavailableMessage(); return [ 'output' => $message, 'exit_code' => $sudoResult['exit_code'], ]; } return $sudoResult; } return $result; } $output = $this->exec($ssh, $command); return [ 'output' => $output, 'exit_code' => $this->getExitStatus($ssh), ]; } private function ensureLocalAdministrativeAccess(): void { if (! $this->isLocalNode || $this->isCurrentProcessRoot()) { return; } $result = $this->runLocalAdminHelper('self-check'); if ($result['exit_code'] === 0) { return; } if ($this->isLocalHelperUnavailable($result['output'])) { $result = $this->runLocalProcess('sudo -n true'); } if ($result['exit_code'] === 0) { return; } throw new \RuntimeException($this->localAdministrativeAccessUnavailableMessage()); } private function executeLocalAdminOperation(string $operation, array $arguments, callable $fallback): array { $result = $this->runLocalAdminHelper($operation, $arguments); if ($result['exit_code'] === 0 || ! $this->isLocalHelperUnavailable($result['output'])) { if ($this->isLocalUserSwitchFailure('sudo', $result['output'])) { return [ 'output' => $this->localAdministrativeAccessUnavailableMessage(), 'exit_code' => $result['exit_code'], ]; } return $result; } return $fallback(); } /** * Create a new hosting account on a node (used by new order flow) */ public function createAccountOnNode(HostingNode $node, array $config): array { $ssh = $this->connect($node); $username = $config['username']; $password = $config['password']; $domain = $config['domain']; $homeDir = "/home/{$username}"; $docRoot = "{$homeDir}/public_html"; // Create system user $commands = [ "useradd -m -d {$homeDir} -s /bin/bash {$username}", "echo '{$username}:{$password}' | chpasswd", "mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp {$homeDir}/backups", "chown -R {$username}:{$username} {$homeDir}", ]; foreach ($commands as $cmd) { $result = $this->exec($ssh, $cmd); if ($this->getExitStatus($ssh) !== 0) { Log::error("Failed to execute: {$cmd}", ['output' => $result]); } } // Create PHP-FPM pool $this->createPhpFpmPoolForUser($ssh, $username, $config['php_version'] ?? '8.4'); $this->hardenAccountFilesystemPaths($ssh, $username, [$docRoot]); // Create nginx config for domain $this->createNginxConfigForDomain($ssh, $domain, $docRoot, $username); // Set disk quota if specified if (!empty($config['disk_quota_mb'])) { $quotaKb = $config['disk_quota_mb'] * 1024; $this->exec($ssh, "setquota -u {$username} {$quotaKb} {$quotaKb} 0 0 /home 2>/dev/null || true"); } return [ 'username' => $username, 'password' => $password, 'home_directory' => $homeDir, 'document_root' => $docRoot, 'domain' => $domain, ]; } /** * Install WordPress on an account */ public function installWordPress(HostingNode $node, string $username, string $domain, array $options = []): array { $ssh = $this->connect($node); $docRoot = $options['document_root'] ?? "/home/{$username}/public_html"; $dbName = substr(preg_replace('/[^a-z0-9]/', '', strtolower($username)), 0, 12) . '_wp'; $dbUser = $dbName; $dbPass = Str::password(16, true, true, false, false); $adminEmail = $options['admin_email'] ?? 'admin@' . $domain; $adminUser = $options['admin_user'] ?? 'admin'; $adminPassword = $options['admin_password'] ?? Str::password(12, true, true, false, false); $siteTitle = $options['site_title'] ?? $domain; // Create database $this->exec($ssh, "mysql -e \"CREATE DATABASE IF NOT EXISTS \\`{$dbName}\\`\""); $this->exec($ssh, "mysql -e \"CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'\""); $this->exec($ssh, "mysql -e \"GRANT ALL PRIVILEGES ON \\`{$dbName}\\`.* TO '{$dbUser}'@'localhost'\""); $this->exec($ssh, "mysql -e \"FLUSH PRIVILEGES\""); // Download WordPress $commands = [ "cd {$docRoot} && rm -rf * 2>/dev/null || true", "cd {$docRoot} && curl -sO https://wordpress.org/latest.tar.gz", "cd {$docRoot} && tar -xzf latest.tar.gz --strip-components=1", "cd {$docRoot} && rm latest.tar.gz", "cd {$docRoot} && cp wp-config-sample.php wp-config.php", "cd {$docRoot} && sed -i \"s/database_name_here/{$dbName}/g\" wp-config.php", "cd {$docRoot} && sed -i \"s/username_here/{$dbUser}/g\" wp-config.php", "cd {$docRoot} && sed -i \"s/password_here/{$dbPass}/g\" wp-config.php", "chown -R {$username}:{$username} {$docRoot}", ]; foreach ($commands as $cmd) { $this->exec($ssh, $cmd); } // Generate unique security keys using WP-CLI (safer than manual sed/echo) $this->exec($ssh, "cd {$docRoot} && runuser -u {$username} -- wp config shuffle-salts 2>&1"); // Run wp core install to create admin user $escapedTitle = addslashes($siteTitle); $escapedAdminUser = escapeshellarg($adminUser); $escapedAdminPass = escapeshellarg($adminPassword); $escapedAdminEmail = escapeshellarg($adminEmail); $wpInstallCmd = "cd {$docRoot} && runuser -u {$username} -- wp core install --url='https://{$domain}' --title='{$escapedTitle}' --admin_user={$escapedAdminUser} --admin_password={$escapedAdminPass} --admin_email={$escapedAdminEmail} --skip-email 2>&1"; $this->exec($ssh, $wpInstallCmd); return [ 'db_name' => $dbName, 'db_user' => $dbUser, 'db_password' => $dbPass, 'admin_user' => $adminUser, 'admin_password' => $adminPassword, 'admin_email' => $adminEmail, ]; } private function createPhpFpmPoolForUser(?SSH2 $ssh, string $username, string $phpVersion = '8.4'): void { foreach ($this->discoverPhpFpmPoolVersions($ssh, $username) as $existingVersion) { if ($existingVersion === $phpVersion) { continue; } $poolPath = "/etc/php/{$existingVersion}/fpm/pool.d/{$username}.conf"; $this->exec($ssh, 'rm -f '.escapeshellarg($poolPath)); $this->exec($ssh, "systemctl reload php{$existingVersion}-fpm 2>/dev/null || true"); } $poolConfig = $this->buildPhpFpmPoolConfig($username, [ 'memory_limit_mb' => 256, 'process_limit' => 5, 'uploads_restricted' => false, ]); $poolPath = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; $escapedConfig = str_replace("'", "'\\'", $poolConfig); $this->exec($ssh, "echo '{$escapedConfig}' > ".escapeshellarg($poolPath)); $this->exec($ssh, "php-fpm{$phpVersion} -t 2>&1 || php{$phpVersion}-fpm -t 2>&1"); $this->exec($ssh, "systemctl reload php{$phpVersion}-fpm"); } private function createNginxConfigForDomain(?SSH2 $ssh, string $domain, string $docRoot, string $username): void { $site = new HostedSite([ 'domain' => $domain, 'document_root' => $docRoot, 'php_version' => '8.4', 'ssl_enabled' => false, 'ssl_status' => null, ]); $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ 'ssl_enabled' => false, ]); } public function createAccount(HostingAccount $account): array { $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned to account'); } $ssh = $this->connect($node); $username = $account->username; $homeDir = "/home/{$username}"; $password = Str::password(16); // Create system user $commands = [ "useradd -m -d {$homeDir} -s /bin/bash {$username}", "echo '{$username}:{$password}' | chpasswd", "mkdir -p {$homeDir}/public_html {$homeDir}/logs {$homeDir}/tmp {$homeDir}/backups", "chown -R {$username}:{$username} {$homeDir}", ]; foreach ($commands as $cmd) { $result = $this->exec($ssh, $cmd); if ($this->getExitStatus($ssh) !== 0) { Log::error("Failed to execute: {$cmd}", ['output' => $result]); } } // Create PHP-FPM pool $this->createPhpFpmPool($ssh, $account); $this->hardenAccountFilesystem($account); return [ 'username' => $username, 'password' => $password, 'home_directory' => $homeDir, 'document_root' => "{$homeDir}/public_html", ]; } public function suspendAccount(HostingAccount $account, string $reason): bool { $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; // Disable user login and stop PHP-FPM pool $this->exec($ssh, "usermod -L {$username}"); $this->exec($ssh, "systemctl stop php-fpm-{$username} 2>/dev/null || true"); // Disable nginx configs for all sites foreach ($account->sites as $site) { $this->exec($ssh, "rm -f /etc/nginx/sites-enabled/{$site->domain}.conf"); } $this->exec($ssh, "nginx -t && systemctl reload nginx"); return true; } public function serveExpiredPage(HostingAccount $account): bool { $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; // Stop PHP-FPM but keep user login enabled so they can access files via panel $this->exec($ssh, "systemctl stop php-fpm-{$username} 2>/dev/null || true"); // Create a simple expired page in the user's home directory $expiredHtml = <<<'HTML' Hosting Account Expired

Hosting Account Expired

This hosting account has expired. The account owner can renew it from their dashboard to bring the site back online.

HTML; $expiredPageDir = "/home/{$username}/expired_page"; $this->exec($ssh, "mkdir -p {$expiredPageDir}"); $encodedHtml = base64_encode($expiredHtml); $this->exec($ssh, "echo '{$encodedHtml}' | base64 -d > {$expiredPageDir}/index.html"); $this->exec($ssh, "chown -R {$username}:{$username} {$expiredPageDir}"); // Replace each site's nginx config with one that serves the expired page foreach ($account->sites as $site) { $domain = $site->domain; $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; $certCheck = $this->exec($ssh, "test -f {$certPath} && test -f {$keyPath} && echo 'exists'"); $sslExists = trim($certCheck) === 'exists'; if ($sslExists) { $expiredConfig = <<exec($ssh, "echo '{$escapedConfig}' > /etc/nginx/sites-available/{$domain}.conf"); $this->exec($ssh, "ln -sf /etc/nginx/sites-available/{$domain}.conf /etc/nginx/sites-enabled/"); } $this->exec($ssh, "nginx -t && systemctl reload nginx"); return true; } public function unsuspendAccount(HostingAccount $account): bool { $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; // Re-enable user and PHP-FPM $this->exec($ssh, "usermod -U {$username}"); $this->exec($ssh, "systemctl start php-fpm-{$username} 2>/dev/null || true"); // Re-enable nginx configs foreach ($account->sites as $site) { $this->exec($ssh, "ln -sf /etc/nginx/sites-available/{$site->domain}.conf /etc/nginx/sites-enabled/"); } $this->exec($ssh, "nginx -t && systemctl reload nginx"); return true; } public function terminateAccount(HostingAccount $account): bool { $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; // Remove nginx configs foreach ($account->sites as $site) { $this->exec($ssh, "rm -f /etc/nginx/sites-enabled/{$site->domain}.conf"); $this->exec($ssh, "rm -f /etc/nginx/sites-available/{$site->domain}.conf"); } // Stop and remove PHP-FPM pool $this->exec($ssh, "systemctl stop php-fpm-{$username} 2>/dev/null || true"); $this->exec($ssh, "rm -f /etc/php/*/fpm/pool.d/{$username}.conf"); // Remove databases foreach ($account->databases as $db) { $this->exec($ssh, "mysql -e \"DROP DATABASE IF EXISTS \\`{$db->name}\\`\""); $this->exec($ssh, "mysql -e \"DROP USER IF EXISTS '{$db->username}'@'localhost'\""); } // Remove user and home directory $this->exec($ssh, "userdel -r {$username} 2>/dev/null || true"); $this->exec($ssh, "nginx -t && systemctl reload nginx"); $this->exec($ssh, "systemctl reload php*-fpm"); return true; } public function changePassword(HostingAccount $account, string $newPassword): bool { $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $this->exec($ssh, "echo '{$account->username}:{$newPassword}' | chpasswd"); return $this->getExitStatus($ssh) === 0; } public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void { $node = $account->node; if (! $node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $username = $account->username; $authorizedKeysPath = $this->sharedAccountAuthorizedKeysPath($username); $tmpPath = "{$authorizedKeysPath}.tmp"; $keyLine = trim($publicKey); if (! str_contains($keyLine, $marker)) { $keyLine .= ' '.$marker; } $command = implode("\n", [ 'set -e', 'mkdir -p '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR), 'touch '.escapeshellarg($authorizedKeysPath), 'grep -v -F '.escapeshellarg($marker).' '.escapeshellarg($authorizedKeysPath).' > '.escapeshellarg($tmpPath).' || true', 'mv '.escapeshellarg($tmpPath).' '.escapeshellarg($authorizedKeysPath), 'printf "%s\n" '.escapeshellarg($keyLine).' >> '.escapeshellarg($authorizedKeysPath), 'chown root:root '.escapeshellarg($authorizedKeysPath), 'chmod 600 '.escapeshellarg($authorizedKeysPath), ]); $this->ensureAdministrativeCommandSucceeded( $ssh, 'bash -lc '.escapeshellarg($command), 'Failed to install developer SSH key.' ); $this->hardenAccountFilesystem($account); } public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void { $node = $account->node; if (! $node) { return; } $ssh = $this->connect($node); $username = $account->username; $authorizedKeysPath = $this->sharedAccountAuthorizedKeysPath($username); $tmpPath = "{$authorizedKeysPath}.tmp"; $command = implode("\n", [ 'set -e', 'if [ ! -f '.escapeshellarg($authorizedKeysPath).' ]; then exit 0; fi', 'grep -v -F '.escapeshellarg($marker).' '.escapeshellarg($authorizedKeysPath).' > '.escapeshellarg($tmpPath).' || true', 'mv '.escapeshellarg($tmpPath).' '.escapeshellarg($authorizedKeysPath), 'chown root:root '.escapeshellarg($authorizedKeysPath), 'chmod 600 '.escapeshellarg($authorizedKeysPath), ]); $this->ensureAdministrativeCommandSucceeded( $ssh, 'bash -lc '.escapeshellarg($command), 'Failed to revoke developer SSH key.' ); } public function addSite(HostedSite $site): array { $account = $site->account; $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $username = $account->username; $domain = $site->domain; $docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}"; $this->ensureSiteDocumentRoot($ssh, $username, $docRoot); $this->hardenAccountFilesystem($account, [$docRoot]); $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), ]); return [ 'document_root' => $docRoot, 'domain' => $domain, ]; } private function ensureSiteDocumentRoot(mixed $ssh, string $username, string $docRoot): void { $quotedDocRoot = escapeshellarg($docRoot); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"), "Failed to create document root at {$docRoot}" ); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"], "chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}" ), "Failed to set document root ownership at {$docRoot}" ); } public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void { $node = $account->node; if (! $node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $documentRoots = collect($account->sites ?? []) ->pluck('document_root') ->merge($additionalDocumentRoots) ->filter() ->values() ->all(); $this->ensureSharedSftpIsolationPolicy($ssh, $account->username); $this->hardenAccountFilesystemPaths($ssh, $account->username, $documentRoots); } public function removeSite(HostedSite $site): bool { $account = $site->account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $domain = $site->domain; $configPath = "/etc/nginx/sites-available/{$domain}.conf"; $enabledPath = "/etc/nginx/sites-enabled/{$domain}.conf"; $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ['rm -f ' . escapeshellarg($enabledPath) . ' ' . escapeshellarg($configPath)], 'rm -f ' . escapeshellarg($enabledPath) . ' ' . escapeshellarg($configPath) ), "Failed to remove nginx configuration for {$domain}" ); $testResult = $this->runLocalAdminOperationOrRemote($ssh, 'nginx-test', [], 'nginx -t 2>&1'); if ($testResult['exit_code'] !== 0 || strpos((string) ($testResult['output'] ?? ''), 'successful') === false) { Log::error("Nginx config test failed after site removal for {$domain}", ['output' => $testResult]); throw new \RuntimeException("Invalid nginx configuration after removing {$domain}"); } $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote($ssh, 'reload-nginx', [], 'systemctl reload nginx'), "Failed to reload nginx after removing {$domain}" ); $this->runMagentoOpenSearchStateCheck($ssh); return true; } public function requestLetsEncryptCertificate(HostedSite $site): bool { $account = $site->account; $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $this->ensureLocalAdministrativeAccess(); $domain = $site->domain; $email = $account->user?->email ?: 'admin@' . $domain; $docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}"; $this->ensureSiteDocumentRoot($ssh, $account->username, $docRoot); $hadCertificate = $this->siteHasCertificate($ssh, $domain); Log::info("SSL: Starting certificate request", [ 'domain' => $domain, 'docRoot' => $docRoot, 'had_certificate' => $hadCertificate, ]); if (! $hadCertificate) { $this->applyManagedSiteConfig($ssh, $site, $docRoot, $account->username, [ 'ssl_enabled' => false, ]); Log::info("SSL: Applied HTTP-only config for ACME challenge", ['domain' => $domain]); } else { Log::info("SSL: Existing certificate detected, keeping SSL nginx config during certificate request", [ 'domain' => $domain, ]); } // Build domain list — include www only for apex sites when it exists. // Subdomains like data.example.com must not request www.data.example.com // (NXDOMAIN), or the whole certificate fails. $domainArgs = ' -d ' . escapeshellarg($domain); $wwwDomain = "www.{$domain}"; $domainModel = $site->domain_id ? $site->domain()->first() : null; $dnsIsManaged = $domainModel && ( (string) $domainModel->dns_mode === 'managed' || (method_exists($domainModel, 'usesManagedDns') && $domainModel->usesManagedDns()) ); $isSubdomainSite = ($site->type === 'subdomain') || str_starts_with(strtolower((string) $site->type), 'sub'); if ($isSubdomainSite) { Log::info('SSL: Skipping www alias for subdomain site', ['domain' => $domain]); } elseif ($dnsIsManaged) { $domainArgs .= ' -d ' . escapeshellarg($wwwDomain); Log::info('SSL: Including www subdomain (managed DNS)', ['domain' => $domain, 'www' => $wwwDomain]); } elseif ($this->domainResolvesToServer($wwwDomain)) { $domainArgs .= ' -d ' . escapeshellarg($wwwDomain); Log::info('SSL: Including www subdomain (resolves)', ['domain' => $domain, 'www' => $wwwDomain]); } else { Log::info('SSL: Skipping www subdomain (manual DNS, does not resolve)', ['domain' => $domain, 'www' => $wwwDomain]); } $result = $this->runLocalAdminOperationOrRemote( $ssh, 'certbot-webroot', [$email, $domain, $docRoot], "certbot certonly --webroot --non-interactive --agree-tos --no-eff-email -m " . escapeshellarg($email) . ' -w ' . escapeshellarg($docRoot) . $domainArgs . ' 2>&1' ); if ($result['exit_code'] !== 0) { Log::error("SSL: Certbot failed", ['domain' => $domain, 'output' => $result['output']]); throw new \RuntimeException(trim((string) $result['output']) ?: "Failed to issue SSL certificate for {$domain}"); } Log::info("SSL: Certbot succeeded, applying SSL nginx config", ['domain' => $domain]); $this->applyManagedSiteConfig($ssh, $site, $docRoot, $account->username, [ 'ssl_enabled' => true, ]); Log::info("SSL: Certificate provisioning complete", ['domain' => $domain]); return true; } /** * @return array{success: bool, output: string, exit_code: int} */ public function renewCertificatesOnNode(HostingNode $node, bool $force = false): array { $ssh = $this->connect($node); $this->ensureLocalAdministrativeAccess(); $remoteCommand = 'certbot renew --quiet --no-random-sleep-on-renew'; if ($force) { $remoteCommand .= ' --force-renewal'; } $remoteCommand .= ' 2>&1'; $result = $this->runLocalAdminOperationOrRemote( $ssh, 'certbot-renew', [], $remoteCommand ); $exitCode = (int) ($result['exit_code'] ?? 1); $output = trim((string) ($result['output'] ?? '')); if ($exitCode !== 0) { Log::warning('SSL: certbot renew completed with errors on node', [ 'node_id' => $node->id, 'hostname' => $node->hostname, 'output' => $output, 'exit_code' => $exitCode, ]); } else { Log::info('SSL: certbot renew completed on node', [ 'node_id' => $node->id, 'hostname' => $node->hostname, ]); } $this->reloadNginxOnNode($ssh); return [ 'success' => $exitCode === 0, 'output' => $output, 'exit_code' => $exitCode, ]; } public function readCertificateExpiryOnNode(HostingNode $node, string $domain): ?CarbonInterface { $ssh = $this->connect($node); $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; $command = sprintf( 'openssl x509 -in %s -noout -enddate 2>/dev/null', escapeshellarg($certPath) ); $result = $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', [$command], $command ); if (($result['exit_code'] ?? 1) !== 0) { return null; } return (new SslCertificateExpiryParser())->parseOpenSslEndDate((string) ($result['output'] ?? '')); } private function reloadNginxOnNode(?SSH2 $ssh): void { $testResult = $this->runLocalAdminOperationOrRemote($ssh, 'nginx-test', [], 'nginx -t 2>&1'); if (($testResult['exit_code'] ?? 1) !== 0) { Log::warning('SSL: nginx config test failed after renew', [ 'output' => $testResult['output'] ?? null, ]); return; } $this->runLocalAdminOperationOrRemote($ssh, 'reload-nginx', [], 'systemctl reload nginx'); } private function domainResolvesToServer(string $hostname): bool { if (! function_exists('dns_get_record')) { return false; } $records = @dns_get_record($hostname, DNS_A); if (! is_array($records) || $records === []) { $cname = @dns_get_record($hostname, DNS_CNAME); return is_array($cname) && $cname !== []; } return true; } public function createDatabase(HostedDatabase $database, string $password): array { $account = $database->account; $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $dbName = $database->name; $dbUser = $database->username; $commands = [ "CREATE DATABASE IF NOT EXISTS `{$dbName}`", "CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$password}'", "GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'localhost'", "FLUSH PRIVILEGES", ]; foreach ($commands as $sql) { $cmd = "mysql -e " . escapeshellarg($sql); if ($this->isLocalNode) { // Local fallback: needs admin privileges for MySQL $result = $this->executeAdministrativeCommand($ssh, $cmd); } else { // SSH as root: mysql auth_socket works automatically $output = $this->exec($ssh, $cmd); $result = [ 'output' => $output, 'exit_code' => $this->getExitStatus($ssh), ]; } if ($result['exit_code'] !== 0) { throw new \RuntimeException("Database command failed: {$sql}. Output: " . $result['output']); } } return [ 'name' => $dbName, 'username' => $dbUser, 'host' => 'localhost', ]; } public function deleteDatabase(HostedDatabase $database): bool { $account = $database->account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $this->exec($ssh, "mysql -e \"DROP DATABASE IF EXISTS \\`{$database->name}\\`\""); $this->exec($ssh, "mysql -e \"DROP USER IF EXISTS '{$database->username}'@'localhost'\""); return true; } public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool { $account = $database->account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $this->exec($ssh, "mysql -e \"ALTER USER '{$database->username}'@'localhost' IDENTIFIED BY '{$newPassword}'\""); return $this->getExitStatus($ssh) === 0; } public function changeAccountPhpVersion(HostingAccount $account, string $version): bool { $node = $account->node; if (! $node) { return false; } $account->loadMissing('sites'); $ssh = $this->connect($node); $currentVersion = $account->php_version ?? '8.4'; if ($currentVersion !== $version) { $this->migratePhpFpmPoolToVersion($ssh, $account, $version, $currentVersion); } else { $this->ensureSinglePhpFpmPool($ssh, $account, $version); } foreach ($account->sites as $site) { try { $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $account->username, [ 'php_version' => $version, 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), ]); } catch (\Throwable $e) { Log::error("Nginx config test failed after PHP version change for {$site->domain}", [ 'error' => $e->getMessage(), ]); return false; } } return true; } public function findPhpFpmPoolVersions(HostingAccount $account): array { $node = $account->node; if (! $node) { return []; } $ssh = $this->connect($node); return $this->discoverPhpFpmPoolVersions($ssh, $account->username); } public function setPhpVersion(HostedSite $site, string $version): bool { $account = $site->account; $node = $account->node; if (! $node) { return false; } $ssh = $this->connect($node); $username = $account->username; $oldVersion = $site->php_version ?? $account->php_version ?? '8.4'; if ($oldVersion !== $version) { $this->migratePhpFpmPoolToVersion($ssh, $account, $version, $oldVersion); } try { $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [ 'php_version' => $version, 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), ]); return true; } catch (\Throwable $e) { Log::error("Nginx config test failed after PHP version change for {$site->domain}", [ 'error' => $e->getMessage(), ]); return false; } } private function migratePhpFpmPoolToVersion(?SSH2 $ssh, HostingAccount $account, string $newVersion, string $oldVersion): void { $username = $account->username; $this->logDuplicatePhpFpmPools($ssh, $username, $account); $removedVersions = $this->removePhpFpmPoolsForAccount($ssh, $username); foreach (array_unique(array_merge($removedVersions, [$oldVersion])) as $version) { if ($version === $newVersion) { continue; } $this->reloadPhpFpmService($ssh, $version, required: false); } $this->writePhpFpmPoolConfig($ssh, $account, $newVersion); if (! $this->reloadPhpFpmService($ssh, $newVersion)) { throw new \RuntimeException("PHP-FPM {$newVersion} failed configuration validation for {$username}"); } } private function ensureSinglePhpFpmPool(?SSH2 $ssh, HostingAccount $account, string $targetVersion): void { $username = $account->username; $existingVersions = $this->discoverPhpFpmPoolVersions($ssh, $username); if (count($existingVersions) > 1 || (count($existingVersions) === 1 && $existingVersions[0] !== $targetVersion)) { $this->migratePhpFpmPoolToVersion($ssh, $account, $targetVersion, $existingVersions[0] ?? $targetVersion); return; } if ($existingVersions === []) { $this->writePhpFpmPoolConfig($ssh, $account, $targetVersion); $this->reloadPhpFpmService($ssh, $targetVersion); } } /** * @return list */ private function discoverPhpFpmPoolVersions(?SSH2 $ssh, string $username): array { $result = $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["find /etc/php -path '*/fpm/pool.d/{$username}.conf' -type f 2>/dev/null | sort"], "find /etc/php -path '*/fpm/pool.d/{$username}.conf' -type f 2>/dev/null | sort" ); $versions = []; foreach (preg_split('/\R/', trim((string) ($result['output'] ?? ''))) ?: [] as $path) { if ($path === '') { continue; } if (preg_match('#/etc/php/(\d+\.\d+)/fpm/pool\.d/#', $path, $matches) === 1) { $versions[] = $matches[1]; } } return array_values(array_unique($versions)); } /** * @return list PHP versions that had a pool file removed. */ private function removePhpFpmPoolsForAccount(?SSH2 $ssh, string $username): array { $removedVersions = []; foreach ($this->discoverPhpFpmPoolVersions($ssh, $username) as $version) { $poolPath = "/etc/php/{$version}/fpm/pool.d/{$username}.conf"; $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["rm -f {$poolPath}"], 'rm -f '.escapeshellarg($poolPath) ); $removedVersions[] = $version; } return $removedVersions; } private function logDuplicatePhpFpmPools(?SSH2 $ssh, string $username, HostingAccount $account): void { $versions = $this->discoverPhpFpmPoolVersions($ssh, $username); if (count($versions) <= 1) { return; } Log::warning('Duplicate PHP-FPM pool configs detected for hosting account', [ 'account_id' => $account->id, 'username' => $username, 'php_versions' => $versions, 'socket' => $this->phpFpmSocketPath($username), ]); } private function phpFpmSocketPath(string $username): string { return "/run/php/php-fpm-{$username}.sock"; } private function testPhpFpmConfig(?SSH2 $ssh, string $phpVersion): bool { $command = "php-fpm{$phpVersion} -t 2>&1 || php{$phpVersion}-fpm -t 2>&1"; $result = $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', [$command], $command); $output = strtolower((string) ($result['output'] ?? '')); return $result['exit_code'] === 0 && (str_contains($output, 'test is successful') || str_contains($output, 'configuration file')); } private function reloadPhpFpmService(?SSH2 $ssh, string $phpVersion, bool $required = true): bool { if (! $this->testPhpFpmConfig($ssh, $phpVersion)) { Log::error('PHP-FPM configuration test failed', [ 'php_version' => $phpVersion, ]); if ($required) { return false; } return true; } $command = "systemctl reload php{$phpVersion}-fpm 2>/dev/null || systemctl restart php{$phpVersion}-fpm"; $result = $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', [$command], $command); if ($result['exit_code'] !== 0 && $required) { Log::error('PHP-FPM service reload failed', [ 'php_version' => $phpVersion, 'output' => $result['output'] ?? null, ]); return false; } return true; } private function writePhpFpmPoolConfig(?SSH2 $ssh, HostingAccount $account, string $phpVersion, array $options = []): void { $username = $account->username; $poolConfig = $this->buildPhpFpmPoolConfig($username, array_merge([ 'memory_limit_mb' => $account->memory_limit_mb ?: 256, 'process_limit' => $account->process_limit ?: 5, 'uploads_restricted' => $account->uploadsAreRestricted(), 'process_priority' => $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED ? 10 : 0, 'max_execution_time' => $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED ? 120 : 300, 'max_requests' => $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED ? 250 : 500, ], $options)); $poolPath = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; $this->runLocalAdminOperationOrRemote( $ssh, 'write-file', [$poolPath, base64_encode($poolConfig)], 'echo '.escapeshellarg($poolConfig).' > '.escapeshellarg($poolPath) ); } private function createPhpFpmPoolForVersion(?SSH2 $ssh, HostingAccount $account, string $phpVersion): void { $this->writePhpFpmPoolConfig($ssh, $account, $phpVersion); $this->reloadPhpFpmService($ssh, $phpVersion); } public function applyAccountResourceProfile(HostingAccount $account, bool $throttled = false): bool { $node = $account->node; if (! $node) { return false; } $ssh = $this->connect($node); $phpVersion = $account->php_version ?? '8.4'; $username = $account->username; $this->logDuplicatePhpFpmPools($ssh, $username, $account); foreach ($this->removePhpFpmPoolsForAccount($ssh, $username) as $removedVersion) { if ($removedVersion !== $phpVersion) { $this->reloadPhpFpmService($ssh, $removedVersion, required: false); } } $memoryLimit = (int) ($account->memory_limit_mb ?: 256); $processLimit = (int) ($account->process_limit ?: 5); if ($throttled) { $memoryLimit = max(128, (int) floor($memoryLimit * 0.5)); $processLimit = max(1, min($processLimit, 2)); } $limitsPath = "/etc/security/limits.d/ladill-{$username}.conf"; $limitsConfig = $this->buildUserLimitsConfig($username, max($processLimit, 1)); $this->writePhpFpmPoolConfig($ssh, $account, $phpVersion, [ 'memory_limit_mb' => $memoryLimit, 'process_limit' => $processLimit, 'uploads_restricted' => $account->uploadsAreRestricted(), 'process_priority' => $throttled ? 10 : 0, 'max_execution_time' => $throttled ? 120 : 300, 'max_requests' => $throttled ? 250 : 500, ]); $limitsResult = $this->runLocalAdminOperationOrRemote( $ssh, 'write-file', [$limitsPath, base64_encode($limitsConfig)], "echo " . escapeshellarg($limitsConfig) . " > " . escapeshellarg($limitsPath) ); return $limitsResult['exit_code'] === 0 && $this->reloadPhpFpmService($ssh, $phpVersion); } public function syncAccountPackage(HostingAccount $account): bool { $node = $account->node; if (! $node) { return false; } $ssh = $this->connect($node); $quotaKb = max((int) $account->requestedDiskGb(), 0) * 1024 * 1024; $quotaCommand = $quotaKb > 0 ? "setquota -u {$account->username} {$quotaKb} {$quotaKb} 0 0 /home" : "setquota -u {$account->username} 0 0 0 0 /home"; $quotaResult = $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', [$quotaCommand], $quotaCommand ); $profileApplied = $this->applyAccountResourceProfile($account, $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED); return $profileApplied && $quotaResult['exit_code'] === 0; } private function buildPhpFpmPoolConfig(string $username, array $options = []): string { $memoryLimitMb = max((int) ($options['memory_limit_mb'] ?? 256), 64); $processLimit = max((int) ($options['process_limit'] ?? 5), 1); $processPriority = (int) ($options['process_priority'] ?? 0); $maxExecutionTime = max((int) ($options['max_execution_time'] ?? 300), 30); $maxRequests = max((int) ($options['max_requests'] ?? 500), 50); $uploadsRestricted = (bool) ($options['uploads_restricted'] ?? false); $uploadLimitMb = $uploadsRestricted ? 1 : 64; $postLimitMb = $uploadsRestricted ? 1 : 64; $socketPath = $this->phpFpmSocketPath($username); return <<account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; $domain = $site->domain; try { $this->applyManagedSiteConfig($ssh, $site, $documentRoot, $username, [ 'php_version' => $site->php_version ?? '8.4', 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), ]); $site->update(['document_root' => $documentRoot]); if ($site->ssl_enabled && ! $this->siteHasCertificate($ssh, $domain)) { try { $this->requestLetsEncryptCertificate($site->fresh()); } catch (\Exception $e) { Log::warning("Failed to re-request SSL after document root update for {$domain}: " . $e->getMessage()); } } return true; } catch (\Throwable $e) { Log::error("Nginx config test failed after document root update for {$domain}", [ 'error' => $e->getMessage(), ]); return false; } } /** * Configure nginx as a reverse proxy for Node.js or Python apps */ public function configureNginxReverseProxy(HostedSite $site, int $port, string $appType = 'nodejs'): bool { $account = $site->account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; $domain = $site->domain; $sslExists = $this->siteHasCertificate($ssh, $domain); $site->forceFill([ 'installed_app' => $site->installed_app ?: $appType, 'app_config' => array_merge((array) ($site->app_config ?? []), [ 'runtime_port' => $port, 'runtime_type' => $appType, ]), ])->save(); try { $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [ 'ssl_enabled' => $sslExists, 'proxy_port' => $port, 'app_type' => $appType, ]); if ($site->ssl_enabled && ! $sslExists) { try { $this->requestLetsEncryptCertificate($site); return $this->configureNginxReverseProxy($site, $port, $appType); } catch (\Exception $e) { Log::warning("Failed to request SSL after proxy config for {$domain}: " . $e->getMessage()); } } return true; } catch (\Throwable $e) { Log::error("Nginx config test failed after proxy config for {$domain}", [ 'error' => $e->getMessage(), ]); return false; } } /** * Create and start a systemd service for a Node.js or Python app */ public function createAppService(HostedSite $site, string $appPath, string $appType, int $port): bool { $account = $site->account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; $domain = $site->domain; $serviceName = "app-{$username}-" . preg_replace('/[^a-z0-9]/', '-', strtolower($domain)); if ($appType === 'nodejs') { $execStart = "/usr/bin/node {$appPath}/app.js"; $environment = "PORT={$port}"; } else { $execStart = "{$appPath}/venv/bin/python {$appPath}/app.py"; $environment = "PORT={$port}"; } $serviceContent = <<runLocalAdminOperationOrRemote($ssh, 'write-file', [$servicePath, base64_encode($serviceContent)], "echo '{$escapedService}' > " . escapeshellarg($servicePath)); $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl daemon-reload"], "systemctl daemon-reload"); $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl enable {$serviceName}"], "systemctl enable {$serviceName}"); $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl start {$serviceName}"], "systemctl start {$serviceName}"); // Verify service started $statusResult = $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl is-active {$serviceName}"], "systemctl is-active {$serviceName}"); return trim($statusResult['output'] ?? '') === 'active'; } /** * Remove a systemd service for a Node.js or Python app and restore nginx to PHP-FPM config */ public function removeAppService(HostedSite $site): bool { $account = $site->account; $node = $account->node; if (!$node) { return false; } $ssh = $this->connect($node); $username = $account->username; $domain = $site->domain; $serviceName = "app-{$username}-" . preg_replace('/[^a-z0-9]/', '-', strtolower($domain)); // Stop and disable the systemd service $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl stop {$serviceName} 2>/dev/null"], "systemctl stop {$serviceName} 2>/dev/null"); $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl disable {$serviceName} 2>/dev/null"], "systemctl disable {$serviceName} 2>/dev/null"); $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["rm -f /etc/systemd/system/{$serviceName}.service"], "rm -f /etc/systemd/system/{$serviceName}.service"); $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl daemon-reload"], "systemctl daemon-reload"); // Restore nginx config to PHP-FPM $docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}"; try { $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), 'proxy_port' => null, 'app_type' => null, ]); if ($site->ssl_enabled && ! $this->siteHasCertificate($ssh, $domain)) { try { $this->requestLetsEncryptCertificate($site); } catch (\Exception $e) { Log::warning("Failed to re-request SSL after app removal for {$domain}: " . $e->getMessage()); } } return true; } catch (\Throwable $e) { Log::error("Nginx config test failed after app removal for {$domain}", [ 'error' => $e->getMessage(), ]); return false; } } public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void { $node = $account->node; if (! $node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $this->applyManagedSiteConfig($ssh, $site, $docRoot, $account->username, [ 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), ]); } public function getUsageStats(HostingAccount $account): array { $node = $account->node; if (!$node) { return []; } $ssh = $this->connect($node); $username = $account->username; $homeDir = "/home/{$username}"; // Get disk usage $diskUsage = trim($this->exec($ssh, "du -sb {$homeDir} 2>/dev/null | cut -f1")); $inodeCount = trim($this->exec($ssh, "find {$homeDir} -xdev 2>/dev/null | wc -l")); $cpuUsage = trim($this->exec($ssh, "ps -u {$username} -o %cpu= 2>/dev/null | awk '{sum += \$1} END {printf \"%.2f\", sum+0}'")); $memoryUsage = trim($this->exec($ssh, "ps -u {$username} -o rss= 2>/dev/null | awk '{sum += \$1} END {printf \"%d\", int(sum/1024)}'")); $processCount = trim($this->exec($ssh, "ps -u {$username} --no-headers 2>/dev/null | wc -l")); // Get database sizes $dbSize = 0; foreach ($account->databases as $db) { $size = trim($this->exec($ssh, "mysql -N -e \"SELECT SUM(data_length + index_length) FROM information_schema.tables WHERE table_schema = '{$db->name}'\" 2>/dev/null")); $dbSize += (int) $size; } return [ 'disk_used_bytes' => (int) $diskUsage + $dbSize, 'database_size_bytes' => $dbSize, 'file_size_bytes' => (int) $diskUsage, 'inode_count' => (int) $inodeCount, 'cpu_usage_percent' => is_numeric($cpuUsage) ? round((float) $cpuUsage, 2) : null, 'memory_used_mb' => is_numeric($memoryUsage) ? (int) $memoryUsage : 0, 'process_count' => is_numeric($processCount) ? max(((int) $processCount) - 1, 0) : 0, ]; } public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array { $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $username = $account->username; // For local nodes, run commands directly as root (since we're on the server) if ($this->isLocalNode) { return $this->runLocalAccountCommand($username, $command, $timeout); } else { // For remote nodes, use runuser $output = $this->exec($ssh, "runuser -u {$username} -- bash -c " . escapeshellarg($command)); $exitCode = $this->getExitStatus($ssh); } return [ 'output' => $output, 'exit_code' => $exitCode, ]; } public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array { $node = $account->node; if (! $node) { throw new \RuntimeException('No hosting node assigned'); } $command = trim($command); if ($command === '') { return [ 'output' => 'Command is required.', 'exit_code' => 64, ]; } $this->assertValidTerminalCommand($command); $username = $account->username; $homeDirectory = "/home/{$username}"; if (! str_starts_with($workingDirectory, $homeDirectory.'/') && $workingDirectory !== $homeDirectory) { throw new \RuntimeException('Terminal working directory must stay within the hosting account home directory.'); } $ssh = $this->connect($node); $sandboxCommand = $this->buildTerminalSandboxCommand($username, $workingDirectory, $command, $timeout); if ($this->isLocalNode) { $sudoCommand = "sudo -n -u {$username} -- bash -lc " . escapeshellarg($sandboxCommand); $result = $this->runLocalProcess($sudoCommand, $timeout + 15); if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('sudo', $result['output'])) { return $result; } $runUserCommand = "runuser -u {$username} -- bash -lc " . escapeshellarg($sandboxCommand); $result = $this->runLocalProcess($runUserCommand, $timeout + 15); if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('runuser', $result['output'])) { return $result; } return [ 'output' => $this->localUserSwitchUnavailableMessage(), 'exit_code' => 1, ]; } $output = $this->exec($ssh, "runuser -u {$username} -- bash -lc " . escapeshellarg($sandboxCommand)); return [ 'output' => $output, 'exit_code' => $this->getExitStatus($ssh), ]; } public function executeCommandAsRoot(HostingAccount $account, string $command): array { $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); if ($this->isLocalNode) { return $this->runLocalProcess($command); } else { $output = $this->exec($ssh, $command); $exitCode = $this->getExitStatus($ssh); } return [ 'output' => $output, 'exit_code' => $exitCode, ]; } public function runWpCli(HostingAccount $account, string $path, string $wpCommand): array { $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $username = $account->username; $ssh = $this->connect($node); // Build the WP-CLI command to run as the hosting user $fullCommand = "cd " . escapeshellarg($path) . " && wp {$wpCommand}"; if ($this->isLocalNode) { // For local node, use sudo to run as the hosting user // www-data needs passwordless sudo for this to work $sudoCommand = "sudo -n -u " . escapeshellarg($username) . " bash -c " . escapeshellarg($fullCommand); return $this->runLocalProcess($sudoCommand); } else { // Remote: use runuser $output = $this->exec($ssh, "runuser -u {$username} -- bash -c " . escapeshellarg($fullCommand)); return [ 'output' => $output, 'exit_code' => $this->getExitStatus($ssh), ]; } } private function ensureAdministrativeCommandSucceeded(?SSH2 $ssh, string $command, string $context): void { $result = $this->executeAdministrativeCommand($ssh, $command); if ($result['exit_code'] !== 0) { throw new \RuntimeException(trim((string) $result['output']) ?: $context); } } private function ensureAdministrativeResultSucceeded(array $result, string $context): void { if ($result['exit_code'] !== 0) { throw new \RuntimeException(trim((string) $result['output']) ?: $context); } } private function runMagentoOpenSearchStateCheck(?SSH2 $ssh): void { $result = $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ['/usr/local/bin/check-opensearch-for-magento.sh'], '/usr/local/bin/check-opensearch-for-magento.sh' ); if (($result['exit_code'] ?? 1) !== 0) { Log::warning('Failed to refresh Magento OpenSearch state after hosting change.', [ 'output' => trim((string) ($result['output'] ?? '')), ]); } } public function runLocalAdminOperationOrRemote(?SSH2 $ssh, string $operation, array $arguments, string $remoteCommand): array { if ($this->isLocalNode) { return $this->executeLocalAdminOperation( $operation, $arguments, fn (): array => $this->executeAdministrativeCommand($ssh, $remoteCommand) ); } return $this->executeAdministrativeCommand($ssh, $remoteCommand); } public function prepareDirectoryForUser(HostingAccount $account, string $path): void { $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $username = $account->username; $quotedPath = escapeshellarg($path); $userLevelResult = $this->executeCommand($account, "mkdir -p {$quotedPath} && test -w {$quotedPath}"); if ($userLevelResult['exit_code'] === 0) { return; } if (trim((string) $userLevelResult['output']) === $this->localUserSwitchUnavailableMessage()) { throw new \RuntimeException($this->localUserSwitchUnavailableMessage()); } $commands = [ ['mkdir', [$path], "mkdir -p {$quotedPath}"], ['chown', ["{$username}:{$username}", $path], "chown -R {$username}:{$username} {$quotedPath}"], ['chmod', ['u+rwx', $path], "chmod u+rwx {$quotedPath}"], ]; foreach ($commands as [$operation, $arguments, $remoteCommand]) { $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote($ssh, $operation, $arguments, $remoteCommand), "Failed to prepare directory: {$path}" ); } } public function setWebServerGroupOwnership(HostingAccount $account, string $path): void { $node = $account->node; if (!$node) { throw new \RuntimeException('No hosting node assigned'); } $ssh = $this->connect($node); $quotedPath = escapeshellarg($path); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote($ssh, 'chgrp', [self::WEB_SERVER_GROUP, $path], "chgrp " . self::WEB_SERVER_GROUP . " {$quotedPath}"), "Failed to set web server group ownership: {$path}" ); } private function hardenAccountFilesystemPaths(?SSH2 $ssh, string $username, array $documentRoots = []): void { $sharedHomeDir = '/home'; $homeDir = "/home/{$username}"; $publicHtml = "{$homeDir}/public_html"; $logsDir = "{$homeDir}/logs"; $tmpDir = "{$homeDir}/tmp"; $backupsDir = "{$homeDir}/backups"; $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["test -d {$sharedHomeDir} || exit 0; chown root:root {$sharedHomeDir} && chmod 0711 {$sharedHomeDir}"], "test -d {$sharedHomeDir} || exit 0; chown root:root {$sharedHomeDir} && chmod 0711 {$sharedHomeDir}" ), 'Failed to secure shared home directory' ); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["test -d {$homeDir} || exit 0; chown root:root {$homeDir} && chmod 0755 {$homeDir}"], "test -d {$homeDir} || exit 0; chown root:root {$homeDir} && chmod 0755 {$homeDir}" ), "Failed to secure account jail root: {$homeDir}" ); $ownershipPaths = array_values(array_unique(array_filter(array_merge([ $publicHtml, $logsDir, $tmpDir, $backupsDir, ], $documentRoots)))); foreach ($ownershipPaths as $path) { $quotedPath = escapeshellarg($path); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["test -e {$quotedPath} || exit 0; chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedPath}"], "test -e {$quotedPath} || exit 0; chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedPath}" ), "Failed to set secure ownership for {$path}" ); } foreach ([ $publicHtml => '2750', $logsDir => '2770', $tmpDir => '1770', $backupsDir => '2750', ] as $path => $mode) { $quotedPath = escapeshellarg($path); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["test -d {$quotedPath} || exit 0; chmod {$mode} {$quotedPath}"], "test -d {$quotedPath} || exit 0; chmod {$mode} {$quotedPath}" ), "Failed to set secure permissions for {$path}" ); } foreach (array_unique(array_filter($documentRoots)) as $path) { $quotedPath = escapeshellarg($path); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["test -d {$quotedPath} || exit 0; chmod 2750 {$quotedPath}"], "test -d {$quotedPath} || exit 0; chmod 2750 {$quotedPath}" ), "Failed to set secure document root permissions for {$path}" ); } } private function ensureSharedSftpIsolationPolicy(?SSH2 $ssh, string $username): void { $authorizedKeysPath = $this->sharedAccountAuthorizedKeysPath($username); $legacyAuthorizedKeysPath = "/home/{$username}/.ssh/authorized_keys"; $sshdConfig = implode("\n", [ '# Managed by Ladill Hosting', '# Shared hosting accounts are confined to SFTP inside their account jail.', 'Match Group '.self::SFTP_ONLY_GROUP, ' ChrootDirectory %h', ' ForceCommand internal-sftp', ' AuthorizedKeysFile '.self::SSH_AUTHORIZED_KEYS_DIR.'/%u', ' PasswordAuthentication yes', ' PubkeyAuthentication yes', ' PermitTTY no', ' AllowTcpForwarding no', ' X11Forwarding no', ' PermitTunnel no', ' GatewayPorts no', '', ]); $setupCommand = implode("\n", [ 'set -e', 'if ! grep -Eq '.escapeshellarg('^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\\.d/\\*\\.conf([[:space:]]|$)').' '.escapeshellarg(self::SSHD_MAIN_CONFIG).'; then', ' printf "\nInclude /etc/ssh/sshd_config.d/*.conf\n" >> '.escapeshellarg(self::SSHD_MAIN_CONFIG), 'fi', 'mkdir -p /etc/ssh/sshd_config.d '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR), 'getent group '.escapeshellarg(self::SFTP_ONLY_GROUP).' >/dev/null 2>&1 || groupadd --system '.escapeshellarg(self::SFTP_ONLY_GROUP), 'usermod -a -G '.escapeshellarg(self::SFTP_ONLY_GROUP).' '.escapeshellarg($username), 'NOLOGIN_SHELL="$(command -v nologin || true)"', 'if [ -z "$NOLOGIN_SHELL" ]; then', ' for candidate in /usr/sbin/nologin /sbin/nologin /bin/false; do', ' if [ -x "$candidate" ]; then', ' NOLOGIN_SHELL="$candidate"', ' break', ' fi', ' done', 'fi', 'if [ -z "$NOLOGIN_SHELL" ]; then', ' echo "No nologin shell available for shared account confinement." >&2', ' exit 1', 'fi', 'CURRENT_SHELL="$(getent passwd '.escapeshellarg($username).' | cut -d: -f7)"', 'if [ "$CURRENT_SHELL" != "$NOLOGIN_SHELL" ]; then', ' usermod -s "$NOLOGIN_SHELL" '.escapeshellarg($username), 'fi', 'touch '.escapeshellarg($authorizedKeysPath), 'if [ -f '.escapeshellarg($legacyAuthorizedKeysPath).' ]; then cat '.escapeshellarg($legacyAuthorizedKeysPath).' >> '.escapeshellarg($authorizedKeysPath).'; fi', 'awk \'NF && !seen[$0]++\' '.escapeshellarg($authorizedKeysPath).' > '.escapeshellarg("{$authorizedKeysPath}.tmp"), 'mv '.escapeshellarg("{$authorizedKeysPath}.tmp").' '.escapeshellarg($authorizedKeysPath), 'chown root:root '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR).' '.escapeshellarg($authorizedKeysPath), 'chmod 0755 '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR), 'chmod 0600 '.escapeshellarg($authorizedKeysPath), ]); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', [$setupCommand], $setupCommand ), 'Failed to prepare shared account SSH isolation policy' ); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'write-file', [self::SSHD_SHARED_HOSTING_CONFIG, base64_encode($sshdConfig)], "printf %s ".escapeshellarg(base64_encode($sshdConfig))." | base64 -d > ".escapeshellarg(self::SSHD_SHARED_HOSTING_CONFIG) ), 'Failed to write shared hosting SSH policy' ); $reloadCommand = 'sshd -t && (systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || service ssh reload 2>/dev/null || service sshd reload 2>/dev/null)'; $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', [$reloadCommand], $reloadCommand ), 'Failed to validate and reload SSH service' ); } private function sharedAccountAuthorizedKeysPath(string $username): string { return self::SSH_AUTHORIZED_KEYS_DIR."/{$username}"; } private function assertValidTerminalCommand(string $command): void { if (strlen($command) > 500) { throw new \RuntimeException('Terminal command is too long.'); } if (preg_match('/[\r\n]/', $command) === 1) { throw new \RuntimeException('Terminal commands must be a single line.'); } if (preg_match('/(?:\|\||&&|[|;<>`$])/', $command) === 1) { throw new \RuntimeException('Terminal commands cannot use shell chaining, pipes, or redirection.'); } $token = strtok($command, " \t"); if ($token === false || $token === '') { throw new \RuntimeException('Terminal command is required.'); } if (str_contains($token, '..') || str_starts_with($token, '/')) { throw new \RuntimeException('Terminal command executable is not allowed.'); } $executable = basename(ltrim($token, './')); if (! in_array($executable, self::TERMINAL_ALLOWED_EXECUTABLES, true)) { throw new \RuntimeException("`{$executable}` is not available in the panel terminal."); } } private function buildTerminalSandboxCommand(string $username, string $workingDirectory, string $command, int $timeout): string { $homeDirectory = "/home/{$username}"; $commandPayload = base64_encode($command); $networkTimeout = max(30, min($timeout, 600)); return implode("\n", [ 'set -eu', 'if ! command -v bwrap >/dev/null 2>&1; then', " echo 'Browser terminal requires bubblewrap on the hosting node.' >&2", ' exit 127', 'fi', 'HOME_DIR='.escapeshellarg($homeDirectory), 'WORKDIR='.escapeshellarg($workingDirectory), 'COMMAND_B64='.escapeshellarg($commandPayload), 'COMMAND="$(printf %s "$COMMAND_B64" | base64 --decode)"', 'if [ ! -d "$WORKDIR" ]; then', " echo 'Working directory does not exist.' >&2", ' exit 1', 'fi', 'export HOME="$HOME_DIR"', 'export USER='.escapeshellarg($username), 'export LOGNAME='.escapeshellarg($username), 'export TMPDIR=/tmp', 'export PATH=/usr/local/bin:/usr/bin:/bin', 'export LANG=C.UTF-8', 'export TERM=xterm-256color', 'BWRAP_ARGS=(', ' --die-with-parent', ' --new-session', ' --unshare-pid', ' --share-net', ' --proc /proc', ' --dev /dev', ' --dir /etc', ' --dir /run', ' --bind "$HOME_DIR" "$HOME_DIR"', ' --bind "$HOME_DIR/tmp" /tmp', ' --chdir "$WORKDIR"', ')', '[ -d /usr ] && BWRAP_ARGS+=(--ro-bind /usr /usr)', '[ -d /usr/local ] && BWRAP_ARGS+=(--ro-bind /usr/local /usr/local)', '[ -d /bin ] && BWRAP_ARGS+=(--ro-bind /bin /bin)', '[ -d /lib ] && BWRAP_ARGS+=(--ro-bind /lib /lib)', '[ -d /lib64 ] && BWRAP_ARGS+=(--ro-bind /lib64 /lib64)', '[ -f /etc/resolv.conf ] && BWRAP_ARGS+=(--ro-bind /etc/resolv.conf /etc/resolv.conf)', '[ -f /etc/hosts ] && BWRAP_ARGS+=(--ro-bind /etc/hosts /etc/hosts)', '[ -f /etc/nsswitch.conf ] && BWRAP_ARGS+=(--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf)', '[ -f /etc/passwd ] && BWRAP_ARGS+=(--ro-bind /etc/passwd /etc/passwd)', '[ -f /etc/group ] && BWRAP_ARGS+=(--ro-bind /etc/group /etc/group)', '[ -d /etc/ssl ] && BWRAP_ARGS+=(--ro-bind /etc/ssl /etc/ssl)', '[ -d /run/mysqld ] && BWRAP_ARGS+=(--ro-bind /run/mysqld /run/mysqld)', 'ulimit -t '.max(15, min($networkTimeout, 120)).' 2>/dev/null || true', 'ulimit -u 64 2>/dev/null || true', 'ulimit -v 1048576 2>/dev/null || true', 'ulimit -f 204800 2>/dev/null || true', 'exec timeout -k 5 '.(int) $networkTimeout.' bwrap "${BWRAP_ARGS[@]}" bash -lc "$COMMAND"', ]); } /** * Ensure PHP-FPM pool exists for the account, creating it if missing */ public function ensurePhpFpmPool(HostingAccount $account): void { $node = $account->node; if (! $node) { return; } $ssh = $this->connect($node); $phpVersion = $account->php_version ?? '8.4'; $this->ensureSinglePhpFpmPool($ssh, $account, $phpVersion); $socketCheck = $this->executeAdministrativeCommand( $ssh, 'test -S '.escapeshellarg($this->phpFpmSocketPath($account->username)).' && echo exists' ); if (! str_contains((string) ($socketCheck['output'] ?? ''), 'exists')) { $this->reloadPhpFpmService($ssh, $phpVersion); } } private function createPhpFpmPool(?SSH2 $ssh, HostingAccount $account): void { $phpVersion = $account->php_version ?? '8.4'; $this->createPhpFpmPoolForVersion($ssh, $account, $phpVersion); } public function generateNginxConfig(HostedSite $site, string $docRoot, string $username, ?string $phpVersion = null, ?SSH2 $ssh = null): string { return $this->renderManagedNginxConfig($site, $docRoot, $username, [ 'php_version' => $phpVersion, 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), ]); } private function applyManagedSiteConfig(?SSH2 $ssh, HostedSite $site, string $docRoot, string $username, array $options = []): void { $config = $this->renderManagedNginxConfig($site, $docRoot, $username, $options); [$configPath, $enabledPath] = $this->nginxConfigPaths($site->domain); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'write-file', [$configPath, base64_encode($config)], "printf %s " . escapeshellarg(base64_encode($config)) . " | base64 -d > " . escapeshellarg($configPath) ), "Failed to write nginx configuration for {$site->domain}" ); $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote( $ssh, 'symlink', [$configPath, $enabledPath], "ln -sf " . escapeshellarg($configPath) . ' ' . escapeshellarg($enabledPath) ), "Failed to enable nginx configuration for {$site->domain}" ); $testResult = $this->runLocalAdminOperationOrRemote($ssh, 'nginx-test', [], 'nginx -t 2>&1'); if ($testResult['exit_code'] !== 0 || strpos((string) ($testResult['output'] ?? ''), 'successful') === false) { Log::error("Nginx config test failed for {$site->domain}", ['output' => $testResult]); throw new \RuntimeException("Invalid nginx configuration for {$site->domain}"); } $this->ensureAdministrativeResultSucceeded( $this->runLocalAdminOperationOrRemote($ssh, 'reload-nginx', [], 'systemctl reload nginx'), "Failed to reload nginx for {$site->domain}" ); } private function renderManagedNginxConfig(HostedSite $site, string $docRoot, string $username, array $options = []): string { $domain = $site->domain; $sslEnabled = (bool) ($options['ssl_enabled'] ?? false); $proxyPort = $this->resolveProxyPort($site, $options); $appType = $options['app_type'] ?? $site->installed_app ?? 'php'; $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; $header = sprintf( self::NGINX_CONFIG_HEADER, $domain, $username, $proxyPort !== null ? "proxy:{$appType}" : 'php' ); $acmeLocation = <<app_config, 'api_runtime_port'); $apiLocation = is_numeric($apiPort) ? <<storedSslState($site); if ($ssh !== null) { $certificateState = $this->probeSiteCertificateState($ssh, $site->domain); return $certificateState ?? $persistedSslState; } return $persistedSslState; } private function siteHasCertificate(?SSH2 $ssh, string $domain): bool { return $this->probeSiteCertificateState($ssh, $domain) === true; } private function probeSiteCertificateState(?SSH2 $ssh, string $domain): ?bool { $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; $certCheckResult = $this->runLocalAdminOperationOrRemote( $ssh, 'run-cmd', ["test -f {$certPath} && test -f {$keyPath} && echo 'exists'"], "test -f {$certPath} && test -f {$keyPath} && echo 'exists'" ); if (($certCheckResult['exit_code'] ?? 1) !== 0) { Log::warning('SSL: Certificate presence check failed, falling back to persisted site state.', [ 'domain' => $domain, 'output' => $certCheckResult['output'] ?? null, 'exit_code' => $certCheckResult['exit_code'] ?? null, ]); return null; } return trim((string) ($certCheckResult['output'] ?? '')) === 'exists'; } private function storedSslState(HostedSite $site): bool { return (bool) ($site->ssl_enabled || $site->ssl_status === 'issued'); } private function resolveProxyPort(HostedSite $site, array $options = []): ?int { if (array_key_exists('proxy_port', $options)) { return $options['proxy_port'] !== null ? (int) $options['proxy_port'] : null; } $runtimePort = data_get($site->app_config, 'runtime_port'); if (is_numeric($runtimePort)) { return (int) $runtimePort; } $appType = array_key_exists('app_type', $options) ? $options['app_type'] : $site->installed_app; if (! $site->id) { return null; } return match ($appType) { 'nodejs' => 3000 + ($site->id % 1000), 'python' => 5000 + ($site->id % 1000), default => null, }; } private function nginxConfigPaths(string $domain): array { return [ "/etc/nginx/sites-available/{$domain}.conf", "/etc/nginx/sites-enabled/{$domain}.conf", ]; } /** * Migrate an account from its current node to a destination node */ public function migrateAccount(HostingAccount $account, HostingNode $destinationNode, callable $progressCallback = null): array { $sourceNode = $account->node; if (!$sourceNode) { throw new \RuntimeException('Account has no source node'); } if ($sourceNode->id === $destinationNode->id) { throw new \RuntimeException('Source and destination nodes are the same'); } $username = $account->username; $homeDir = "/home/{$username}"; $timestamp = time(); $archivePath = "/tmp/{$username}_migration_{$timestamp}.tar.gz"; $phpVersion = $account->php_version ?? '8.4'; $progress = function ($message) use ($progressCallback) { Log::info("Migration: {$message}"); if ($progressCallback) { $progressCallback($message); } }; try { // Step 1: Create archive on source node $progress("Creating archive on source node..."); $sourceSsh = $this->connect($sourceNode); $this->exec($sourceSsh, "tar -czf {$archivePath} -C /home {$username}"); // Export databases $dbDumps = []; foreach ($account->databases as $db) { $dumpPath = "/tmp/{$db->name}_migration_{$timestamp}.sql"; $this->exec($sourceSsh, "mysqldump --single-transaction {$db->name} > {$dumpPath}"); $dbDumps[$db->name] = $dumpPath; } // Step 2: Transfer files to destination $progress("Transferring files to destination node..."); $sourceIsLocal = $this->isLocal($sourceNode); $destIsLocal = $this->isLocal($destinationNode); if ($sourceIsLocal && $destIsLocal) { // Both local - files already in place $progress("Both nodes are local, no transfer needed"); } elseif ($sourceIsLocal) { // Source local, destination remote $keyFile = $this->writeKeyToTempFile($destinationNode->ssh_private_key); Process::run("scp -i {$keyFile} -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$archivePath} root@{$destinationNode->ip_address}:{$archivePath}"); foreach ($dbDumps as $dbName => $dumpPath) { Process::run("scp -i {$keyFile} -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$dumpPath} root@{$destinationNode->ip_address}:{$dumpPath}"); } @unlink($keyFile); } elseif ($destIsLocal) { // Source remote, destination local $keyFile = $this->writeKeyToTempFile($sourceNode->ssh_private_key); Process::run("scp -i {$keyFile} -P {$sourceNode->ssh_port} -o StrictHostKeyChecking=no root@{$sourceNode->ip_address}:{$archivePath} {$archivePath}"); foreach ($dbDumps as $dbName => $dumpPath) { Process::run("scp -i {$keyFile} -P {$sourceNode->ssh_port} -o StrictHostKeyChecking=no root@{$sourceNode->ip_address}:{$dumpPath} {$dumpPath}"); } @unlink($keyFile); } else { // Both remote - transfer via source $keyFile = $this->writeKeyToTempFile($destinationNode->ssh_private_key); $this->exec($sourceSsh, "scp -i /tmp/dest_key -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$archivePath} root@{$destinationNode->ip_address}:{$archivePath}"); foreach ($dbDumps as $dbName => $dumpPath) { $this->exec($sourceSsh, "scp -i /tmp/dest_key -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$dumpPath} root@{$destinationNode->ip_address}:{$dumpPath}"); } } // Step 3: Create user on destination $progress("Creating user on destination node..."); $destSsh = $this->connect($destinationNode); $newPassword = Str::password(16, true, true, false, false); $this->exec($destSsh, "useradd -m -d {$homeDir} -s /bin/bash {$username} 2>/dev/null || true"); $this->exec($destSsh, "echo '{$username}:{$newPassword}' | chpasswd"); // Step 4: Extract archive on destination $progress("Extracting files on destination node..."); $this->exec($destSsh, "rm -rf {$homeDir}/* 2>/dev/null || true"); $this->exec($destSsh, "tar -xzf {$archivePath} -C /home --overwrite"); $this->exec($destSsh, "chown -R {$username}:{$username} {$homeDir}"); // Step 5: Restore databases $progress("Restoring databases..."); foreach ($account->databases as $db) { $dumpPath = "/tmp/{$db->name}_migration_{$timestamp}.sql"; $this->exec($destSsh, "mysql -e \"CREATE DATABASE IF NOT EXISTS \\`{$db->name}\\`\""); $this->exec($destSsh, "mysql -e \"CREATE USER IF NOT EXISTS '{$db->username}'@'localhost' IDENTIFIED BY 'temp_pass'\""); $this->exec($destSsh, "mysql -e \"GRANT ALL PRIVILEGES ON \\`{$db->name}\\`.* TO '{$db->username}'@'localhost'\""); $this->exec($destSsh, "mysql {$db->name} < {$dumpPath}"); $this->exec($destSsh, "rm -f {$dumpPath}"); } // Step 6: Create PHP-FPM pool on destination $progress("Creating PHP-FPM pool..."); $this->createPhpFpmPoolForUser($destSsh, $username, $phpVersion); // Step 7: Create nginx configs for all sites $progress("Creating nginx configurations..."); foreach ($account->sites as $site) { $docRoot = $site->document_root ?: "/home/{$username}/public_html"; $this->createNginxConfigForDomain($destSsh, $site->domain, $docRoot, $username); } $this->hardenAccountFilesystemPaths( $destSsh, $username, $account->sites->pluck('document_root')->filter()->values()->all() ); // If no sites, create config for primary domain if ($account->sites->isEmpty() && $account->domain) { $this->createNginxConfigForDomain($destSsh, $account->domain, "/home/{$username}/public_html", $username); } // Step 8: Cleanup source node $progress("Cleaning up source node..."); $sourceSsh = $this->connect($sourceNode); // Remove nginx configs foreach ($account->sites as $site) { $this->exec($sourceSsh, "rm -f /etc/nginx/sites-enabled/{$site->domain}.conf"); $this->exec($sourceSsh, "rm -f /etc/nginx/sites-available/{$site->domain}.conf"); } if ($account->domain) { $this->exec($sourceSsh, "rm -f /etc/nginx/sites-enabled/{$account->domain}.conf"); $this->exec($sourceSsh, "rm -f /etc/nginx/sites-available/{$account->domain}.conf"); } // Remove PHP-FPM pool $this->exec($sourceSsh, "rm -f /etc/php/*/fpm/pool.d/{$username}.conf"); // Drop databases on source foreach ($account->databases as $db) { $this->exec($sourceSsh, "mysql -e \"DROP DATABASE IF EXISTS \\`{$db->name}\\`\""); $this->exec($sourceSsh, "mysql -e \"DROP USER IF EXISTS '{$db->username}'@'localhost'\""); } // Remove user on source $this->exec($sourceSsh, "userdel -r {$username} 2>/dev/null || true"); // Reload services on source $this->exec($sourceSsh, "nginx -t && systemctl reload nginx"); $this->exec($sourceSsh, "systemctl reload php*-fpm"); // Cleanup temp files $this->exec($sourceSsh, "rm -f {$archivePath}"); foreach ($dbDumps as $dumpPath) { $this->exec($sourceSsh, "rm -f {$dumpPath}"); } // Cleanup on destination $destSsh = $this->connect($destinationNode); $this->exec($destSsh, "rm -f {$archivePath}"); $progress("Migration completed successfully!"); return [ 'success' => true, 'new_password' => $newPassword, 'source_node' => $sourceNode->name, 'destination_node' => $destinationNode->name, ]; } catch (\Throwable $e) { Log::error("Migration failed for {$username}: " . $e->getMessage()); throw new \RuntimeException("Migration failed: " . $e->getMessage(), 0, $e); } } private function writeKeyToTempFile(string $privateKey): string { $keyFile = tempnam(sys_get_temp_dir(), 'ssh_key_'); file_put_contents($keyFile, $privateKey); chmod($keyFile, 0600); return $keyFile; } }