feat(security): implement server patching functionality
- Add CheckUpdates and UpdatePackage actions for managing server updates. - Create ServerPackageUpdated event for broadcasting update status. - Introduce Patches Livewire component for user interface to check and apply updates. - Update navigation and sidebar to include security patching options.
This commit is contained in:
227
app/Actions/Server/CheckUpdates.php
Normal file
227
app/Actions/Server/CheckUpdates.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CheckUpdates
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
try {
|
||||
if ($server->serverStatus() === false) {
|
||||
return [
|
||||
'error' => 'Server is not reachable or not ready.',
|
||||
];
|
||||
}
|
||||
|
||||
// Try first method - using instant_remote_process
|
||||
$output = instant_remote_process(['cat /etc/os-release'], $server);
|
||||
|
||||
// Parse os-release into an associative array
|
||||
$osInfo = [];
|
||||
foreach (explode("\n", $output) as $line) {
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$osInfo[$key] = trim($value, '"');
|
||||
}
|
||||
|
||||
// Get the main OS identifier
|
||||
$osId = $osInfo['ID'] ?? '';
|
||||
// $osIdLike = $osInfo['ID_LIKE'] ?? '';
|
||||
// $versionId = $osInfo['VERSION_ID'] ?? '';
|
||||
|
||||
// Normalize OS types based on install.sh logic
|
||||
switch ($osId) {
|
||||
case 'manjaro':
|
||||
case 'manjaro-arm':
|
||||
case 'endeavouros':
|
||||
$osType = 'arch';
|
||||
break;
|
||||
case 'pop':
|
||||
case 'linuxmint':
|
||||
case 'zorin':
|
||||
$osType = 'ubuntu';
|
||||
break;
|
||||
case 'fedora-asahi-remix':
|
||||
$osType = 'fedora';
|
||||
break;
|
||||
default:
|
||||
$osType = $osId;
|
||||
}
|
||||
|
||||
// Determine package manager based on OS type
|
||||
$packageManager = match ($osType) {
|
||||
'arch' => 'pacman',
|
||||
'alpine' => 'apk',
|
||||
'ubuntu', 'debian', 'raspbian' => 'apt',
|
||||
'centos', 'fedora', 'rhel', 'ol', 'rocky', 'almalinux', 'amzn' => 'dnf',
|
||||
'sles', 'opensuse-leap', 'opensuse-tumbleweed' => 'zypper',
|
||||
default => null
|
||||
};
|
||||
|
||||
switch ($packageManager) {
|
||||
case 'zypper':
|
||||
$output = instant_remote_process(['LANG=C zypper -tx list-updates'], $server);
|
||||
$out = $this->parseZypperOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
case 'dnf':
|
||||
$output = instant_remote_process(['LANG=C dnf list -q --updates --refresh'], $server);
|
||||
$out = $this->parseDnfOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
$rebootRequired = instant_remote_process(['LANG=C dnf needs-restarting -r'], $server);
|
||||
$out['reboot_required'] = $rebootRequired === '0' ? true : false;
|
||||
|
||||
return $out;
|
||||
case 'apt':
|
||||
instant_remote_process(['apt-get update -qq'], $server);
|
||||
$output = instant_remote_process(['LANG=C apt list --upgradable 2>/dev/null'], $server);
|
||||
|
||||
$out = $this->parseAptOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
$rebootRequired = instant_remote_process(['LANG=C test -f /var/run/reboot-required && echo "YES" || echo "NO"'], $server);
|
||||
$out['reboot_required'] = $rebootRequired === 'YES' ? true : false;
|
||||
|
||||
return $out;
|
||||
default:
|
||||
return [
|
||||
'osId' => $osId,
|
||||
'error' => 'Unsupported package manager',
|
||||
'package_manager' => $packageManager,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
ray('Error:', $e->getMessage());
|
||||
|
||||
return [
|
||||
'osId' => $osId,
|
||||
'package_manager' => $packageManager,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function parseZypperOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($output);
|
||||
if ($xml === false) {
|
||||
return [
|
||||
'total_updates' => 0,
|
||||
'updates' => [],
|
||||
'error' => 'Failed to parse XML output',
|
||||
];
|
||||
}
|
||||
|
||||
// Navigate to the update-list node
|
||||
$updateList = $xml->xpath('//update-list/update');
|
||||
|
||||
foreach ($updateList as $update) {
|
||||
$updates[] = [
|
||||
'package' => (string) $update['name'],
|
||||
'new_version' => (string) $update['edition'],
|
||||
'current_version' => (string) $update['edition-old'],
|
||||
'architecture' => (string) $update['arch'],
|
||||
'repository' => (string) $update->source['alias'],
|
||||
'summary' => (string) $update->summary,
|
||||
'description' => (string) $update->description,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'total_updates' => 0,
|
||||
'updates' => [],
|
||||
'error' => 'Error parsing zypper output: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function parseDnfOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split by multiple spaces/tabs and filter out empty elements
|
||||
$parts = array_values(array_filter(preg_split('/\s+/', $line)));
|
||||
|
||||
if (count($parts) >= 3) {
|
||||
$package = $parts[0];
|
||||
$new_version = $parts[1];
|
||||
$repository = $parts[2];
|
||||
|
||||
// Extract architecture from package name (e.g., "cloud-init.noarch" -> "noarch")
|
||||
$architecture = str_contains($package, '.') ? explode('.', $package)[1] : 'noarch';
|
||||
|
||||
$updates[] = [
|
||||
'package' => $package,
|
||||
'new_version' => $new_version,
|
||||
'repository' => $repository,
|
||||
'architecture' => $architecture,
|
||||
'current_version' => 'unknown', // DNF doesn't show current version in check-update output
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseAptOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip the "Listing... Done" line and empty lines
|
||||
if (empty($line) || str_contains($line, 'Listing...')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Example line: package/stable 2.0-1 amd64 [upgradable from: 1.0-1]
|
||||
if (preg_match('/^(.+?)\/(\S+)\s+(\S+)\s+(\S+)\s+\[upgradable from: (.+?)\]/', $line, $matches)) {
|
||||
$updates[] = [
|
||||
'package' => $matches[1],
|
||||
'repository' => $matches[2],
|
||||
'new_version' => $matches[3],
|
||||
'architecture' => $matches[4],
|
||||
'current_version' => $matches[5],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Actions/Server/UpdatePackage.php
Normal file
81
app/Actions/Server/UpdatePackage.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class UpdatePackage
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server, string $osId, ?string $package = null, ?string $packageManager = null, bool $all = false): Activity|array
|
||||
{
|
||||
try {
|
||||
if ($server->serverStatus() === false) {
|
||||
return [
|
||||
'error' => 'Server is not reachable or not ready.',
|
||||
];
|
||||
}
|
||||
switch ($packageManager) {
|
||||
case 'zypper':
|
||||
$commandAll = 'zypper update -y';
|
||||
$commandInstall = 'zypper install -y '.$package;
|
||||
break;
|
||||
case 'dnf':
|
||||
$commandAll = 'dnf update -y';
|
||||
$commandInstall = 'dnf update -y '.$package;
|
||||
break;
|
||||
case 'apt':
|
||||
$commandAll = 'apt update && apt upgrade -y';
|
||||
$commandInstall = 'apt install -y '.$package;
|
||||
break;
|
||||
default:
|
||||
return [
|
||||
'error' => 'OS not supported',
|
||||
];
|
||||
}
|
||||
if ($all) {
|
||||
return remote_process([$commandAll], $server);
|
||||
}
|
||||
|
||||
return remote_process([$commandInstall], $server);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function parseAptOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip the "Listing... Done" line and empty lines
|
||||
if (empty($line) || str_contains($line, 'Listing...')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Example line: package/stable 2.0-1 amd64 [upgradable from: 1.0-1]
|
||||
if (preg_match('/^(.+?)\/(\S+)\s+(\S+)\s+(\S+)\s+\[upgradable from: (.+?)\]/', $line, $matches)) {
|
||||
$updates[] = [
|
||||
'package' => $matches[1],
|
||||
'repository' => $matches[2],
|
||||
'new_version' => $matches[3],
|
||||
'architecture' => $matches[4],
|
||||
'current_version' => $matches[5],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user