v4.0.0-beta.465 (#8853)

This commit is contained in:
Andras Bacsai 2026-03-10 21:17:42 +01:00 committed by GitHub
commit d2de0307bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1064 additions and 245 deletions

View file

@ -51,9 +51,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
} }
$configuration_dir = database_proxy_dir($database->uuid); $configuration_dir = database_proxy_dir($database->uuid);
$host_configuration_dir = $configuration_dir;
if (isDev()) { if (isDev()) {
$configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; $host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
} }
$timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout);
$nginxconf = <<<EOF $nginxconf = <<<EOF
user nginx; user nginx;
worker_processes auto; worker_processes auto;
@ -67,6 +69,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
server { server {
listen $database->public_port; listen $database->public_port;
proxy_pass $containerName:$internalPort; proxy_pass $containerName:$internalPort;
$timeoutConfig
} }
} }
EOF; EOF;
@ -85,7 +88,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
'volumes' => [ 'volumes' => [
[ [
'type' => 'bind', 'type' => 'bind',
'source' => "$configuration_dir/nginx.conf", 'source' => "$host_configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf', 'target' => '/etc/nginx/nginx.conf',
], ],
], ],
@ -160,4 +163,13 @@ private function isNonTransientError(string $message): bool
return false; return false;
} }
private function buildProxyTimeoutConfig(?int $timeout): string
{
if ($timeout === null || $timeout < 1) {
$timeout = 3600;
}
return "proxy_timeout {$timeout}s;";
}
} }

View file

@ -805,9 +805,15 @@ private function deploy_docker_compose_buildpack()
); );
$this->write_deployment_configurations(); $this->write_deployment_configurations();
$this->execute_remote_command( if ($this->preserveRepository) {
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true], $this->execute_remote_command(
); ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => true],
);
} else {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
);
}
} else { } else {
$command = "{$this->coolify_variables} docker compose"; $command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) { if ($this->preserveRepository) {

View file

@ -6,12 +6,13 @@
use App\Models\Server; use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated; use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class CheckTraefikVersionForServerJob implements ShouldQueue class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -5,12 +5,13 @@
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Models\Server; use App\Models\Server;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class CheckTraefikVersionJob implements ShouldQueue class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -7,13 +7,14 @@
use App\Models\Team; use App\Models\Team;
use App\Notifications\SslExpirationNotification; use App\Notifications\SslExpirationNotification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class RegenerateSslCertJob implements ShouldQueue class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -4,16 +4,27 @@
use App\Notifications\Dto\SlackMessage; use App\Notifications\Dto\SlackMessage;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
class SendMessageToSlackJob implements ShouldQueue class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*/
public $backoff = 10;
public function __construct( public function __construct(
private SlackMessage $message, private SlackMessage $message,
private string $webhookUrl private string $webhookUrl

View file

@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue
*/ */
public $tries = 5; public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*/
public $backoff = 10;
/** /**
* The maximum number of unhandled exceptions to allow before failing. * The maximum number of unhandled exceptions to allow before failing.
*/ */

View file

@ -7,6 +7,7 @@
use App\Models\Team; use App\Models\Team;
use Cron\CronExpression; use Cron\CronExpression;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
@ -15,7 +16,7 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class ServerManagerJob implements ShouldQueue class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -4,11 +4,12 @@
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\Team; use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class StripeProcessJob implements ShouldQueue class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Queueable; use Queueable;

View file

@ -4,12 +4,13 @@
use App\Models\Subscription; use App\Models\Subscription;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class SyncStripeSubscriptionsJob implements ShouldQueue class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -8,13 +8,14 @@
use App\Events\ServerValidated; use App\Events\ServerValidated;
use App\Models\Server; use App\Models\Server;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class ValidateAndInstallServerJob implements ShouldQueue class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -4,12 +4,13 @@
use App\Models\Subscription; use App\Models\Subscription;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class VerifyStripeSubscriptionStatusJob implements ShouldQueue class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -51,9 +51,7 @@ public function mount()
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);

View file

@ -36,6 +36,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null; public ?string $dbUrl = null;
@ -80,6 +82,7 @@ protected function rules(): array
'portsMappings' => 'nullable|string', 'portsMappings' => 'nullable|string',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string', 'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string', 'dbUrlPublic' => 'nullable|string',
@ -99,6 +102,8 @@ protected function messages(): array
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.', 'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
] ]
); );
} }
@ -115,6 +120,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save(); $this->database->save();
@ -130,6 +136,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url; $this->dbUrl = $this->database->internal_db_url;

View file

@ -36,6 +36,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null; public ?string $dbUrl = null;
@ -91,6 +93,7 @@ protected function rules(): array
'portsMappings' => 'nullable|string', 'portsMappings' => 'nullable|string',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string', 'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string', 'dbUrlPublic' => 'nullable|string',
@ -109,6 +112,8 @@ protected function messages(): array
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.', 'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
] ]
); );
} }
@ -124,6 +129,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl; $this->database->enable_ssl = $this->enable_ssl;
@ -139,6 +145,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl; $this->enable_ssl = $this->database->enable_ssl;

View file

@ -401,20 +401,24 @@ public function checkFile()
} }
} }
public function runImport() public function runImport(string $password = ''): bool|string
{ {
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
if ($this->filename === '') { if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.'); $this->dispatch('error', 'Please select a file to import.');
return; return true;
} }
if (! $this->server) { if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.'); $this->dispatch('error', 'Server not found. Please refresh the page.');
return; return true;
} }
try { try {
@ -434,7 +438,7 @@ public function runImport()
if (! $this->validateServerPath($this->customLocation)) { if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return; return true;
} }
$tmpPath = '/tmp/restore_'.$this->resourceUuid; $tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation); $escapedCustomLocation = escapeshellarg($this->customLocation);
@ -442,7 +446,7 @@ public function runImport()
} else { } else {
$this->dispatch('error', 'The file does not exist or has been deleted.'); $this->dispatch('error', 'The file does not exist or has been deleted.');
return; return true;
} }
// Copy the restore command to a script file // Copy the restore command to a script file
@ -474,11 +478,15 @@ public function runImport()
$this->dispatch('databaserestore'); $this->dispatch('databaserestore');
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); handleError($e, $this);
return true;
} finally { } finally {
$this->filename = null; $this->filename = null;
$this->importCommands = []; $this->importCommands = [];
} }
return true;
} }
public function loadAvailableS3Storages() public function loadAvailableS3Storages()
@ -577,26 +585,30 @@ public function checkS3File()
} }
} }
public function restoreFromS3() public function restoreFromS3(string $password = ''): bool|string
{ {
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
if (! $this->s3StorageId || blank($this->s3Path)) { if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.'); $this->dispatch('error', 'Please select S3 storage and provide a path first.');
return; return true;
} }
if (is_null($this->s3FileSize)) { if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".'); $this->dispatch('error', 'Please check the file first by clicking "Check File".');
return; return true;
} }
if (! $this->server) { if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.'); $this->dispatch('error', 'Server not found. Please refresh the page.');
return; return true;
} }
try { try {
@ -613,7 +625,7 @@ public function restoreFromS3()
if (! $this->validateBucketName($bucket)) { if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return; return true;
} }
// Clean the S3 path // Clean the S3 path
@ -623,7 +635,7 @@ public function restoreFromS3()
if (! $this->validateS3Path($cleanPath)) { if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return; return true;
} }
// Get helper image // Get helper image
@ -711,9 +723,12 @@ public function restoreFromS3()
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->importRunning = false; $this->importRunning = false;
handleError($e, $this);
return handleError($e, $this); return true;
} }
return true;
} }
public function buildRestoreCommand(string $tmpPath): string public function buildRestoreCommand(string $tmpPath): string

View file

@ -38,6 +38,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null; public ?string $dbUrl = null;
@ -94,6 +96,7 @@ protected function rules(): array
'portsMappings' => 'nullable|string', 'portsMappings' => 'nullable|string',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string', 'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string', 'dbUrlPublic' => 'nullable|string',
@ -114,6 +117,8 @@ protected function messages(): array
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.', 'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
] ]
); );
} }
@ -130,6 +135,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl; $this->database->enable_ssl = $this->enable_ssl;
@ -146,6 +152,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl; $this->enable_ssl = $this->database->enable_ssl;

View file

@ -44,6 +44,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
@ -79,6 +81,7 @@ protected function rules(): array
'portsMappings' => 'nullable', 'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean', 'enableSsl' => 'boolean',
@ -97,6 +100,8 @@ protected function messages(): array
'mariadbDatabase.required' => 'The MariaDB Database field is required.', 'mariadbDatabase.required' => 'The MariaDB Database field is required.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
] ]
); );
} }
@ -113,6 +118,7 @@ protected function messages(): array
'portsMappings' => 'Port Mapping', 'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public', 'isPublic' => 'Is Public',
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options', 'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL', 'enableSsl' => 'Enable SSL',
]; ];
@ -154,6 +160,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl; $this->database->enable_ssl = $this->enableSsl;
@ -173,6 +180,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl; $this->enableSsl = $this->database->enable_ssl;

View file

@ -42,6 +42,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
@ -78,6 +80,7 @@ protected function rules(): array
'portsMappings' => 'nullable', 'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean', 'enableSsl' => 'boolean',
@ -96,6 +99,8 @@ protected function messages(): array
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
] ]
); );
@ -112,6 +117,7 @@ protected function messages(): array
'portsMappings' => 'Port Mapping', 'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public', 'isPublic' => 'Is Public',
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL', 'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode', 'sslMode' => 'SSL Mode',
@ -153,6 +159,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl; $this->database->enable_ssl = $this->enableSsl;
@ -172,6 +179,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl; $this->enableSsl = $this->database->enable_ssl;

View file

@ -44,6 +44,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
@ -81,6 +83,7 @@ protected function rules(): array
'portsMappings' => 'nullable', 'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean', 'enableSsl' => 'boolean',
@ -100,6 +103,8 @@ protected function messages(): array
'mysqlDatabase.required' => 'The MySQL Database field is required.', 'mysqlDatabase.required' => 'The MySQL Database field is required.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
] ]
); );
@ -117,6 +122,7 @@ protected function messages(): array
'portsMappings' => 'Port Mapping', 'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public', 'isPublic' => 'Is Public',
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL', 'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode', 'sslMode' => 'SSL Mode',
@ -159,6 +165,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl; $this->database->enable_ssl = $this->enableSsl;
@ -179,6 +186,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl; $this->enableSsl = $this->database->enable_ssl;

View file

@ -48,6 +48,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
@ -93,6 +95,7 @@ protected function rules(): array
'portsMappings' => 'nullable', 'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean', 'enableSsl' => 'boolean',
@ -111,6 +114,8 @@ protected function messages(): array
'postgresDb.required' => 'The Postgres Database field is required.', 'postgresDb.required' => 'The Postgres Database field is required.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
] ]
); );
@ -130,6 +135,7 @@ protected function messages(): array
'portsMappings' => 'Port Mapping', 'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public', 'isPublic' => 'Is Public',
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL', 'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode', 'sslMode' => 'SSL Mode',
@ -174,6 +180,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl; $this->database->enable_ssl = $this->enableSsl;
@ -196,6 +203,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl; $this->enableSsl = $this->database->enable_ssl;

View file

@ -36,6 +36,8 @@ class General extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
@ -74,6 +76,7 @@ protected function rules(): array
'portsMappings' => 'nullable', 'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'redisUsername' => 'required', 'redisUsername' => 'required',
@ -90,6 +93,8 @@ protected function messages(): array
'name.required' => 'The Name field is required.', 'name.required' => 'The Name field is required.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'redisUsername.required' => 'The Redis Username field is required.', 'redisUsername.required' => 'The Redis Username field is required.',
'redisPassword.required' => 'The Redis Password field is required.', 'redisPassword.required' => 'The Redis Password field is required.',
] ]
@ -104,6 +109,7 @@ protected function messages(): array
'portsMappings' => 'Port Mapping', 'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public', 'isPublic' => 'Is Public',
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options', 'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username', 'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password', 'redisPassword' => 'Redis Password',
@ -143,6 +149,7 @@ public function syncData(bool $toModel = false)
$this->database->ports_mappings = $this->portsMappings; $this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic; $this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl; $this->database->enable_ssl = $this->enableSsl;
@ -158,6 +165,7 @@ public function syncData(bool $toModel = false)
$this->portsMappings = $this->database->ports_mappings; $this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public; $this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl; $this->enableSsl = $this->database->enable_ssl;

View file

@ -53,6 +53,8 @@ class Index extends Component
public ?int $publicPort = null; public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isPublic = false; public bool $isPublic = false;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
@ -90,6 +92,7 @@ class Index extends Component
'image' => 'required', 'image' => 'required',
'excludeFromStatus' => 'required|boolean', 'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer', 'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isPublic' => 'required|boolean', 'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean',
// Application-specific rules // Application-specific rules
@ -158,6 +161,7 @@ private function syncDatabaseData(bool $toModel = false): void
$this->serviceDatabase->image = $this->image; $this->serviceDatabase->image = $this->image;
$this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus;
$this->serviceDatabase->public_port = $this->publicPort; $this->serviceDatabase->public_port = $this->publicPort;
$this->serviceDatabase->public_port_timeout = $this->publicPortTimeout;
$this->serviceDatabase->is_public = $this->isPublic; $this->serviceDatabase->is_public = $this->isPublic;
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
} else { } else {
@ -166,6 +170,7 @@ private function syncDatabaseData(bool $toModel = false): void
$this->image = $this->serviceDatabase->image; $this->image = $this->serviceDatabase->image;
$this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false;
$this->publicPort = $this->serviceDatabase->public_port; $this->publicPort = $this->serviceDatabase->public_port;
$this->publicPortTimeout = $this->serviceDatabase->public_port_timeout;
$this->isPublic = $this->serviceDatabase->is_public ?? false; $this->isPublic = $this->serviceDatabase->is_public ?? false;
$this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false;
} }

View file

@ -11,6 +11,10 @@ class ServiceDatabase extends BaseModel
protected $guarded = []; protected $guarded = [];
protected $casts = [
'public_port_timeout' => 'integer',
];
protected static function booted() protected static function booted()
{ {
static::deleting(function ($service) { static::deleting(function ($service) {

View file

@ -19,6 +19,7 @@ class StandaloneClickhouse extends BaseModel
protected $casts = [ protected $casts = [
'clickhouse_password' => 'encrypted', 'clickhouse_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -19,6 +19,7 @@ class StandaloneDragonfly extends BaseModel
protected $casts = [ protected $casts = [
'dragonfly_password' => 'encrypted', 'dragonfly_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -19,6 +19,7 @@ class StandaloneKeydb extends BaseModel
protected $casts = [ protected $casts = [
'keydb_password' => 'encrypted', 'keydb_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -20,6 +20,7 @@ class StandaloneMariadb extends BaseModel
protected $casts = [ protected $casts = [
'mariadb_password' => 'encrypted', 'mariadb_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -18,6 +18,7 @@ class StandaloneMongodb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -20,6 +20,7 @@ class StandaloneMysql extends BaseModel
protected $casts = [ protected $casts = [
'mysql_password' => 'encrypted', 'mysql_password' => 'encrypted',
'mysql_root_password' => 'encrypted', 'mysql_root_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -20,6 +20,7 @@ class StandalonePostgresql extends BaseModel
protected $casts = [ protected $casts = [
'init_scripts' => 'array', 'init_scripts' => 'array',
'postgres_password' => 'encrypted', 'postgres_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -18,6 +18,7 @@ class StandaloneRedis extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
'last_restart_type' => 'string', 'last_restart_type' => 'string',

View file

@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$value = str($value); $value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches); preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[1]) > 0) { if (count($valueMatches[2]) > 0) {
foreach ($valueMatches[1] as $match) { foreach ($valueMatches[2] as $match) {
$match = replaceVariables($match); $match = str($match);
if ($match->startsWith('SERVICE_')) { if ($match->startsWith('SERVICE_')) {
if ($magicEnvironments->has($match->value())) { if ($magicEnvironments->has($match->value())) {
continue; continue;
@ -1509,6 +1509,18 @@ function serviceParser(Service $resource): Collection
return collect([]); return collect([]);
} }
$services = data_get($yaml, 'services', collect([])); $services = data_get($yaml, 'services', collect([]));
// Clean up corrupted environment variables from previous parser bugs
// (keys starting with $ or ending with } should not exist as env var names)
$resource->environment_variables()
->where('resourceable_type', get_class($resource))
->where('resourceable_id', $resource->id)
->where(function ($q) {
$q->where('key', 'LIKE', '$%')
->orWhere('key', 'LIKE', '%}');
})
->delete();
$topLevel = collect([ $topLevel = collect([
'volumes' => collect(data_get($yaml, 'volumes', [])), 'volumes' => collect(data_get($yaml, 'volumes', [])),
'networks' => collect(data_get($yaml, 'networks', [])), 'networks' => collect(data_get($yaml, 'networks', [])),
@ -1686,9 +1698,9 @@ function serviceParser(Service $resource): Collection
$value = str($value); $value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches); preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[1]) > 0) { if (count($valueMatches[2]) > 0) {
foreach ($valueMatches[1] as $match) { foreach ($valueMatches[2] as $match) {
$match = replaceVariables($match); $match = str($match);
if ($match->startsWith('SERVICE_')) { if ($match->startsWith('SERVICE_')) {
if ($magicEnvironments->has($match->value())) { if ($magicEnvironments->has($match->value())) {
continue; continue;
@ -1928,7 +1940,7 @@ function serviceParser(Service $resource): Collection
} else { } else {
$value = generateEnvValue($command, $resource); $value = generateEnvValue($command, $resource);
$resource->environment_variables()->updateOrCreate([ $resource->environment_variables()->firstOrCreate([
'key' => $key->value(), 'key' => $key->value(),
'resourceable_type' => get_class($resource), 'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id, 'resourceable_id' => $resource->id,

View file

@ -128,6 +128,11 @@ function replaceVariables(string $variable): Stringable
return $str->replaceFirst('{', '')->before('}'); return $str->replaceFirst('{', '')->before('}');
} }
// Handle bare $VAR format (no braces)
if ($str->startsWith('$')) {
return $str->replaceFirst('$', '');
}
return $str; return $str;
} }

View file

@ -2,9 +2,9 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.464', 'version' => '4.0.0-beta.465',
'helper_version' => '1.0.12', 'helper_version' => '1.0.12',
'realtime_version' => '1.0.10', 'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'), 'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),

View file

@ -0,0 +1,60 @@
<?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
{
$tables = [
'standalone_postgresqls',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_redis',
'standalone_mongodbs',
'standalone_clickhouses',
'standalone_keydbs',
'standalone_dragonflies',
'service_databases',
];
foreach ($tables as $table) {
if (Schema::hasTable($table) && !Schema::hasColumn($table, 'public_port_timeout')) {
Schema::table($table, function (Blueprint $table) {
$table->integer('public_port_timeout')->nullable()->default(3600)->after('public_port');
});
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tables = [
'standalone_postgresqls',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_redis',
'standalone_mongodbs',
'standalone_clickhouses',
'standalone_keydbs',
'standalone_dragonflies',
'service_databases',
];
foreach ($tables as $table) {
if (Schema::hasTable($table) && Schema::hasColumn($table, 'public_port_timeout')) {
Schema::table($table, function (Blueprint $table) {
$table->dropColumn('public_port_timeout');
});
}
}
}
};

View file

@ -73,6 +73,7 @@ services:
volumes: volumes:
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
environment: environment:
SOKETI_DEBUG: "false" SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"

View file

@ -73,6 +73,7 @@ services:
volumes: volumes:
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
environment: environment:
SOKETI_DEBUG: "false" SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"

View file

@ -60,7 +60,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11'
ports: ports:
- "${SOKETI_PORT:-6001}:6001" - "${SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"

View file

@ -16,6 +16,7 @@ RUN npm i
RUN npm rebuild node-pty --update-binary RUN npm rebuild node-pty --update-binary
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js
# Install Cloudflared based on architecture # Install Cloudflared based on architecture
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \

View file

@ -5,29 +5,29 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "5.5.0", "@xterm/xterm": "6.0.0",
"axios": "1.12.0", "axios": "1.13.6",
"cookie": "1.0.2", "cookie": "1.1.1",
"dotenv": "16.5.0", "dotenv": "17.3.1",
"node-pty": "1.0.0", "node-pty": "1.1.0",
"ws": "8.18.1" "ws": "8.19.0"
} }
}, },
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
"version": "0.10.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT", "license": "MIT"
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}, },
"node_modules/@xterm/xterm": { "node_modules/@xterm/xterm": {
"version": "5.5.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT" "license": "MIT",
"workspaces": [
"addons/*"
]
}, },
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
@ -36,13 +36,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.12.0", "version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@ -72,12 +72,16 @@
} }
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "1.0.2", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
@ -90,9 +94,9 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.5.0", "version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -161,9 +165,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.9", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -181,9 +185,9 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.4", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@ -323,20 +327,20 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/nan": { "node_modules/node-addon-api": {
"version": "2.23.0", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-pty": { "node_modules/node-pty": {
"version": "1.0.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nan": "^2.17.0" "node-addon-api": "^7.1.0"
} }
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
@ -346,9 +350,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.1", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View file

@ -2,12 +2,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "5.5.0", "@xterm/xterm": "6.0.0",
"cookie": "1.0.2", "cookie": "1.1.1",
"axios": "1.12.0", "axios": "1.13.6",
"dotenv": "16.5.0", "dotenv": "17.3.1",
"node-pty": "1.0.0", "node-pty": "1.1.0",
"ws": "8.18.1" "ws": "8.19.0"
} }
} }

View file

@ -4,8 +4,33 @@ import pty from 'node-pty';
import axios from 'axios'; import axios from 'axios';
import cookie from 'cookie'; import cookie from 'cookie';
import 'dotenv/config'; import 'dotenv/config';
import {
extractHereDocContent,
extractSshArgs,
extractTargetHost,
extractTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
const userSessions = new Map(); const userSessions = new Map();
const terminalDebugEnabled = ['local', 'development'].includes(
String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
);
function logTerminal(level, message, context = {}) {
if (!terminalDebugEnabled) {
return;
}
const formattedMessage = `[TerminalServer] ${message}`;
if (Object.keys(context).length > 0) {
console[level](formattedMessage, context);
return;
}
console[level](formattedMessage);
}
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.url === '/ready') { if (req.url === '/ready') {
@ -31,9 +56,19 @@ const getSessionCookie = (req) => {
const verifyClient = async (info, callback) => { const verifyClient = async (info, callback) => {
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req); const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
const requestContext = {
remoteAddress: info.req.socket?.remoteAddress,
origin: info.origin,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
};
logTerminal('log', 'Verifying websocket client.', requestContext);
// Verify presence of required tokens // Verify presence of required tokens
if (!laravelSession || !xsrfToken) { if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext);
return callback(false, 401, 'Unauthorized: Missing required tokens'); return callback(false, 401, 'Unauthorized: Missing required tokens');
} }
@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => {
}); });
if (response.status === 200) { if (response.status === 200) {
// Authentication successful logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
callback(true); callback(true);
} else { } else {
logTerminal('warn', 'Websocket client authentication returned a non-success status.', {
...requestContext,
status: response.status,
});
callback(false, 401, 'Unauthorized: Invalid credentials'); callback(false, 401, 'Unauthorized: Invalid credentials');
} }
} catch (error) { } catch (error) {
console.error('Authentication error:', error.message); logTerminal('error', 'Websocket client authentication failed.', {
...requestContext,
error: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
});
callback(false, 500, 'Internal Server Error'); callback(false, 500, 'Internal Server Error');
} }
}; };
@ -65,28 +109,62 @@ wss.on('connection', async (ws, req) => {
const userId = generateUserId(); const userId = generateUserId();
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
remoteAddress: req.socket?.remoteAddress,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
};
// Verify presence of required tokens // Verify presence of required tokens
if (!laravelSession || !xsrfToken) { if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
ws.close(401, 'Unauthorized: Missing required tokens'); ws.close(401, 'Unauthorized: Missing required tokens');
return; return;
} }
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
headers: { try {
'Cookie': `${sessionCookieName}=${laravelSession}`, const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
'X-XSRF-TOKEN': xsrfToken headers: {
}, 'Cookie': `${sessionCookieName}=${laravelSession}`,
}); 'X-XSRF-TOKEN': xsrfToken
userSession.authorizedIPs = response.data.ipAddresses || []; },
});
userSession.authorizedIPs = response.data.ipAddresses || [];
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
...connectionContext,
authorizedIPs: userSession.authorizedIPs,
});
} catch (error) {
logTerminal('error', 'Failed to fetch authorized terminal hosts.', {
...connectionContext,
error: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
});
ws.close(1011, 'Failed to fetch terminal authorization data');
return;
}
userSessions.set(userId, userSession); userSessions.set(userId, userSession);
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
});
ws.on('message', (message) => { ws.on('message', (message) => {
handleMessage(userSession, message); handleMessage(userSession, message);
}); });
ws.on('error', (err) => handleError(err, userId)); ws.on('error', (err) => handleError(err, userId));
ws.on('close', () => handleClose(userId)); ws.on('close', (code, reason) => {
logTerminal('log', 'Terminal websocket connection closed.', {
userId,
code,
reason: reason?.toString(),
});
handleClose(userId);
});
}); });
const messageHandlers = { const messageHandlers = {
@ -98,6 +176,7 @@ const messageHandlers = {
}, },
pause: (session) => session.ptyProcess.pause(), pause: (session) => session.ptyProcess.pause(),
resume: (session) => session.ptyProcess.resume(), resume: (session) => session.ptyProcess.resume(),
ping: (session) => session.ws.send('pong'),
checkActive: (session, data) => { checkActive: (session, data) => {
if (data === 'force' && session.isActive) { if (data === 'force' && session.isActive) {
killPtyProcess(session.userId); killPtyProcess(session.userId);
@ -110,12 +189,34 @@ const messageHandlers = {
function handleMessage(userSession, message) { function handleMessage(userSession, message) {
const parsed = parseMessage(message); const parsed = parseMessage(message);
if (!parsed) return; if (!parsed) {
logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', {
userId: userSession.userId,
rawMessage: String(message).slice(0, 500),
});
return;
}
logTerminal('log', 'Received websocket message.', {
userId: userSession.userId,
keys: Object.keys(parsed),
isActive: userSession.isActive,
});
Object.entries(parsed).forEach(([key, value]) => { Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key]; const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) { if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
handler(userSession, value); handler(userSession, value);
} else if (!handler) {
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
userId: userSession.userId,
key,
});
} else {
logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', {
userId: userSession.userId,
key,
});
} }
}); });
} }
@ -124,7 +225,9 @@ function parseMessage(message) {
try { try {
return JSON.parse(message); return JSON.parse(message);
} catch (e) { } catch (e) {
console.error('Failed to parse message:', e); logTerminal('error', 'Failed to parse websocket message.', {
error: e?.message ?? e,
});
return null; return null;
} }
} }
@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) {
if (userSession && userSession.isActive) { if (userSession && userSession.isActive) {
const result = await killPtyProcess(userId); const result = await killPtyProcess(userId);
if (!result) { if (!result) {
logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', {
userId,
});
// if terminal is still active, even after we tried to kill it, dont continue and show error // if terminal is still active, even after we tried to kill it, dont continue and show error
ws.send('unprocessable'); ws.send('unprocessable');
return; return;
@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) {
// Extract target host from SSH command // Extract target host from SSH command
const targetHost = extractTargetHost(sshArgs); const targetHost = extractTargetHost(sshArgs);
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
timeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
if (!targetHost) { if (!targetHost) {
logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', {
userId,
sshArgs,
});
ws.send('Invalid SSH command: No target host found'); ws.send('Invalid SSH command: No target host found');
return; return;
} }
// Validate target host against authorized IPs // Validate target host against authorized IPs
if (!userSession.authorizedIPs.includes(targetHost)) { if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) {
logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', {
userId,
targetHost,
authorizedIPs: userSession.authorizedIPs,
});
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`); ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
return; return;
} }
@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) {
// NOTE: - Initiates a process within the Terminal container // NOTE: - Initiates a process within the Terminal container
// Establishes an SSH connection to root@coolify with RequestTTY enabled // Establishes an SSH connection to root@coolify with RequestTTY enabled
// Executes the 'docker exec' command to connect to a specific container // Executes the 'docker exec' command to connect to a specific container
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
timeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
userSession.ptyProcess = ptyProcess; userSession.ptyProcess = ptyProcess;
@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) {
// when parent closes // when parent closes
ptyProcess.onExit(({ exitCode, signal }) => { ptyProcess.onExit(({ exitCode, signal }) => {
console.error(`Process exited with code ${exitCode} and signal ${signal}`); logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', {
userId,
exitCode,
signal,
});
ws.send('pty-exited'); ws.send('pty-exited');
userSession.isActive = false; userSession.isActive = false;
}); });
@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) {
} }
} }
function extractTargetHost(sshArgs) {
// Find the argument that matches the pattern user@host
const userAtHost = sshArgs.find(arg => {
// Skip paths that contain 'storage/app/ssh/keys/'
if (arg.includes('storage/app/ssh/keys/')) {
return false;
}
return /^[^@]+@[^@]+$/.test(arg);
});
if (!userAtHost) return null;
// Extract host from user@host
const host = userAtHost.split('@')[1];
return host;
}
async function handleError(err, userId) { async function handleError(err, userId) {
console.error('WebSocket error:', err); logTerminal('error', 'WebSocket error.', {
userId,
error: err?.message ?? err,
});
await killPtyProcess(userId); await killPtyProcess(userId);
} }
async function handleClose(userId) { async function handleClose(userId) {
logTerminal('log', 'Cleaning up terminal websocket session.', {
userId,
});
await killPtyProcess(userId); await killPtyProcess(userId);
userSessions.delete(userId); userSessions.delete(userId);
} }
@ -231,6 +353,11 @@ async function killPtyProcess(userId) {
const attemptKill = () => { const attemptKill = () => {
killAttempts++; killAttempts++;
logTerminal('log', 'Attempting to terminate PTY process.', {
userId,
killAttempts,
maxAttempts,
});
// session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098 // session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098
// patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947 // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947
@ -238,6 +365,10 @@ async function killPtyProcess(userId) {
setTimeout(() => { setTimeout(() => {
if (!session.isActive || !session.ptyProcess) { if (!session.isActive || !session.ptyProcess) {
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
});
resolve(true); resolve(true);
return; return;
} }
@ -245,6 +376,10 @@ async function killPtyProcess(userId) {
if (killAttempts < maxAttempts) { if (killAttempts < maxAttempts) {
attemptKill(); attemptKill();
} else { } else {
logTerminal('warn', 'PTY process still active after maximum termination attempts.', {
userId,
killAttempts,
});
resolve(false); resolve(false);
} }
}, 500); }, 500);
@ -258,76 +393,8 @@ function generateUserId() {
return Math.random().toString(36).substring(2, 11); return Math.random().toString(36).substring(2, 11);
} }
function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
}
function extractSshArgs(commandString) {
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
if (!sshCommandMatch) return [];
const argsString = sshCommandMatch[1];
let sshArgs = [];
// Parse shell arguments respecting quotes
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < argsString.length) {
const char = argsString[i];
const nextChar = argsString[i + 1];
if (!inQuotes && (char === '"' || char === "'")) {
// Starting a quoted section
inQuotes = true;
quoteChar = char;
current += char;
} else if (inQuotes && char === quoteChar) {
// Ending a quoted section
inQuotes = false;
current += char;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
// Space outside quotes - end of argument
if (current.trim()) {
sshArgs.push(current.trim());
current = '';
}
} else {
// Regular character
current += char;
}
i++;
}
// Add final argument if exists
if (current.trim()) {
sshArgs.push(current.trim());
}
// Replace RequestTTY=no with RequestTTY=yes
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
// Add RequestTTY=yes if not present
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
sshArgs.push('-o', 'RequestTTY=yes');
}
return sshArgs;
}
function extractHereDocContent(commandString) {
const delimiterMatch = commandString.match(/<< (\S+)/);
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
const hereDocMatch = commandString.match(hereDocRegex);
return hereDocMatch ? hereDocMatch[1] : '';
}
server.listen(6002, () => { server.listen(6002, () => {
console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!'); logTerminal('log', 'Terminal debug logging is enabled.', {
terminalDebugEnabled,
});
}); });

View file

@ -0,0 +1,127 @@
export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
}
function normalizeShellArgument(argument) {
if (!argument) {
return argument;
}
return argument
.replace(/'([^']*)'/g, '$1')
.replace(/"([^"]*)"/g, '$1');
}
export function extractSshArgs(commandString) {
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
if (!sshCommandMatch) return [];
const argsString = sshCommandMatch[1];
let sshArgs = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < argsString.length) {
const char = argsString[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
current += char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
current += char;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
if (current.trim()) {
sshArgs.push(current.trim());
current = '';
}
} else {
current += char;
}
i++;
}
if (current.trim()) {
sshArgs.push(current.trim());
}
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
sshArgs.push('-o', 'RequestTTY=yes');
}
return sshArgs;
}
export function extractHereDocContent(commandString) {
const delimiterMatch = commandString.match(/<< (\S+)/);
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
if (!escapedDelimiter) {
return '';
}
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
const hereDocMatch = commandString.match(hereDocRegex);
return hereDocMatch ? hereDocMatch[1] : '';
}
export function normalizeHostForAuthorization(host) {
if (!host) {
return null;
}
let normalizedHost = host.trim();
while (
normalizedHost.length >= 2 &&
((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) ||
(normalizedHost.startsWith('"') && normalizedHost.endsWith('"')))
) {
normalizedHost = normalizedHost.slice(1, -1).trim();
}
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
normalizedHost = normalizedHost.slice(1, -1);
}
return normalizedHost.toLowerCase();
}
export function extractTargetHost(sshArgs) {
const userAtHost = sshArgs.find(arg => {
if (arg.includes('storage/app/ssh/keys/')) {
return false;
}
return /^[^@]+@[^@]+$/.test(arg);
});
if (!userAtHost) {
return null;
}
const atIndex = userAtHost.indexOf('@');
return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1));
}
export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) {
const normalizedTargetHost = normalizeHostForAuthorization(targetHost);
if (!normalizedTargetHost) {
return false;
}
return authorizedHosts
.map(host => normalizeHostForAuthorization(host))
.includes(normalizedTargetHost);
}

View file

@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
extractSshArgs,
extractTargetHost,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
} from './terminal-utils.js';
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
);
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
});
test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
);
assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']);
});
test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc"
);
assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h');
assert.equal(sshArgs[4], 'root@example.com');
});
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
});
test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10');
assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true);
});
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});

View file

@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
function logTerminal(level, message, ...context) {
if (!terminalDebugEnabled) {
return;
}
console[level](message, ...context);
}
export function initializeTerminalComponent() { export function initializeTerminalComponent() {
function terminalData() { function terminalData() {
return { return {
@ -30,6 +40,8 @@ export function initializeTerminalComponent() {
pingTimeoutId: null, pingTimeoutId: null,
heartbeatMissed: 0, heartbeatMissed: 0,
maxHeartbeatMisses: 3, maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
// Resize handling // Resize handling
resizeObserver: null, resizeObserver: null,
resizeTimeout: null, resizeTimeout: null,
@ -120,6 +132,7 @@ export function initializeTerminalComponent() {
this.checkIfProcessIsRunningAndKillIt(); this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers(); this.clearAllTimers();
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
this.pendingCommand = null;
if (this.socket) { if (this.socket) {
this.socket.close(1000, 'Client cleanup'); this.socket.close(1000, 'Client cleanup');
} }
@ -154,6 +167,7 @@ export function initializeTerminalComponent() {
this.pendingWrites = 0; this.pendingWrites = 0;
this.paused = false; this.paused = false;
this.commandBuffer = ''; this.commandBuffer = '';
this.pendingCommand = null;
// Notify parent component that terminal disconnected // Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
@ -188,7 +202,7 @@ export function initializeTerminalComponent() {
initializeWebSocket() { initializeWebSocket() {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
console.log('[Terminal] WebSocket already connecting/connected, skipping'); logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping');
return; // Already connecting or connected return; // Already connecting or connected
} }
@ -197,7 +211,7 @@ export function initializeTerminalComponent() {
// Ensure terminal config is available // Ensure terminal config is available
if (!window.terminalConfig) { if (!window.terminalConfig) {
console.warn('[Terminal] Terminal config not available, using defaults'); logTerminal('warn', '[Terminal] Terminal config not available, using defaults');
window.terminalConfig = {}; window.terminalConfig = {};
} }
@ -223,7 +237,7 @@ export function initializeTerminalComponent() {
} }
const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
console.log(`[Terminal] Attempting connection to: ${url}`); logTerminal('log', `[Terminal] Attempting connection to: ${url}`);
try { try {
this.socket = new WebSocket(url); this.socket = new WebSocket(url);
@ -232,7 +246,7 @@ export function initializeTerminalComponent() {
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout; const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
this.connectionTimeoutId = setTimeout(() => { this.connectionTimeoutId = setTimeout(() => {
if (this.connectionState === 'connecting') { if (this.connectionState === 'connecting') {
console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`); logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`);
this.socket.close(); this.socket.close();
this.handleConnectionError('Connection timeout'); this.handleConnectionError('Connection timeout');
} }
@ -244,13 +258,13 @@ export function initializeTerminalComponent() {
this.socket.onclose = this.handleSocketClose.bind(this); this.socket.onclose = this.handleSocketClose.bind(this);
} catch (error) { } catch (error) {
console.error('[Terminal] Failed to create WebSocket:', error); logTerminal('error', '[Terminal] Failed to create WebSocket:', error);
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`); this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
} }
}, },
handleSocketOpen() { handleSocketOpen() {
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.'); logTerminal('log', '[Terminal] WebSocket connection established.');
this.connectionState = 'connected'; this.connectionState = 'connected';
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.heartbeatMissed = 0; this.heartbeatMissed = 0;
@ -262,6 +276,12 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null; this.connectionTimeoutId = null;
} }
// Flush any buffered command from before WebSocket was ready
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
}
// Start ping timeout monitoring // Start ping timeout monitoring
this.resetPingTimeout(); this.resetPingTimeout();
@ -270,16 +290,16 @@ export function initializeTerminalComponent() {
}, },
handleSocketError(error) { handleSocketError(error) {
console.error('[Terminal] WebSocket error:', error); logTerminal('error', '[Terminal] WebSocket error:', error);
console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1); logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.handleConnectionError('WebSocket error occurred'); this.handleConnectionError('WebSocket error occurred');
}, },
handleSocketClose(event) { handleSocketClose(event) {
console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
console.log('[Terminal] Was clean close:', event.code === 1000); logTerminal('log', '[Terminal] Was clean close:', event.code === 1000);
console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1); logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
this.clearAllTimers(); this.clearAllTimers();
@ -297,7 +317,7 @@ export function initializeTerminalComponent() {
}, },
handleConnectionError(reason) { handleConnectionError(reason) {
console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load // Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
@ -310,7 +330,7 @@ export function initializeTerminalComponent() {
scheduleReconnect() { scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) { if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[Terminal] Max reconnection attempts reached'); logTerminal('error', '[Terminal] Max reconnection attempts reached');
this.message = '(connection failed - max retries exceeded)'; this.message = '(connection failed - max retries exceeded)';
return; return;
} }
@ -323,7 +343,7 @@ export function initializeTerminalComponent() {
this.maxReconnectDelay this.maxReconnectDelay
); );
console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
this.reconnectInterval = setTimeout(() => { this.reconnectInterval = setTimeout(() => {
this.reconnectAttempts++; this.reconnectAttempts++;
@ -335,17 +355,21 @@ export function initializeTerminalComponent() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message)); this.socket.send(JSON.stringify(message));
} else { } else {
console.warn('[Terminal] WebSocket not ready, message not sent:', message); logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
} }
}, },
sendCommandWhenReady(message) { sendCommandWhenReady(message) {
if (this.isWebSocketReady()) { if (this.isWebSocketReady()) {
this.sendMessage(message); this.sendMessage(message);
} else {
this.pendingCommand = message;
} }
}, },
handleSocketMessage(event) { handleSocketMessage(event) {
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
// Handle pong responses // Handle pong responses
if (event.data === 'pong') { if (event.data === 'pong') {
this.heartbeatMissed = 0; this.heartbeatMissed = 0;
@ -354,6 +378,10 @@ export function initializeTerminalComponent() {
return; return;
} }
if (!this.term?._initialized && event.data !== 'pty-ready') {
logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data);
}
if (event.data === 'pty-ready') { if (event.data === 'pty-ready') {
if (!this.term._initialized) { if (!this.term._initialized) {
this.term.open(document.getElementById('terminal')); this.term.open(document.getElementById('terminal'));
@ -398,17 +426,24 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal disconnected // Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
) {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
} else { } else {
try { try {
this.pendingWrites++; this.pendingWrites++;
this.term.write(event.data, (err) => { this.term.write(event.data, (err) => {
if (err) { if (err) {
console.error('[Terminal] Write error:', err); logTerminal('error', '[Terminal] Write error:', err);
} }
this.flowControlCallback(); this.flowControlCallback();
}); });
} catch (error) { } catch (error) {
console.error('[Terminal] Write operation failed:', error); logTerminal('error', '[Terminal] Write operation failed:', error);
this.pendingWrites = Math.max(0, this.pendingWrites - 1); this.pendingWrites = Math.max(0, this.pendingWrites - 1);
} }
} }
@ -483,10 +518,10 @@ export function initializeTerminalComponent() {
clearTimeout(this.pingTimeoutId); clearTimeout(this.pingTimeoutId);
this.pingTimeoutId = null; this.pingTimeoutId = null;
} }
console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring');
} else if (wasVisible === false) { } else if (wasVisible === false) {
// Tab is now visible again // Tab is now visible again
console.log('[Terminal] Tab visible, resuming connection management'); logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
// Send immediate ping to verify connection is still alive // Send immediate ping to verify connection is still alive
@ -508,10 +543,10 @@ export function initializeTerminalComponent() {
this.pingTimeoutId = setTimeout(() => { this.pingTimeoutId = setTimeout(() => {
this.heartbeatMissed++; this.heartbeatMissed++;
console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
if (this.heartbeatMissed >= this.maxHeartbeatMisses) { if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
console.error('[Terminal] Too many missed heartbeats, closing connection'); logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection');
this.socket.close(1001, 'Heartbeat timeout'); this.socket.close(1001, 'Heartbeat timeout');
} }
}, this.pingTimeout); }, this.pingTimeout);
@ -553,7 +588,7 @@ export function initializeTerminalComponent() {
// Check if dimensions are valid // Check if dimensions are valid
if (height <= 0 || width <= 0) { if (height <= 0 || width <= 0) {
console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
setTimeout(() => this.resizeTerminal(), 100); setTimeout(() => this.resizeTerminal(), 100);
return; return;
} }
@ -562,7 +597,7 @@ export function initializeTerminalComponent() {
if (!charSize.height || !charSize.width) { if (!charSize.height || !charSize.width) {
// Fallback values if char size not available yet // Fallback values if char size not available yet
console.warn('[Terminal] Character size not available, retrying...'); logTerminal('warn', '[Terminal] Character size not available, retrying...');
setTimeout(() => this.resizeTerminal(), 100); setTimeout(() => this.resizeTerminal(), 100);
return; return;
} }
@ -583,10 +618,10 @@ export function initializeTerminalComponent() {
}); });
} }
} else { } else {
console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
} }
} catch (error) { } catch (error) {
console.error('[Terminal] Resize error:', error); logTerminal('error', '[Terminal] Resize error:', error);
} }
}, },

View file

@ -46,7 +46,7 @@
href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Scheduled Tasks</span></a> href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Scheduled Tasks</span></a>
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active" <a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Webhooks</span></a> href="{{ route('project.application.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Webhooks</span></a>
@if ($application->deploymentType() !== 'deploy_key') @if ($application->git_based())
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active" <a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.preview-deployments', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Preview Deployments</span></a> href="{{ route('project.application.preview-deployments', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Preview Deployments</span></a>
@endif @endif

View file

@ -28,7 +28,7 @@
<div class="pb-4">Code source of your application.</div> <div class="pb-4">Code source of your application.</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@if (!$privateKeyId) @if (blank($privateKeyId))
<div>Currently connected source: <span <div>Currently connected source: <span
class="font-bold text-warning">{{ data_get($application, 'source.name', 'No source connected') }}</span> class="font-bold text-warning">{{ data_get($application, 'source.name', 'No source connected') }}</span>
</div> </div>
@ -44,7 +44,7 @@ class="font-bold text-warning">{{ data_get($application, 'source.name', 'No sour
</div> </div>
</div> </div>
@if ($privateKeyId) @if (filled($privateKeyId))
<h3 class="pt-4">Deploy Key</h3> <h3 class="pt-4">Deploy Key</h3>
<div class="py-2 pt-4">Currently attached Private Key: <span <div class="py-2 pt-4">Currently attached Private Key: <span
class="dark:text-warning">{{ $privateKeyName }}</span> class="dark:text-warning">{{ $privateKeyName }}</span>

View file

@ -78,6 +78,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
canGate="update" :canResource="$database" /> canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
</form> </form>
<h3 class="pt-4">Advanced</h3> <h3 class="pt-4">Advanced</h3>

View file

@ -115,6 +115,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
canGate="update" :canResource="$database" /> canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
</form> </form>
<h3 class="pt-4">Advanced</h3> <h3 class="pt-4">Advanced</h3>

View file

@ -115,6 +115,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
canGate="update" :canResource="$database" /> canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
<x-forms.textarea <x-forms.textarea
helper="<a target='_blank' class='underline dark:text-white' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>KeyDB Default Configuration</a>" helper="<a target='_blank' class='underline dark:text-white' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>KeyDB Default Configuration</a>"

View file

@ -139,6 +139,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
id="publicPort" label="Public Port" canGate="update" :canResource="$database" /> id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
<x-forms.textarea label="Custom MariaDB Configuration" rows="10" id="mariadbConf" <x-forms.textarea label="Custom MariaDB Configuration" rows="10" id="mariadbConf"
canGate="update" :canResource="$database" /> canGate="update" :canResource="$database" />

View file

@ -153,6 +153,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
id="publicPort" label="Public Port" canGate="update" :canResource="$database" /> id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
<x-forms.textarea label="Custom MongoDB Configuration" rows="10" id="mongoConf" <x-forms.textarea label="Custom MongoDB Configuration" rows="10" id="mongoConf"
canGate="update" :canResource="$database" /> canGate="update" :canResource="$database" />

View file

@ -155,6 +155,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
id="publicPort" label="Public Port" canGate="update" :canResource="$database" /> id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
<x-forms.textarea label="Custom Mysql Configuration" rows="10" id="mysqlConf" canGate="update" :canResource="$database" /> <x-forms.textarea label="Custom Mysql Configuration" rows="10" id="mysqlConf" canGate="update" :canResource="$database" />
<h3 class="pt-4">Advanced</h3> <h3 class="pt-4">Advanced</h3>

View file

@ -165,6 +165,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
label="Public Port" canGate="update" :canResource="$database" /> label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">

View file

@ -134,6 +134,8 @@
</div> </div>
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" <x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
id="publicPort" label="Public Port" canGate="update" :canResource="$database" /> id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div> </div>
<x-forms.textarea placeholder="# maxmemory 256mb <x-forms.textarea placeholder="# maxmemory 256mb
# maxmemory-policy allkeys-lru # maxmemory-policy allkeys-lru

View file

@ -21,7 +21,8 @@
<div>No containers are running or terminal access is disabled on this server.</div> <div>No containers are running or terminal access is disabled on this server.</div>
@else @else
<form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')" <form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"
x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) { x-data="{ autoConnected: false }"
x-on:terminal-websocket-ready.window="if ({{ count($containers) }} === 1 && !autoConnected) {
autoConnected = true; autoConnected = true;
$nextTick(() => $wire.dispatchSelf('connectToContainer')); $nextTick(() => $wire.dispatchSelf('connectToContainer'));
}"> }">

View file

@ -139,7 +139,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div> <div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div>
<div class="flex justify-between gap-6 text-sm font-bold"> <div class="flex justify-between gap-6 text-sm font-bold">
<span class="dark:text-white">Prorated charge</span> <span class="dark:text-white">Prorated charge</span>
<span class="dark:text-warning" x-text="fmt(preview.due_now)"></span> <span class="dark:text-warning" x-text="fmt(preview?.due_now)"></span>
</div> </div>
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p> <p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
</div> </div>
@ -147,8 +147,8 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Next billing cycle</div> <div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Next billing cycle</div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex justify-between gap-6 text-sm"> <div class="flex justify-between gap-6 text-sm">
<span class="text-neutral-500" x-text="preview.quantity + ' servers × ' + fmt(preview.unit_price)"></span> <span class="text-neutral-500" x-text="preview?.quantity + ' servers × ' + fmt(preview?.unit_price)"></span>
<span class="dark:text-white" x-text="fmt(preview.recurring_subtotal)"></span> <span class="dark:text-white" x-text="fmt(preview?.recurring_subtotal)"></span>
</div> </div>
<div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak> <div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak>
<span class="text-neutral-500" x-text="preview?.tax_description"></span> <span class="text-neutral-500" x-text="preview?.tax_description"></span>
@ -156,7 +156,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
</div> </div>
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200"> <div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
<span class="dark:text-white">Total / month</span> <span class="dark:text-white">Total / month</span>
<span class="dark:text-white" x-text="fmt(preview.recurring_total)"></span> <span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -168,9 +168,23 @@
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->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray(); $ipAddresses = $team->servers
->where('settings.is_terminal_enabled', true)
->pluck('ip')
->filter()
->values();
return response()->json(['ipAddresses' => $ipAddresses], 200); if (isDev()) {
$ipAddresses = $ipAddresses->merge([
'coolify-testing-host',
'host.docker.internal',
'localhost',
'127.0.0.1',
base_ip(),
])->filter()->unique()->values();
}
return response()->json(['ipAddresses' => $ipAddresses->all()], 200);
} }
return response()->json(['ipAddresses' => []], 401); return response()->json(['ipAddresses' => []], 401);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,59 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('Application Source with localhost key (id=0)', function () {
test('renders deploy key section when private_key_id is 0', function () {
$privateKey = PrivateKey::create([
'id' => 0,
'name' => 'localhost',
'private_key' => 'test-key-content',
'team_id' => $this->team->id,
]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'private_key_id' => 0,
]);
Livewire::test(Source::class, ['application' => $application])
->assertSuccessful()
->assertSet('privateKeyId', 0)
->assertSee('Deploy Key');
});
test('shows no source connected section when private_key_id is null', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'private_key_id' => null,
]);
Livewire::test(Source::class, ['application' => $application])
->assertSuccessful()
->assertSet('privateKeyId', null)
->assertDontSee('Deploy Key')
->assertSee('No source connected');
});
});

View file

@ -0,0 +1,34 @@
<?php
it('copies the realtime terminal utilities into the container image', function () {
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
expect($dockerfile)->toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js');
});
it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) {
$composeContents = file_get_contents(base_path($composeFile));
expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js');
})->with([
'default dev compose' => 'docker-compose.dev.yml',
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
]);
it('keeps terminal browser logging restricted to Vite development mode', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
it('keeps realtime terminal server logging restricted to development environments', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});

View file

@ -43,3 +43,15 @@
->and($method->invoke($action, 'network timeout'))->toBeFalse() ->and($method->invoke($action, 'network timeout'))->toBeFalse()
->and($method->invoke($action, 'connection refused'))->toBeFalse(); ->and($method->invoke($action, 'connection refused'))->toBeFalse();
}); });
test('buildProxyTimeoutConfig normalizes invalid values to default', function (?int $input, string $expected) {
$action = new StartDatabaseProxy;
$method = new ReflectionMethod($action, 'buildProxyTimeoutConfig');
expect($method->invoke($action, $input))->toBe($expected);
})->with([
[null, 'proxy_timeout 3600s;'],
[0, 'proxy_timeout 3600s;'],
[-10, 'proxy_timeout 3600s;'],
[120, 'proxy_timeout 120s;'],
]);

View file

@ -0,0 +1,51 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.env', 'local');
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
});
it('includes development terminal host aliases for authenticated users', function () {
Server::factory()->create([
'name' => 'Localhost',
'ip' => 'coolify-testing-host',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$response = $this->postJson('/terminal/auth/ips');
$response->assertSuccessful();
$response->assertJsonPath('ipAddresses.0', 'coolify-testing-host');
expect($response->json('ipAddresses'))
->toContain('coolify-testing-host')
->toContain('localhost')
->toContain('127.0.0.1')
->toContain('host.docker.internal');
});

View file

@ -0,0 +1,95 @@
<?php
/**
* Test to verify that docker-compose custom start commands use the correct
* execution context based on the preserveRepository setting.
*
* When preserveRepository is enabled, the compose file and .env file are
* written to the host at /data/coolify/applications/{uuid}/. The start
* command must run on the host (not inside the helper container) so it
* can access these files.
*
* When preserveRepository is disabled, the files are inside the helper
* container at /artifacts/{uuid}/, so the command must run inside the
* container via executeInDocker().
*
* @see https://github.com/coollabsio/coolify/issues/8417
*/
it('generates host command (not executeInDocker) when preserveRepository is true', function () {
$deploymentUuid = 'test-deployment-uuid';
$serverWorkdir = '/data/coolify/applications/app-uuid';
$basedir = '/artifacts/test-deployment-uuid';
$preserveRepository = true;
$startCommand = 'docker compose -f /data/coolify/applications/app-uuid/compose.yml --env-file /data/coolify/applications/app-uuid/.env --profile all up -d';
// Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack()
if ($preserveRepository) {
$command = "cd {$serverWorkdir} && {$startCommand}";
} else {
$command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}");
}
// When preserveRepository is true, the command should NOT be wrapped in executeInDocker
expect($command)->not->toContain('docker exec');
expect($command)->toStartWith("cd {$serverWorkdir}");
expect($command)->toContain($startCommand);
});
it('generates executeInDocker command when preserveRepository is false', function () {
$deploymentUuid = 'test-deployment-uuid';
$serverWorkdir = '/data/coolify/applications/app-uuid';
$basedir = '/artifacts/test-deployment-uuid';
$workdir = '/artifacts/test-deployment-uuid/backend';
$preserveRepository = false;
$startCommand = 'docker compose -f /artifacts/test-deployment-uuid/backend/compose.yml --env-file /artifacts/test-deployment-uuid/backend/.env --profile all up -d';
// Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack()
if ($preserveRepository) {
$command = "cd {$serverWorkdir} && {$startCommand}";
} else {
$command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}");
}
// When preserveRepository is false, the command SHOULD be wrapped in executeInDocker
expect($command)->toContain('docker exec');
expect($command)->toContain($deploymentUuid);
expect($command)->toContain("cd {$basedir}");
});
it('uses host paths for env-file when preserveRepository is true', function () {
$serverWorkdir = '/data/coolify/applications/app-uuid';
$composeLocation = '/compose.yml';
$preserveRepository = true;
$workdirPath = $preserveRepository ? $serverWorkdir : '/artifacts/deployment-uuid/backend';
$startCommand = injectDockerComposeFlags(
'docker compose --profile all up -d',
"{$workdirPath}{$composeLocation}",
"{$workdirPath}/.env"
);
// Verify the injected paths point to the host filesystem
expect($startCommand)->toContain("--env-file {$serverWorkdir}/.env");
expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}");
});
it('uses container paths for env-file when preserveRepository is false', function () {
$workdir = '/artifacts/deployment-uuid/backend';
$composeLocation = '/compose.yml';
$preserveRepository = false;
$serverWorkdir = '/data/coolify/applications/app-uuid';
$workdirPath = $preserveRepository ? $serverWorkdir : $workdir;
$startCommand = injectDockerComposeFlags(
'docker compose --profile all up -d',
"{$workdirPath}{$composeLocation}",
"{$workdirPath}/.env"
);
// Verify the injected paths point to the container filesystem
expect($startCommand)->toContain("--env-file {$workdir}/.env");
expect($startCommand)->toContain("-f {$workdir}{$composeLocation}");
expect($startCommand)->not->toContain('/data/coolify/applications/');
});

View file

@ -206,6 +206,39 @@
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json'); expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
}); });
test('replaceVariables strips leading dollar sign from bare $VAR format', function () {
// Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces),
// replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES.
// Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES.
expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES')
->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES')
->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE');
});
test('bare dollar variable in bash-style fallback does not capture trailing brace', function () {
// Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to
// capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex
// greedily matches the closing brace of the outer ${...} construct.
// The fix uses capture group 2 (clean variable name) instead of group 1.
$value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}';
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches);
// Group 2 should contain clean variable names without any braces
expect($valueMatches[2])->toContain('_APP_DOMAIN')
->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE');
// Verify no match in group 2 has trailing }
foreach ($valueMatches[2] as $match) {
expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }");
}
// Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE}
// This demonstrates why group 2 must be used instead
expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}');
});
test('operator precedence with nesting', function () { test('operator precedence with nesting', function () {
// The first :- at depth 0 should be used, not the one inside nested braces // The first :- at depth 0 should be used, not the one inside nested braces
$input = '${A:-${B:-default}}'; $input = '${A:-${B:-default}}';

View file

@ -0,0 +1,11 @@
<?php
use App\Livewire\Project\Service\Index;
test('service database proxy timeout requires a minimum of one second', function () {
$component = new Index;
$rules = (fn (): array => $this->rules)->call($component);
expect($rules['publicPortTimeout'])
->toContain('min:1');
});

View file

@ -1,19 +1,19 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.464" "version": "4.0.0-beta.465"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.465" "version": "4.0.0-beta.466"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"
}, },
"realtime": { "realtime": {
"version": "1.0.10" "version": "1.0.11"
}, },
"sentinel": { "sentinel": {
"version": "0.0.18" "version": "0.0.19"
} }
}, },
"traefik": { "traefik": {
@ -26,4 +26,4 @@
"v3.0": "3.0.4", "v3.0": "3.0.4",
"v2.11": "2.11.32" "v2.11": "2.11.32"
} }
} }