Deploy Ladill Hosting / deploy (push) Successful in 41s
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>
2845 lines
104 KiB
PHP
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;
|
|
}
|
|
}
|