feat: Implement vCenter management APIs, console ticket retrieval, password encryption, and background synchronization.

This commit is contained in:
pbao9
2026-03-06 17:29:52 +07:00
parent cb8510cf56
commit 23a496f511
10 changed files with 332 additions and 52 deletions
@@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Http\Facade\SignRSA;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
class EncryptVCenterPasswords extends Command
{
protected $signature = 'vcenter:encrypt-passwords';
protected $description = 'Re-encrypt vCenter passwords from Laravel encrypted cast to SignRSA format';
public function handle(): int
{
$rows = DB::table('module_vcenter')->get(['id', 'password']);
$fixed = 0;
foreach ($rows as $row) {
if (!$row->password) continue;
// Try to decrypt as Laravel encrypted cast (from previous migration)
$plainPassword = null;
try {
$plainPassword = Crypt::decryptString($row->password);
$this->info("vCenter #{$row->id}: detected Laravel encrypted password, will re-encrypt with SignRSA");
} catch (\Exception) {
// Not Laravel encrypted — check if it's already SignRSA or plain text
$decrypted = SignRSA::DecryptFunc($row->password);
if ($decrypted) {
$this->info("vCenter #{$row->id}: already SignRSA encrypted, skipping");
continue;
}
// Plain text password
$plainPassword = $row->password;
$this->info("vCenter #{$row->id}: detected plain text password, will encrypt with SignRSA");
}
if ($plainPassword) {
$encrypted = SignRSA::EncryptFunc($plainPassword);
DB::table('module_vcenter')
->where('id', $row->id)
->update(['password' => $encrypted]);
$fixed++;
$this->info("vCenter #{$row->id}: re-encrypted successfully");
}
}
$this->info("Done. Fixed {$fixed} password(s).");
return 0;
}
}
@@ -3,28 +3,166 @@
namespace App\Http\Controllers\Api\Admin\VCenter;
use App\Http\Controllers\Controller;
use App\Jobs\VirtualMachine\Sync\SyncAllPackage;
use App\Models\ModuleDatacenter;
use App\Models\ModuleDatastore;
use App\Models\ModuleFolder;
use App\Models\ModuleHost;
use App\Models\ModuleNetwork;
use App\Models\vCenter;
use App\Models\VirtualMachines;
use App\Http\Services\vCenter as ServiceVCenter;
use App\Http\Facade\SignRSA;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VCenterController extends Controller
{
public function store(Request $request): JsonResponse
{
$request->validate([
'host' => 'required|string|max:255',
'user' => 'required|string|max:255',
'password' => 'required|string|max:255',
]);
$vc = vCenter::create([
'host' => $request->input('host'),
'user' => $request->input('user'),
'password' => SignRSA::EncryptFunc($request->input('password')),
'status' => 1,
]);
return response()->json(['error' => false, 'data' => $vc], 201);
}
public function consoleTicket(Request $request, ServiceVCenter $serviceVCenter): JsonResponse
{
$vmDbId = $request->integer('vm_id');
if (!$vmDbId) {
return response()->json(['error' => true, 'message' => 'Thiếu vm_id'], 422);
}
$vm = VirtualMachines::with('datacenter.vCenter')->find($vmDbId);
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
}
$vcenter = $vm->datacenter?->vCenter ?? null;
if (!$vcenter) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404);
}
if (!$serviceVCenter->loginServer($vcenter)) {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter'], 500);
}
$ticket = $serviceVCenter->GetConsoleTickets($vm->vm_id);
if ($ticket === 'Login_Again') {
if ($serviceVCenter->loginServer($vcenter)) {
$ticket = $serviceVCenter->GetConsoleTickets($vm->vm_id);
} else {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter'], 500);
}
}
if (is_string($ticket)) {
return response()->json(['error' => true, 'message' => "Lấy ticket thất bại: $ticket"], 500);
}
if ($ticket['error']) {
return response()->json(['error' => true, 'message' => $ticket['data']], 500);
}
return response()->json([
'error' => false,
'data' => $ticket['data'],
'host' => $vcenter->host,
'vmware-api-session-id' => $serviceVCenter->token,
])->cookie('vmware-api-session-id', $serviceVCenter->token, 60, '/', null, false, false);
}
public function sync(): JsonResponse
{
dispatch(new SyncAllPackage())->delay(now()->addSeconds(5));
return response()->json([
'error' => false,
'message' => 'Sync job dispatched, running in background.',
]);
}
public function updateNode(Request $request, string $type, int $id): JsonResponse
{
$model = $this->resolveModel($type, $id);
$rules = match ($type) {
'vcenter' => ['host' => 'sometimes|string|max:255', 'user' => 'sometimes|string|max:255', 'password' => 'sometimes|string|max:255', 'status' => 'sometimes|integer|in:0,1'],
'datacenter' => ['name' => 'sometimes|string|max:255', 'datacenter_id' => 'sometimes|string|max:255'],
'host' => ['name' => 'sometimes|string|max:255', 'host_id' => 'sometimes|string|max:255', 'template' => 'sometimes|nullable|string|max:500', 'console' => 'sometimes|nullable|string|max:500', 'iso_path' => 'sometimes|nullable|string|max:500'],
'datastore' => ['name' => 'sometimes|string|max:255', 'datastore' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255', 'space' => 'sometimes|nullable|integer', 'status' => 'sometimes|integer|in:0,1'],
'network' => ['name' => 'sometimes|string|max:255', 'network' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255'],
'folder' => ['name' => 'sometimes|string|max:255', 'folder' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255'],
'virtual_machine' => ['name' => 'sometimes|string|max:255', 'vm_id' => 'sometimes|string|max:255'],
default => [],
};
$request->validate($rules);
$data = $request->only(array_keys($rules));
if ($type === 'vcenter' && isset($data['password'])) {
$data['password'] = SignRSA::EncryptFunc($data['password']);
}
$model->update($data);
return response()->json(['error' => false, 'data' => $model]);
}
public function destroyNode(string $type, int $id): JsonResponse
{
$model = $this->resolveModel($type, $id);
$childCheck = match ($type) {
'vcenter' => $model->datacenters()->exists(),
'datacenter' => ModuleHost::where('module_datacenter_id', $id)->exists(),
'host' => $model->datastore()->exists() || $model->network()->exists(),
default => false,
};
if ($childCheck) {
return response()->json([
'error' => true,
'message' => 'Không thể xoá vì vẫn còn dữ liệu liên kết bên dưới.',
], 422);
}
$model->delete();
return response()->json(['error' => false, 'message' => 'Đã xoá thành công.']);
}
public function tree(Request $request): JsonResponse
{
$vcenters = vCenter::with(['datacenters'])->get();
$allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id');
$vcenters = vCenter::with(['datacenters'])->get();
$allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id');
$allFolders = ModuleFolder::all()->groupBy('module_datacenter_id');
// VMs with a host assignment — grouped by host DB id
$allVMs = VirtualMachines::whereNotNull('host_id')->get()->groupBy('host_id');
// VMs without a host assignment (synced before host_id was tracked) — grouped by datacenter DB id
$orphanVMs = VirtualMachines::whereNull('host_id')->get()->groupBy('datacenter_id');
$tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders) {
$tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders, $allVMs, $orphanVMs) {
$dcGroups = ($vc->datacenters ?? collect())->groupBy('datacenter_id');
$datacenters = $dcGroups->map(function ($dcGroup) use ($allHosts, $allFolders) {
$datacenters = $dcGroups->map(function ($dcGroup) use ($allHosts, $allFolders, $allVMs, $orphanVMs) {
$dc = $dcGroup->first();
$dcIds = $dcGroup->pluck('id');
$uniqueHosts = $dcIds->flatMap(fn($id) => $allHosts->get($id) ?? collect())
->unique('host_id')->values();
// Networks — deduplicated at datacenter level
$dcNetworks = $uniqueHosts
->flatMap(fn($host) => $host->network ?? collect())
->unique('network')
@@ -32,10 +170,36 @@ class VCenterController extends Controller
'id' => 'network-' . $net->id,
'name' => $net->name,
'type' => 'network',
'meta' => ['db_id' => $net->id, 'network_id' => $net->network, 'net_type' => $net->type],
'meta' => [
'db_id' => $net->id,
'network' => $net->network,
'net_type' => $net->type,
'module_host_id' => $net->module_host_id,
],
]);
$dcHosts = $uniqueHosts->map(function ($host) {
// Hosts — VMs placed under their host, then Datastores
$dcHosts = $uniqueHosts->map(function ($host) use ($allVMs) {
$hostVMs = ($allVMs->get($host->id) ?? collect())
->map(fn($vm) => [
'id' => 'virtual_machine-' . $vm->id,
'name' => $vm->name,
'type' => 'virtual_machine',
'meta' => [
'db_id' => $vm->id,
'uuid' => $vm->uuid,
'vm_id' => $vm->vm_id,
'power_state' => $vm->power_state,
'folder' => $vm->folder,
'cpu' => $vm->cpu,
'ram_mb' => $vm->ram_mb,
'is_template' => $vm->is_template,
'is_system' => $vm->is_system,
'last_synced_at' => $vm->last_synced_at,
],
'children' => [],
]);
$datastores = ($host->datastore ?? collect())
->unique('datastore')
->map(fn($ds) => [
@@ -43,11 +207,13 @@ class VCenterController extends Controller
'name' => $ds->name,
'type' => 'datastore',
'meta' => [
'db_id' => $ds->id,
'datastore_id' => $ds->datastore,
'store_type' => $ds->type,
'space_gb' => $ds->space ? round($ds->space / 1024, 1) : null,
'status' => $ds->status,
'db_id' => $ds->id,
'datastore' => $ds->datastore,
'store_type' => $ds->type,
'space' => $ds->space,
'space_gb' => $ds->space ? round($ds->space / 1024, 1) : null,
'status' => $ds->status,
'module_host_id' => $ds->module_host_id,
],
]);
@@ -55,11 +221,21 @@ class VCenterController extends Controller
'id' => 'host-' . $host->id,
'name' => $host->name,
'type' => 'host',
'meta' => ['db_id' => $host->id, 'host_id' => $host->host_id, 'console' => $host->console],
'children' => array_values($datastores->toArray()),
'meta' => [
'db_id' => $host->id,
'host_id' => $host->host_id,
'template' => $host->template,
'console' => $host->console,
'iso_path' => $host->iso_path,
'module_datacenter_id' => $host->module_datacenter_id,
'vm_count' => $hostVMs->count(),
],
// VMs first, then Datastores — correct vSphere order
'children' => array_values($hostVMs->concat($datastores)->toArray()),
];
});
// Folders — datacenter level
$dcFolders = $dcIds
->flatMap(fn($id) => $allFolders->get($id) ?? collect())
->unique('folder')
@@ -67,15 +243,48 @@ class VCenterController extends Controller
'id' => 'folder-' . $f->id,
'name' => $f->name,
'type' => 'folder',
'meta' => ['db_id' => $f->id, 'folder_id' => $f->folder, 'folder_type' => $f->type],
'meta' => [
'db_id' => $f->id,
'folder' => $f->folder,
'folder_type' => $f->type,
'module_datacenter_id' => $f->module_datacenter_id,
],
]);
// Orphan VMs — no host_id, grouped directly under datacenter
$dcOrphanVMs = $dcIds
->flatMap(fn($id) => $orphanVMs->get($id) ?? collect())
->unique('id')
->map(fn($vm) => [
'id' => 'virtual_machine-' . $vm->id,
'name' => $vm->name,
'type' => 'virtual_machine',
'meta' => [
'db_id' => $vm->id,
'uuid' => $vm->uuid,
'vm_id' => $vm->vm_id,
'power_state' => $vm->power_state,
'folder' => $vm->folder,
'cpu' => $vm->cpu,
'ram_mb' => $vm->ram_mb,
'is_template' => $vm->is_template,
'is_system' => $vm->is_system,
'last_synced_at' => $vm->last_synced_at,
],
'children' => [],
]);
return [
'id' => 'datacenter-' . $dc->id,
'name' => $dc->name,
'type' => 'datacenter',
'meta' => ['db_id' => $dc->id, 'datacenter_id' => $dc->datacenter_id],
'children' => array_values($dcFolders->concat($dcNetworks)->concat($dcHosts)->toArray()),
'meta' => [
'db_id' => $dc->id,
'datacenter_id' => $dc->datacenter_id,
'vcenter_id' => $dc->vcenter_id,
],
// vSphere order: Hosts → Networks → Folders → Orphan VMs
'children' => array_values($dcHosts->concat($dcNetworks)->concat($dcFolders)->concat($dcOrphanVMs)->toArray()),
];
})->values();
@@ -90,4 +299,18 @@ class VCenterController extends Controller
return response()->json(['error' => false, 'data' => $tree->values()]);
}
private function resolveModel(string $type, int $id)
{
return match ($type) {
'vcenter' => vCenter::findOrFail($id),
'datacenter' => ModuleDatacenter::findOrFail($id),
'host' => ModuleHost::findOrFail($id),
'datastore' => ModuleDatastore::findOrFail($id),
'network' => ModuleNetwork::findOrFail($id),
'folder' => ModuleFolder::findOrFail($id),
'virtual_machine' => VirtualMachines::findOrFail($id),
default => abort(404, 'Unknown node type'),
};
}
}
@@ -17,6 +17,7 @@ class ProductResource extends JsonResource
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'slug' => $this->slug,
'product_type' => $this->product_type,
'category' => $this->whenLoaded('category', fn() => $this->category?->name),
'cpu' => $this->cpu,
-1
View File
@@ -148,7 +148,6 @@ class vCenter
->get("https://$this->apiUri/rest/vcenter/folder", [
'filter.datacenters.1' => $datacenter,
'filter.type' => "VIRTUAL_MACHINE",
'filter.names' => "vm"
])->json();
if (!isset($response['type'])) {
return $response['value'];
@@ -91,14 +91,10 @@ class SyncDatacenter implements ShouldQueue
continue; // skip malformed items
}
$exists = ModuleDatacenter::where('datacenter_id', $_item['datacenter'])->first();
if (!$exists) {
ModuleDatacenter::create([
'name' => $_item['name'],
'datacenter_id' => $_item['datacenter'],
'vcenter_id' => $item->id
]);
}
ModuleDatacenter::updateOrCreate(
['datacenter_id' => $_item['datacenter']],
['name' => $_item['name'], 'vcenter_id' => $item->id]
);
}
}
// Notify success via Telegram
+18 -14
View File
@@ -63,22 +63,26 @@ class SyncFolder implements ShouldQueue
vCenter::where('id', $item->id)->update(['token' => $token]);
$this->vCenter->token = $token;
}
}
$folder = $this->vCenter->getAllFolder($_item['datacenter_id']);
if ($folder) {
foreach ($folder as $_subItem) {
if (!ModuleFolder::where('folder', $_subItem['folder'])->first()) {
ModuleFolder::create([
'name' => $_subItem['name'],
'folder' => $_subItem['folder'],
'type' => $_subItem['type'],
'module_datacenter_id' => $_item['id']
]);
}
}
$folder = $this->vCenter->getAllFolder($_item['datacenter_id']);
}
if (!is_array($folder)) {
continue;
}
$syncedFolderIds = [];
foreach ($folder as $_subItem) {
$record = ModuleFolder::updateOrCreate(
['folder' => $_subItem['folder'], 'module_datacenter_id' => $_item['id']],
['name' => $_subItem['name'], 'type' => $_subItem['type']]
);
$syncedFolderIds[] = $record->id;
}
// Remove stale folders no longer in vCenter
ModuleFolder::where('module_datacenter_id', $_item['id'])
->whereNotIn('id', $syncedFolderIds)
->delete();
}
}
+9 -11
View File
@@ -77,17 +77,15 @@ class SyncHost implements ShouldQueue
// Use TemplateService
$template = $templateService->getAllTemplate();
if ($host) {
foreach ($host as $_subItem) {
if (!ModuleHost::where('host_id', $_subItem['host'])->exists()) {
ModuleHost::create([
'name' => $_subItem['name'],
'host_id' => $_subItem['host'],
'module_datacenter_id' => $_item['id'],
'template' => $template[0] ?? null,
]);
}
}
foreach ($host as $_subItem) {
ModuleHost::updateOrCreate(
['host_id' => $_subItem['host']],
[
'name' => $_subItem['name'],
'module_datacenter_id' => $_item['id'],
'template' => $template[0] ?? null,
]
);
}
}
}
+2
View File
@@ -18,6 +18,8 @@ class vCenter extends Model
'token',
'status'
];
protected $casts = [];
public function datacenters()
{
return $this->hasMany(\App\Models\ModuleDatacenter::class, 'vcenter_id');
@@ -12,8 +12,7 @@ return new class extends Migration {
public function up(): void
{
Schema::table('invoice', function (Blueprint $table) {
//
$table->enum('reminder_step', [InvoiceStep::STEP_1->value, InvoiceStep::STEP_2->value, InvoiceStep::STEP_3->value, InvoiceStep::STEP_4->value, InvoiceStep::STEP_5->value])->default(InvoiceStep::STEP_1->value)->after('auto_renew');
});
}
+5
View File
@@ -72,6 +72,11 @@ Route::prefix('v1')
// vCenter
Route::prefix('vcenter')->group(function () {
Route::get('tree', [VCenterController::class, 'tree']);
Route::get('console-ticket', [VCenterController::class, 'consoleTicket']);
Route::post('/', [VCenterController::class, 'store']);
Route::post('sync', [VCenterController::class, 'sync']);
Route::put('node/{type}/{id}', [VCenterController::class, 'updateNode']);
Route::delete('node/{type}/{id}', [VCenterController::class, 'destroyNode']);
});
});
});