From e7536d3fb8ee45b7d9f69465b41c1d09065c9ef1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 15 May 2025 22:21:54 +0200 Subject: [PATCH] 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. --- app/Actions/Server/CheckUpdates.php | 227 ++++++++++++++++++ app/Actions/Server/UpdatePackage.php | 81 +++++++ app/Events/ServerPackageUpdated.php | 35 +++ app/Livewire/Server/Security/Patches.php | 92 +++++++ .../views/components/server/navbar.blade.php | 6 + .../server/sidebar-security.blade.php | 6 + .../server/security/patches.blade.php | 118 +++++++++ routes/web.php | 3 + 8 files changed, 568 insertions(+) create mode 100644 app/Actions/Server/CheckUpdates.php create mode 100644 app/Actions/Server/UpdatePackage.php create mode 100644 app/Events/ServerPackageUpdated.php create mode 100644 app/Livewire/Server/Security/Patches.php create mode 100644 resources/views/components/server/sidebar-security.blade.php create mode 100644 resources/views/livewire/server/security/patches.blade.php diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php new file mode 100644 index 000000000..01a9a764d --- /dev/null +++ b/app/Actions/Server/CheckUpdates.php @@ -0,0 +1,227 @@ +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, + ]; + } +} diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php new file mode 100644 index 000000000..85e495784 --- /dev/null +++ b/app/Actions/Server/UpdatePackage.php @@ -0,0 +1,81 @@ +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, + ]; + } +} diff --git a/app/Events/ServerPackageUpdated.php b/app/Events/ServerPackageUpdated.php new file mode 100644 index 000000000..4bde14068 --- /dev/null +++ b/app/Events/ServerPackageUpdated.php @@ -0,0 +1,35 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php new file mode 100644 index 000000000..183055813 --- /dev/null +++ b/app/Livewire/Server/Security/Patches.php @@ -0,0 +1,92 @@ +user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ServerPackageUpdated" => 'checkForUpdatesDispatch', + ]; + } + + public function mount() + { + if (! auth()->user()->isAdmin()) { + abort(403); + } + $this->parameters = get_route_parameters(); + $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); + } + + public function checkForUpdatesDispatch() + { + $this->totalUpdates = null; + $this->updates = null; + $this->error = null; + $this->osId = null; + $this->packageManager = null; + $this->dispatch('checkForUpdatesDispatch'); + } + + public function checkForUpdates() + { + $job = CheckUpdates::run($this->server); + if (isset($job['error'])) { + $this->error = data_get($job, 'error', 'Something went wrong.'); + } else { + $this->totalUpdates = data_get($job, 'total_updates', 0); + $this->updates = data_get($job, 'updates', []); + $this->osId = data_get($job, 'osId', null); + $this->packageManager = data_get($job, 'package_manager', null); + } + } + + public function updateAllPackages() + { + try { + $activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, all: true); + $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); + } catch (\Exception $e) { + $this->dispatch('error', message: $e->getMessage()); + } + } + + public function updatePackage($package) + { + try { + $activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, package: $package); + $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); + } catch (\Exception $e) { + $this->dispatch('error', message: $e->getMessage()); + } + } + + public function render() + { + return view('livewire.server.security.patches'); + } +} diff --git a/resources/views/components/server/navbar.blade.php b/resources/views/components/server/navbar.blade.php index fe0b02ce8..ff56a096d 100644 --- a/resources/views/components/server/navbar.blade.php +++ b/resources/views/components/server/navbar.blade.php @@ -45,6 +45,12 @@ ]) }}"> + + +