diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php index eb6b6b1..a5a9362 100644 --- a/app/Http/Controllers/Api/Service/VmController.php +++ b/app/Http/Controllers/Api/Service/VmController.php @@ -23,13 +23,16 @@ class VmController extends Controller /** * POST /v1/system/vm-power-on * - * Body: { "product": } + * Body: { "product": } */ public function powerOn(Request $request, PowerOnVMAction $action): JsonResponse { - $validated = $request->validate(['product' => ['required', 'integer']]); + $productId = $request->input('product'); + if (!$productId) { + return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422); + } - $vm = $this->resolveVm((int) $validated['product'], $request); + $vm = $this->resolveVmById($productId, $request); if ($vm instanceof JsonResponse) { return $vm; } @@ -46,13 +49,16 @@ class VmController extends Controller /** * POST /v1/system/vm-power-off * - * Body: { "product": } + * Body: { "product": } */ public function powerOff(Request $request, PowerOffVMAction $action): JsonResponse { - $validated = $request->validate(['product' => ['required', 'integer']]); + $productId = $request->input('product'); + if (!$productId) { + return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422); + } - $vm = $this->resolveVm((int) $validated['product'], $request); + $vm = $this->resolveVmById($productId, $request); if ($vm instanceof JsonResponse) { return $vm; } @@ -190,17 +196,17 @@ class VmController extends Controller * POST /v1/system/vm-change-port * * Body: - * product int ModuleVmware.id + * product int|string ModuleVmware.id|vmware_id|service_uuid * new_port int New RDP port (1024–65535) */ public function changePort(Request $request, ChangePortVMAction $action): JsonResponse { $validated = $request->validate([ - 'product' => ['required', 'integer'], + 'product' => ['required'], 'new_port' => ['required', 'integer', 'min:1024', 'max:65535'], ]); - $vm = $this->resolveVm((int) $validated['product'], $request); + $vm = $this->resolveVmById($validated['product'], $request); if ($vm instanceof JsonResponse) { return $vm; } @@ -239,25 +245,57 @@ class VmController extends Controller // ------------------------------------------------------------------------- /** - * Load a ModuleVmware by ID, verify the authenticated user owns it. + * Load a ModuleVmware by ID (int), vmware_id (string), or Service UUID. + * Verify the authenticated user owns it. * Returns JsonResponse on error, ModuleVmware on success. */ - private function resolveVm(int $id, Request $request): ModuleVmware|JsonResponse + private function resolveVmById(int|string $productId, Request $request): ModuleVmware|JsonResponse { - $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) - ->find($id); + $customer = $request->user(); + $vm = null; + + // Check if it's a UUID (Service UUID) + if (is_string($productId) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productId)) { + $service = Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') + ->where('uuid', $productId) + ->where('customer_id', $customer->id) + ->first(); + if ($service && $service->vmware) { + $vm = $service->vmware; + } + } elseif (is_numeric($productId)) { + // Numeric ID + $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) + ->find((int) $productId); + } else { + // vmware_id string (e.g., "vm-3238") + $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) + ->where('vmware_id', $productId) + ->where('customer_id', $customer->id) + ->first(); + } if (!$vm) { return response()->json(['success' => false, 'message' => 'Không tìm thấy VM.'], 404); } - if ((int) $vm->customer_id !== (int) $request->user()->id) { + if ((int) $vm->customer_id !== (int) $customer->id) { return response()->json(['success' => false, 'message' => 'Bạn không có quyền thao tác VM này.'], 403); } return $vm; } + /** + * Load a ModuleVmware by ID, verify the authenticated user owns it. + * Returns JsonResponse on error, ModuleVmware on success. + * @deprecated Use resolveVmById instead for better ID format support + */ + private function resolveVm(int $id, Request $request): ModuleVmware|JsonResponse + { + return $this->resolveVmById($id, $request); + } + /** * Normalise the array|string return value from Power ON/OFF actions. */ diff --git a/app/Http/Services/vCenter.php b/app/Http/Services/vCenter.php index 6830e11..929d84b 100644 --- a/app/Http/Services/vCenter.php +++ b/app/Http/Services/vCenter.php @@ -1159,13 +1159,25 @@ PS; ->status(); } - public function ConfigVirtualMachineUbuntu($vmware_id = '', $product = [], $password = ''): void + public function ConfigVirtualMachineUbuntu($vmware_id = '', $product = null, $password = ''): void { - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + if (!$product) { + Log::error('ConfigVirtualMachineUbuntu: Product is null', ['vmware_id' => $vmware_id]); + return; + } + + // Use resolveVCenterFromProduct to get vCenter + $vcenter = $this->resolveVCenterFromProduct($product); + if (!$vcenter) { + Log::error('ConfigVirtualMachineUbuntu: Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; - $IPAddress = $product->IpAddress->ip; - $Gateway = $product->IpAddress->gateway; - $HostName = strtolower($product->hostname); + $IPAddress = $product->ipAddress?->ip ?? '0.0.0.0'; + $Gateway = $product->ipAddress?->gateway ?? '0.0.0.0'; + $HostName = strtolower($product->hostname ?? 'vm'); Log::channel('Custom')->info("\r\n"); @@ -1317,11 +1329,20 @@ PS; public function ConfigCentOSVersion8($vmware_id = '', $product = [], $password = ''): void { - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + // Use resolveVCenterFromProduct to get vCenter + $vcenter = null; + if ($product) { + $vcenter = $this->resolveVCenterFromProduct($product); + } + if (!$vcenter) { + Log::error('Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; - $IPAddress = $product->IpAddress->ip; - $Gateway = $product->IpAddress->gateway; - $HostName = strtolower($product->hostname); + $IPAddress = $product->ipAddress?->ip ?? '0.0.0.0'; + $Gateway = $product->ipAddress?->gateway ?? '0.0.0.0'; + $HostName = strtolower($product->hostname ?? 'vm'); Log::channel('Custom')->info("\r\n"); try { @@ -1350,11 +1371,20 @@ PS; public function ConfigCentOSVersion7($vmware_id = '', $product = [], $password = ''): void { - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + // Use resolveVCenterFromProduct to get vCenter + $vcenter = null; + if ($product) { + $vcenter = $this->resolveVCenterFromProduct($product); + } + if (!$vcenter) { + Log::error('Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; - $IPAddress = $product->IpAddress->ip; - $Gateway = $product->IpAddress->gateway; - $HostName = strtolower($product->hostname); + $IPAddress = $product->ipAddress?->ip ?? '0.0.0.0'; + $Gateway = $product->ipAddress?->gateway ?? '0.0.0.0'; + $HostName = strtolower($product->hostname ?? 'vm'); Log::channel('Custom')->info("\r\n"); try { @@ -1385,19 +1415,21 @@ PS; { try { // Kiểm tra null safety - if (!$product || !$product->IpAddress || !$product->IpAddress->Host || !$product->IpAddress->Host->Datacenter || !$product->IpAddress->Host->Datacenter->vCenter) { - Log::error('❌ ConfigWindowsVersion10: Missing required data', [ - 'vmware_id' => $vmware_id, - 'product_id' => $product?->id ?? 'unknown', - 'ip_address' => $product?->IpAddress?->ip ?? 'unknown', - 'host' => $product?->IpAddress?->Host?->name ?? 'unknown', - 'datacenter' => $product?->IpAddress?->Host?->Datacenter?->name ?? 'unknown', - 'vcenter' => $product?->IpAddress?->Host?->Datacenter?->vCenter?->host ?? 'unknown' - ]); + if (!$product) { + Log::error('❌ ConfigWindowsVersion10: Product is null', ['vmware_id' => $vmware_id]); return; } - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + // Use resolveVCenterFromProduct to get vCenter + $vcenter = null; + if ($product) { + $vcenter = $this->resolveVCenterFromProduct($product); + } + if (!$vcenter) { + Log::error('Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; $IPAddress = $product->IpAddress->ip; $Gateway = $product->IpAddress->gateway; @@ -1429,19 +1461,21 @@ PS; { try { // Kiểm tra null safety - if (!$product || !$product->IpAddress || !$product->IpAddress->Host || !$product->IpAddress->Host->Datacenter || !$product->IpAddress->Host->Datacenter->vCenter) { - Log::error('❌ ConfigWindowsServer: Missing required data', [ - 'vmware_id' => $vmware_id, - 'product_id' => $product?->id ?? 'unknown', - 'ip_address' => $product?->IpAddress?->ip ?? 'unknown', - 'host' => $product?->IpAddress?->Host?->name ?? 'unknown', - 'datacenter' => $product?->IpAddress?->Host?->Datacenter?->name ?? 'unknown', - 'vcenter' => $product?->IpAddress?->Host?->Datacenter?->vCenter?->host ?? 'unknown' - ]); + if (!$product) { + Log::error('❌ ConfigWindowsServer: Product is null', ['vmware_id' => $vmware_id]); return; } - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + // Use resolveVCenterFromProduct to get vCenter + $vcenter = null; + if ($product) { + $vcenter = $this->resolveVCenterFromProduct($product); + } + if (!$vcenter) { + Log::error('Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; $IPAddress = $product->IpAddress->ip; $Gateway = $product->IpAddress->gateway; @@ -1473,19 +1507,18 @@ PS; { try { // Kiểm tra null safety - if (!$product || !$product->IpAddress || !$product->IpAddress->Host || !$product->IpAddress->Host->Datacenter || !$product->IpAddress->Host->Datacenter->vCenter) { - Log::error('❌ ConfigFixWindowsClick: Missing required data', [ - 'vmware_id' => $vmware_id, - 'product_id' => $product?->id ?? 'unknown', - 'ip_address' => $product?->IpAddress?->ip ?? 'unknown', - 'host' => $product?->IpAddress?->Host?->name ?? 'unknown', - 'datacenter' => $product?->IpAddress?->Host?->Datacenter?->name ?? 'unknown', - 'vcenter' => $product?->IpAddress?->Host?->Datacenter?->vCenter?->host ?? 'unknown' - ]); + if (!$product) { + Log::error('❌ ConfigFixWindowsClick: Product is null', ['vmware_id' => $vmware_id]); return; } - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + // Use resolveVCenterFromProduct to get vCenter + $vcenter = $this->resolveVCenterFromProduct($product); + if (!$vcenter) { + Log::error('Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; Log::channel('Custom')->info("==================================================================="); @@ -1513,23 +1546,22 @@ PS; { try { // Kiểm tra null safety - if (!$product || !$product->IpAddress || !$product->IpAddress->Host || !$product->IpAddress->Host->Datacenter || !$product->IpAddress->Host->Datacenter->vCenter) { - Log::error('❌ ConfigRockyLinuxVersion9: Missing required data', [ - 'vmware_id' => $vmware_id, - 'product_id' => $product?->id ?? 'unknown', - 'ip_address' => $product?->IpAddress?->ip ?? 'unknown', - 'host' => $product?->IpAddress?->Host?->name ?? 'unknown', - 'datacenter' => $product?->IpAddress?->Host?->Datacenter?->name ?? 'unknown', - 'vcenter' => $product?->IpAddress?->Host?->Datacenter?->vCenter?->host ?? 'unknown' - ]); + if (!$product) { + Log::error('❌ ConfigRockyLinuxVersion9: Product is null', ['vmware_id' => $vmware_id]); return; } - $this->SetConfig($product->IpAddress->Host->Datacenter->vCenter); + // Use resolveVCenterFromProduct to get vCenter + $vcenter = $this->resolveVCenterFromProduct($product); + if (!$vcenter) { + Log::error('Cannot resolve vCenter', ['vmware_id' => $vmware_id]); + return; + } + $this->SetConfig($vcenter); $VirtualMachine = "VirtualMachine-$vmware_id"; - $IPAddress = $product->IpAddress->ip; - $Gateway = $product->IpAddress->gateway; - $HostName = strtolower($product->hostname); + $IPAddress = $product->ipAddress?->ip ?? '0.0.0.0'; + $Gateway = $product->ipAddress?->gateway ?? '0.0.0.0'; + $HostName = strtolower($product->hostname ?? 'vm'); Log::channel('Custom')->info("\r\n"); Log::channel('Custom')->info("==================================================================="); diff --git a/app/Jobs/VirtualMachine/Create/ChangePasswordViaSshJob.php b/app/Jobs/VirtualMachine/Create/ChangePasswordViaSshJob.php new file mode 100644 index 0000000..207576c --- /dev/null +++ b/app/Jobs/VirtualMachine/Create/ChangePasswordViaSshJob.php @@ -0,0 +1,192 @@ + $this->moduleVmwareId, + 'ip' => $this->ip, + 'username' => $this->username, + ]); + + $output = $this->runWithRetry(); + + if ($output === null) { + $this->fail(new \RuntimeException( + "SSH connection to {$this->ip} failed after max attempts" + )); + return; + } + + Log::info('ChangePasswordViaSshJob: chpasswd output', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'output' => trim($output), + ]); + + // Update DB + $vm = ModuleVmware::find($this->moduleVmwareId); + + if (!$vm) { + Log::error('ChangePasswordViaSshJob: ModuleVmware not found', [ + 'module_vmware_id' => $this->moduleVmwareId, + ]); + return; + } + + $vm->update([ + 'password' => SignRSA::EncryptFunc($this->newPassword), + 'plain_password' => $this->newPassword, + ]); + + $this->sendPasswordEmail($vm); + } + + // ───────────────────────────────────────────────────────────────────────── + + /** + * Run chpasswd on the remote VM via sshpass + native SSH. + * Retries up to 10 times with 30 s delay (VM may still be booting). + */ + private function runWithRetry(int $maxAttempts = 10, int $sleepSeconds = 30): ?string + { + // Remote bash command: echo 'user:pass' | chpasswd + $credential = $this->username . ':' . $this->newPassword; + $remoteCmd = 'echo ' . escapeshellarg($credential) . ' | chpasswd 2>&1'; + + $sshCmd = sprintf( + 'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -p 22 %s@%s %s 2>&1', + escapeshellarg($this->initialPassword), + escapeshellarg($this->username), + escapeshellarg($this->ip), + escapeshellarg($remoteCmd), + ); + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $output = shell_exec($sshCmd); + + // null = shell_exec failed (command not found, etc.) + // sshpass exits non-zero on connection failure → output contains error text + if ($output !== null && !$this->isSshConnectionError($output)) { + Log::info('ChangePasswordViaSshJob: SSH connected', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'attempt' => $attempt, + ]); + return $output; + } + + Log::warning('ChangePasswordViaSshJob: SSH not ready, retrying', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'attempt' => $attempt, + 'output_preview' => substr((string) $output, 0, 200), + ]); + + if ($attempt < $maxAttempts) { + sleep($sleepSeconds); + } + } + + return null; + } + + /** + * Detect SSH connection failures (not auth errors) in sshpass output. + */ + private function isSshConnectionError(string $output): bool + { + $lower = strtolower($output); + + return str_contains($lower, 'connection refused') + || str_contains($lower, 'no route to host') + || str_contains($lower, 'connection timed out') + || str_contains($lower, 'host is down') + || str_contains($lower, 'network is unreachable'); + } + + // ───────────────────────────────────────────────────────────────────────── + + public function failed(\Throwable $e): void + { + Log::error('ChangePasswordViaSshJob: failed', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'ip' => $this->ip, + 'error' => $e->getMessage(), + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + + private function sendPasswordEmail(ModuleVmware $vm): void + { + try { + $customer = $vm->customer; + + if (!$customer) { + Log::error('ChangePasswordViaSshJob: customer not found', [ + 'module_vmware_id' => $this->moduleVmwareId, + ]); + return; + } + + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + $emailData = [ + 'company_name' => $settingValue->company ?? 'Thế Giới Số', + 'company_logo' => $settingValue->logo ?? '', + 'client_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'product_name' => 'Cloud VPS', + 'product_billing' => 'N/A', + 'product_price' => 'N/A', + 'service_host' => $this->ip, + 'service_username' => $this->username, + 'service_port' => 22, + 'service_password' => $this->newPassword, + 'os_name' => ($vm->os?->osGroup?->name ?? '') . ' ' . ($vm->os?->version ?? ''), + 'service_due_exp' => $vm->next_due_date ? date('d/m/Y', (int) $vm->next_due_date) : 'N/A', + ]; + + SendInformationVirtualMachine::dispatch($emailData, $customer) + ->delay(now()->addSeconds(5)); + + Log::info('ChangePasswordViaSshJob: email dispatched', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'customer_email' => $customer->email, + ]); + } catch (\Throwable $e) { + Log::error('ChangePasswordViaSshJob: email failed: ' . $e->getMessage(), [ + 'module_vmware_id' => $this->moduleVmwareId, + ]); + } + } +} diff --git a/app/Jobs/VirtualMachine/Create/ConfigCLICommand.php b/app/Jobs/VirtualMachine/Create/ConfigCLICommand.php index 37c2db5..79f9e86 100644 --- a/app/Jobs/VirtualMachine/Create/ConfigCLICommand.php +++ b/app/Jobs/VirtualMachine/Create/ConfigCLICommand.php @@ -3,6 +3,8 @@ namespace App\Jobs\VirtualMachine\Create; use App\Http\Facade\HelperFacade as Helper; +use App\Jobs\Mail\SendInformationVirtualMachine; +use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -36,87 +38,171 @@ class ConfigCLICommand implements ShouldQueue */ public function handle() { + // Generate new password $PasswordVirtualMachine = Helper::general_password(); $usersName = ''; $port = ''; + // Get VM with relationships + $vm = \App\Models\ModuleVmware::with(['ipAddress', 'os.osGroup']) + ->where('vmware_id', $this->vmware_id) + ->first(); + + if (!$vm) { + Log::error('❌ ConfigCLICommand: VM not found', ['vmware_id' => $this->vmware_id]); + return; + } + + // Determine OS type and config + $osGroupName = $vm->os?->osGroup?->name ?? ''; + $osVersion = $vm->os?->version ?? ''; + try { - $vm = \App\Models\ModuleVmware::where('vmware_id', $this->vmware_id)->first(); - if ($vm) { - $vm->update([ - 'password' => \App\Http\Facade\SignRSA::EncryptFunc($PasswordVirtualMachine), - 'plain_password' => $PasswordVirtualMachine, - ]); + switch (true) { + case (str_contains($osGroupName, "Windows") && str_contains($osVersion, 10)): + $usersName = 'administrator'; + $port = 3389; + $this->serviceVCenter->ConfigWindowsVersion10($this->vmware_id, $vm, $PasswordVirtualMachine); + break; + + case (str_contains($osGroupName, "Windows")): + $usersName = 'administrator'; + $port = 3389; + $this->serviceVCenter->ConfigWindowsServer($this->vmware_id, $vm, $PasswordVirtualMachine); + break; + + case (str_contains($osGroupName, "CentOS") && str_contains($osVersion, 7)): + $usersName = 'root'; + $port = 22; + $this->serviceVCenter->ConfigCentOSVersion7($this->vmware_id, $vm, $PasswordVirtualMachine); + break; + + case (str_contains($osGroupName, "CentOS") && str_contains($osVersion, 8)): + $usersName = 'root'; + $port = 22; + $this->serviceVCenter->ConfigCentOSVersion8($this->vmware_id, $vm, $PasswordVirtualMachine); + break; + + case (str_contains($osGroupName, "Ubuntu")): + $usersName = 'root'; + $port = 22; + $this->serviceVCenter->ConfigVirtualMachineUbuntu($this->vmware_id, $vm, $PasswordVirtualMachine); + break; + + case (str_contains($osGroupName, "Rocky")): + $usersName = 'root'; + $port = 22; + $this->serviceVCenter->ConfigRockyLinuxVersion9($this->vmware_id, $vm, $PasswordVirtualMachine); + break; + + default: + Log::warning('❌ ConfigCLICommand: Unknown OS', [ + 'vmware_id' => $this->vmware_id, + 'os_group' => $osGroupName, + 'os_version' => $osVersion, + ]); + return; } + + // Wait for VM to be ready + sleep(20); + + $VirtualMachine = $this->serviceVCenter->CheckVirtualMachine($this->vmware_id); + + if ($VirtualMachine['data']['operations_ready'] === false) { + $maxAttempts = 30; // 5 minutes max (30 * 10 seconds) + $attempt = 0; + do { + sleep(10); + $VirtualMachine = $this->serviceVCenter->CheckVirtualMachine($this->vmware_id); + Log::channel('Custom')->info(json_encode($VirtualMachine)); + $attempt++; + } while ($VirtualMachine['data']['operations_ready'] !== true && $attempt < $maxAttempts); + } + + // Fix Windows click if Windows + if (str_contains($osGroupName, "Windows")) { + $this->serviceVCenter->ConfigFixWindowsClick($this->vmware_id, $vm); + } + + // ✅ Save password to DB AFTER successful config + $vm->update([ + 'password' => \App\Http\Facade\SignRSA::EncryptFunc($PasswordVirtualMachine), + 'plain_password' => $PasswordVirtualMachine, + 'port' => $port, + 'user' => $usersName, + ]); + + Log::info('✅ ConfigCLICommand: Password changed and saved', [ + 'vmware_id' => $this->vmware_id, + 'username' => $usersName, + 'port' => $port, + ]); + + // Send email with new password to customer + $this->sendPasswordEmail($vm, $PasswordVirtualMachine, $usersName, $port); + + // Dispatch CheckIP job + $jobs_CheckIP = new CheckIPAddress( + $this->vmware_id, + $vm, + ['user' => $usersName, 'password' => $PasswordVirtualMachine, 'port' => $port], + $this->serviceVCenter + ); + dispatch($jobs_CheckIP)->delay(now()->addSeconds(5)); + } catch (\Exception $e) { - Log::error('❌ Lỗi khi lưu password: ' . $e->getMessage()); + Log::error('❌ ConfigCLICommand: Error during configuration', [ + 'vmware_id' => $this->vmware_id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; } + } - switch (true) { - case (str_contains($this->data->Os->OsGroup->name, "Windows") && str_contains($this->data->Os->version, 10)): - $usersName = 'administrator'; - $port = 533892; - $this->serviceVCenter->ConfigWindowsVersion10($this->vmware_id, $this->data, $PasswordVirtualMachine); - break; + /** + * Send email with new password to customer + */ + private function sendPasswordEmail($vm, string $password, string $username, int $port): void + { + try { + $customer = $vm->customer; + if (!$customer) { + Log::error('ConfigCLICommand: Cannot find customer', ['vm_id' => $vm->id]); + return; + } - case (str_contains($this->data->Os->OsGroup->name, "Windows")): - $usersName = 'administrator'; - $port = 53389; - $this->serviceVCenter->ConfigWindowsServer($this->vmware_id, $this->data, - $PasswordVirtualMachine); + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode($setting->value) : null; - break; + $emailData = [ + 'company_name' => $settingValue->company ?? 'Thế Giới Số', + 'company_logo' => $settingValue->logo ?? '', + 'client_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'product_name' => 'Cloud VPS', + 'product_billing' => 'N/A', + 'product_price' => 'N/A', + 'service_host' => $vm->ipAddress?->ip ?? 'N/A', + 'service_username' => $username, + 'service_port' => $port, + 'service_password' => $password, + 'os_name' => $vm->os?->osGroup?->name . ' ' . $vm->os?->version ?? 'Unknown OS', + 'service_due_exp' => $vm->next_due_date ? date('d/m/Y', $vm->next_due_date) : 'N/A', + ]; - case (str_contains($this->data->Os->OsGroup->name, "CentOS") && str_contains($this->data->Os->version, 7)): - $usersName = 'root'; - $port = 22; - $this->serviceVCenter->ConfigCentOSVersion7($this->vmware_id, $this->data, - $PasswordVirtualMachine); + SendInformationVirtualMachine::dispatch($emailData, $customer) + ->delay(now()->addSeconds(10)); - break; - - case (str_contains($this->data->Os->OsGroup->name, "CentOS") && str_contains($this->data->Os->version, 8)): - $usersName = 'root'; - $port = 22; - $this->serviceVCenter->ConfigCentOSVersion8($this->vmware_id, $this->data, - $PasswordVirtualMachine); - - break; - - case (str_contains($this->data->Os->OsGroup->name, "Ubuntu")): - $usersName = 'root'; - $port = 22; - $this->serviceVCenter->ConfigVirtualMachineUbuntu($this->vmware_id, $this->data, - $PasswordVirtualMachine); - break; - - case (str_contains($this->data->Os->OsGroup->name, "Rocky")): - $usersName = 'root'; - $port = 22; - $this->serviceVCenter->ConfigRockyLinuxVersion9($this->vmware_id, $this->data, - $PasswordVirtualMachine); - break; + Log::info('✅ ConfigCLICommand: Password email dispatched', [ + 'customer_email' => $customer->email, + 'vmware_id' => $vm->vmware_id, + ]); + } catch (\Throwable $e) { + Log::error('❌ ConfigCLICommand: Failed to dispatch password email: ' . $e->getMessage(), [ + 'vmware_id' => $vm->vmware_id, + 'error' => $e->getMessage(), + ]); } - sleep(20); - - $VirtualMachine = $this->serviceVCenter->CheckVirtualMachine($this->vmware_id); - - if ($VirtualMachine['data']['operations_ready'] === false) { - do { - sleep(10); - - $VirtualMachine = $this->serviceVCenter->CheckVirtualMachine($this->vmware_id); - - Log::channel('Custom')->info(json_encode($VirtualMachine)); - } while ($VirtualMachine['data']['operations_ready'] !== true); - - } - if (str_contains($this->data->Os->OsGroup->name, "Windows")) - { - $this->serviceVCenter->ConfigFixWindowsClick($this->vmware_id, $this->data); - } - $jobs_CheckIP = new CheckIPAddress($this->vmware_id,$this->data,['user' => $usersName,'password' => $PasswordVirtualMachine,'port' => $port],$this->serviceVCenter); - dispatch($jobs_CheckIP)->delay(now()->addSeconds(5)); - } } diff --git a/app/Jobs/VirtualMachine/Create/ConfigDatabase.php b/app/Jobs/VirtualMachine/Create/ConfigDatabase.php index 0af2731..40ee000 100644 --- a/app/Jobs/VirtualMachine/Create/ConfigDatabase.php +++ b/app/Jobs/VirtualMachine/Create/ConfigDatabase.php @@ -72,9 +72,9 @@ class ConfigDatabase implements ShouldQueue 'service_due_exp' => Carbon::parse($this->data->next_due_date)->format('d/m/Y') ]; - $mail = new SendInformationVirtualMachine($data, $this->data->Customer); - - dispatch($mail)->delay(now()->addSeconds(5)); + // Note: Email is now sent by ConfigCLICommand after password is changed + // $mail = new SendInformationVirtualMachine($data, $this->data->Customer); + // dispatch($mail)->delay(now()->addSeconds(5)); Event(new Create_VirtualMachine_Success("Máy chủ của bạn đã được khởi tạo thành công!", $this->data->customer->id)); diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index ed9ffc7..2929e8a 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -6,8 +6,9 @@ use App\Http\Facade\SignRSA; use App\Http\Services\Telegram; use App\Http\Services\vCenter; use App\Jobs\Mail\Customer\VMCreationFailedJob; -use App\Jobs\VirtualMachine\Create\AssignIPJob; -use App\Jobs\VirtualMachine\Create\ConfigCLICommand; +use App\Jobs\Mail\SendInformationVirtualMachine; +use App\Jobs\VirtualMachine\Provision\ChangePasswordViaVsphereJob; +use App\Services\VmProvisionService; use App\Models\OrderItem; use App\Models\Os; use App\Models\ModuleVmware; @@ -15,6 +16,7 @@ use App\Models\ModuleIpAddress; use App\Models\IpPool; use App\Models\Log as CustomerLog; use App\Models\Service; +use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -27,6 +29,12 @@ class CreateVirtualMachineJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** Cho phép job chạy tối đa 10 phút (VM clone có thể mất 5-8 phút) */ + public int $timeout = 600; + + /** Không retry tự động — lỗi VM cần admin kiểm tra thủ công */ + public int $tries = 1; + public OrderItem $item; public bool $isRetry = false; @@ -152,6 +160,12 @@ class CreateVirtualMachineJob implements ShouldQueue } } + // Xác định OS family để chọn username và luồng cấu hình phù hợp + $osGroupName = $osModel->osGroup?->name ?? ''; + $isWindows = str_contains(strtolower($osGroupName), 'windows'); + $vmUser = $isWindows ? 'administrator' : 'root'; + $vmPort = $isWindows ? 3389 : 22; + $result = $vCenter->CreateVirtualMachine([ 'item' => $this->item, 'host' => $host, @@ -205,10 +219,7 @@ class CreateVirtualMachineJob implements ShouldQueue // vm_id được lưu vào tbl.services qua ModuleVmwareObserver::created() $startResult = $this->startVirtualMachineDirectly($vCenter, $vmId); - if (is_array($startResult) && $startResult['error'] === false) { - $configJob = new ConfigCLICommand($vmId, $this->item, $vCenter); - dispatch($configJob)->delay(now()->addMinutes(2)); // Chờ 2 phút để VM boot hoàn toàn - } + // VM started — SSH vào vSphere host để đổi mật khẩu qua PS1 script try { $customer = $this->item->order->customer; @@ -232,20 +243,19 @@ class CreateVirtualMachineJob implements ShouldQueue $firstPayment = (int) $this->item->price * ($months ?: 1); $nextDueDate = $months > 0 ? now()->addMonths($months)->timestamp : null; - $initialPassword = 'TGS@' . \Illuminate\Support\Str::random(8); - $moduleVmware = ModuleVmware::create([ 'name' => $serverName, 'module_ip_address' => $moduleIpAddress->id, - 'port' => 22, + 'ip' => $availableIP, + 'port' => $vmPort, 'order_id' => $this->item->order->id, 'first_payment' => $firstPayment, 'next_payment' => $firstPayment, 'next_due_date' => $nextDueDate, 'next_invoice' => $nextDueDate, - 'user' => 'administrator', - 'password' => $initialPassword, - 'plain_password' => $initialPassword, + 'user' => $vmUser, + 'password' => VmProvisionService::TEMPLATE_PASSWORD, + 'plain_password' => VmProvisionService::TEMPLATE_PASSWORD, 'hostname' => $serverName, 'vmware_id' => $vmId, 'vcenter_id' => $vcenter->id, @@ -254,11 +264,21 @@ class CreateVirtualMachineJob implements ShouldQueue 'status' => 1, ]); - // Explicit sync: update services table directly (belt + suspenders vs observer) + // Sync services table $this->syncServiceRecord($moduleVmware); - AssignIPJob::dispatch($vmId, $moduleVmware->id, $moduleIpAddress->ip) - ->delay(now()->addMinutes(5)); + // SSH → vSphere host → PS1 → change password → send email + ChangePasswordViaVsphereJob::dispatch( + $moduleVmware->id, + $vmId, + $isWindows ? VmProvisionService::OS_WINDOWS : VmProvisionService::OS_LINUX, + ); + + Log::info('ChangePasswordViaVsphereJob dispatched', [ + 'module_vmware_id' => $moduleVmware->id, + 'vm_id' => $vmId, + 'os_family' => $isWindows ? 'WINDOWS' : 'LINUX', + ]); } catch (\Exception $e) { Log::error('❌ Lỗi khi lưu dữ liệu VM và IP address: ' . $e->getMessage(), [ 'vmware_id' => $vmId, @@ -380,26 +400,31 @@ class CreateVirtualMachineJob implements ShouldQueue private function getAvailableIPFromPool(int $networkId): string { - // Lấy tất cả IP từ IPPool theo network_id, order by ip_address - $ipPoolList = IpPool::where('module_network_id', $networkId) - ->orderBy('ip_address') - ->get(); - - if ($ipPoolList->isEmpty()) { - // Nếu không có IP trong pool, trả về IP default - return '0.0.0.0'; - } - + // Lấy tất cả IPs đã được assign trong network này $assignedIPs = ModuleIpAddress::where('module_network_id', $networkId) ->pluck('ip') ->toArray(); - foreach ($ipPoolList as $ipPool) { - if (!in_array($ipPool->ip_address, $assignedIPs)) { - return $ipPool->ip_address; - } + // Lấy ngẫu nhiên 1 IP từ pool chưa được assign + $available = IpPool::where('module_network_id', $networkId) + ->when(!empty($assignedIPs), fn ($q) => $q->whereNotIn('ip_address', $assignedIPs)) + ->inRandomOrder() + ->first(); + + if (!$available) { + Log::warning('getAvailableIPFromPool: không còn IP trống trong pool', [ + 'network_id' => $networkId, + 'assigned_count' => count($assignedIPs), + ]); + return '0.0.0.0'; } - return '0.0.0.0'; + + Log::info('getAvailableIPFromPool: IP được chọn', [ + 'network_id' => $networkId, + 'ip' => $available->ip_address, + ]); + + return $available->ip_address; } /** @@ -504,4 +529,20 @@ class CreateVirtualMachineJob implements ShouldQueue ]); } } + + /** + * Gửi email thông báo VM đã khởi tạo thành công cho khách hàng. + * Gọi ngay sau khi tạo module_vmware — cho cả Linux lẫn Windows. + */ + private function getBillingCycleLabel(?int $months): string + { + return match ($months) { + 1 => 'Hàng tháng', + 3 => '3 Tháng', + 6 => '6 Tháng', + 12 => 'Hàng năm', + 24 => '2 Năm', + default => $months . ' Tháng', + }; + } } diff --git a/app/Jobs/VirtualMachine/Provision/ChangePasswordJob.php b/app/Jobs/VirtualMachine/Provision/ChangePasswordJob.php new file mode 100644 index 0000000..2bd5c8e --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/ChangePasswordJob.php @@ -0,0 +1,138 @@ + $this->vmId, + 'ip' => $this->guestIp, + 'os_family' => $this->osFamily, + 'username' => $this->username, + ]); + + // [7] Generate secure password + $newPassword = $provision->generatePassword(16); + + // [8] Change password on OS + $this->changePassword($provision, $newPassword); + + // [9] Verify new credentials work (critical — throws on failure) + $this->verifyPassword($provision, $newPassword); + + // [10] Persist encrypted password + guest IP to DB + $provision->savePassword($this->moduleVmwareId, $this->guestIp, $newPassword); + + // [11] Send credentials email + SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); + + Log::info('ChangePasswordJob: completed', [ + 'vm_id' => $this->vmId, + 'module_vmware_id' => $this->moduleVmwareId, + ]); + } + + public function failed(\Throwable $e): void + { + Log::error('ChangePasswordJob: failed after max retries', [ + 'vm_id' => $this->vmId, + 'module_vmware_id' => $this->moduleVmwareId, + 'error' => $e->getMessage(), + ]); + + \App\Models\ModuleVmware::where('id', $this->moduleVmwareId) + ->update(['status' => 0]); + } + + // ------------------------------------------------------------------------- + + private function changePassword(VmProvisionService $provision, string $newPassword): void + { + if ($this->osFamily === VmProvisionService::OS_WINDOWS) { + Log::info('ChangePasswordJob: changing password via WinRM', [ + 'vm_id' => $this->vmId, + 'ip' => $this->guestIp, + ]); + + $provision->winrmChangePassword( + $this->guestIp, + $this->username, + $newPassword, + $this->vmId, + ); + } else { + Log::info('ChangePasswordJob: changing password via SSH', [ + 'vm_id' => $this->vmId, + 'ip' => $this->guestIp, + ]); + + $provision->sshChangePassword( + $this->guestIp, + $this->username, + $newPassword, + $this->vmId, + ); + } + } + + private function verifyPassword(VmProvisionService $provision, string $newPassword): void + { + Log::info('ChangePasswordJob: verifying new credentials', [ + 'vm_id' => $this->vmId, + 'ip' => $this->guestIp, + ]); + + if ($this->osFamily === VmProvisionService::OS_WINDOWS) { + $provision->verifyWinrmLogin($this->guestIp, $this->username, $newPassword, $this->vmId); + } else { + $provision->verifySshLogin($this->guestIp, $this->username, $newPassword, $this->vmId); + } + + Log::info('ChangePasswordJob: credential verification passed', [ + 'vm_id' => $this->vmId, + ]); + } +} diff --git a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php new file mode 100644 index 0000000..dc4a178 --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php @@ -0,0 +1,131 @@ + $this->moduleVmwareId, + 'vm_id' => $this->vmId, + 'os_family' => $this->osFamily, + ]); + + // [2] Generate plain-text password (not hashed) + $newPassword = $provision->generatePassword(16); + + // [3] Load vCenter credentials from database + $vcenter = vCenter::first(); + + if (!$vcenter) { + throw new \RuntimeException('No vCenter record found in module_vcenter'); + } + + $vcenterHost = $vcenter->host; + $vcenterUser = $vcenter->user; + $vcenterPass = SignRSA::DecryptFunc($vcenter->password); + + // [4–5] SSH → vSphere host → run PS1 with 5 params + $output = $ssh->changePassword( + $this->vmId, + $newPassword, + $this->osFamily, + $vcenterHost, + $vcenterUser, + $vcenterPass, + ); + + // [6] Parse output — throw on FAIL so job retries + $this->assertSuccess($output); + + // [7] Save encrypted + plain password; IP already set during VM creation + $provision->updatePassword($this->moduleVmwareId, $newPassword); + + // [8] Send credentials email (loads fresh VM data from DB) + SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); + + Log::info('ChangePasswordViaVsphereJob: completed', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + + public function failed(\Throwable $e): void + { + Log::error('ChangePasswordViaVsphereJob: all retries exhausted', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + 'error' => $e->getMessage(), + ]); + + ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); + } + + // ───────────────────────────────────────────────────────────────────────── + + /** + * PS1 scripts output exactly "SUCCESS" or "FAIL". + * Throw RuntimeException on FAIL → triggers job retry. + * + * @throws \RuntimeException + */ + private function assertSuccess(string $output): void + { + $trimmed = trim($output); + + if (str_contains(strtoupper($trimmed), 'SUCCESS')) { + return; + } + + throw new \RuntimeException( + "PS1 script returned failure for VM {$this->vmId}. Output: {$trimmed}", + ); + } +} diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php new file mode 100644 index 0000000..61c7542 --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -0,0 +1,113 @@ +find($this->moduleVmwareId); + + if (!$vm) { + Log::error('SendVmCredentialsEmailJob: ModuleVmware not found', [ + 'module_vmware_id' => $this->moduleVmwareId, + ]); + return; + } + + $customer = $vm->customer; + + if (!$customer?->email) { + Log::error('SendVmCredentialsEmailJob: customer or email missing', [ + 'module_vmware_id' => $this->moduleVmwareId, + ]); + return; + } + + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + // Resolve billing info from order items + $orderItem = $vm->order?->orderItems?->first(); + $months = (int) ($orderItem?->months ?? 0); + + $emailData = [ + 'company_name' => $settingValue->company ?? 'Thế Giới Số', + 'company_logo' => $settingValue->logo ?? '', + 'client_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS', + 'product_billing' => $this->billingLabel($months), + 'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A', + 'service_host' => $vm->ip ?? 'N/A', + 'service_username' => $vm->user, + 'service_port' => $vm->port, + 'service_password' => $vm->plain_password ?? '(xem tài khoản quản lý)', + 'os_name' => $vm->os + ? (($vm->os->osGroup?->name ?? '') . ' ' . $vm->os->version) + : 'Unknown OS', + 'service_due_exp' => $vm->next_due_date + ? date('d/m/Y', (int) $vm->next_due_date) + : 'N/A', + ]; + + SendInformationVirtualMachine::dispatch($emailData, $customer); + + Log::info('SendVmCredentialsEmailJob: email dispatched', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'customer_email' => $customer->email, + 'ip' => $vm->ip, + ]); + } + + public function failed(\Throwable $e): void + { + Log::error('SendVmCredentialsEmailJob: failed', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'error' => $e->getMessage(), + ]); + } + + // ------------------------------------------------------------------------- + + private function billingLabel(?int $months): string + { + return match ($months) { + 1 => 'Hàng tháng', + 3 => '3 Tháng', + 6 => '6 Tháng', + 12 => 'Hàng năm', + 24 => '2 Năm', + default => ($months ?? 0) . ' Tháng', + }; + } +} diff --git a/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php b/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php new file mode 100644 index 0000000..5a6f128 --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php @@ -0,0 +1,138 @@ +attempts(); + + Log::info('WaitVmReadyJob: polling', [ + 'vm_id' => $this->vmId, + 'attempt' => $attempt, + 'os_family' => $this->osFamily, + ]); + + // ── 1. Login to vCenter ─────────────────────────────────────────────── + $vcenter = \App\Models\vCenter::find($this->vcenterId); + + if (!$vcenter) { + Log::error('WaitVmReadyJob: vCenter model not found', ['vcenter_id' => $this->vcenterId]); + $this->fail(new \RuntimeException("vCenter id={$this->vcenterId} not found")); + return; + } + + try { + $provision->loginVCenter($vcenter, $vCenterService); + } catch (\Throwable $e) { + Log::warning('WaitVmReadyJob: vCenter login failed, will retry', [ + 'vm_id' => $this->vmId, + 'error' => $e->getMessage(), + ]); + $this->releaseOrFail(15, $attempt); + return; + } + + // ── 2. Check VMware Tools + guest IP ───────────────────────────────── + // Invoke-VMScript requires VMware Tools to be running inside the guest. + $guest = $provision->getVmGuestInfo($vCenterService, $this->vmId); + + Log::info('WaitVmReadyJob: guest info', [ + 'vm_id' => $this->vmId, + 'attempt' => $attempt, + 'tools_running' => $guest['tools_running'], + 'ip_address' => $guest['ip_address'], + ]); + + if (!$guest['tools_running'] || $guest['ip_address'] === null) { + Log::info('WaitVmReadyJob: VMware Tools not ready yet', ['vm_id' => $this->vmId]); + $this->releaseOrFail(15, $attempt); + return; + } + + $guestIp = $guest['ip_address']; + + // ── 3. VM ready — dispatch password-change job ──────────────────────── + Log::info('WaitVmReadyJob: VMware Tools ready, dispatching ChangePasswordViaVsphereJob', [ + 'vm_id' => $this->vmId, + 'guest_ip' => $guestIp, + ]); + + ChangePasswordViaVsphereJob::dispatch( + $this->moduleVmwareId, + $this->vmId, + $guestIp, + $this->osFamily, + $this->username, + ); + } + + // ------------------------------------------------------------------------- + + public function failed(\Throwable $e): void + { + Log::error('WaitVmReadyJob: exceeded max attempts, marking VM failed', [ + 'vm_id' => $this->vmId, + 'module_vmware_id' => $this->moduleVmwareId, + 'error' => $e->getMessage(), + ]); + + \App\Models\ModuleVmware::where('id', $this->moduleVmwareId) + ->update(['status' => 0]); + } + + // ------------------------------------------------------------------------- + + private function releaseOrFail(int $delaySeconds, int $attempt): void + { + if ($attempt >= $this->tries) { + $this->fail(new \RuntimeException( + "VM {$this->vmId} not ready after {$this->tries} attempts (~10 min)", + )); + return; + } + + $this->release($delaySeconds); + } +} diff --git a/app/Models/ModuleNetwork.php b/app/Models/ModuleNetwork.php index e64d5f5..43fec7c 100644 --- a/app/Models/ModuleNetwork.php +++ b/app/Models/ModuleNetwork.php @@ -26,4 +26,9 @@ class ModuleNetwork extends Model { return $this->hasMany(\App\Models\ModuleIpAddress::class, 'module_network_id'); } + + public function ipPools() + { + return $this->hasMany(\App\Models\IpPool::class, 'module_network_id'); + } } diff --git a/app/Services/VmProvisionService.php b/app/Services/VmProvisionService.php new file mode 100644 index 0000000..c758198 --- /dev/null +++ b/app/Services/VmProvisionService.php @@ -0,0 +1,141 @@ +loginServer($vcenter)) { + throw new \RuntimeException("vCenter login failed for host={$vcenter->host}"); + } + } + + /** + * @return array{tools_running:bool,ip_address:string|null,os_family:string|null} + */ + public function getVmGuestInfo(VCenterService $service, string $vmId): array + { + try { + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $service->token]) + ->timeout(10) + ->get("https://{$service->apiUri}/rest/vcenter/vm/{$vmId}/guest/identity"); + + if (!$response->successful()) { + return ['tools_running' => false, 'ip_address' => null, 'os_family' => null]; + } + + $value = $response->json('value', []); + $ip = trim((string) ($value['ip_address'] ?? '')); + + return [ + 'tools_running' => $ip !== '' && $ip !== '0.0.0.0', + 'ip_address' => $ip ?: null, + 'os_family' => isset($value['family']) ? strtoupper((string) $value['family']) : null, + ]; + } catch (\Throwable $e) { + Log::warning('VmProvisionService::getVmGuestInfo error', [ + 'vm_id' => $vmId, 'error' => $e->getMessage(), + ]); + return ['tools_running' => false, 'ip_address' => null, 'os_family' => null]; + } + } + + // ------------------------------------------------------------------------- + // Password + // ------------------------------------------------------------------------- + + /** + * Generate a cryptographically secure 16-char password. + * Charset deliberately excludes % (breaks cmd.exe env expansion) + * and " (breaks PowerShell argument quoting). + */ + public function generatePassword(int $length = 16): string + { + $sets = [ + 'ABCDEFGHJKLMNPQRSTUVWXYZ', + 'abcdefghjkmnpqrstuvwxyz', + '23456789', + '!@#$*', // no % or " — safe in cmd.exe double-quoted arguments + ]; + + $password = ''; + $allChars = implode('', $sets); + + foreach ($sets as $set) { + $password .= $set[random_int(0, strlen($set) - 1)]; + } + + for ($i = count($sets); $i < $length; $i++) { + $password .= $allChars[random_int(0, strlen($allChars) - 1)]; + } + + $chars = str_split($password); + for ($i = count($chars) - 1; $i > 0; $i--) { + $j = random_int(0, $i); + [$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]]; + } + + return implode('', $chars); + } + + public function encryptPassword(string $plaintext): string + { + return SignRSA::EncryptFunc($plaintext); + } + + // ------------------------------------------------------------------------- + // Persistence + // ------------------------------------------------------------------------- + + /** + * Update password only — IP already set during VM creation. + */ + public function updatePassword(int $moduleVmwareId, string $plainPassword): void + { + ModuleVmware::where('id', $moduleVmwareId)->update([ + 'password' => $this->encryptPassword($plainPassword), + 'plain_password' => $plainPassword, + ]); + + Log::info('VmProvisionService::updatePassword saved', [ + 'module_vmware_id' => $moduleVmwareId, + ]); + } + + /** + * Update password + overwrite IP (used when real guest IP differs from pool IP). + */ + public function savePassword(int $moduleVmwareId, string $ip, string $plainPassword): void + { + ModuleVmware::where('id', $moduleVmwareId)->update([ + 'ip' => $ip, + 'password' => $this->encryptPassword($plainPassword), + 'plain_password' => $plainPassword, + ]); + + Log::info('VmProvisionService::savePassword saved', [ + 'module_vmware_id' => $moduleVmwareId, + 'ip' => $ip, + ]); + } +} diff --git a/app/Services/VsphereHostSshService.php b/app/Services/VsphereHostSshService.php new file mode 100644 index 0000000..54a1ced --- /dev/null +++ b/app/Services/VsphereHostSshService.php @@ -0,0 +1,133 @@ +warg($vmId), + $this->warg($newPassword), + $this->warg($vcenterHost), + $this->warg($vcenterUser), + $this->warg($vcenterPassword), + ); + + Log::info('VsphereHostSshService: executing script', [ + 'vm_id' => $vmId, + 'script' => $script, + 'vcenter_host' => $vcenterHost, + ]); + + $output = $this->sshExec($remoteCmd); + + Log::info('VsphereHostSshService: script output', [ + 'vm_id' => $vmId, + 'output' => trim($output), + ]); + + return $output; + } + + // ───────────────────────────────────────────────────────────────────────── + + /** + * Run a command on the vSphere SSH host via sshpass. + * Retries up to MAX_RETRIES times on connection failure. + * + * @throws \RuntimeException + */ + private function sshExec(string $remoteCmd): string + { + // Full native SSH command executed on local shell + $sshCmd = sprintf( + 'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=%d -p %d %s@%s %s 2>&1', + escapeshellarg(self::PASS), + self::CONNECT_TIMEOUT, + self::PORT, + escapeshellarg(self::USER), + escapeshellarg(self::HOST), + escapeshellarg($remoteCmd), + ); + + for ($attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++) { + $output = shell_exec($sshCmd); + + if ($output === null) { + Log::warning('VsphereHostSshService: shell_exec returned null', [ + 'attempt' => $attempt, + ]); + } else { + Log::info('VsphereHostSshService: SSH executed', ['attempt' => $attempt]); + return $output; + } + + if ($attempt < self::MAX_RETRIES) { + sleep(self::RETRY_DELAY); + } + } + + throw new \RuntimeException( + 'SSH exec to ' . self::HOST . ' failed after ' . self::MAX_RETRIES . ' attempts', + ); + } + + /** + * Windows cmd.exe safe argument: wrap in double quotes, escape inner double quotes. + * Password charset excludes " and % so wrapping is always safe. + */ + private function warg(string $value): string + { + return '"' . str_replace('"', '\\"', $value) . '"'; + } +} diff --git a/database/migrations/2026_04_16_000004_add_ip_dns_to_module_vmware.php b/database/migrations/2026_04_16_000004_add_ip_dns_to_module_vmware.php new file mode 100644 index 0000000..fc1a551 --- /dev/null +++ b/database/migrations/2026_04_16_000004_add_ip_dns_to_module_vmware.php @@ -0,0 +1,28 @@ +string('ip', 45)->nullable()->after('plain_password') + ->comment('IP address assigned from ip_pools'); + } + if (!Schema::hasColumn('module_vmware', 'dns')) { + $table->string('dns', 100)->nullable()->after('ip') + ->comment('DNS assigned from network config'); + } + }); + } + + public function down(): void + { + Schema::table('module_vmware', function (Blueprint $table) { + $table->dropColumn(array_filter(['ip', 'dns'], fn ($col) => Schema::hasColumn('module_vmware', $col))); + }); + } +}; diff --git a/database/seeders/IpPoolSeeder.php b/database/seeders/IpPoolSeeder.php new file mode 100644 index 0000000..a59d7f1 --- /dev/null +++ b/database/seeders/IpPoolSeeder.php @@ -0,0 +1,189 @@ + 'LAN-VPS-01', 'cidr' => '103.90.224.0/26'] + * + * // Theo range: + * ['network' => 'LAN-VPS-01', 'start' => '103.90.224.10', 'end' => '103.90.224.60'] + * + * // Danh sách IP thủ công: + * ['network' => 'LAN-VPS-01', 'ips' => ['103.90.224.10', '103.90.224.11', ...]] + * + * // Có thể dùng module_network_id thay vì tên network: + * ['network_id' => 1, 'cidr' => '103.90.224.0/26'] + * + * IP đầu (network address) và IP cuối (broadcast) trong CIDR tự động bị loại bỏ. + * Khi VM được tạo, hệ thống sẽ lấy IP đầu tiên chưa được assign trong module_ip_address. + */ +class IpPoolSeeder extends Seeder +{ + /** + * ============================================================ + * ĐỊA CHỈ IP POOLS — CHỈNH SỬA TẠI ĐÂY + * ============================================================ + */ + private function pools(): array + { + return [ + // --- Pool 1: VPS Public Network (module_network id=1, name="VM Network") --- + [ + 'network_id' => 1, // module_network.id + 'cidr' => '103.90.224.0/26', // /26 = 62 usable IPs (.1 → .62) + ], + + // --- Pool 2: thêm range thủ công --- + // [ + // 'network' => 'LAN-VPS-02', + // 'start' => '10.10.20.50', + // 'end' => '10.10.20.100', + // ], + + // --- Pool 3: danh sách IP cụ thể --- + // [ + // 'network_id' => 2, + // 'ips' => [ + // '192.168.100.10', + // '192.168.100.11', + // '192.168.100.12', + // ], + // ], + ]; + } + + // ============================================================ + + public function run(): void + { + $totalInserted = 0; + $totalSkipped = 0; + + foreach ($this->pools() as $pool) { + $network = $this->resolveNetwork($pool); + + if (!$network) { + $this->command->warn('IpPoolSeeder: không tìm thấy network: ' . json_encode($pool)); + continue; + } + + $ips = $this->generateIps($pool); + + if (empty($ips)) { + $this->command->warn("IpPoolSeeder: không có IP nào được generate cho network #{$network->id}"); + continue; + } + + foreach ($ips as $ip) { + $created = IpPool::updateOrCreate( + [ + 'ip_address' => $ip, + 'module_network_id' => $network->id, + ] + ); + + if ($created->wasRecentlyCreated) { + $totalInserted++; + } else { + $totalSkipped++; + } + } + + $this->command->info("✅ Network [{$network->name}] (id={$network->id}): " . count($ips) . " IPs xử lý xong."); + } + + $this->command->info("IpPoolSeeder hoàn tất: {$totalInserted} IPs mới, {$totalSkipped} IPs đã tồn tại."); + } + + // ------------------------------------------------------------------------- + + private function resolveNetwork(array $pool): ?ModuleNetwork + { + if (!empty($pool['network_id'])) { + return ModuleNetwork::find($pool['network_id']); + } + + if (!empty($pool['network'])) { + return ModuleNetwork::where('name', $pool['network'])->first(); + } + + return null; + } + + /** + * Generate danh sách IP từ CIDR, range start–end, hoặc mảng IP thủ công. + * + * @return string[] + */ + private function generateIps(array $pool): array + { + if (!empty($pool['ips'])) { + return array_values(array_filter($pool['ips'], fn($ip) => filter_var($ip, FILTER_VALIDATE_IP))); + } + + if (!empty($pool['cidr'])) { + return $this->ipsFromCidr($pool['cidr']); + } + + if (!empty($pool['start']) && !empty($pool['end'])) { + return $this->ipsFromRange($pool['start'], $pool['end']); + } + + return []; + } + + /** + * Lấy tất cả usable IPs từ CIDR (bỏ network address và broadcast). + * Ví dụ: 103.90.224.0/26 → .1 đến .62 + */ + private function ipsFromCidr(string $cidr): array + { + [$baseIp, $prefix] = explode('/', $cidr); + + $prefix = (int) $prefix; + $baseInt = ip2long($baseIp); + $mask = ~((1 << (32 - $prefix)) - 1); + $network = $baseInt & $mask; + $broadcast = $network | ~$mask; + + $ips = []; + // Bỏ qua network address (.0) và broadcast (.255 hoặc cuối range) + for ($i = $network + 1; $i < $broadcast; $i++) { + $ips[] = long2ip($i); + } + + return $ips; + } + + /** + * Lấy tất cả IPs trong range start → end (inclusive). + */ + private function ipsFromRange(string $start, string $end): array + { + $startInt = ip2long($start); + $endInt = ip2long($end); + + if ($startInt === false || $endInt === false || $startInt > $endInt) { + return []; + } + + $ips = []; + for ($i = $startInt; $i <= $endInt; $i++) { + $ips[] = long2ip($i); + } + + return $ips; + } +} diff --git a/routes/client.php b/routes/client.php index d433625..9d0ef41 100644 --- a/routes/client.php +++ b/routes/client.php @@ -227,7 +227,29 @@ Route::prefix('v1/system')->middleware(['api.auth'])->group(function () { ], 422); } - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter')->find($productId); + // Kiểm tra nếu là UUID (Service UUID) + $isUuid = preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productId); + + $product = null; + if ($isUuid) { + // Tìm qua Service UUID + $service = \App\Models\Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') + ->where('uuid', $productId) + ->where('customer_id', $customer->id) + ->first(); + if ($service && $service->vmware) { + $product = $service->vmware; + } + } elseif (is_numeric($productId)) { + $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter')->find((int)$productId); + } else { + // Tìm theo vmware_id (ví dụ: vm-3238) + $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter') + ->where('vmware_id', $productId) + ->where('customer_id', $customer->id) + ->first(); + } + if (!$product) { return response()->json([ 'error' => true,