Files
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

314 lines
13 KiB
PHP

<?php
use App\Actions\Server\AuthorizedKeys;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Services\Dns\DnsRecord;
use App\Services\Dns\DnsResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Symfony\Component\Process\Process as ShellProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Once::flush();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
Process::fake();
config(['constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0, 'custom_dns_servers' => '192.0.2.53']);
$this->team = Team::factory()->create();
$this->key = PrivateKey::factory()->create([
'team_id' => $this->team->id,
'private_key' => generateSSHKey('ed25519')['private'],
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id, 'private_key_id' => $this->key->id,
'ip' => '192.0.2.10', 'user' => 'root',
]);
$this->scripts = [];
Process::fake(function (PendingProcess $process) {
// Batches go to the remote shell on stdin.
if (filled($process->input)) {
$this->scripts[] = rtrim((string) $process->input, "\n");
}
return Process::result();
});
$this->home = sys_get_temp_dir().'/authorized-keys-test-'.bin2hex(random_bytes(8));
mkdir($this->home.'/.ssh', 0700, true);
});
afterEach(function () {
File::deleteDirectory($this->home);
Server::flushIdentityMap();
});
it('keeps the live file intact if its intermediate file disappears', function () {
AuthorizedKeys::remove($this->server, $this->key);
$file = $this->home.'/.ssh/authorized_keys';
$original = $this->key->publicKeyBody()." old\nssh-ed25519 RECOVERY operator\n";
file_put_contents($file, $original);
mkdir($this->home.'/bin');
// Simulate the other cleanup deleting the staging file before it can be copied.
file_put_contents($this->home.'/bin/grep', <<<'SH'
#!/bin/sh
output=$(readlink /proc/$$/fd/1)
/usr/bin/grep "$@"
rc=$?
rm -f "$output"
exit "$rc"
SH);
chmod($this->home.'/bin/grep', 0700);
$process = new ShellProcess(['sh', '-se'], null, [
'HOME' => $this->home, 'PATH' => $this->home.'/bin:'.getenv('PATH'),
], end($this->scripts));
$process->run();
expect(file_get_contents($file))->toBe($original);
});
it('serializes concurrent additions and removals without losing recovery keys or file mode', function () {
AuthorizedKeys::remove($this->server, $this->key);
$remove = end($this->scripts);
$newKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
$nextKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
AuthorizedKeys::add($this->server, $nextKey, $newKey);
$add = end($this->scripts);
$file = $this->home.'/.ssh/authorized_keys';
file_put_contents($file, $this->key->publicKeyBody()." old\n".$newKey->publicKeyBody()." current\nssh-ed25519 RECOVERY operator\n");
chmod($file, 0640);
$processes = [];
for ($i = 0; $i < 6; $i++) {
foreach ([$remove, $add] as $script) {
$process = new ShellProcess(['sh', '-se'], null, ['HOME' => $this->home], $script);
$process->start();
$processes[] = $process;
}
}
foreach ($processes as $process) {
$process->wait();
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput());
}
expect(file_get_contents($file))->toBe($newKey->publicKeyBody()." current\nssh-ed25519 RECOVERY operator\n".$nextKey->authorizedKeysLine()."\n")
->and(fileperms($file) & 0777)->toBe(0640);
});
it('retains equivalent key material used by another record for the same host on a different port', function () {
$otherTeam = Team::factory()->create();
$copy = PrivateKey::factory()->create([
'team_id' => $otherTeam->id, 'private_key' => $this->key->private_key,
]);
Server::factory()->create([
'team_id' => $otherTeam->id, 'private_key_id' => $copy->id,
'ip' => $this->server->ip, 'user' => 'root', 'port' => 2222,
]);
expect(AuthorizedKeys::remove($this->server, $this->key))->toBeFalse();
});
it('retains a key used through a hostname alias or an unresolved host', function (bool $resolved) {
Server::factory()->create([
'team_id' => $this->team->id, 'private_key_id' => $this->key->id,
'ip' => 'ssh.example.test', 'user' => 'root',
]);
app()->bind(DnsResolver::class, fn () => new class($resolved) extends DnsResolver
{
public function __construct(private bool $resolved)
{
parent::__construct('192.0.2.53');
}
public function query(string $host, string $type = self::TYPE_A): array|false
{
return $this->resolved ? ($type === self::TYPE_A ? [new DnsRecord(self::TYPE_A, '192.0.2.10')] : []) : false;
}
});
expect(AuthorizedKeys::remove($this->server, $this->key))->toBeFalse();
})->with([true, false]);
it('removes a shared key from an independent literal IP server', function () {
$other = Server::factory()->create([
'team_id' => $this->team->id, 'private_key_id' => $this->key->id,
'ip' => '192.0.2.11', 'user' => 'root',
]);
$this->server->settings->forceFill(['ssh_host_key' => 'ssh-ed25519 AAAAHOSTA'])->save();
$other->settings->forceFill(['ssh_host_key' => 'ssh-ed25519 AAAAHOSTB'])->save();
expect(AuthorizedKeys::remove($this->server->fresh(), $this->key))->toBeTrue();
});
it('retains a shared key used through another IP of the same machine', function (?string $otherHostKey) {
$other = Server::factory()->create([
'team_id' => $this->team->id, 'private_key_id' => $this->key->id,
'ip' => '100.64.0.10', 'user' => 'root',
]);
$this->server->settings->forceFill(['ssh_host_key' => "ecdsa-sha2-nistp256 AAAAOTHER\nssh-ed25519 AAAAHOSTA"])->save();
$other->settings->forceFill(['ssh_host_key' => $otherHostKey])->save();
expect(AuthorizedKeys::remove($this->server->fresh(), $this->key))->toBeFalse();
})->with([
'same host key' => 'ssh-ed25519 AAAAHOSTA',
'host key not known yet' => null,
]);
it('retains the same legacy key with no fingerprint through a differently cased hostname', function () {
$this->key->forceFill(['fingerprint' => null])->saveQuietly();
$this->server->forceFill(['ip' => 'SSH.EXAMPLE.TEST.'])->saveQuietly();
Server::factory()->create([
'team_id' => $this->team->id, 'private_key_id' => $this->key->id,
'ip' => 'ssh.example.test', 'user' => 'root',
]);
expect(AuthorizedKeys::remove($this->server, $this->key))->toBeFalse();
});
it('does not retain an unrelated key used by another record on the same host', function () {
$otherKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
Server::factory()->create([
'team_id' => $this->team->id, 'private_key_id' => $otherKey->id,
'ip' => $this->server->ip, 'user' => 'root',
]);
expect(AuthorizedKeys::remove($this->server, $this->key))->toBeTrue();
});
it('preserves quoted restrictions and duplicate entries when rotating a key', function () {
$newKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
$options = 'from="192.0.2.0/24",command="echo hello world",restrict';
$file = $this->home.'/.ssh/authorized_keys';
file_put_contents($file, $options.' '.$this->key->publicKeyBody()." old\n".'restrict '.$this->key->publicKeyBody()." second\n");
AuthorizedKeys::add($this->server, $newKey, $this->key);
$process = new ShellProcess(['sh', '-se'], null, ['HOME' => $this->home], end($this->scripts));
$process->run();
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput());
expect(file_get_contents($file))
->toContain($options.' '.$newKey->authorizedKeysLine())
->toContain('restrict '.$newKey->authorizedKeysLine());
});
function runAuthorizedKeysScript(string $home, string $script): ShellProcess
{
$process = new ShellProcess(['bash', '-se'], null, ['HOME' => $home, 'PATH' => getenv('PATH')], $script);
$process->run();
return $process;
}
it('keeps the last line intact when a rotation appends to a file without a final newline', function () {
$newKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
$file = $this->home.'/.ssh/authorized_keys';
file_put_contents($file, $this->key->publicKeyBody()." old\nssh-ed25519 PERSONAL operator");
AuthorizedKeys::add($this->server, $newKey, $this->key);
$process = runAuthorizedKeysScript($this->home, end($this->scripts));
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(file($file, FILE_IGNORE_NEW_LINES))->toBe([
$this->key->publicKeyBody().' old',
'ssh-ed25519 PERSONAL operator',
$newKey->authorizedKeysLine(),
]);
// A failed login test removes the new key again; the personal key must stay.
expect(AuthorizedKeys::undoAdd($this->server, $newKey, $this->key))->toBeTrue();
$process = runAuthorizedKeysScript($this->home, end($this->scripts));
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(file_get_contents($file))->toBe($this->key->publicKeyBody()." old\nssh-ed25519 PERSONAL operator\n");
});
it('takes over a lock that a crashed edit left behind', function () {
$lock = $this->home.'/.ssh/.coolify-authorized-keys.lock';
mkdir($lock);
touch($lock, time() - 1200);
$file = $this->home.'/.ssh/authorized_keys';
file_put_contents($file, $this->key->publicKeyBody()." old\nssh-ed25519 RECOVERY operator\n");
AuthorizedKeys::remove($this->server, $this->key);
$started = microtime(true);
$process = runAuthorizedKeysScript($this->home, end($this->scripts));
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(microtime(true) - $started)->toBeLessThan(5)
->and(file_get_contents($file))->toBe("ssh-ed25519 RECOVERY operator\n")
->and(is_dir($lock))->toBeFalse();
});
it('does not retain a key because of another key without a fingerprint on the same host', function () {
$otherTeam = Team::factory()->create();
$legacy = PrivateKey::factory()->create([
'team_id' => $otherTeam->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
$legacy->forceFill(['fingerprint' => null])->saveQuietly();
Server::factory()->create([
'team_id' => $otherTeam->id, 'private_key_id' => $legacy->id,
'ip' => $this->server->ip, 'user' => 'root',
]);
expect(AuthorizedKeys::remove($this->server, $this->key))->toBeTrue();
});
it('removes only the lines it added when the new key cannot log in', function () {
$newKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
$file = $this->home.'/.ssh/authorized_keys';
$manual = 'from="192.0.2.0/24" '.$newKey->publicKeyBody().' added by hand';
$original = 'restrict '.$this->key->publicKeyBody()." old\n{$manual}\nssh-ed25519 PERSONAL operator\n";
file_put_contents($file, $original);
AuthorizedKeys::add($this->server, $newKey, $this->key);
expect(runAuthorizedKeysScript($this->home, end($this->scripts))->isSuccessful())->toBeTrue()
->and(file_get_contents($file))->toContain('restrict '.$newKey->authorizedKeysLine());
AuthorizedKeys::undoAdd($this->server, $newKey, $this->key);
$process = runAuthorizedKeysScript($this->home, end($this->scripts));
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(file_get_contents($file))->toBe($original);
});
it('stops with its own exit code when the old key is not in the file', function () {
$newKey = PrivateKey::factory()->create([
'team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private'],
]);
$file = $this->home.'/.ssh/authorized_keys';
file_put_contents($file, "ssh-ed25519 PERSONAL operator\n");
AuthorizedKeys::add($this->server, $newKey, $this->key);
$process = runAuthorizedKeysScript($this->home, end($this->scripts));
expect($process->getExitCode())->toBe(AuthorizedKeys::OLD_KEY_NOT_FOUND)
->and(file_get_contents($file))->toBe("ssh-ed25519 PERSONAL operator\n");
});
it('retains the same key material saved without a fingerprint by another team on the same host', function () {
$otherTeam = Team::factory()->create();
$copy = PrivateKey::factory()->create([
'team_id' => $otherTeam->id, 'private_key' => $this->key->private_key,
]);
$copy->forceFill(['fingerprint' => null])->saveQuietly();
Server::factory()->create([
'team_id' => $otherTeam->id, 'private_key_id' => $copy->id,
'ip' => $this->server->ip, 'user' => 'root', 'port' => 2222,
]);
expect(AuthorizedKeys::remove($this->server, $this->key))->toBeFalse();
});