feat(server): add configurable SSH connection timeout per server

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
This commit is contained in:
Andras Bacsai 2026-04-28 15:39:36 +02:00
parent 255c21ddc1
commit 6293b14586
8 changed files with 104 additions and 5 deletions

View file

@ -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} "

View file

@ -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';

View file

@ -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()

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->integer('connection_timeout')->default(10)->after('deployment_queue_limit');
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('connection_timeout');
});
}
};

View file

@ -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"

View file

@ -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'

View file

@ -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" />
</div>
</div>
<div class="w-full lg:w-64">
<x-forms.input canGate="update" :canResource="$server" type="number"
id="connectionTimeout" label="SSH Connection Timeout (s)"
helper="Seconds to wait for SSH connection before failing. Default: 10."
min="1" max="300" required :disabled="$isValidating" />
</div>
<div class="w-full">
<div class="flex items-center mb-1">
<label for="serverTimezone">Server Timezone</label>

View file

@ -0,0 +1,43 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
it('defaults connection_timeout to 10 seconds for new servers', function () {
expect($this->server->settings->connection_timeout)->toBe(10);
});
it('persists a custom connection_timeout value', function () {
$this->server->settings->connection_timeout = 30;
$this->server->settings->save();
expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
});
it('returns the per-server connection_timeout from getConnectionTimeout', function () {
$this->server->settings->connection_timeout = 45;
$this->server->settings->save();
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
});
it('falls back to config default when connection_timeout is invalid', function () {
$this->server->settings->connection_timeout = 0;
$this->server->settings->saveQuietly();
$expected = (int) config('constants.ssh.connection_timeout');
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
});