diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index 498714f..b8ea5e9 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -238,8 +238,15 @@ class HardeningService return match ($action) { 'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')), 'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')), + // Pin the sshd jail to the SYSTEMD backend (reads the journal) instead of the distro + // default /var/log/auth.log — which is absent on minimal/container images without + // rsyslog, where fail2ban would otherwise fail to start. All supported targets are + // systemd, and `apt/dnf install fail2ban` pulls the systemd binding via Recommends. 'fail2ban' => $enable - ? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban' + ? $pm->ensureInstalledScript('fail2ban') + .' && mkdir -p /etc/fail2ban/jail.d' + .' && printf \'[sshd]\nbackend = systemd\nenabled = true\n\' > /etc/fail2ban/jail.d/00-clusev-sshd.local' + .' && systemctl enable --now fail2ban' : 'systemctl disable --now fail2ban', 'unattended' => $this->autoUpdateScript($os, $pm, $enable), default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), diff --git a/tests/Feature/HardeningServiceTest.php b/tests/Feature/HardeningServiceTest.php index 6f38d17..303452a 100644 --- a/tests/Feature/HardeningServiceTest.php +++ b/tests/Feature/HardeningServiceTest.php @@ -113,6 +113,28 @@ class HardeningServiceTest extends TestCase $this->assertSame(__('backend.ssh_root_self_lockout'), $res['output']); } + public function test_fail2ban_enable_pins_the_sshd_jail_to_the_systemd_backend(): void + { + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($this->debianProfile()); + + $captured = ''; + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPrivileged')->once() + ->andReturnUsing(function ($s, $cmd) use (&$captured) { + $captured = $cmd; + + return ['ok' => true, 'output' => '']; + }); + + $service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector); + $service->apply(new Server, 'fail2ban', true); + + // The default /var/log/auth.log backend fails on minimal images — pin systemd (journal). + $this->assertStringContainsString('backend = systemd', $captured); + $this->assertStringContainsString('/etc/fail2ban/jail.d/00-clusev-sshd.local', $captured); + } + public function test_disabling_root_login_proceeds_for_a_non_root_credential(): void { $detector = Mockery::mock(OsDetector::class);