Merge remote-tracking branch 'origin/next' into audit-policies

# Conflicts:
#	app/Http/Controllers/Api/SecurityController.php
#	app/Http/Controllers/Api/ServersController.php
#	app/Livewire/Admin/Index.php
#	app/Livewire/Destination/Show.php
#	app/Livewire/NavbarDeleteTeam.php
#	app/Livewire/Project/Application/Previews.php
#	app/Livewire/Project/DeleteProject.php
#	app/Livewire/Project/Shared/ResourceOperations.php
#	app/Livewire/Server/Resources.php
#	app/Livewire/Server/ValidateAndInstall.php
#	app/Livewire/Storage/Show.php
#	resources/views/livewire/dashboard.blade.php
#	resources/views/livewire/project/application/heading.blade.php
#	resources/views/livewire/project/database/heading.blade.php
#	resources/views/livewire/project/service/heading.blade.php
#	resources/views/livewire/project/shared/environment-variable/show.blade.php
#	resources/views/livewire/project/shared/scheduled-task/show.blade.php
#	tests/Feature/Security/TrustHostsMiddlewareTest.php
This commit is contained in:
Andras Bacsai committed 2026-04-19 15:19:37 +02:00
commit 43f33d94ad
685 files changed
+41904 -5674

No files matched your search

+8 -8
View File
@@ -15,17 +15,17 @@ class Advanced extends Component
#[Validate(['string'])]
public string $serverDiskUsageCheckFrequency = '0 23 * * *';
#[Validate(['integer', 'min:1', 'max:99'])]
public int $serverDiskUsageNotificationThreshold = 50;
#[Validate(['required', 'integer', 'min:1', 'max:99'])]
public int|string $serverDiskUsageNotificationThreshold = 50;
#[Validate(['integer', 'min:1'])]
public int $concurrentBuilds = 1;
#[Validate(['required', 'integer', 'min:1'])]
public int|string $concurrentBuilds = 1;
#[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1;
#[Validate(['required', 'integer', 'min:1'])]
public int|string $dynamicTimeout = 1;
#[Validate(['integer', 'min:1'])]
public int $deploymentQueueLimit = 25;
#[Validate(['required', 'integer', 'min:1'])]
public int|string $deploymentQueueLimit = 25;
public function mount(string $server_uuid)
{
+27 -4
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Jobs\DeleteResourceJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -15,6 +16,8 @@ class Delete extends Component
public bool $delete_from_hetzner = false;
public bool $force_delete_resources = false;
public function mount(string $server_uuid)
{
try {
@@ -24,19 +27,30 @@ class Delete extends Component
}
}
public function delete($password)
public function delete($password, $selectedActions = [])
{
if (! verifyPasswordConfirmation($password, $this)) {
return;
return 'The provided password is incorrect.';
}
if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
$this->authorize('delete', $this->server);
if ($this->server->hasDefinedResources()) {
$this->dispatch('error', 'Server has defined resources. Please delete them first.');
if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
$this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
return;
}
if ($this->force_delete_resources) {
foreach ($this->server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$this->server->delete();
DeleteServer::dispatch(
$this->server->id,
@@ -56,6 +70,15 @@ class Delete extends Component
{
$checkboxes = [];
if ($this->server->hasDefinedResources()) {
$resourceCount = $this->server->definedResources()->count();
$checkboxes[] = [
'id' => 'force_delete_resources',
'label' => "Delete all resources ({$resourceCount} total)",
'default_warning' => 'Server cannot be deleted while it has resources.',
];
}
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
+6 -6
View File
@@ -24,16 +24,16 @@ class LogDrains extends Component
#[Validate(['boolean'])]
public bool $isLogDrainAxiomEnabled = false;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainNewRelicLicenseKey = null;
#[Validate(['url', 'nullable'])]
public ?string $logDrainNewRelicBaseUri = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainAxiomDatasetName = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainAxiomApiKey = null;
#[Validate(['string', 'nullable'])]
@@ -127,7 +127,7 @@ class LogDrains extends Component
if ($this->isLogDrainNewRelicEnabled) {
try {
$this->validate([
'logDrainNewRelicLicenseKey' => ['required'],
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainNewRelicBaseUri' => ['required', 'url'],
]);
} catch (\Throwable $e) {
@@ -138,8 +138,8 @@ class LogDrains extends Component
} elseif ($this->isLogDrainAxiomEnabled) {
try {
$this->validate([
'logDrainAxiomDatasetName' => ['required'],
'logDrainAxiomApiKey' => ['required'],
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
]);
} catch (\Throwable $e) {
$this->isLogDrainAxiomEnabled = false;
+3 -43
View File
@@ -8,6 +8,7 @@ use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -165,7 +166,7 @@ class ByHetzner extends Component
'selectedHetznerSshKeyIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml],
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
@@ -299,11 +300,6 @@ class ByHetzner extends Component
public function getAvailableServerTypesProperty()
{
ray('Getting available server types', [
'selected_location' => $this->selected_location,
'total_server_types' => count($this->serverTypes),
]);
if (! $this->selected_location) {
return $this->serverTypes;
}
@@ -326,21 +322,11 @@ class ByHetzner extends Component
->values()
->toArray();
ray('Filtered server types', [
'selected_location' => $this->selected_location,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getAvailableImagesProperty()
{
ray('Getting available images', [
'selected_server_type' => $this->selected_server_type,
'total_images' => count($this->images),
'images' => $this->images,
]);
if (! $this->selected_server_type) {
return $this->images;
@@ -348,10 +334,7 @@ class ByHetzner extends Component
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
ray('Server type data', $serverType);
if (! $serverType || ! isset($serverType['architecture'])) {
ray('No architecture in server type, returning all');
return $this->images;
}
@@ -363,11 +346,6 @@ class ByHetzner extends Component
->values()
->toArray();
ray('Filtered images', [
'architecture' => $architecture,
'filtered_count' => count($filtered),
]);
return $filtered;
}
@@ -390,8 +368,6 @@ class ByHetzner extends Component
public function updatedSelectedLocation($value)
{
ray('Location selected', $value);
// Reset server type and image when location changes
$this->selected_server_type = null;
$this->selected_image = null;
@@ -399,15 +375,13 @@ class ByHetzner extends Component
public function updatedSelectedServerType($value)
{
ray('Server type selected', $value);
// Reset image when server type changes
$this->selected_image = null;
}
public function updatedSelectedImage($value)
{
ray('Image selected', $value);
//
}
public function updatedSelectedCloudInitScriptId($value)
@@ -437,18 +411,10 @@ class ByHetzner extends Component
$publicKey = $privateKey->getPublicKey();
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
ray('Private Key Info', [
'private_key_id' => $this->private_key_id,
'sha256_fingerprint' => $privateKey->fingerprint,
'md5_fingerprint' => $md5Fingerprint,
]);
// Check if SSH key already exists on Hetzner by comparing MD5 fingerprints
$existingSshKeys = $hetznerService->getSshKeys();
$existingKey = null;
ray('Existing SSH Keys on Hetzner', $existingSshKeys);
foreach ($existingSshKeys as $key) {
if ($key['fingerprint'] === $md5Fingerprint) {
$existingKey = $key;
@@ -459,12 +425,10 @@ class ByHetzner extends Component
// Upload SSH key if it doesn't exist
if ($existingKey) {
$sshKeyId = $existingKey['id'];
ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]);
} else {
$sshKeyName = $privateKey->name;
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
$sshKeyId = $uploadedKey['id'];
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
}
// Normalize server name to lowercase for RFC 1123 compliance
@@ -499,13 +463,9 @@ class ByHetzner extends Component
$params['user_data'] = $this->cloud_init_script;
}
ray('Server creation parameters', $params);
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
ray('Hetzner server created', $hetznerServer);
return $hetznerServer;
}
+2 -1
View File
@@ -63,7 +63,8 @@ class Show extends Component
$this->dispatch('success', 'Server is reachable.');
$this->dispatch('refreshServerShow');
} else {
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$sanitizedError);
return;
}
+10 -3
View File
@@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -41,9 +42,13 @@ class Proxy extends Component
];
}
protected $rules = [
'generateExactLabels' => 'required|boolean',
];
protected function rules()
{
return [
'generateExactLabels' => 'required|boolean',
'redirectUrl' => ['nullable', new SafeExternalUrl],
];
}
public function mount()
{
@@ -51,6 +56,7 @@ class Proxy extends Component
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false);
$this->loadProxyConfiguration();
}
private function syncData(bool $toModel = false): void
@@ -150,6 +156,7 @@ class Proxy extends Component
{
try {
$this->authorize('update', $this->server);
$this->validate();
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
+16
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Models\Server;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -31,6 +32,11 @@ class Resources extends Component
{
try {
$this->authorize('update', $this->server);
if (! ValidationPatterns::isValidContainerName($id)) {
$this->dispatch('error', 'Invalid container identifier.');
return;
}
$this->server->startUnmanaged($id);
$this->dispatch('success', 'Container started.');
$this->loadUnmanagedContainers();
@@ -43,6 +49,11 @@ class Resources extends Component
{
try {
$this->authorize('update', $this->server);
if (! ValidationPatterns::isValidContainerName($id)) {
$this->dispatch('error', 'Invalid container identifier.');
return;
}
$this->server->restartUnmanaged($id);
$this->dispatch('success', 'Container restarted.');
$this->loadUnmanagedContainers();
@@ -55,6 +66,11 @@ class Resources extends Component
{
try {
$this->authorize('update', $this->server);
if (! ValidationPatterns::isValidContainerName($id)) {
$this->dispatch('error', 'Invalid container identifier.');
return;
}
$this->server->stopUnmanaged($id);
$this->dispatch('success', 'Container stopped.');
$this->loadUnmanagedContainers();
@@ -31,7 +31,7 @@ class TerminalAccess extends Component
}
}
public function toggleTerminal($password)
public function toggleTerminal($password, $selectedActions = [])
{
try {
$this->authorize('update', $this->server);
@@ -43,7 +43,7 @@ class TerminalAccess extends Component
// Verify password
if (! verifyPasswordConfirmation($password, $this)) {
return;
return 'The provided password is incorrect.';
}
// Toggle the terminal setting
@@ -55,6 +55,8 @@ class TerminalAccess extends Component
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
return true;
} catch (\Throwable $e) {
return handleError($e, $this);
}
+4 -4
View File
@@ -19,19 +19,19 @@ class Sentinel extends Component
public bool $isMetricsEnabled;
#[Validate(['required'])]
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
public string $sentinelToken;
public ?string $sentinelUpdatedAt = null;
#[Validate(['required', 'integer', 'min:1'])]
public int $sentinelMetricsRefreshRateSeconds;
public int|string $sentinelMetricsRefreshRateSeconds;
#[Validate(['required', 'integer', 'min:1'])]
public int $sentinelMetricsHistoryDays;
public int|string $sentinelMetricsHistoryDays;
#[Validate(['required', 'integer', 'min:10'])]
public int $sentinelPushIntervalSeconds;
public int|string $sentinelPushIntervalSeconds;
#[Validate(['nullable', 'url'])]
public ?string $sentinelCustomUrl = null;
+16
View File
@@ -489,6 +489,22 @@ class Show extends Component
}
}
public function refreshServerMetadata(): void
{
try {
$this->authorize('update', $this->server);
$result = $this->server->gatherServerMetadata();
if ($result) {
$this->server->refresh();
$this->dispatch('success', 'Server details refreshed.');
} else {
$this->dispatch('error', 'Could not fetch server details. Is the server reachable?');
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function submit()
{
try {
+5 -1
View File
@@ -94,7 +94,8 @@ class ValidateAndInstall extends Component
$this->authorize('update', $this->server);
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$error.'</div>';
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$sanitizedError.'</div>';
$this->server->update([
'validation_logs' => $this->error,
]);
@@ -206,6 +207,9 @@ class ValidateAndInstall extends Component
// Mark validation as complete
$this->server->update(['is_validating' => false]);
// Auto-fetch server details now that validation passed
$this->server->gatherServerMetadata();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);