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:
parent
255c21ddc1
commit
6293b14586
8 changed files with 104 additions and 5 deletions
|
|
@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
|
||||||
$sshConfig = self::serverSshConfiguration($server);
|
$sshConfig = self::serverSshConfiguration($server);
|
||||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||||
$muxSocket = $sshConfig['muxFilename'];
|
$muxSocket = $sshConfig['muxFilename'];
|
||||||
$connectionTimeout = config('constants.ssh.connection_timeout');
|
$connectionTimeout = self::getConnectionTimeout($server);
|
||||||
$serverInterval = config('constants.ssh.server_interval');
|
$serverInterval = config('constants.ssh.server_interval');
|
||||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
$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 .= '-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()) {
|
if ($server->isIpv6()) {
|
||||||
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
||||||
} else {
|
} 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 .= "-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 = Hash::make($command);
|
||||||
$delimiter = base64_encode($delimiter);
|
$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
|
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
||||||
{
|
{
|
||||||
$options = "-i {$sshKeyLocation} "
|
$options = "-i {$sshKeyLocation} "
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class Show extends Component
|
||||||
|
|
||||||
public string $port;
|
public string $port;
|
||||||
|
|
||||||
|
public int $connectionTimeout;
|
||||||
|
|
||||||
public ?string $validationLogs = null;
|
public ?string $validationLogs = null;
|
||||||
|
|
||||||
public ?string $wildcardDomain = null;
|
public ?string $wildcardDomain = null;
|
||||||
|
|
@ -110,6 +112,7 @@ protected function rules(): array
|
||||||
'ip' => ['required', new ValidServerIp],
|
'ip' => ['required', new ValidServerIp],
|
||||||
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||||
'port' => 'required|integer|between:1,65535',
|
'port' => 'required|integer|between:1,65535',
|
||||||
|
'connectionTimeout' => 'required|integer|min:1|max:300',
|
||||||
'validationLogs' => 'nullable',
|
'validationLogs' => 'nullable',
|
||||||
'wildcardDomain' => 'nullable|url',
|
'wildcardDomain' => 'nullable|url',
|
||||||
'isReachable' => 'required',
|
'isReachable' => 'required',
|
||||||
|
|
@ -138,6 +141,10 @@ protected function messages(): array
|
||||||
'ip.required' => 'The IP Address field is required.',
|
'ip.required' => 'The IP Address field is required.',
|
||||||
'user.required' => 'The User field is required.',
|
'user.required' => 'The User field is required.',
|
||||||
'port.required' => 'The Port 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.',
|
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
|
||||||
'sentinelToken.required' => 'The Sentinel Token field is required.',
|
'sentinelToken.required' => 'The Sentinel Token field is required.',
|
||||||
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate 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->validation_logs = $this->validationLogs;
|
||||||
$this->server->save();
|
$this->server->save();
|
||||||
|
|
||||||
|
$this->server->settings->connection_timeout = $this->connectionTimeout;
|
||||||
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
|
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
|
||||||
$this->server->settings->wildcard_domain = $this->wildcardDomain;
|
$this->server->settings->wildcard_domain = $this->wildcardDomain;
|
||||||
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
|
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
|
||||||
|
|
@ -237,6 +245,7 @@ public function syncData(bool $toModel = false)
|
||||||
$this->ip = $this->server->ip;
|
$this->ip = $this->server->ip;
|
||||||
$this->user = $this->server->user;
|
$this->user = $this->server->user;
|
||||||
$this->port = $this->server->port;
|
$this->port = $this->server->port;
|
||||||
|
$this->connectionTimeout = $this->server->settings->connection_timeout;
|
||||||
|
|
||||||
$this->wildcardDomain = $this->server->settings->wildcard_domain;
|
$this->wildcardDomain = $this->server->settings->wildcard_domain;
|
||||||
$this->isReachable = $this->server->settings->is_reachable;
|
$this->isReachable = $this->server->settings->is_reachable;
|
||||||
|
|
@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false)
|
||||||
return;
|
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);
|
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||||
|
|
||||||
$this->hetznerServerStatus = $serverData['status'] ?? null;
|
$this->hetznerServerStatus = $serverData['status'] ?? null;
|
||||||
|
|
@ -471,7 +480,7 @@ public function startHetznerServer()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
|
||||||
$hetznerService->powerOnServer($this->server->hetzner_server_id);
|
$hetznerService->powerOnServer($this->server->hetzner_server_id);
|
||||||
|
|
||||||
$this->hetznerServerStatus = 'starting';
|
$this->hetznerServerStatus = 'starting';
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
'updated_at' => ['type' => 'string'],
|
'updated_at' => ['type' => 'string'],
|
||||||
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
|
'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.'],
|
'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
|
class ServerSetting extends Model
|
||||||
|
|
@ -97,6 +98,7 @@ class ServerSetting extends Model
|
||||||
'is_terminal_enabled',
|
'is_terminal_enabled',
|
||||||
'deployment_queue_limit',
|
'deployment_queue_limit',
|
||||||
'disable_application_image_retention',
|
'disable_application_image_retention',
|
||||||
|
'connection_timeout',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
@ -108,6 +110,7 @@ class ServerSetting extends Model
|
||||||
'is_usable' => 'boolean',
|
'is_usable' => 'boolean',
|
||||||
'is_terminal_enabled' => 'boolean',
|
'is_terminal_enabled' => 'boolean',
|
||||||
'disable_application_image_retention' => 'boolean',
|
'disable_application_image_retention' => 'boolean',
|
||||||
|
'connection_timeout' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted()
|
protected static function booted()
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -13349,6 +13349,10 @@
|
||||||
"delete_unused_networks": {
|
"delete_unused_networks": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "The flag to indicate if the unused networks should be deleted."
|
"description": "The flag to indicate if the unused networks should be deleted."
|
||||||
|
},
|
||||||
|
"connection_timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "SSH connection timeout in seconds."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|
|
||||||
|
|
@ -8538,6 +8538,9 @@ components:
|
||||||
delete_unused_networks:
|
delete_unused_networks:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'The flag to indicate if the unused networks should be deleted.'
|
description: 'The flag to indicate if the unused networks should be deleted.'
|
||||||
|
connection_timeout:
|
||||||
|
type: integer
|
||||||
|
description: 'SSH connection timeout in seconds.'
|
||||||
type: object
|
type: object
|
||||||
Service:
|
Service:
|
||||||
description: 'Service model'
|
description: 'Service model'
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
label="Port" required :disabled="$isValidating" />
|
||||||
</div>
|
</div>
|
||||||
</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="w-full">
|
||||||
<div class="flex items-center mb-1">
|
<div class="flex items-center mb-1">
|
||||||
<label for="serverTimezone">Server Timezone</label>
|
<label for="serverTimezone">Server Timezone</label>
|
||||||
|
|
|
||||||
43
tests/Feature/ServerConnectionTimeoutTest.php
Normal file
43
tests/Feature/ServerConnectionTimeoutTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue