fix(hardening): guard against root-login self-lockout

Disabling SSH root login (PermitRootLogin no) severs Clusev's OWN access when it
connects AS root — afterwards neither key nor password reaches root. The
password-auth toggle already guards against self-lockout; the root toggle did not.
Refuse to disable root login while the active credential's username is root, with a
clear message: add a non-root sudo user + key and switch Clusev to it first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 00:41:50 +02:00
parent cf70317534
commit 7ec2c065bb
4 changed files with 49 additions and 0 deletions

View File

@ -206,6 +206,13 @@ class HardeningService
return ['ok' => $res['ok'], 'output' => $res['output']];
}
// Lock-out guard: disabling root login (PermitRootLogin no) severs Clusev's OWN access when
// it connects AS root — no key or password can reach root afterwards. Refuse until Clusev is
// switched to a non-root credential (a sudo user with a key), then root login can be disabled.
if ($action === 'ssh_root' && ! $enable && $server->credential?->username === 'root') {
return ['ok' => false, 'output' => __('backend.ssh_root_self_lockout')];
}
// Lock-out guard: never disable password auth without a usable key path.
if ($action === 'ssh_password' && ! $enable) {
if ($server->credential?->auth_type === 'password') {

View File

@ -41,6 +41,7 @@ return [
'ssh_probe_no_exec' => 'SSH-Anmeldung erfolgreich, aber es lassen sich keine Befehle ausführen.',
'ssh_password_self_lockout' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.',
'ssh_password_no_key' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',
'ssh_root_self_lockout' => 'Clusev verbindet sich als root — Root-Login kann nicht deaktiviert werden, sonst sperrt sich Clusev selbst aus. Lege zuerst einen Nicht-Root-Benutzer (mit sudo + SSH-Key) an und stelle Clusev darauf um.',
// ── FirewallService: guards / reasons / errors ───────────────────────
'firewalld_read_only' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.',

View File

@ -41,6 +41,7 @@ return [
'ssh_probe_no_exec' => 'SSH login succeeded, but commands could not be executed.',
'ssh_password_self_lockout' => 'Clusev connects itself via password — password login cannot be disabled, otherwise Clusev loses access. Set up SSH key access first.',
'ssh_password_no_key' => 'No SSH key in place — password login cannot be disabled (lockout risk).',
'ssh_root_self_lockout' => 'Clusev connects as root — root login cannot be disabled, otherwise Clusev locks itself out. Add a non-root user (sudo + SSH key) first and switch Clusev to it.',
// ── FirewallService: guards / reasons / errors ───────────────────────
'firewalld_read_only' => 'Rule management for firewalld is read-only in this version — please configure it on the server.',

View File

@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Models\Server;
use App\Models\SshCredential;
use App\Services\FirewallService;
use App\Services\FleetService;
use App\Services\HardeningService;
@ -89,4 +90,43 @@ class HardeningServiceTest extends TestCase
$this->assertTrue($au['neutral'] ?? false, 'auto-updates row must be flagged neutral');
$this->assertFalse($au['featureOn'], 'inactive auto-updates → toggle shows enable');
}
/**
* Lock-out guard: when Clusev connects AS root, disabling SSH root login (PermitRootLogin no)
* would sever its own access it must be refused before any command runs.
*/
public function test_disabling_root_login_is_refused_when_clusev_connects_as_root(): void
{
$detector = Mockery::mock(OsDetector::class);
$detector->shouldReceive('detect')->andReturn($this->debianProfile());
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldNotReceive('runPrivileged'); // guard must short-circuit before any command
$server = new Server;
$server->setRelation('credential', new SshCredential(['username' => 'root']));
$service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector);
$res = $service->apply($server, 'ssh_root', false);
$this->assertFalse($res['ok']);
$this->assertSame(__('backend.ssh_root_self_lockout'), $res['output']);
}
public function test_disabling_root_login_proceeds_for_a_non_root_credential(): void
{
$detector = Mockery::mock(OsDetector::class);
$detector->shouldReceive('detect')->andReturn($this->debianProfile());
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => '']);
$server = new Server;
$server->setRelation('credential', new SshCredential(['username' => 'deploy']));
$service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector);
$res = $service->apply($server, 'ssh_root', false);
$this->assertTrue($res['ok']);
}
}