feat(terminal-access): implement terminal access control for servers and containers, including UI updates and backend logic

This commit is contained in:
Andras Bacsai
2025-05-29 14:09:05 +02:00
parent e9deaca8cd
commit 46b4cfac68
11 changed files with 149 additions and 25 deletions

View File

@@ -49,17 +49,18 @@ class ExecuteContainerCommand extends Component
if (! auth()->user()->isAdmin()) { if (! auth()->user()->isAdmin()) {
abort(403); abort(403);
} }
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->containers = collect(); $this->containers = collect();
$this->servers = collect(); $this->servers = collect();
if (data_get($this->parameters, 'application_uuid')) { if (data_get($this->parameters, 'application_uuid')) {
$this->type = 'application'; $this->type = 'application';
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
if ($this->resource->destination->server->isFunctional()) { if ($this->resource->destination->server->isFunctional() && $this->resource->destination->server->isTerminalEnabled()) {
$this->servers = $this->servers->push($this->resource->destination->server); $this->servers = $this->servers->push($this->resource->destination->server);
} }
foreach ($this->resource->additional_servers as $server) { foreach ($this->resource->additional_servers as $server) {
if ($server->isFunctional()) { if ($server->isFunctional() && $server->isTerminalEnabled()) {
$this->servers = $this->servers->push($server); $this->servers = $this->servers->push($server);
} }
} }
@@ -71,14 +72,14 @@ class ExecuteContainerCommand extends Component
abort(404); abort(404);
} }
$this->resource = $resource; $this->resource = $resource;
if ($this->resource->destination->server->isFunctional()) { if ($this->resource->destination->server->isFunctional() && $this->resource->destination->server->isTerminalEnabled()) {
$this->servers = $this->servers->push($this->resource->destination->server); $this->servers = $this->servers->push($this->resource->destination->server);
} }
$this->loadContainers(); $this->loadContainers();
} elseif (data_get($this->parameters, 'service_uuid')) { } elseif (data_get($this->parameters, 'service_uuid')) {
$this->type = 'service'; $this->type = 'service';
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
if ($this->resource->server->isFunctional()) { if ($this->resource->server->isFunctional() && $this->resource->server->isTerminalEnabled()) {
$this->servers = $this->servers->push($this->resource->server); $this->servers = $this->servers->push($this->resource->server);
} }
$this->loadContainers(); $this->loadContainers();

View File

@@ -4,9 +4,12 @@ namespace App\Livewire\Server;
use App\Helpers\SslHelper; use App\Helpers\SslHelper;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate; use App\Models\SslCertificate;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -36,6 +39,9 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])] #[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1; public int $dynamicTimeout = 1;
#[Validate(['boolean'])]
public bool $isTerminalEnabled = false;
public function mount(string $server_uuid) public function mount(string $server_uuid)
{ {
try { try {
@@ -63,6 +69,37 @@ class Advanced extends Component
$this->showCertificate = ! $this->showCertificate; $this->showCertificate = ! $this->showCertificate;
} }
public function toggleTerminal($password)
{
try {
// Check if user is admin or owner
if (! auth()->user()->isAdmin()) {
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
}
// Toggle the terminal setting
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
$this->server->settings->save();
// Update the local property
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function saveCaCertificate() public function saveCaCertificate()
{ {
try { try {
@@ -149,6 +186,7 @@ class Advanced extends Component
$this->dynamicTimeout = $this->server->settings->dynamic_timeout; $this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
} }
} }

View File

@@ -21,7 +21,9 @@ class Index extends Component
if (! auth()->user()->isAdmin()) { if (! auth()->user()->isAdmin()) {
abort(403); abort(403);
} }
$this->servers = Server::isReachable()->get(); $this->servers = Server::isReachable()->get()->filter(function ($server) {
return $server->isTerminalEnabled();
});
} }
public function loadContainers() public function loadContainers()

View File

@@ -952,6 +952,11 @@ $schema://$host {
} }
} }
public function isTerminalEnabled()
{
return $this->settings->is_terminal_enabled ?? false;
}
public function isSwarm() public function isSwarm()
{ {
return data_get($this, 'settings.is_swarm_manager') || data_get($this, 'settings.is_swarm_worker'); return data_get($this, 'settings.is_swarm_manager') || data_get($this, 'settings.is_swarm_worker');

View File

@@ -28,6 +28,7 @@ use OpenApi\Attributes as OA;
'is_sentinel_enabled' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'],
'is_swarm_manager' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'],
'is_swarm_worker' => ['type' => 'boolean'], 'is_swarm_worker' => ['type' => 'boolean'],
'is_terminal_enabled' => ['type' => 'boolean'],
'is_usable' => ['type' => 'boolean'], 'is_usable' => ['type' => 'boolean'],
'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_api_key' => ['type' => 'string'],
'logdrain_axiom_dataset_name' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'],
@@ -59,6 +60,7 @@ class ServerSetting extends Model
'sentinel_token' => 'encrypted', 'sentinel_token' => 'encrypted',
'is_reachable' => 'boolean', 'is_reachable' => 'boolean',
'is_usable' => 'boolean', 'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
]; ];
protected static function booted() protected static function booted()

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('is_terminal_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('is_terminal_enabled');
});
}
};

View File

@@ -23,11 +23,15 @@
'dispatchEventType' => 'success', 'dispatchEventType' => 'success',
'dispatchEventMessage' => '', 'dispatchEventMessage' => '',
'ignoreWire' => true, 'ignoreWire' => true,
'temporaryDisableTwoStepConfirmation' => false,
]) ])
@php @php
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
$disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation'); $disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation');
if ($temporaryDisableTwoStepConfirmation) {
$disableTwoStepConfirmation = false;
}
@endphp @endphp
<div {{ $ignoreWire ? 'wire:ignore' : '' }} x-data="{ <div {{ $ignoreWire ? 'wire:ignore' : '' }} x-data="{
@@ -262,7 +266,7 @@
{{ $shortConfirmationLabel }} {{ $shortConfirmationLabel }}
</label> </label>
<input type="text" x-model="userConfirmationText" <input type="text" x-model="userConfirmationText"
class="p-2 mt-1 w-full text-black rounded-sm input"> class="p-2 mt-1 px-3 w-full rounded-sm input">
</div> </div>
@endif @endif
@endif @endif

View File

@@ -44,7 +44,7 @@
</div> </div>
@else @else
@if (count($containers) === 0) @if (count($containers) === 0)
<div class="pt-4">No containers are running.</div> <div class="pt-4">No containers are running on this server or terminal access is disabled.</div>
@else @else
@if (count($containers) === 1) @if (count($containers) === 1)
<form class="w-full pt-4" wire:submit="$dispatchSelf('connectToContainer')" <form class="w-full pt-4" wire:submit="$dispatchSelf('connectToContainer')"

View File

@@ -14,6 +14,46 @@
<div class="mb-4">Advanced configuration for your server.</div> <div class="mb-4">Advanced configuration for your server.</div>
</div> </div>
<div class="flex items-center gap-2">
<h3>Terminal Access</h3>
<x-helper
helper="Control whether terminal access is available for this server and its containers.<br/>Only team
administrators and owners can modify this setting." />
@if ($isTerminalEnabled)
<span
class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Enabled
</span>
@else
<span
class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Disabled
</span>
@endif
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-4 pt-4">
@if (auth()->user()->isAdmin())
<div wire:key="terminal-access-change-{{ $isTerminalEnabled }}" class="pb-4">
<x-modal-confirmation title="Confirm Terminal Access Change?"
buttonTitle="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}"
submitAction="toggleTerminal" :actions="[
$isTerminalEnabled
? 'This will disable terminal access for this server and all its containers.'
: 'This will enable terminal access for this server and all its containers.',
$isTerminalEnabled
? 'Users will no longer be able to access terminal views from the UI.'
: 'Users will be able to access terminal views from the UI.',
'This change will take effect immediately.',
]" confirmationText="{{ $server->name }}"
shortConfirmationLabel="Server Name"
step3ButtonText="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}">
</x-modal-confirmation>
</div>
@endif
</div>
</div>
<h3>Disk Usage</h3> <h3>Disk Usage</h3>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex flex-col"> <div class="flex flex-col">

View File

@@ -14,25 +14,29 @@
<x-loading text="Loading servers and containers..." /> <x-loading text="Loading servers and containers..." />
</div> </div>
@else @else
<form class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row" @if ($servers->count() > 0)
wire:submit="$dispatchSelf('connectToContainer')"> <form class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row"
<x-forms.select id="server" required wire:model.live="selected_uuid"> wire:submit="$dispatchSelf('connectToContainer')">
@foreach ($servers as $server) <x-forms.select id="server" required wire:model.live="selected_uuid">
@if ($loop->first) @foreach ($servers as $server)
<option disabled value="default">Select a server or container</option> @if ($loop->first)
@endif <option disabled value="default">Select a server or container</option>
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
@foreach ($containers as $container)
@if ($container['server_uuid'] == $server->uuid)
<option value="{{ $container['uuid'] }}">
{{ $server->name }} -> {{ $container['name'] }}
</option>
@endif @endif
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
@foreach ($containers as $container)
@if ($container['server_uuid'] == $server->uuid)
<option value="{{ $container['uuid'] }}">
{{ $server->name }} -> {{ $container['name'] }}
</option>
@endif
@endforeach
@endforeach @endforeach
@endforeach </x-forms.select>
</x-forms.select> <x-forms.button type="submit">Connect</x-forms.button>
<x-forms.button type="submit">Connect</x-forms.button> </form>
</form> @else
<div>No servers with terminal access found.</div>
@endif
@endif @endif
<livewire:project.shared.terminal /> <livewire:project.shared.terminal />
</div> </div>

View File

@@ -153,7 +153,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/terminal/auth/ips', function () { Route::post('/terminal/auth/ips', function () {
if (auth()->check()) { if (auth()->check()) {
$team = auth()->user()->currentTeam(); $team = auth()->user()->currentTeam();
$ipAddresses = $team->servers()->pluck('ip')->toArray(); $ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray();
return response()->json(['ipAddresses' => $ipAddresses], 200); return response()->json(['ipAddresses' => $ipAddresses], 200);
} }