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,
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Events/ServerPackageUpdated.php
Normal file
35
app/Events/ServerPackageUpdated.php
Normal 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}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
92
app/Livewire/Server/Security/Patches.php
Normal file
92
app/Livewire/Server/Security/Patches.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
118
resources/views/livewire/server/security/patches.blade.php
Normal file
118
resources/views/livewire/server/security/patches.blade.php
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user