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

This commit is contained in:
Andras Bacsai 2026-04-28 22:25:36 +02:00 committed by GitHub
commit 092ea3bb7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 196 additions and 10 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

@ -612,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@ -648,7 +649,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -674,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -718,7 +720,7 @@ public function update_server(Request $request)
], 422);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}

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

@ -10545,6 +10545,10 @@
"server_disk_usage_check_frequency": {
"type": "string",
"description": "Cron expression for disk usage check frequency."
},
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds (1-300). Default: 10."
}
},
"type": "object"
@ -13349,6 +13353,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

@ -6734,6 +6734,9 @@ paths:
server_disk_usage_check_frequency:
type: string
description: 'Cron expression for disk usage check frequency.'
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds (1-300). Default: 10.'
type: object
responses:
'201':
@ -8538,6 +8541,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,74 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
$newToken = $this->user->createToken('write-token', ['write']);
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->token = $newToken->plainTextToken;
});
it('PATCH updates connection_timeout via API', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 45,
]);
$response->assertStatus(201);
expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
});
it('PATCH rejects connection_timeout out of range', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 0,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
it('PATCH rejects connection_timeout above max', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 999,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
it('PATCH rejects non-integer connection_timeout', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 'fast',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});

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);
});

View file

@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const viteHost = env.VITE_HOST || null;
const vitePort = Number(env.VITE_PORT || 5173);
return {
server: {
@ -14,9 +16,11 @@ export default defineConfig(({ mode }) => {
],
},
host: "0.0.0.0",
hmr: {
host: env.VITE_HOST || '0.0.0.0'
},
allowedHosts: true,
origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined,
hmr: viteHost
? { host: viteHost, clientPort: vitePort }
: true,
},
plugins: [
laravel({