Files
ladill-hosting/app/Services/Hosting/Providers/SharedNodeProvider.php
T
isaaccladandCursor 335c0fea61
Deploy Ladill Hosting / deploy (push) Successful in 41s
Do not request www aliases when issuing SSL for subdomain sites.
Certbot was including www.data.example.com for managed DNS subdomains, which NXDOMAIN and failed the whole certificate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:18:27 +00:00

2845 lines
104 KiB
PHP

<?php
namespace App\Services\Hosting\Providers;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostedSite;
use App\Models\HostedDatabase;
use App\Services\Hosting\Contracts\SharedHostingProviderInterface;
use App\Services\Ssl\SslCertificateExpiryParser;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
class SharedNodeProvider implements SharedHostingProviderInterface
{
private const LOCAL_ADMIN_HELPER = '/usr/local/bin/ladill-hosting-admin';
private const LOCAL_USER_HELPER = '/usr/local/bin/ladill-hosting-user';
private const WEB_SERVER_GROUP = 'www-data';
private const SFTP_ONLY_GROUP = 'ladill-sftp-only';
private const SSH_AUTHORIZED_KEYS_DIR = '/etc/ssh/authorized_keys';
private const SSHD_MAIN_CONFIG = '/etc/ssh/sshd_config';
private const SSHD_SHARED_HOSTING_CONFIG = '/etc/ssh/sshd_config.d/50-ladill-shared-hosting.conf';
private const NGINX_CONFIG_HEADER = "# Managed by Ladill Hosting\n# Domain: %s\n# User: %s\n# Mode: %s\n# Do not edit manually\n";
private const TERMINAL_ALLOWED_EXECUTABLES = [
'php',
'composer',
'artisan',
'phpunit',
'pest',
'pint',
'git',
'node',
'npm',
'npx',
'yarn',
'pnpm',
'python',
'python3',
'pip',
'pip3',
'wp',
'mysql',
'mysqldump',
'ls',
'pwd',
'cat',
'head',
'tail',
'grep',
'find',
'whoami',
'which',
'env',
];
private const SUPPORTED_PHP_VERSIONS = ['8.0', '8.1', '8.2', '8.3', '8.4'];
private ?SSH2 $ssh = null;
private ?HostingNode $connectedNode = null;
private bool $isLocalNode = false;
private int $lastLocalExitCode = 0;
private function isLocal(HostingNode $node): bool
{
return $node->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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hosting Account Expired</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f8fafc;color:#334155}
.card{max-width:28rem;text-align:center;padding:3rem 2rem;background:#fff;border-radius:1rem;border:1px solid #e2e8f0;box-shadow:0 1px 3px rgba(0,0,0,.06)}
.icon{width:3.5rem;height:3.5rem;margin:0 auto 1.5rem;border-radius:.75rem;background:#fef2f2;display:flex;align-items:center;justify-content:center}
.icon svg{width:1.5rem;height:1.5rem;color:#ef4444}
h1{font-size:1.25rem;font-weight:700;margin-bottom:.5rem}
p{font-size:.875rem;color:#64748b;line-height:1.6}
</style>
</head>
<body>
<div class="card">
<div class="icon"><svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg></div>
<h1>Hosting Account Expired</h1>
<p>This hosting account has expired. The account owner can renew it from their dashboard to bring the site back online.</p>
</div>
</body>
</html>
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 = <<<NGINX
server {
listen 80;
listen [::]:80;
server_name {$domain} www.{$domain};
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {$domain} www.{$domain};
root {$expiredPageDir};
index index.html;
ssl_certificate {$certPath};
ssl_certificate_key {$keyPath};
ssl_protocols TLSv1.2 TLSv1.3;
location / {
try_files /index.html =503;
}
}
NGINX;
} else {
$expiredConfig = <<<NGINX
server {
listen 80;
listen [::]:80;
server_name {$domain} www.{$domain};
root {$expiredPageDir};
index index.html;
location / {
try_files /index.html =503;
}
}
NGINX;
}
$escapedConfig = str_replace("'", "'\\''", $expiredConfig);
$this->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<string>
*/
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<string> 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 <<<POOL
[{$username}]
user = {$username}
group = {$username}
listen = {$socketPath}
listen.owner = www-data
listen.group = www-data
pm = ondemand
pm.max_children = {$processLimit}
pm.process_idle_timeout = 10s
pm.max_requests = {$maxRequests}
process.priority = {$processPriority}
php_admin_value[open_basedir] = /home/{$username}:/tmp:/usr/share/php
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_value[upload_max_filesize] = {$uploadLimitMb}M
php_admin_value[post_max_size] = {$postLimitMb}M
php_admin_value[memory_limit] = {$memoryLimitMb}M
php_admin_value[max_execution_time] = {$maxExecutionTime}
POOL;
}
private function buildUserLimitsConfig(string $username, int $processLimit): string
{
// nproc must be much higher than FPM process_limit because it covers ALL
// user processes (shell, git, unzip, composer, artisan, etc.), not just FPM workers.
$nproc = max($processLimit * 10, 100);
return <<<LIMITS
{$username} soft nproc {$nproc}
{$username} hard nproc {$nproc}
LIMITS;
}
/**
* Update the nginx document root for a site (e.g., for Laravel apps that need public/ as root)
*/
public function updateNginxDocumentRoot(HostedSite $site, string $documentRoot): bool
{
$account = $site->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 = <<<SERVICE
[Unit]
Description={$appType} app for {$domain}
After=network.target
[Service]
Type=simple
User={$username}
WorkingDirectory={$appPath}
Environment={$environment}
ExecStart={$execStart}
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
SERVICE;
$servicePath = "/etc/systemd/system/{$serviceName}.service";
$escapedService = str_replace("'", "'\\''", $serviceContent);
$this->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 = <<<NGINX
location ^~ /.well-known/acme-challenge/ {
root {$docRoot};
default_type "text/plain";
allow all;
}
NGINX;
if ($proxyPort !== null) {
$proxyHeaders = <<<NGINX
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
NGINX;
$apiPort = data_get($site->app_config, 'api_runtime_port');
$apiLocation = is_numeric($apiPort)
? <<<NGINX
location /api/ {
proxy_pass http://127.0.0.1:{$apiPort};
{$proxyHeaders}
}
NGINX
: '';
$httpBody = <<<NGINX
access_log /home/{$username}/logs/{$domain}-access.log;
error_log /home/{$username}/logs/{$domain}-error.log;
{$acmeLocation}
{$apiLocation}
location / {
proxy_pass http://127.0.0.1:{$proxyPort};
{$proxyHeaders}
}
client_max_body_size 64M;
NGINX;
} else {
$httpBody = <<<NGINX
root {$docRoot};
index index.php index.html index.htm;
access_log /home/{$username}/logs/{$domain}-access.log;
error_log /home/{$username}/logs/{$domain}-error.log;
{$acmeLocation}
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \\.php\$ {
fastcgi_pass unix:/run/php/php-fpm-{$username}.sock;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
location ~ /\\.ht {
deny all;
}
location ~* \\.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|eot)\$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
client_max_body_size 64M;
NGINX;
}
if (! $sslEnabled) {
return $header . <<<NGINX
server {
listen 80;
listen [::]:80;
server_name {$domain} www.{$domain};
{$httpBody}
}
NGINX;
}
return $header . <<<NGINX
server {
listen 80;
listen [::]:80;
server_name {$domain} www.{$domain};
{$acmeLocation}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {$domain} www.{$domain};
{$httpBody}
ssl_certificate {$certPath};
ssl_certificate_key {$keyPath};
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
}
NGINX;
}
private function shouldRenderSslForSite(HostedSite $site, ?SSH2 $ssh = null): bool
{
$persistedSslState = $this->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;
}
}