From 6293b14586b1dd7e23de9a121963a4ff22ea4eb6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:39:36 +0200 Subject: [PATCH 1/4] feat(server): add configurable SSH connection timeout per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `connection_timeout` field to server settings, allowing per-server override of the global SSH connection timeout constant. - Migration adds `connection_timeout` integer column (default 10s) - `ServerSetting` model exposes and casts the new field - `SshMultiplexingHelper::getConnectionTimeout()` resolves per-server value with fallback to `constants.ssh.connection_timeout` - All SSH/SCP command builders use the new resolver instead of the global config directly - Livewire `Show` component binds `connectionTimeout` with validation (1–300 seconds) and syncs to/from the model - UI input added to server settings form with helper text - Feature tests cover default, persistence, resolver, and fallback --- app/Helpers/SshMultiplexingHelper.php | 15 +++++-- app/Livewire/Server/Show.php | 13 +++++- app/Models/ServerSetting.php | 3 ++ ..._connection_timeout_to_server_settings.php | 22 ++++++++++ openapi.json | 4 ++ openapi.yaml | 3 ++ .../views/livewire/server/show.blade.php | 6 +++ tests/Feature/ServerConnectionTimeoutTest.php | 43 +++++++++++++++++++ 8 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php create mode 100644 tests/Feature/ServerConnectionTimeoutTest.php diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index aa9d06996..4629df571 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; $muxSocket = $sshConfig['muxFilename']; - $connectionTimeout = config('constants.ssh.connection_timeout'); + $connectionTimeout = self::getConnectionTimeout($server); $serverInterval = config('constants.ssh.server_interval'); $muxPersistTime = config('constants.ssh.mux_persist_time'); @@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); + $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { @@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; } - $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); + $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); $delimiter = Hash::make($command); $delimiter = base64_encode($delimiter); @@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void } } + public static function getConnectionTimeout(Server $server): int + { + $timeout = data_get($server, 'settings.connection_timeout'); + + return is_numeric($timeout) && (int) $timeout > 0 + ? (int) $timeout + : (int) config('constants.ssh.connection_timeout'); + } + private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string { $options = "-i {$sshKeyLocation} " diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 84cb65ee6..3e05d9306 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -32,6 +32,8 @@ class Show extends Component public string $port; + public int $connectionTimeout; + public ?string $validationLogs = null; public ?string $wildcardDomain = null; @@ -110,6 +112,7 @@ protected function rules(): array 'ip' => ['required', new ValidServerIp], 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', + 'connectionTimeout' => 'required|integer|min:1|max:300', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', @@ -138,6 +141,10 @@ protected function messages(): array 'ip.required' => 'The IP Address field is required.', 'user.required' => 'The User field is required.', 'port.required' => 'The Port field is required.', + 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.', + 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.', + 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.', + 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.', 'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.', 'sentinelToken.required' => 'The Sentinel Token field is required.', 'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.', @@ -210,6 +217,7 @@ public function syncData(bool $toModel = false) $this->server->validation_logs = $this->validationLogs; $this->server->save(); + $this->server->settings->connection_timeout = $this->connectionTimeout; $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; @@ -237,6 +245,7 @@ public function syncData(bool $toModel = false) $this->ip = $this->server->ip; $this->user = $this->server->user; $this->port = $this->server->port; + $this->connectionTimeout = $this->server->settings->connection_timeout; $this->wildcardDomain = $this->server->settings->wildcard_domain; $this->isReachable = $this->server->settings->is_reachable; @@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false) return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $this->hetznerServerStatus = $serverData['status'] ?? null; @@ -471,7 +480,7 @@ public function startHetznerServer() return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $hetznerService->powerOnServer($this->server->hetzner_server_id); $this->hetznerServerStatus = 'starting'; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 30fc1e165..8d85c8932 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -49,6 +49,7 @@ 'updated_at' => ['type' => 'string'], 'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'], 'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'], ] )] class ServerSetting extends Model @@ -97,6 +98,7 @@ class ServerSetting extends Model 'is_terminal_enabled', 'deployment_queue_limit', 'disable_application_image_retention', + 'connection_timeout', ]; protected $casts = [ @@ -108,6 +110,7 @@ class ServerSetting extends Model 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', + 'connection_timeout' => 'integer', ]; protected static function booted() diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php new file mode 100644 index 000000000..1700feebc --- /dev/null +++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php @@ -0,0 +1,22 @@ +integer('connection_timeout')->default(10)->after('deployment_queue_limit'); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('connection_timeout'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 09232be2f..8b3d5b91f 100644 --- a/openapi.json +++ b/openapi.json @@ -13349,6 +13349,10 @@ "delete_unused_networks": { "type": "boolean", "description": "The flag to indicate if the unused networks should be deleted." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index d6a8d7635..07d9b6095 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8538,6 +8538,9 @@ components: delete_unused_networks: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds.' type: object Service: description: 'Service model' diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d694174d5..cfbeccd0c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 label="Port" required :disabled="$isValidating" /> +