610 lines
22 KiB
PHP
610 lines
22 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api\Service;
|
||
|
||
use App\Actions\VM\ChangeIpVMAction;
|
||
use App\Actions\VM\ChangePortVMAction;
|
||
use App\Actions\VM\PowerOffVMAction;
|
||
use App\Actions\VM\PowerOnVMAction;
|
||
use App\Http\Controllers\Api\Service\Concerns\ResolvesVm;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Services\Telegram;
|
||
use App\Http\Services\vCenter;
|
||
use App\Jobs\VirtualMachine\ChangeVmPasswordJob;
|
||
use App\Models\ModuleVmware;
|
||
use App\Models\Os;
|
||
use App\Models\Service;
|
||
use App\Services\ActivityLogger;
|
||
use App\Services\VmProvisionService;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
class VmController extends Controller
|
||
{
|
||
use ResolvesVm {
|
||
ResolvesVm::resolveVmById as resolveVmForClient;
|
||
}
|
||
|
||
public function __construct(protected ActivityLogger $activityLogger) {}
|
||
// -------------------------------------------------------------------------
|
||
// Power ON
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* POST /v1/system/vm-power-on
|
||
*
|
||
* Body: { "product": <ModuleVmware.id|vmware_id|service_uuid> }
|
||
*/
|
||
public function powerOn(Request $request, PowerOnVMAction $action): JsonResponse
|
||
{
|
||
$productId = $request->input('product');
|
||
if (!$productId) {
|
||
return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422);
|
||
}
|
||
|
||
$vm = $this->resolveVmById($productId, $request);
|
||
if ($vm instanceof JsonResponse) {
|
||
return $vm;
|
||
}
|
||
|
||
$lock = $this->acquireVmLock($vm);
|
||
if (!$lock) {
|
||
return $this->vmLocked();
|
||
}
|
||
|
||
try {
|
||
$result = $action->execute($vm);
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
|
||
$this->activityLogger->log(
|
||
event: 'power_on',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} bật VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||
subject: $vm,
|
||
);
|
||
|
||
return $this->vmResponse($result, 'Đã gửi lệnh bật VM.');
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Power OFF
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* POST /v1/system/vm-power-off
|
||
*
|
||
* Body: { "product": <ModuleVmware.id|vmware_id|service_uuid> }
|
||
*/
|
||
public function powerOff(Request $request, PowerOffVMAction $action): JsonResponse
|
||
{
|
||
$productId = $request->input('product');
|
||
if (!$productId) {
|
||
return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422);
|
||
}
|
||
|
||
$vm = $this->resolveVmById($productId, $request);
|
||
if ($vm instanceof JsonResponse) {
|
||
return $vm;
|
||
}
|
||
|
||
$lock = $this->acquireVmLock($vm);
|
||
if (!$lock) {
|
||
return $this->vmLocked();
|
||
}
|
||
|
||
try {
|
||
$result = $action->execute($vm);
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
|
||
$this->activityLogger->log(
|
||
event: 'power_off',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} tắt VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||
subject: $vm,
|
||
);
|
||
|
||
return $this->vmResponse($result, 'Đã gửi lệnh tắt VM.');
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Change IP
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* POST /v1/system/vm-change-ip
|
||
*
|
||
* Body:
|
||
* server_name string Service.server_name (e.g. "xasdf.com.vnx")
|
||
* ip_address string New IP to assign
|
||
* gateway string (optional) Gateway — falls back to existing IP record
|
||
*/
|
||
public function changeIp(Request $request, ChangeIpVMAction $action): JsonResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'server_name' => ['required', 'string', 'max:255'],
|
||
'ip_address' => ['required', 'ip'],
|
||
'gateway' => ['nullable', 'ip'],
|
||
'dns' => ['nullable', 'ip'],
|
||
'guest_user' => ['required', 'string', 'max:255'],
|
||
'guest_password' => ['required', 'string', 'max:255'],
|
||
]);
|
||
|
||
$customer = $request->user();
|
||
|
||
// Look up Service by server_name + owner
|
||
// VM chain: Service → orderItem → order → vmWare (same as ServiceClientDetailResource)
|
||
$service = Service::with([
|
||
'orderItem.order.vmWare.vCenter',
|
||
'orderItem.order.vmWare.ipAddress.host.datacenter.vCenter',
|
||
'orderItem.os.osGroup',
|
||
])
|
||
->where('server_name', $validated['server_name'])
|
||
->where('customer_id', $customer->id)
|
||
->first();
|
||
|
||
if (!$service) {
|
||
return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404);
|
||
}
|
||
|
||
$vm = $service->orderItem?->order?->vmWare?->first();
|
||
|
||
if (!$vm) {
|
||
return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404);
|
||
}
|
||
|
||
// Auto-detect OS type from the service's order item OS group
|
||
$osGroup = strtolower($service->orderItem?->os?->osGroup?->name ?? '');
|
||
$osType = (str_contains($osGroup, 'linux') || str_contains($osGroup, 'ubuntu') || str_contains($osGroup, 'centos'))
|
||
? 'linux'
|
||
: 'windows';
|
||
|
||
$gateway = $validated['gateway'] ?? '';
|
||
|
||
$dns = $validated['dns'] ?? '8.8.8.8';
|
||
|
||
$lock = $this->acquireVmLock($vm, 120);
|
||
if (!$lock) {
|
||
return $this->vmLocked();
|
||
}
|
||
|
||
try {
|
||
$raw = $action->execute(
|
||
$vm,
|
||
$validated['ip_address'],
|
||
$gateway,
|
||
$osType,
|
||
$dns,
|
||
$validated['guest_user'],
|
||
$validated['guest_password']
|
||
);
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
|
||
// Always log the raw PowerShell output for debugging
|
||
Log::channel('Custom')->info('[changeIp] raw output', [
|
||
'server_name' => $validated['server_name'],
|
||
'ip_address' => $validated['ip_address'],
|
||
'gateway' => $gateway,
|
||
'os_type' => $osType,
|
||
'vm_name' => $vm->name,
|
||
'vmware_id' => $vm->vmware_id,
|
||
'raw' => $raw,
|
||
]);
|
||
|
||
$telegram = new Telegram();
|
||
|
||
if ($raw === 'Login_Again' || $raw === 'vCenter_Not_Found') {
|
||
$errMsg = $raw === 'vCenter_Not_Found'
|
||
? 'Không tìm thấy vCenter cho VM này.'
|
||
: 'Không thể đăng nhập vào vCenter.';
|
||
|
||
$telegram->SendErrorMessage("❌ Đổi IP thất bại [{$osType}]\n"
|
||
. "🖥 VM: {$vm->name} ({$vm->vmware_id})\n"
|
||
. "📍 IP mới: {$validated['ip_address']}\n"
|
||
. "🔗 GW: {$gateway}\n"
|
||
. "🚫 Lỗi: {$errMsg}");
|
||
|
||
return response()->json(['success' => false, 'message' => $errMsg], 400);
|
||
}
|
||
|
||
if (str_starts_with((string) $raw, 'Error:') || str_contains((string) $raw, '[STDERR]')) {
|
||
$telegram->SendErrorMessage("❌ Đổi IP thất bại [{$osType}]\n"
|
||
. "🖥 VM: {$vm->name} ({$vm->vmware_id})\n"
|
||
. "📍 IP mới: {$validated['ip_address']}\n"
|
||
. "🔗 GW: {$gateway}\n"
|
||
. "📋 Output:\n" . mb_substr((string) $raw, 0, 800));
|
||
|
||
return response()->json(['success' => false, 'message' => 'Lệnh đổi IP thất bại. Admin đã được thông báo.', 'data' => ['raw' => $raw]], 400);
|
||
}
|
||
|
||
// Empty raw = script chạy không có output → coi là thành công (PowerShell script không echo)
|
||
// Nhưng vẫn gửi cảnh báo để theo dõi
|
||
if (trim((string) $raw) === '') {
|
||
$telegram->SendMessage("⚠️ Đổi IP [{$osType}] - Script chạy nhưng không có output\n"
|
||
. "🖥 VM: {$vm->name} ({$vm->vmware_id})\n"
|
||
. "📍 IP mới: {$validated['ip_address']}\n"
|
||
. "🔗 GW: {$gateway}\n"
|
||
. "ℹ️ Kiểm tra lại VM để xác nhận IP đã thay đổi chưa.");
|
||
}
|
||
|
||
$this->activityLogger->log(
|
||
event: 'change_ip',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} đổi IP VM: {$vm->name} → {$validated['ip_address']}",
|
||
subject: $vm,
|
||
newValues: ['ip_address' => $validated['ip_address'], 'gateway' => $gateway, 'os_type' => $osType],
|
||
);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Đã gửi lệnh đổi IP.',
|
||
'data' => ['raw' => $raw],
|
||
]);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Change Port (RDP)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* POST /v1/system/vm-change-port
|
||
*
|
||
* Body:
|
||
* 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'],
|
||
'new_port' => ['required', 'integer', 'min:1024', 'max:65535'],
|
||
]);
|
||
|
||
$vm = $this->resolveVmById($validated['product'], $request);
|
||
if ($vm instanceof JsonResponse) {
|
||
return $vm;
|
||
}
|
||
|
||
$lock = $this->acquireVmLock($vm);
|
||
if (!$lock) {
|
||
return $this->vmLocked();
|
||
}
|
||
|
||
try {
|
||
$result = $action->execute($vm, (string) $validated['new_port']);
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
|
||
// changeRDPPortWindows returns array on success/failure or string on login fail
|
||
if ($result === 'Login_Again' || $result === 'Login_Fail') {
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => 'Không thể đăng nhập vào vCenter.',
|
||
], 400);
|
||
}
|
||
|
||
if (is_array($result)) {
|
||
$ok = isset($result['success'])
|
||
? (bool) $result['success']
|
||
: !($result['error'] ?? true);
|
||
|
||
if ($ok) {
|
||
$this->activityLogger->log(
|
||
event: 'change_port',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} đổi cổng RDP VM: {$vm->name} → port {$validated['new_port']}",
|
||
subject: $vm,
|
||
newValues: ['new_port' => $validated['new_port']],
|
||
);
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => $ok,
|
||
'message' => $ok ? 'Đổi cổng RDP thành công.' : ($result['message'] ?? 'Đổi cổng thất bại.'),
|
||
'data' => $result,
|
||
], $ok ? 200 : 400);
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => 'Phản hồi không hợp lệ từ vCenter.',
|
||
'data' => ['raw' => $result],
|
||
], 400);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Console Ticket
|
||
// -------------------------------------------------------------------------
|
||
|
||
public function consoleTicket(Request $request, vCenter $serviceVCenter): JsonResponse
|
||
{
|
||
$productId = $request->get('product');
|
||
if (!$productId) {
|
||
return response()->json(['error' => true, 'message' => 'Please choose product']);
|
||
}
|
||
|
||
$vm = ModuleVmware::with('vCenter')->find((int) $productId);
|
||
if (!$vm) {
|
||
return response()->json(['error' => true, 'message' => "Can't find product"]);
|
||
}
|
||
|
||
$result = $this->getConsoleTicket($serviceVCenter, $vm);
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
$this->activityLogger->log(
|
||
event: 'console_access',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} truy cập console VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||
subject: $vm,
|
||
newValues: ['product_id' => $productId, 'vm_name' => $vm->name],
|
||
);
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => $result['data'],
|
||
]);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Restart VM
|
||
// -------------------------------------------------------------------------
|
||
|
||
public function restart(Request $request, vCenter $serviceVCenter): JsonResponse
|
||
{
|
||
$productId = $request->input('product');
|
||
if (!$productId) {
|
||
return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422);
|
||
}
|
||
|
||
$vm = $this->resolveVmForClient($productId, $request);
|
||
if ($vm instanceof JsonResponse) {
|
||
return $vm;
|
||
}
|
||
|
||
$lock = $this->acquireVmLock($vm);
|
||
if (!$lock) {
|
||
return $this->vmLocked();
|
||
}
|
||
|
||
try {
|
||
$result = $serviceVCenter->restartVM($vm, $vm->vmware_id);
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
|
||
if (is_string($result) || ($result['error'] ?? true)) {
|
||
return response()->json([
|
||
'error' => true,
|
||
'message' => is_string($result) ? $result : ($result['data'] ?? 'Không thể restart VM'),
|
||
], 400);
|
||
}
|
||
|
||
$this->activityLogger->log(
|
||
event: 'restart',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} restart VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||
subject: $vm,
|
||
);
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'message' => 'Đang restart máy ảo vui lòng chờ!',
|
||
'data' => $result['data'] ?? 'success',
|
||
]);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Change Password
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* POST /v1/system/vm-change-password
|
||
*
|
||
* Body: { product, current_password, new_password }
|
||
* Dispatches ChangeVmPasswordJob — returns immediately, runs async.
|
||
*/
|
||
public function changePassword(Request $request): JsonResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'product' => ['required'],
|
||
'current_password' => ['required', 'string'],
|
||
'new_password' => ['required', 'string', 'min:8'],
|
||
]);
|
||
|
||
$vm = $this->resolveVmById($validated['product'], $request);
|
||
if ($vm instanceof JsonResponse) {
|
||
return $vm;
|
||
}
|
||
|
||
// ponytail: don't release — lock expires after 5 min while the async job runs
|
||
$lock = $this->acquireVmLock($vm, 300);
|
||
if (!$lock) {
|
||
return $this->vmLocked();
|
||
}
|
||
|
||
// Detect OS family from VM's os_id record
|
||
$os = $vm->os_id ? Os::with('osGroup')->find($vm->os_id) : null;
|
||
$osGroupName = strtolower($os?->osGroup?->name ?? '');
|
||
$osFamily = str_contains($osGroupName, 'windows')
|
||
? VmProvisionService::OS_WINDOWS
|
||
: VmProvisionService::OS_LINUX;
|
||
|
||
ChangeVmPasswordJob::dispatch(
|
||
$vm->id,
|
||
$vm->vmware_id,
|
||
$osFamily,
|
||
$validated['new_password'],
|
||
$validated['current_password'],
|
||
);
|
||
|
||
$this->activityLogger->log(
|
||
event: 'change_password',
|
||
logName: 'VM',
|
||
description: "Customer {$request->user()->email} yêu cầu đổi mật khẩu VM: {$vm->name}",
|
||
subject: $vm,
|
||
newValues: ['os_family' => $osFamily],
|
||
);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Yêu cầu đổi mật khẩu đã được ghi nhận. Hệ thống sẽ xử lý trong vài phút.',
|
||
]);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Power State
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* GET /v1/system/vm-power-state?product={id|uuid|vmware_id}
|
||
*
|
||
* Calls vSphere REST API directly: GET /rest/vcenter/vm/{vmware_id}/power
|
||
* Returns: { success: true, data: { state: "POWERED_ON"|"POWERED_OFF"|"SUSPENDED", clean_power_off: bool } }
|
||
*/
|
||
public function powerState(Request $request, vCenter $serviceVCenter): JsonResponse
|
||
{
|
||
$productId = $request->input('product');
|
||
if (!$productId) {
|
||
return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422);
|
||
}
|
||
|
||
$vm = $this->resolveVmById($productId, $request);
|
||
if ($vm instanceof JsonResponse) {
|
||
return $vm;
|
||
}
|
||
|
||
$result = $serviceVCenter->getVmPowerState($vm, $vm->vmware_id);
|
||
|
||
if ($result === 'Login_Again') {
|
||
return response()->json(['success' => false, 'message' => 'Không thể đăng nhập vào vCenter.'], 400);
|
||
}
|
||
|
||
if (is_array($result) && ($result['error'] ?? true)) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => $result['data'] ?? 'Không thể lấy trạng thái VM.',
|
||
], 400);
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'data' => [
|
||
'state' => $result['data']['state'] ?? null,
|
||
'clean_power_off' => $result['data']['clean_power_off'] ?? null,
|
||
],
|
||
]);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Shared helpers
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 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 resolveVmById(int|string $productId, Request $request): ModuleVmware|JsonResponse
|
||
{
|
||
$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) $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);
|
||
}
|
||
|
||
/** Try to acquire a per-VM operation lock. Returns null if already locked. */
|
||
private function acquireVmLock(ModuleVmware $vm, int $ttl = 60): ?\Illuminate\Contracts\Cache\Lock
|
||
{
|
||
$lock = Cache::lock("vm_op:{$vm->id}", $ttl);
|
||
return $lock->get() ? $lock : null;
|
||
}
|
||
|
||
private function vmLocked(): JsonResponse
|
||
{
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => 'VM đang có thao tác đang xử lý, vui lòng chờ rồi thử lại.',
|
||
], 429);
|
||
}
|
||
|
||
/**
|
||
* Normalise the array|string return value from Power ON/OFF actions.
|
||
*/
|
||
private function vmResponse(array|string $result, string $successMessage): JsonResponse
|
||
{
|
||
if ($result === 'Login_Again') {
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => 'Không thể đăng nhập vào vCenter.',
|
||
], 400);
|
||
}
|
||
|
||
if (is_string($result)) {
|
||
return response()->json(['success' => false, 'message' => $result], 400);
|
||
}
|
||
|
||
if ($result['error'] ?? true) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => $result['data'] ?? 'Thao tác thất bại.',
|
||
'data' => $result,
|
||
], 400);
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => $successMessage,
|
||
'data' => $result['data'] ?? 'success',
|
||
]);
|
||
}
|
||
}
|