feat: Add IP address and DNS fields to module_vmware table

- Introduced `ip` and `dns` columns to the `module_vmware` table to store assigned IP addresses and DNS configurations.
- Implemented `ChangePasswordViaSshJob` for secure password changes via SSH.
- Created `ChangePasswordJob` for handling password changes during VM provisioning.
- Developed `ChangePasswordViaVsphereJob` to reset VM guest OS passwords using PowerShell scripts executed on the vSphere host.
- Added `SendVmCredentialsEmailJob` to send final VM credentials to customers.
- Implemented `WaitVmReadyJob` to poll vSphere API until VMware Tools are running and guest IP is available.
- Created `VmProvisionService` for managing VM provisioning tasks, including password generation and updates.
- Developed `VsphereHostSshService` for executing PowerShell scripts on the vSphere host via SSH.
- Added `IpPoolSeeder` to populate IP pools for VM service initialization.
This commit is contained in:
pbao9
2026-04-16 23:21:27 +07:00
parent d68fda2cf0
commit 5043bea79a
16 changed files with 1599 additions and 172 deletions
@@ -23,13 +23,16 @@ class VmController extends Controller
/**
* POST /v1/system/vm-power-on
*
* Body: { "product": <ModuleVmware.id> }
* Body: { "product": <ModuleVmware.id|vmware_id|service_uuid> }
*/
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": <ModuleVmware.id> }
* Body: { "product": <ModuleVmware.id|vmware_id|service_uuid> }
*/
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.
*/
+88 -56
View File
@@ -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("===================================================================");
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Create;
use App\Http\Facade\SignRSA;
use App\Jobs\Mail\SendInformationVirtualMachine;
use App\Models\ModuleVmware;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ChangePasswordViaSshJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 120;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $ip,
public readonly string $username,
public readonly string $initialPassword,
public readonly string $newPassword,
) {}
// ─────────────────────────────────────────────────────────────────────────
public function handle(): void
{
Log::info('ChangePasswordViaSshJob: starting', [
'module_vmware_id' => $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,
]);
}
}
}
@@ -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));
}
}
@@ -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));
@@ -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',
};
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Services\VmProvisionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* ChangePasswordJob
*
* Connects to the VM via SSH (Linux) or WinRM (Windows) using the
* template default password (TGS@123), then:
* [7] Generates a new secure 16-char password
* [8] Changes the password on the OS
* [9] Verifies the new password works (re-login)
* [10] Saves encrypted password + plain_password to module_vmware
* [11] Dispatches SendVmCredentialsEmailJob
*
* Uses the REAL IP reported by VMware Tools (from WaitVmReadyJob),
* NOT the IP pool address.
*
* Up to 3 job-level retries if an unexpected error occurs.
*/
class ChangePasswordJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/** SSH + WinRM operations should complete within 2 min */
public int $timeout = 120;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $vmId,
public readonly string $guestIp, // real IP from VMware Tools
public readonly string $osFamily, // VmProvisionService::OS_LINUX | OS_WINDOWS
public readonly string $username, // root | Administrator
) {}
public function handle(VmProvisionService $provision): void
{
Log::info('ChangePasswordJob: starting', [
'vm_id' => $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,
]);
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Http\Facade\SignRSA;
use App\Models\ModuleVmware;
use App\Models\vCenter;
use App\Services\VmProvisionService;
use App\Services\VsphereHostSshService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* ChangePasswordViaVsphereJob
*
* Resets the VM guest OS password via:
* Laravel → SSH (10.10.10.173) → PowerShell PS1 → Invoke-VMScript (guest OS)
*
* Flow:
* [1] VM already exists, vm_id known
* [2] Generate random 16-char password (plain text)
* [3] Load vCenter credentials from module_vcenter
* [4] SSH into vSphere host
* [5] Execute changePassword{Linux|Windows}.ps1 with 5 params
* [6] Parse "SUCCESS" / "FAIL" from script output
* [7] On SUCCESS → save encrypted password to DB, dispatch email
* [8] On FAIL → throw exception → job retries
*/
class ChangePasswordViaVsphereJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $timeout = 180;
public int $backoff = 60;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $vmId,
public readonly string $osFamily, // VmProvisionService::OS_LINUX | OS_WINDOWS
) {}
// ─────────────────────────────────────────────────────────────────────────
public function handle(VmProvisionService $provision, VsphereHostSshService $ssh): void
{
Log::info('ChangePasswordViaVsphereJob: starting', [
'module_vmware_id' => $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}",
);
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Jobs\Mail\SendInformationVirtualMachine;
use App\Models\ModuleVmware;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* SendVmCredentialsEmailJob
*
* Sends the final VM credential email to the customer.
* Always loads fresh data from DB so it gets the real IP and
* the final password saved by ChangePasswordJob.
*
* This is step [11] in the provisioning flow.
*/
class SendVmCredentialsEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public function __construct(
public readonly int $moduleVmwareId,
) {}
public function handle(): void
{
$vm = ModuleVmware::with(['customer', 'os.osGroup', 'order.orderItems'])->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',
};
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Http\Services\vCenter as VCenterService;
use App\Services\VmProvisionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* WaitVmReadyJob
*
* Polls vSphere REST API every 15 s until VMware Tools is running
* and the guest IP is available. No direct TCP port check is required
* because password changes are performed via Invoke-VMScript (vSphere
* path), not direct SSH/WinRM into the VM.
*
* Max 40 attempts × 15 s ≈ 10 minutes before giving up.
* On success → dispatches ChangePasswordViaVsphereJob.
*/
class WaitVmReadyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** 40 attempts × 15 s release delay ≈ 10 min total */
public int $tries = 40;
/** Each poll attempt must finish within 30 s */
public int $timeout = 30;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $vmId,
public readonly int $vcenterId,
public readonly string $osFamily, // VmProvisionService::OS_LINUX | OS_WINDOWS
public readonly string $username, // root | Administrator
) {}
// -------------------------------------------------------------------------
public function handle(VCenterService $vCenterService, VmProvisionService $provision): void
{
$attempt = $this->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);
}
}
+5
View File
@@ -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');
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Http\Facade\SignRSA;
use App\Http\Services\vCenter as VCenterService;
use App\Models\ModuleVmware;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class VmProvisionService
{
/** Default password baked into every VM template */
public const TEMPLATE_PASSWORD = 'TGS@123';
public const OS_LINUX = 'LINUX';
public const OS_WINDOWS = 'WINDOWS';
// -------------------------------------------------------------------------
// vCenter REST helpers (used by WaitVmReadyJob if still in flow)
// -------------------------------------------------------------------------
public function loginVCenter(\App\Models\vCenter $vcenter, VCenterService $service): void
{
if (!$service->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,
]);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Log;
/**
* VsphereHostSshService
*
* Executes pre-deployed PowerShell scripts on the vSphere Windows host
* via native SSH (sshpass). No phpseclib dependency.
*
* Scripts in C:/CLISave/ accept 5 positional params:
* VM_ID NEW_PASSWORD VCENTER_HOST VCENTER_USER VCENTER_PASSWORD
*
* Scripts only output "SUCCESS" or "FAIL".
*/
final class VsphereHostSshService
{
private const HOST = '10.10.10.173';
private const PORT = 22;
private const USER = 'administrator';
private const PASS = 'Thegioiso@123**$$8899TgTk2';
private const SCRIPT_DIR = 'C:\\CLISave'; // backslash required for Windows cmd.exe
private const SCRIPT_LINUX = 'changePasswordLinux.ps1';
private const SCRIPT_WINDOWS = 'changePasswordWindows.ps1';
private const CONNECT_TIMEOUT = 20;
private const MAX_RETRIES = 5;
private const RETRY_DELAY = 5;
// ─────────────────────────────────────────────────────────────────────────
/**
* Run the OS-appropriate PS1 script on the vSphere host.
*
* @throws \RuntimeException on SSH failure
*/
public function changePassword(
string $vmId,
string $newPassword,
string $osFamily,
string $vcenterHost,
string $vcenterUser,
string $vcenterPassword,
): string {
$script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS
? self::SCRIPT_WINDOWS
: self::SCRIPT_LINUX;
// Remote Windows cmd.exe command — all args double-quoted
$remoteCmd = sprintf(
'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s',
self::SCRIPT_DIR,
$script,
$this->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) . '"';
}
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('module_vmware', function (Blueprint $table) {
if (!Schema::hasColumn('module_vmware', 'ip')) {
$table->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)));
});
}
};
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\IpPool;
use App\Models\ModuleNetwork;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Log;
/**
* IpPoolSeeder — nhập danh sách IP vào ip_pools để assign cho VM khi khởi tạo dịch vụ.
*
* Cách cấu hình pools() bên dưới:
*
* // Theo CIDR:
* ['network' => '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;
}
}
+23 -1
View File
@@ -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,