Files
coolify/tests/Feature/SshConnectionOptionsTest.php
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

156 lines
6.2 KiB
PHP

<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
function makeSshOptionsTestServer(array $attributes = []): Server
{
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
."b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n"
."QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n"
."hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n"
."AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n"
."uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n"
.'-----END OPENSSH PRIVATE KEY-----';
$privateKey = PrivateKey::create([
'name' => 'ssh-options-test-key-'.uniqid(),
'private_key' => $privateKeyContent,
'team_id' => $team->id,
]);
Storage::fake('ssh-keys');
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'user' => 'root',
'port' => 22,
...$attributes,
]);
}
function resolveSshCommand(string $command): string
{
return (string) shell_exec(preg_replace('/\bssh /', 'ssh -G ', $command, 1).' 2>&1');
}
/**
* Every option must come before `--`: ssh and scp read anything after it as the destination,
* the remote command, or a path.
*/
function expectFixedOptionsBefore(string $command, string $afterEndOfOptions): void
{
$endOfOptions = strpos($command, '-- '.$afterEndOfOptions);
expect($endOfOptions)->not->toBeFalse("Missing `-- {$afterEndOfOptions}` in: {$command}");
foreach ([
'-F /dev/null',
'-o IdentitiesOnly=yes',
'-o PasswordAuthentication=no',
'-o ClearAllForwardings=yes',
'-o ForwardAgent=no',
] as $option) {
$position = strpos($command, $option);
expect($position)->not->toBeFalse("Missing `{$option}` in: {$command}");
expect($position)->toBeLessThan($endOfOptions);
}
expect(substr_count($command, ' -- '))->toBe(1);
}
/**
* Servers with `AuthenticationMethods publickey,keyboard-interactive` and a second factor
* that does not prompt (for example Duo autopush) need keyboard-interactive after the key.
*/
it('keeps keyboard-interactive authentication available after the public key', function () {
if (trim((string) shell_exec('command -v ssh')) === '') {
$this->markTestSkipped('ssh client is not installed.');
}
config(['constants.ssh.mux_enabled' => false]);
$command = SshMultiplexingHelper::generateSshStdinCommand(makeSshOptionsTestServer(), 'true');
$resolved = resolveSshCommand($command);
expect($resolved)
->toContain("identitiesonly yes\n")
->toContain("batchmode no\n")
->not->toMatch('/^preferredauthentications (?!.*keyboard-interactive)/m');
});
it('ends option parsing directly before the destination in every ssh and scp command', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeSshOptionsTestServer(['ip' => '10.0.0.5']);
expectFixedOptionsBefore(SshMultiplexingHelper::generateSshCommand($server, 'echo ok'), "'root'@'10.0.0.5' 'sh -c");
expectFixedOptionsBefore(SshMultiplexingHelper::generateSshStdinCommand($server, 'cat'), "'root'@'10.0.0.5' 'cat'");
expectFixedOptionsBefore(SshMultiplexingHelper::generateScpCommand($server, '/tmp/a', '/tmp/b'), "'/tmp/a' 'root'@'10.0.0.5':'/tmp/b'");
expectFixedOptionsBefore(SshMultiplexingHelper::generateScpDownloadCommand($server, '/tmp/b', '/tmp/a'), "'root'@'10.0.0.5':'/tmp/b' '/tmp/a'");
$ipv6Server = makeSshOptionsTestServer(['ip' => '2001:db8::1']);
expectFixedOptionsBefore(SshMultiplexingHelper::generateScpCommand($ipv6Server, '/tmp/a', '/tmp/b'), "'/tmp/a' 'root'@['2001:db8::1']:'/tmp/b'");
expectFixedOptionsBefore(SshMultiplexingHelper::generateScpDownloadCommand($ipv6Server, '/tmp/b', '/tmp/a'), "'root'@['2001:db8::1']:'/tmp/b' '/tmp/a'");
});
it('ends option parsing before the destination in mux control commands', function () {
$server = makeSshOptionsTestServer(['ip' => '10.0.0.5']);
$muxControlCommand = new ReflectionMethod(SshMultiplexingHelper::class, 'muxControlCommand');
expect($muxControlCommand->invoke(null, $server, 'check'))->toEndWith(" -- 'root'@'10.0.0.5'");
});
/**
* The health check uses ControlMaster=auto. If the master stops before it runs, ssh opens a
* direct connection, which must use the server's key and port, not the container's defaults.
*/
it('connects with the server key, port and fixed options when the health check falls back to a direct connection', function () {
if (trim((string) shell_exec('command -v ssh')) === '') {
$this->markTestSkipped('ssh client is not installed.');
}
Process::fake();
$server = makeSshOptionsTestServer(['ip' => '10.0.0.5', 'port' => 2222]);
SshMultiplexingHelper::isConnectionHealthy($server);
$healthCommand = null;
Process::assertRan(function ($process) use (&$healthCommand) {
$healthCommand = $process->command;
return true;
});
expectFixedOptionsBefore($healthCommand, "'root'@'10.0.0.5' 'echo");
expect(resolveSshCommand($healthCommand))
->toContain("port 2222\n")
->toContain('identityfile '.$server->privateKey->getKeyLocation()."\n")
->toContain("identitiesonly yes\n");
});
it('ignores ssh config files and ends option parsing in the host key scan', function () {
Process::fake();
$server = makeSshOptionsTestServer(['ip' => '10.0.0.5']);
SshMultiplexingHelper::scanHostKey($server);
$scanCommand = null;
Process::assertRan(function ($process) use (&$scanCommand) {
$scanCommand = $process->command;
return true;
});
expect($scanCommand)
->toContain('-F /dev/null ')
->toEndWith(" -- 'root'@'10.0.0.5' true");
expect(strpos($scanCommand, '-F /dev/null'))->toBeLessThan(strpos($scanCommand, ' -- '));
});