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 if we manage DNS or if it resolves
$domainArgs = ' -d ' . escapeshellarg($domain);
$wwwDomain = "www.{$domain}";
$domainModel = $site->domain_id ? $site->domain()->first() : null;
$dnsIsManaged = $domainModel && $domainModel->dns_mode === 'managed';
if ($dnsIsManaged) {
// We manage DNS, so www record exists - always include it
$domainArgs .= ' -d ' . escapeshellarg($wwwDomain);
Log::info("SSL: Including www subdomain (managed DNS)", ['domain' => $domain, 'www' => $wwwDomain]);
} elseif ($this->domainResolvesToServer($wwwDomain)) {
// Manual DNS - only include www if it resolves
$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;
}
}