391 lines
13 KiB
PHP
391 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Http\Facade\SignRSA;
|
||
use App\Models\InfraManagement;
|
||
use App\Support\PowerShellEscaper;
|
||
use App\Support\SshHostKeyVerifier;
|
||
use Illuminate\Support\Facades\Log;
|
||
use phpseclib3\Net\SSH2;
|
||
|
||
/**
|
||
* VsphereHostSshService
|
||
*
|
||
* Executes pre-deployed PowerShell scripts on the vSphere Windows host
|
||
* via phpseclib SSH (no sshpass required).
|
||
*
|
||
* 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 SCRIPT_DIR = 'C:\\CLISave'; // backslash required for Windows cmd.exe
|
||
private const SCRIPT_LINUX = 'changePasswordLinux.ps1';
|
||
private const SCRIPT_WINDOWS = 'changePasswordWindows.ps1';
|
||
|
||
private const ASSIGN_DIR = 'C:\\CLISave\\AssignIP';
|
||
private const SCRIPT_ASSIGN_LINUX = 'assignIPLinux.ps1';
|
||
private const SCRIPT_ASSIGN_WINDOWS = 'assignIPWindows.ps1';
|
||
private const SCRIPT_VERIFY_LINUX = 'verifyIPLinux.ps1';
|
||
private const SCRIPT_VERIFY_WINDOWS = 'verifyIPWindows.ps1';
|
||
|
||
private const CHANGE_PWD_DIR = 'C:\\CLISave\\changePassword';
|
||
private const SCRIPT_CHANGE_PWD_LINUX = 'changePwdLinux.ps1';
|
||
private const SCRIPT_CHANGE_PWD_WINDOWS = 'changePwdWindows.ps1';
|
||
|
||
private const ADDON_DIR = 'C:\\CLISave\\Addon';
|
||
private const SCRIPT_UPDATE_VM_CONFIG = 'updateVMConfig.ps1';
|
||
|
||
private const CONNECT_TIMEOUT = 20;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 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;
|
||
$powerCliVmId = $this->powerCliVmId($vmId);
|
||
|
||
$remoteCmd = $this->windowsPowerShellCommand(self::SCRIPT_DIR, $script, [
|
||
$powerCliVmId,
|
||
$newPassword,
|
||
$vcenterHost,
|
||
$vcenterUser,
|
||
$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;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Assign a static IP to the VM guest OS via PS1 script.
|
||
* Calls assignIPLinux.ps1 or assignIPWindows.ps1 in C:\CLISave\AssignIP\.
|
||
*
|
||
* Script params:
|
||
* VmId IP Gateway Cidr VCenterHost VCenterUser VCenterPassword GuestPassword
|
||
*
|
||
* Script output: JSON {"status":"ok"} or {"status":"error","message":"..."}
|
||
*/
|
||
public function assignIp(
|
||
string $vmId,
|
||
string $ip,
|
||
string $gateway,
|
||
string $cidr,
|
||
string $vcenterHost,
|
||
string $vcenterUser,
|
||
string $vcenterPassword,
|
||
string $guestPassword,
|
||
string $osFamily,
|
||
string $dns,
|
||
string $guestUser,
|
||
): string {
|
||
$isWindows = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS;
|
||
$script = $isWindows ? self::SCRIPT_ASSIGN_WINDOWS : self::SCRIPT_ASSIGN_LINUX;
|
||
$powerCliVmId = $this->powerCliVmId($vmId);
|
||
|
||
$remoteCmd = $this->windowsPowerShellCommand(self::ASSIGN_DIR, $script, [
|
||
$powerCliVmId,
|
||
$ip,
|
||
$gateway,
|
||
$cidr,
|
||
$vcenterHost,
|
||
$vcenterUser,
|
||
$vcenterPassword,
|
||
$guestPassword,
|
||
]);
|
||
|
||
Log::info('VsphereHostSshService: executing assignIp', [
|
||
'vm_id' => $vmId,
|
||
'powercli_vm_id' => $powerCliVmId,
|
||
'ip' => $ip,
|
||
'script' => $script,
|
||
'os' => $osFamily,
|
||
]);
|
||
|
||
$output = $this->sshExec($remoteCmd);
|
||
|
||
Log::info('VsphereHostSshService: assignIp output', [
|
||
'vm_id' => $vmId,
|
||
'ip' => $ip,
|
||
'output' => trim($output),
|
||
]);
|
||
|
||
return $output;
|
||
}
|
||
|
||
/**
|
||
* Verify that an IP is active inside the VM guest OS.
|
||
* Calls verifyIPLinux.ps1 or verifyIPWindows.ps1 in C:\CLISave\AssignIP\.
|
||
*
|
||
* Script params:
|
||
* VmId IP VCenterHost VCenterUser VCenterPassword GuestPassword
|
||
*
|
||
* Script output: JSON {"status":"ok"} or {"status":"error","message":"..."}
|
||
*/
|
||
public function verifyIpInGuest(
|
||
string $vmId,
|
||
string $ip,
|
||
string $vcenterHost,
|
||
string $vcenterUser,
|
||
string $vcenterPassword,
|
||
string $guestPassword,
|
||
string $osFamily,
|
||
string $guestUser,
|
||
): string {
|
||
$isWindows = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS;
|
||
$script = $isWindows ? self::SCRIPT_VERIFY_WINDOWS : self::SCRIPT_VERIFY_LINUX;
|
||
$powerCliVmId = $this->powerCliVmId($vmId);
|
||
|
||
$remoteCmd = $this->windowsPowerShellCommand(self::ASSIGN_DIR, $script, [
|
||
$powerCliVmId,
|
||
$ip,
|
||
$vcenterHost,
|
||
$vcenterUser,
|
||
$vcenterPassword,
|
||
$guestPassword,
|
||
]);
|
||
|
||
Log::info('VsphereHostSshService: executing verifyIpInGuest', [
|
||
'vm_id' => $vmId,
|
||
'powercli_vm_id' => $powerCliVmId,
|
||
'ip' => $ip,
|
||
'script' => $script,
|
||
'os' => $osFamily,
|
||
]);
|
||
|
||
$output = $this->sshExec($remoteCmd);
|
||
|
||
Log::info('VsphereHostSshService: verifyIpInGuest output', [
|
||
'vm_id' => $vmId,
|
||
'ip' => $ip,
|
||
'output' => trim($output),
|
||
]);
|
||
|
||
return $output;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Run a command on the vSphere SSH host via phpseclib (no sshpass required).
|
||
*
|
||
* @throws \RuntimeException
|
||
*/
|
||
private function sshExec(string $remoteCmd): string
|
||
{
|
||
$config = $this->sshConfig();
|
||
|
||
$client = new SSH2($config['host'], $config['port'], self::CONNECT_TIMEOUT);
|
||
|
||
SshHostKeyVerifier::verify($client, $config['host']);
|
||
|
||
if (!$client->login($config['username'], $config['password'])) {
|
||
throw new \RuntimeException(
|
||
"SSH login failed: {$config['host']}:{$config['port']}"
|
||
);
|
||
}
|
||
|
||
$client->setTimeout(0); // PS1 scripts can run 30–90 s; block until done
|
||
|
||
$stdout = $client->exec($remoteCmd);
|
||
$stderr = $client->getStdError();
|
||
|
||
Log::info('VsphereHostSshService: SSH executed', [
|
||
'host' => $config['host'],
|
||
'port' => $config['port'],
|
||
'stdout' => trim((string) $stdout),
|
||
'stderr' => trim((string) $stderr),
|
||
]);
|
||
|
||
if ($stdout === false) {
|
||
throw new \RuntimeException(
|
||
"SSH exec failed on {$config['host']}:{$config['port']}. stderr: " . trim((string) $stderr)
|
||
);
|
||
}
|
||
|
||
// Windows SSH servers sometimes write script output to stderr instead of stdout
|
||
if (trim($stdout) === '' && trim((string) $stderr) !== '') {
|
||
return (string) $stderr;
|
||
}
|
||
|
||
return (string) $stdout;
|
||
}
|
||
|
||
/**
|
||
* @return array{host:string, port:int, username:string, password:string}
|
||
*/
|
||
private function sshConfig(): array
|
||
{
|
||
$infra = InfraManagement::where('type', 'jumphost')
|
||
->where('status', true)
|
||
->first();
|
||
|
||
if (!$infra) {
|
||
throw new \RuntimeException('No active jumphost configuration found in InfraManagement.');
|
||
}
|
||
|
||
return [
|
||
'host' => $infra->host,
|
||
'port' => (int) ($infra->meta_data['port'] ?? 22),
|
||
'username' => $infra->username,
|
||
'password' => SignRSA::DecryptFunc($infra->password),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Update VM CPU and memory via updateVMConfig.ps1.
|
||
*
|
||
* Script path: C:\CLISave\Addon\updateVMConfig.ps1
|
||
* Params: $server_name $cpu $memoryGB $vcenter_host $vcenter_user $vcenter_password
|
||
* Output: "SUCCESS" or "FAIL"
|
||
*
|
||
* PS1 must call Connect-VIServer before Get-VM — see updateVMConfig.ps1 on vSphere host.
|
||
*/
|
||
public function updateVmConfig(
|
||
string $serverName,
|
||
int $cpu,
|
||
int $memoryGB,
|
||
string $vcenterHost,
|
||
string $vcenterUser,
|
||
string $vcenterPassword,
|
||
): string {
|
||
$remoteCmd = $this->windowsPowerShellCommand(self::ADDON_DIR, self::SCRIPT_UPDATE_VM_CONFIG, [
|
||
$serverName,
|
||
(string) $cpu,
|
||
(string) $memoryGB,
|
||
$vcenterHost,
|
||
$vcenterUser,
|
||
$vcenterPassword,
|
||
]);
|
||
|
||
Log::info('VsphereHostSshService: executing updateVmConfig', [
|
||
'server_name' => $serverName,
|
||
'cpu' => $cpu,
|
||
'memoryGB' => $memoryGB,
|
||
'vcenter_host' => $vcenterHost,
|
||
]);
|
||
|
||
$output = $this->sshExec($remoteCmd);
|
||
|
||
Log::info('VsphereHostSshService: updateVmConfig output', [
|
||
'server_name' => $serverName,
|
||
'output' => trim($output),
|
||
]);
|
||
|
||
return $output;
|
||
}
|
||
|
||
/**
|
||
* Customer-initiated VM password change.
|
||
*
|
||
* Calls changePwdLinux.ps1 / changePwdWindows.ps1 in C:\CLISave\changePassword\.
|
||
*
|
||
* Param order (matches PS1 declaration):
|
||
* $vm vm_id / vmware display name double-quoted
|
||
* $password new password single-quoted
|
||
* $vcenter_host vCenter host double-quoted
|
||
* $vcenter_user vCenter user double-quoted
|
||
* $vcenter_password vCenter password single-quoted
|
||
* $guest_password current guest password single-quoted
|
||
*
|
||
* PS1 outputs "SUCCESS" or "ERROR".
|
||
*/
|
||
public function changePasswordCustomer(
|
||
string $vmId,
|
||
string $newPassword,
|
||
string $currentGuestPassword,
|
||
string $osFamily,
|
||
string $vcenterHost,
|
||
string $vcenterUser,
|
||
string $vcenterPassword,
|
||
): string {
|
||
$script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS
|
||
? self::SCRIPT_CHANGE_PWD_WINDOWS
|
||
: self::SCRIPT_CHANGE_PWD_LINUX;
|
||
$powerCliVmId = $this->powerCliVmId($vmId);
|
||
|
||
$remoteCmd = $this->windowsPowerShellCommand(self::CHANGE_PWD_DIR, $script, [
|
||
$powerCliVmId,
|
||
$newPassword,
|
||
$vcenterHost,
|
||
$vcenterUser,
|
||
$vcenterPassword,
|
||
$currentGuestPassword,
|
||
]);
|
||
|
||
Log::info('VsphereHostSshService: changePasswordCustomer', [
|
||
'vm_id' => $vmId,
|
||
'script' => $script,
|
||
'os' => $osFamily,
|
||
]);
|
||
|
||
$output = $this->sshExec($remoteCmd);
|
||
|
||
Log::info('VsphereHostSshService: changePasswordCustomer output', [
|
||
'vm_id' => $vmId,
|
||
'output' => trim($output),
|
||
]);
|
||
|
||
return $output;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* @param array<int, string> $arguments
|
||
*/
|
||
private function windowsPowerShellCommand(string $workingDirectory, string $script, array $arguments): string
|
||
{
|
||
$scriptPath = $workingDirectory . '\\' . $script;
|
||
$psScript = '& ' . PowerShellEscaper::singleQuote($scriptPath) . ' ' . implode(' ', array_map(
|
||
fn (string $argument): string => PowerShellEscaper::singleQuote($argument),
|
||
$arguments,
|
||
));
|
||
|
||
return 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand '
|
||
. PowerShellEscaper::encodedCommand($psScript);
|
||
}
|
||
|
||
/**
|
||
* vCenter REST returns MoRef strings like "vm-3340"; PowerCLI scripts build
|
||
* IDs as "VirtualMachine-$vmId", so keep the MoRef intact.
|
||
*/
|
||
private function powerCliVmId(string $vmId): string
|
||
{
|
||
return $vmId;
|
||
}
|
||
|
||
}
|