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:
Andras Bacsai
2025-05-15 22:21:54 +02:00
parent 24d7429e4f
commit e7536d3fb8
8 changed files with 568 additions and 0 deletions

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServerPackageUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public function __construct($teamId = null)
{
if (is_null($teamId) && auth()->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}"),
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Livewire\Server\Security;
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\UpdatePackage;
use App\Events\ServerPackageUpdated;
use App\Models\Server;
use Livewire\Component;
class Patches extends Component
{
public array $parameters;
public Server $server;
public ?int $totalUpdates = null;
public ?array $updates = null;
public ?string $error = null;
public ?string $osId = null;
public ?string $packageManager = null;
public function getListeners()
{
$teamId = auth()->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');
}
}

View File

@@ -45,6 +45,12 @@
]) }}">
<button>Terminal</button>
</a>
<a class="{{ request()->routeIs('server.security.patches') ? 'dark:text-white' : '' }}"
href="{{ route('server.security.patches', [
'server_uuid' => data_get($server, 'uuid'),
]) }}">
<button>Security</button>
</a>
</nav>
<div class="order-first sm:order-last">
<livewire:server.proxy.deploy :server="$server" />

View File

@@ -0,0 +1,6 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="{{ request()->routeIs('server.security.patches') ? 'menu-item menu-item-active' : 'menu-item' }}"
href="{{ route('server.security.patches', $parameters) }}">
<button>Server Patching</button>
</a>
</div>

View File

@@ -0,0 +1,118 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Security | Coolify
</x-slot>
<x-server.navbar :server="$server" />
<x-slide-over closeWithX fullScreen @startupdate.window="slideOverOpen = true">
<x-slot:title>Updating Packages</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" />
</x-slot:content>
</x-slide-over>
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<form wire:submit='submit' class="w-full">
<div>
<div class="flex items-center gap-2">
<h2>Server Patching</h2>
<x-forms.button type="button" wire:click="$dispatch('checkForUpdatesDispatch')">Manually
Check</x-forms.button>
</div>
<div>Check if your server has updates available.</div>
<div class="text-xs pt-1">(only available for apt, dnf and zypper package managers atm, more coming
soon as well as more features...)
</div>
<div>
<div class="flex flex-col gap-6 pt-4">
<div class="flex flex-col">
<div>
<div wire:target="checkForUpdates" wire:loading>
Checking for updates. It may take a few minutes. <x-loading />
</div>
@if ($error)
<div class="text-red-500">{{ $error }}</div>
@else
@if ($totalUpdates === 0)
<div class="text-green-500">Your server is up to date.</div>
@endif
@if (isset($updates) && count($updates) > 0)
<x-modal-confirmation title="Confirm package update?"
buttonTitle="Update All
Packages"
isHighlightedButton submitAction="updateAllPackages" dispatchAction
:actions="[
'All packages will be updated to the latest version.',
'This action could restart your currently running containers if docker will be updated.',
]" confirmationText="Update All Packages"
confirmationLabel="Please confirm the execution of the actions by entering the name below"
shortConfirmationLabel="Name" :confirmWithPassword=false
step2ButtonText="Update All
Packages" />
<table>
<thead>
<tr>
<th>Package</th>
@if ($packageManager !== 'dnf')
<th>Current Version</th>
@endif
<th>New Version</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach ($updates as $update)
<tr>
<td class="inline-flex gap-2 justify-center items-center">
@if (data_get_str($update, 'package')->contains('docker'))
<x-helper :helper="'This package will restart your currently running containers'">
<x-slot:icon>
<svg class="w-4 h-4 text-red-500 block"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
</path>
</svg>
</x-slot:icon>
</x-helper>
@endif
{{ data_get($update, 'package') }}
</td>
@if ($packageManager !== 'dnf')
<td>{{ data_get($update, 'current_version') }}</td>
@endif
<td>{{ data_get($update, 'new_version') }}</td>
<td>
<x-forms.button type="button"
wire:click="$dispatch('updatePackage', { package: '{{ data_get($update, 'package') }}' })">Update</x-forms.button>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
@endif
</div>
</div>
</div>
</div>
</div>
</form>
</div>
@script
<script>
$wire.on('updateAllPackages', () => {
window.dispatchEvent(new CustomEvent('startupdate'));
$wire.$call('updateAllPackages');
});
$wire.on('updatePackage', (data) => {
window.dispatchEvent(new CustomEvent('startupdate'));
$wire.$call('updatePackage', data.package);
});
$wire.on('checkForUpdatesDispatch', () => {
$wire.$call('checkForUpdates');
});
</script>
@endscript
</div>

View File

@@ -49,6 +49,7 @@ use App\Livewire\Server\Proxy\DynamicConfigurations as ProxyDynamicConfiguration
use App\Livewire\Server\Proxy\Logs as ProxyLogs;
use App\Livewire\Server\Proxy\Show as ProxyShow;
use App\Livewire\Server\Resources as ResourcesShow;
use App\Livewire\Server\Security\Patches;
use App\Livewire\Server\Show as ServerShow;
use App\Livewire\Settings\Index as SettingsIndex;
use App\Livewire\SettingsBackup;
@@ -252,6 +253,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command');
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
Route::get('/security', fn () => redirect(route('dashboard')));
Route::get('/security/patches', Patches::class)->name('server.security.patches');
});
Route::get('/destinations', DestinationIndex::class)->name('destination.index');
Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show');