Files
jabali-panel/app/Jobs/RunWhmMigrationBatch.php
2026-02-02 03:11:45 +02:00

411 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\User;
use App\Services\Agent\AgentClient;
use App\Services\Migration\MigrationDnsSyncService;
use App\Services\Migration\WhmApiService;
use App\Services\Migration\WhmMigrationStatusStore;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class RunWhmMigrationBatch implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 7200;
private bool $shouldReloadFpm = false;
/**
* @param array<int, array<string, mixed>> $accounts
* @param array<int, string> $selectedAccounts
*/
public function __construct(
public string $cacheKey,
public string $hostname,
public string $whmUsername,
public string $apiToken,
public int $port,
public bool $useSSL,
public array $accounts,
public array $selectedAccounts,
public bool $restoreFiles,
public bool $restoreDatabases,
public bool $restoreEmails,
public bool $restoreSsl,
public bool $createLinuxUsers,
) {}
public function handle(AgentClient $agent, MigrationDnsSyncService $dnsSyncService): void
{
$store = new WhmMigrationStatusStore($this->cacheKey);
$store->initialize($this->selectedAccounts);
$reloadDelaySeconds = 15;
try {
$whm = new WhmApiService(
$this->hostname,
$this->whmUsername,
$this->apiToken,
$this->port,
$this->useSSL,
);
$accountsByUser = $this->indexAccountsByUser($this->accounts);
foreach ($this->selectedAccounts as $cpanelUser) {
$this->migrateAccount($store, $whm, $agent, $dnsSyncService, $cpanelUser, $accountsByUser[$cpanelUser] ?? []);
}
$store->setMigrating(false);
try {
$agent->send('service.reload', ['service' => 'nginx']);
} catch (Exception $e) {
Log::warning('Failed to reload nginx after WHM migration', ['error' => $e->getMessage()]);
}
if ($this->shouldReloadFpm) {
try {
$agent->send('php.reload_all_fpm', [
'background' => true,
'delay' => $reloadDelaySeconds,
]);
} catch (Exception $e) {
Log::warning('Failed to reload PHP-FPM after WHM migration', ['error' => $e->getMessage()]);
}
}
} catch (Exception $e) {
Log::error('WHM migration batch failed', ['error' => $e->getMessage()]);
} finally {
$state = $store->get();
if (($state['isMigrating'] ?? false) === true) {
$store->setMigrating(false);
}
}
}
/**
* @param array<string, mixed> $account
*/
protected function migrateAccount(WhmMigrationStatusStore $store, WhmApiService $whm, AgentClient $agent, MigrationDnsSyncService $dnsSyncService, string $cpanelUser, array $account): void
{
$store->updateAccountStatus($cpanelUser, 'processing', __('Starting migration...'));
try {
$domain = $account['domain'] ?? '';
$email = $account['email'] ?? ($domain !== '' ? "{$cpanelUser}@{$domain}" : "{$cpanelUser}@example.com");
$user = $this->createOrGetUser($agent, $cpanelUser, $email);
if (! $user) {
throw new Exception(__('Failed to create user'));
}
$store->addAccountLog($cpanelUser, __('User ready: :username', ['username' => $user->username]), 'success');
$store->updateAccountStatus($cpanelUser, 'backup_creating', __('Setting up backup transfer...'));
$keyName = $this->getSshKeyName();
$destPath = $this->getBackupDestPath();
if (! is_dir($destPath)) {
mkdir($destPath, 0755, true);
}
$agent->send('jabali_ssh.ensure_exists', []);
$publicKeyResult = $agent->send('jabali_ssh.get_public_key', []);
if (! ($publicKeyResult['success'] ?? false) || ! ($publicKeyResult['exists'] ?? false)) {
throw new Exception(__('Failed to get Jabali public key'));
}
$publicKey = $publicKeyResult['public_key'] ?? null;
$agent->send('jabali_ssh.add_to_authorized_keys', [
'public_key' => $publicKey,
'comment' => 'whm-migration-'.$cpanelUser,
]);
$privateKeyResult = $agent->send('jabali_ssh.get_private_key', []);
if (! ($privateKeyResult['success'] ?? false) || ! ($privateKeyResult['exists'] ?? false)) {
throw new Exception(__('Failed to read Jabali private key'));
}
$privateKey = $privateKeyResult['private_key'] ?? null;
if (empty($privateKey)) {
throw new Exception(__('Private key is empty'));
}
$store->addAccountLog($cpanelUser, __('Importing SSH key to cPanel...'), 'pending');
$importResult = $whm->importSshPrivateKey($cpanelUser, $keyName, $privateKey);
if (! ($importResult['success'] ?? false)) {
throw new Exception($importResult['message'] ?? __('Failed to import SSH key'));
}
$actualKeyName = $importResult['actual_key_name'] ?? $keyName;
$store->addAccountLog($cpanelUser, __('SSH key imported'), 'success');
$authResult = $whm->authorizeSshKey($cpanelUser, $actualKeyName);
if (! ($authResult['success'] ?? false)) {
$store->addAccountLog($cpanelUser, __('SSH key authorization skipped'), 'info');
} else {
$store->addAccountLog($cpanelUser, __('SSH key authorized'), 'success');
}
$store->addAccountLog($cpanelUser, __('Initiating backup transfer...'), 'pending');
$jabaliIp = $this->getJabaliPublicIp();
$backupResult = $whm->createBackupToScpWithKey(
$cpanelUser,
$jabaliIp,
'root',
$destPath,
$actualKeyName,
22
);
if (! ($backupResult['success'] ?? false)) {
throw new Exception($backupResult['message'] ?? __('Failed to start backup'));
}
$store->addAccountLog($cpanelUser, __('Backup initiated, transferring via SCP...'), 'success');
$store->updateAccountStatus($cpanelUser, 'backup_downloading', __('Waiting for backup file...'));
$backupPath = $this->waitForBackupFile($agent, $store, $cpanelUser, $destPath);
if (! $backupPath) {
throw new Exception(__('Backup file did not arrive'));
}
$store->addAccountLog($cpanelUser, __('Backup received: :size', ['size' => $this->formatBytes(filesize($backupPath))]), 'success');
$summary = $whm->getUserMigrationSummary($cpanelUser);
$discoveredData = $whm->convertApiDataToAgentFormat($summary);
$store->updateAccountStatus($cpanelUser, 'restoring', __('Restoring data...'));
$result = $agent->send('cpanel.restore_backup', [
'backup_path' => $backupPath,
'username' => $user->username,
'restore_files' => $this->restoreFiles,
'restore_databases' => $this->restoreDatabases,
'restore_emails' => $this->restoreEmails,
'restore_ssl' => $this->restoreSsl,
'discovered_data' => $discoveredData,
]);
if ($result['success'] ?? false) {
foreach ($result['log'] ?? [] as $entry) {
$store->addAccountLog($cpanelUser, $entry['message'], $entry['status'] ?? 'info');
}
$this->syncDnsZones($dnsSyncService, $user, $discoveredData);
$store->updateAccountStatus($cpanelUser, 'completed', __('Migration completed'));
@unlink($backupPath);
} else {
throw new Exception($result['error'] ?? __('Restore failed'));
}
} catch (Exception $e) {
Log::error('WHM migration failed for user', ['user' => $cpanelUser, 'error' => $e->getMessage()]);
$store->updateAccountStatus($cpanelUser, 'error', $e->getMessage(), 'error');
}
}
protected function createOrGetUser(AgentClient $agent, string $cpanelUser, string $email): ?User
{
$existingUser = User::where('username', $cpanelUser)->first();
if ($existingUser) {
return $existingUser;
}
if (User::where('email', $email)->exists()) {
$email = "{$cpanelUser}.".time().'@'.explode('@', $email)[1];
}
$password = bin2hex(random_bytes(12));
try {
if ($this->createLinuxUsers) {
exec('id '.escapeshellarg($cpanelUser).' 2>/dev/null', $output, $exitCode);
if ($exitCode !== 0) {
$result = $agent->send('user.create', [
'username' => $cpanelUser,
'password' => $password,
]);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? __('Failed to create system user'));
}
if (($result['fpm_pool_created'] ?? false) === true) {
$this->shouldReloadFpm = true;
}
}
}
return User::create([
'name' => ucfirst($cpanelUser),
'username' => $cpanelUser,
'email' => $email,
'password' => Hash::make($password),
'home_directory' => '/home/'.$cpanelUser,
'disk_quota_mb' => null,
'is_active' => true,
'is_admin' => false,
]);
} catch (Exception $e) {
Log::error('Failed to create user', ['username' => $cpanelUser, 'error' => $e->getMessage()]);
return null;
}
}
protected function waitForBackupFile(AgentClient $agent, WhmMigrationStatusStore $store, string $cpanelUser, string $destPath): ?string
{
$maxAttempts = 120;
$attempt = 0;
$lastSeenSize = 0;
$sizeStableCount = 0;
while ($attempt < $maxAttempts) {
$attempt++;
sleep(5);
$pattern = "{$destPath}/backup-*_{$cpanelUser}.tar.gz";
$files = glob($pattern);
if (empty($files)) {
$pattern = "{$destPath}/cpmove-{$cpanelUser}.tar.gz";
$files = glob($pattern);
}
if (empty($files)) {
if ($attempt % 6 === 0) {
$store->addAccountLog($cpanelUser, __('Waiting for backup file... (:count s)', ['count' => $attempt * 5]), 'pending');
}
continue;
}
usort($files, fn ($a, $b) => filemtime($b) - filemtime($a));
$backupFile = $files[0];
$currentSize = filesize($backupFile);
if ($currentSize > 0 && $currentSize === $lastSeenSize) {
$sizeStableCount++;
} else {
$sizeStableCount = 0;
}
$lastSeenSize = $currentSize;
if ($sizeStableCount >= 3 && $currentSize >= 10 * 1024) {
$agent->send('file.chown', [
'path' => $backupFile,
'owner' => 'www-data',
'group' => 'www-data',
]);
$handle = fopen($backupFile, 'rb');
$magic = $handle ? fread($handle, 2) : '';
if ($handle) {
fclose($handle);
}
if ($magic === "\x1f\x8b") {
return $backupFile;
}
$store->addAccountLog($cpanelUser, __('Invalid backup file format, waiting...'), 'warning');
$sizeStableCount = 0;
}
if ($attempt % 6 === 0) {
$store->addAccountLog($cpanelUser, __('Receiving backup... :size', [
'size' => $this->formatBytes($currentSize),
]), 'pending');
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $accounts
* @return array<string, array<string, mixed>>
*/
protected function indexAccountsByUser(array $accounts): array
{
$indexed = [];
foreach ($accounts as $account) {
if (! isset($account['user'])) {
continue;
}
$indexed[$account['user']] = $account;
}
return $indexed;
}
protected function getBackupDestPath(): string
{
return '/var/backups/jabali/whm-migrations';
}
protected function getSshKeyName(): string
{
return 'jabali-system-key';
}
/**
* @param array<string, mixed> $discoveredData
*/
protected function syncDnsZones(MigrationDnsSyncService $dnsSyncService, User $user, array $discoveredData): void
{
try {
$domains = $discoveredData['domains'] ?? [];
$dnsSyncService->syncDomainsForUser($user, $domains);
} catch (Exception $e) {
Log::warning('Failed to sync DNS zones after WHM migration', [
'user' => $user->username,
'error' => $e->getMessage(),
]);
}
}
protected function getJabaliPublicIp(): string
{
$ip = trim(shell_exec('curl -s ifconfig.me 2>/dev/null') ?? '');
if (empty($ip)) {
$ip = gethostbyname(gethostname());
}
return $ip;
}
protected function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision).' '.$units[$pow];
}
}