feat(hardening): bidirectional on/off toggles, real state read, UFW install, Codex-clean

Rework the server-hardening UX per operator feedback ("only activate, can't
deactivate or adjust, UFW won't even activate, is the existing state read?").

- Bidirectional toggles: each item (SSH root-login, SSH password-login, fail2ban,
  UFW, unattended-upgrades) is a clean Aktivieren/Deaktivieren toggle driven by the
  live feature state, not a one-shot "Anwenden".
- Real state read (HardeningService::state, privileged): SSH from `sshd -T` (the
  EFFECTIVE config — honours Include order + Match blocks), UFW from `ufw status`,
  unattended from `apt-config dump` (effective periodic value), packages via dpkg —
  so nicht-installiert / inaktiv / aktiv are detected; a feature counts as "secure"
  only when installed AND active.
- UFW activation installs ufw if missing (fixes "ufw: not found"); opens the detected
  sshd port + 80/443 before `ufw --force enable`; adds disable().
- SSH drop-in renamed 00-clusev.conf (sorts + wins first); apt periodic written to a
  last-winning 99zz-clusev file. Long timeout for apt installs.
- preview() returns the EXACT command apply() runs (single source — no drift between
  the confirmation preview and the executed mutation). Password-disable lock-out guard
  intact (refused when Clusev uses password auth or no key exists).

R15 — Codex review gate: `codex review --uncommitted` run iteratively; fixed every
finding across 5 rounds (drop-in precedence, apt periodic config, preview/apply drift,
ufw status detection, privileged-read error handling, stale-config secure state) until
"no actionable regressions". 0 security issues throughout.

Live-verified on 10.10.90.162: state via sshd -T (effective), fail2ban + unattended
toggled on/off both ways, ufw installed + state reflects, lock-out guard refuses
disabling password auth. R12: server detail 200 / 0 console errors, toggles render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-13 03:52:53 +02:00
parent 0b2dffe0de
commit 3fc2edf4fc
6 changed files with 202 additions and 210 deletions

View File

@ -4,21 +4,18 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FirewallService;
use App\Services\HardeningService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Preview + confirm modal for a single server-hardening action (R5).
* Preview + confirm modal for one server-hardening TOGGLE (R5).
*
* mount() loads a human title + the EXACT shell commands (from the service's
* preview) so the operator sees what will run BEFORE applying. `apply()` runs
* the action over SSH, AUDITs (`harden.<key>`), notifies, asks the page to
* reload the snapshot, and on success closes.
*
* Action keys: ssh_root, ssh_password, fail2ban, unattended, firewall.
* `$enable` is the desired feature state true turns the feature on, false off.
* mount() loads a direction-aware title + the exact root commands (preview) so the
* operator sees what will run BEFORE applying. apply() runs it, AUDITs, notifies,
* asks the page to reload its hardening state, and shows the result.
*/
class HardeningAction extends ModalComponent
{
@ -26,8 +23,8 @@ class HardeningAction extends ModalComponent
public string $action;
/** Optional TCP port (reserved for future allow/deny firewall actions). */
public ?int $port = null;
/** Desired feature state: true = aktivieren, false = deaktivieren. */
public bool $enable;
public string $heading = '';
@ -35,21 +32,19 @@ class HardeningAction extends ModalComponent
public string $preview = '';
/** Result of the apply run, set after the operator confirms. */
public bool $done = false;
public bool $ok = false;
public string $output = '';
/** Set when the action key is unknown / server vanished — blocks apply. */
public ?string $error = null;
public function mount(int $serverId, string $action, ?int $port = null): void
public function mount(int $serverId, string $action, bool $enable): void
{
$this->serverId = $serverId;
$this->action = $action;
$this->port = $port;
$this->enable = $enable;
$server = Server::find($serverId);
if (! $server) {
@ -59,16 +54,10 @@ class HardeningAction extends ModalComponent
}
try {
if ($action === 'firewall') {
$this->heading = 'Firewall (UFW) aktivieren';
$this->description = 'Öffnet zuerst den SSH-Port und 80/443, dann wird UFW aktiviert — die laufende SSH-Sitzung bleibt erreichbar.';
$this->preview = app(FirewallService::class)->enablePreview($server);
} else {
$hardening = app(HardeningService::class);
$this->heading = $hardening->title($action);
$this->description = $hardening->description($action);
$this->preview = $hardening->preview($server, $action);
}
$hardening = app(HardeningService::class);
$this->heading = $hardening->title($action, $enable);
$this->description = $hardening->description($action, $enable);
$this->preview = $hardening->preview($server, $action, $enable);
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@ -93,9 +82,7 @@ class HardeningAction extends ModalComponent
}
try {
$result = $this->action === 'firewall'
? app(FirewallService::class)->enable($server)
: app(HardeningService::class)->apply($server, $this->action);
$result = app(HardeningService::class)->apply($server, $this->action, $this->enable);
} catch (Throwable $e) {
$this->done = true;
$this->ok = false;
@ -112,7 +99,7 @@ class HardeningAction extends ModalComponent
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'harden.'.$this->action,
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
'ip' => request()->ip(),
]);

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use App\Services\HardeningService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
@ -73,8 +74,14 @@ class Show extends Component
$this->volumes = $snap['volumes'];
$this->interfaces = $snap['interfaces'];
$this->hardening = $snap['hardening'];
$this->sshKeys = $snap['sshKeys'];
// Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks).
try {
$this->hardening = app(HardeningService::class)->state($this->server);
} catch (Throwable) {
$this->hardening = [];
}
$this->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
$this->connected = true;
} catch (Throwable) {

View File

@ -61,11 +61,20 @@ class FirewallService
{
$port = $this->sshPort($server);
$preview = $this->enableScript($port);
$res = $this->fleet->runPrivileged($server, $this->enableScript($port));
// long timeout: may apt-install ufw first.
$res = $this->fleet->runPrivileged($server, $this->enableScript($port), 600);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Turn the firewall off (ufw disable). */
public function disable(Server $server): array
{
$res = $this->fleet->runPrivileged($server, 'ufw disable');
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => 'ufw disable'];
}
/**
* Allow a TCP port through the firewall.
*
@ -94,10 +103,11 @@ class FirewallService
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Build the guarded enable script for a given sshd port. */
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
private function enableScript(int $port): string
{
return "ufw allow {$port}/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable";
return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && '
."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
}
/**

View File

@ -241,8 +241,7 @@ class FleetService
.'echo '.self::MARK.'addr===; ip -o -4 addr show; '
.'echo '.self::MARK.'link===; ip -o link show; '
.'echo '.self::MARK.'netdev===; cat /proc/net/dev; '
.'echo '.self::MARK.'sshd===; grep -Ei "^[[:space:]]*(permitrootlogin|passwordauthentication)" /etc/ssh/sshd_config 2>/dev/null; '
.'echo '.self::MARK.'active===; for u in fail2ban ufw nftables unattended-upgrades; do echo "$u $(systemctl is-active $u 2>/dev/null)"; done; '
// hardening state (sshd -T + pkg checks) is read privileged via HardeningService::state().
.'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null';
try {
@ -275,7 +274,6 @@ class FleetService
],
'volumes' => $volumes,
'interfaces' => $this->parseInterfaces($s['addr'] ?? '', $s['link'] ?? '', $s['netdev'] ?? ''),
'hardening' => $this->parseHardening($s['sshd'] ?? '', $s['active'] ?? ''),
'sshKeys' => $this->parseKeys($s['keys'] ?? ''),
];
}
@ -670,26 +668,6 @@ class FleetService
return $out;
}
private function parseHardening(string $sshd, string $active): string|array
{
$root = preg_match('/permitrootlogin\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes';
$pwauth = preg_match('/passwordauthentication\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes';
$svc = [];
foreach ($this->lines($active) as $line) {
[$name, $state] = array_pad(preg_split('/\s+/', trim($line)), 2, '');
$svc[$name] = $state;
}
$isActive = fn (string $u) => ($svc[$u] ?? '') === 'active';
$firewall = $isActive('ufw') || $isActive('nftables');
return [
['label' => 'SSH-Root-Login deaktiviert', 'detail' => "PermitRootLogin {$root}", 'status' => $root === 'no' ? 'online' : 'offline'],
['label' => 'SSH-Passwort-Login deaktiviert', 'detail' => "PasswordAuthentication {$pwauth}", 'status' => $pwauth === 'no' ? 'online' : 'warning'],
['label' => 'fail2ban aktiv', 'detail' => 'fail2ban.service · '.($svc['fail2ban'] ?? 'unbekannt'), 'status' => $isActive('fail2ban') ? 'online' : 'offline'],
['label' => 'Firewall aktiv', 'detail' => $firewall ? 'ufw/nftables · aktiv' : 'ufw/nftables · inaktiv', 'status' => $firewall ? 'online' : 'offline'],
['label' => 'Automatische Updates', 'detail' => 'unattended-upgrades · '.($svc['unattended-upgrades'] ?? 'unbekannt'), 'status' => $isActive('unattended-upgrades') ? 'online' : 'warning'],
];
}
private function parseKeys(string $body): array
{

View File

@ -6,150 +6,190 @@ use App\Models\Server;
use InvalidArgumentException;
/**
* Applies the dashboard server-hardening checklist items over SSH (as root).
* Applies the dashboard server-hardening toggles over SSH (as root). Every item is
* BIDIRECTIONAL apply($server, $action, $enable) toggles the underlying feature
* on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand.
*
* Safety model: "guards + confirmation". Every action exposes a `preview()`
* of the exact shell commands the operator sees BEFORE applying, and
* `ssh_password` is GUARDED so the operator can never lock themselves out by
* disabling password auth without an authorized SSH key in place.
*
* Each apply returns array{ok: bool, output: string, preview: string}.
* preview() returns the EXACT command apply() runs (single source via commandFor()),
* so the operator always confirms precisely what executes. Disabling SSH password
* auth is GUARDED so the operator can never lock themselves out.
*/
class HardeningService
{
/** Drop-in that holds every Clusev-managed sshd override. */
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/99-clusev.conf';
// 00- so it sorts + wins before distro drop-ins like 50-cloud-init.conf (sshd uses the first value).
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/00-clusev.conf';
public function __construct(private FleetService $fleet) {}
// 99zz- so Clusev's apt periodic setting wins (apt uses the LAST value across apt.conf.d/*).
private const APT_DROPIN = '/etc/apt/apt.conf.d/99zz-clusev';
/** Human title for an action key (shown in the modal heading). */
public function title(string $action): string
{
return match ($action) {
'ssh_root' => 'SSH-Root-Login deaktivieren',
'ssh_password' => 'SSH-Passwort-Login deaktivieren',
'fail2ban' => 'fail2ban installieren',
'unattended' => 'Automatische Updates aktivieren',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/** Short description of what an action does (shown above the command preview). */
public function description(string $action): string
{
return match ($action) {
'ssh_root' => 'Schreibt PermitRootLogin no als Drop-in und lädt den SSH-Dienst neu. Direkter Root-Login über SSH wird unterbunden.',
'ssh_password' => 'Schreibt PasswordAuthentication no als Drop-in und lädt den SSH-Dienst neu. Anmeldung ist danach nur noch per SSH-Key möglich.',
'fail2ban' => 'Installiert fail2ban und startet den Dienst. Wiederholte fehlgeschlagene Logins werden automatisch gesperrt.',
'unattended' => 'Installiert unattended-upgrades und aktiviert automatische Sicherheitsupdates.',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
public function __construct(private FleetService $fleet, private FirewallService $firewall) {}
/**
* The exact shell command(s) an action will run shown to the operator
* before applying. Pure string-building, no remote calls.
*/
public function preview(Server $server, string $action): string
{
return match ($action) {
'ssh_root' => $this->sshDropInPreview('PermitRootLogin no'),
'ssh_password' => $this->sshDropInPreview('PasswordAuthentication no'),
'fail2ban' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban'."\n"
.'systemctl enable --now fail2ban',
'unattended' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'."\n"
.'systemctl enable --now unattended-upgrades'."\n"
.'dpkg-reconfigure -f noninteractive unattended-upgrades',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/**
* Apply an action over SSH (as root). Dispatcher over the action keys.
* Live state of every hardening item, read PRIVILEGED in one shot. SSH uses
* `sshd -T` (the EFFECTIVE config honours Include order + Match blocks);
* UFW uses `ufw status`; unattended uses the apt periodic config.
*
* @return array{ok: bool, output: string, preview: string}
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}>
*/
public function apply(Server $server, string $action): array
public function state(Server $server): array
{
$cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; '
.'echo "fail2ban $(dpkg -l fail2ban 2>/dev/null | grep -c "^ii") $(systemctl is-active fail2ban 2>/dev/null)"; '
.'ufw_inst=$(dpkg -l ufw 2>/dev/null | grep -c "^ii"); ufw_act=inactive; '
.'[ "$ufw_inst" = "1" ] && ufw status 2>/dev/null | grep -qi "Status: active" && ufw_act=active; '
.'echo "ufw $ufw_inst $ufw_act"; '
.'ui=$(dpkg -l unattended-upgrades 2>/dev/null | grep -c "^ii"); ua=inactive; '
// apt-config dump = the EFFECTIVE periodic value across all apt.conf.d files (last wins).
.'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\' && ua=active; '
.'echo "unattended-upgrades $ui $ua"';
$res = $this->fleet->runPrivileged($server, $cmd);
if (! $res['ok']) {
throw new \RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
}
return $this->parseState($res['output']);
}
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}> */
private function parseState(string $out): array
{
$root = 'default';
$pwauth = 'default';
$pkg = [];
foreach (preg_split('/\R/', $out) as $line) {
$line = trim($line);
if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) {
$root = strtolower($m[1]);
} elseif (preg_match('/^passwordauthentication\s+(\S+)/i', $line, $m)) {
$pwauth = strtolower($m[1]);
} else {
$p = preg_split('/\s+/', $line);
if (count($p) === 3 && in_array($p[0], ['fail2ban', 'ufw', 'unattended-upgrades'], true)) {
$pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active'];
}
}
}
$f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false];
$ufw = $pkg['ufw'] ?? ['installed' => false, 'active' => false];
$upg = $pkg['unattended-upgrades'] ?? ['installed' => false, 'active' => false];
$detail = fn (string $name, array $st): string => $name.' · '
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
// A package feature is only effectively ON when the package is installed AND active —
// stale config (e.g. apt periodic = 1 after removal) must not read as secure.
$on = fn (array $st): bool => $st['installed'] && $st['active'];
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
return [
['key' => 'ssh_root', 'label' => 'SSH-Root-Login', 'detail' => 'PermitRootLogin '.$root,
'featureOn' => $root !== 'no', 'secure' => $root === 'no'],
['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication '.$pwauth,
'featureOn' => $pwauth !== 'no', 'secure' => $pwauth === 'no'],
['key' => 'fail2ban', 'label' => 'fail2ban', 'detail' => $detail('fail2ban.service', $f2b),
'featureOn' => $on($f2b), 'secure' => $on($f2b)],
['key' => 'firewall', 'label' => 'Firewall (UFW)', 'detail' => $detail('ufw', $ufw),
'featureOn' => $on($ufw), 'secure' => $on($ufw)],
['key' => 'unattended', 'label' => 'Automatische Updates', 'detail' => $detail('unattended-upgrades', $upg),
'featureOn' => $on($upg), 'secure' => $on($upg)],
];
}
/** Modal title, reflecting the toggle direction. */
public function title(string $action, bool $enable): string
{
return match ($action) {
'ssh_root' => $this->applySshRoot($server),
'ssh_password' => $this->applySshPassword($server),
'fail2ban' => $this->applyFail2ban($server),
'unattended' => $this->applyUnattended($server),
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
'firewall' => $enable ? 'Firewall (UFW) aktivieren' : 'Firewall (UFW) deaktivieren',
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/** Disable direct SSH root login via a drop-in, then reload sshd. */
private function applySshRoot(Server $server): array
public function description(string $action, bool $enable): string
{
$preview = $this->preview($server, 'ssh_root');
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PermitRootLogin no'));
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
return match ($action) {
'ssh_root' => $enable
? 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.'
: 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.',
'ssh_password' => $enable
? 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).'
: 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
'fail2ban' => $enable
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
: 'Stoppt fail2ban und deaktiviert den Autostart.',
'firewall' => $enable
? 'Installiert UFW (falls nötig), gibt SSH + 80/443 frei und aktiviert die Firewall.'
: 'Deaktiviert die UFW-Firewall (ufw disable).',
'unattended' => $enable
? 'Installiert unattended-upgrades (falls nötig) und aktiviert die automatische Update-Periodik + Timer.'
: 'Schaltet die automatische Update-Periodik ab.',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/**
* GUARD: refuse to disable password auth unless >=1 authorized SSH key is
* present otherwise the operator could be locked out (Aussperrgefahr).
*/
private function applySshPassword(Server $server): array
/** The EXACT root command(s) the toggle will run — identical to what apply() executes. */
public function preview(Server $server, string $action, bool $enable): string
{
$preview = $this->preview($server, 'ssh_password');
// GUARD 1: if Clusev itself logs in with a PASSWORD, disabling password
// auth would cut Clusev's own access (its credential is not a key) — refuse.
if ($server->credential?->auth_type === 'password') {
return [
'ok' => false,
'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.',
'preview' => $preview,
];
if ($action === 'firewall') {
return $enable ? $this->firewall->enablePreview($server) : 'ufw disable';
}
// GUARD 2: there must be at least one authorized key on the box.
if (! $this->hasAuthorizedKey($server)) {
return [
'ok' => false,
'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',
'preview' => $preview,
];
return $this->commandFor($action, $enable);
}
/** @return array{ok: bool, output: string, preview: string} */
public function apply(Server $server, string $action, bool $enable): array
{
$preview = $this->preview($server, $action, $enable);
if ($action === 'firewall') {
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PasswordAuthentication no'));
// Lock-out guard: never disable password auth without a usable key path.
if ($action === 'ssh_password' && ! $enable) {
if ($server->credential?->auth_type === 'password') {
return ['ok' => false, 'preview' => $preview, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.'];
}
if (! $this->hasAuthorizedKey($server)) {
return ['ok' => false, 'preview' => $preview, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).'];
}
}
// apt operations need a long timeout; the 12s default would abort the install.
$timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60;
$res = $this->fleet->runPrivileged($server, $this->commandFor($action, $enable), $timeout);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Install + enable fail2ban. */
private function applyFail2ban(Server $server): array
/** Single source of truth for the shell command of a (non-firewall) toggle. */
private function commandFor(string $action, bool $enable): string
{
$preview = $this->preview($server, 'fail2ban');
$cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban && systemctl enable --now fail2ban';
// apt download+install can take a minute — long timeout (12s default kills it).
$res = $this->fleet->runPrivileged($server, $cmd, 600);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
return match ($action) {
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
'fail2ban' => $enable
? '(dpkg -l fail2ban 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban) && systemctl enable --now fail2ban'
: 'systemctl disable --now fail2ban',
'unattended' => $enable
? '(dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades)'
.' && printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "1";\' \'APT::Periodic::Unattended-Upgrade "1";\' > '.self::APT_DROPIN
.' && systemctl enable --now unattended-upgrades apt-daily-upgrade.timer apt-daily.timer'
: 'printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "0";\' \'APT::Periodic::Unattended-Upgrade "0";\' > '.self::APT_DROPIN
.' && (systemctl disable --now unattended-upgrades 2>/dev/null || true)',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/** Install + enable unattended-upgrades. */
private function applyUnattended(Server $server): array
{
$preview = $this->preview($server, 'unattended');
$cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'
.' && systemctl enable --now unattended-upgrades'
.' && dpkg-reconfigure -f noninteractive unattended-upgrades';
// apt download+install can take a minute — long timeout (12s default kills it).
$res = $this->fleet->runPrivileged($server, $cmd, 600);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/**
* True if the SSH user has at least one authorized key. Tries the structured
* FleetService::sshKeys() first, then falls back to a raw grep so the guard
* never wrongly reports "no key" on a parsing hiccup.
*/
/** True if the SSH user has at least one authorized key. */
private function hasAuthorizedKey(Server $server): bool
{
try {
@ -160,26 +200,14 @@ class HardeningService
// fall through to the raw check
}
$res = $this->fleet->runPlain(
$server,
'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0'
);
$res = $this->fleet->runPlain($server, 'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0');
return $res['ok'] && (int) trim($res['output']) > 0;
}
/** Human-readable preview of writing a directive into the Clusev drop-in. */
private function sshDropInPreview(string $directive): string
{
return 'mkdir -p /etc/ssh/sshd_config.d'."\n"
."echo '{$directive}' > ".self::SSHD_DROPIN."\n"
.'systemctl reload ssh || systemctl reload sshd';
}
/**
* The shell run to install one sshd directive: ensure the drop-in dir, write
* the directive, then reload whichever ssh unit exists. Appends if the
* drop-in already holds other Clusev directives (keeps both lines).
* Install one sshd directive into the Clusev drop-in (replacing any prior value
* of the same key), then reload whichever ssh unit exists.
*/
private function sshDropInScript(string $directive): string
{
@ -187,7 +215,6 @@ class HardeningService
return 'mkdir -p /etc/ssh/sshd_config.d'
.' && touch '.self::SSHD_DROPIN
// drop any prior copy of this directive, then append the new value
.' && grep -viE "^[[:space:]]*'.$key.'[[:space:]]" '.self::SSHD_DROPIN.' > '.self::SSHD_DROPIN.'.tmp 2>/dev/null; '
.'mv '.self::SSHD_DROPIN.'.tmp '.self::SSHD_DROPIN.' 2>/dev/null; '
.'echo "'.$directive.'" >> '.self::SSHD_DROPIN

View File

@ -165,41 +165,24 @@
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
<div class="divide-y divide-line">
@php
// Map each checklist label to its hardening-action key.
$hardenKey = function (string $label): ?string {
return match (true) {
str_contains($label, 'Root-Login') => 'ssh_root',
str_contains($label, 'Passwort-Login') => 'ssh_password',
str_contains($label, 'fail2ban') => 'fail2ban',
str_contains($label, 'Firewall') => 'firewall',
str_contains($label, 'Updates') => 'unattended',
default => null,
};
};
@endphp
@foreach ($hardening as $check)
@php $action = $hardenKey($check['label']); @endphp
<div @class([
'flex items-center gap-3 border-l-2 px-4 py-3 sm:px-5',
'border-online' => $check['status'] === 'online',
'border-warning' => $check['status'] === 'warning',
'border-offline' => $check['status'] === 'offline',
'border-online' => $check['secure'],
'border-warning' => ! $check['secure'],
])>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-ink">{{ $check['label'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
</div>
@if ($check['status'] !== 'online' && $action)
<x-btn variant="accent" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $action }}' } })">
Anwenden
<div class="flex shrink-0 items-center gap-2">
<x-status-pill :status="$check['secure'] ? 'online' : 'warning'">{{ $check['secure'] ? 'OK' : 'Offen' }}</x-status-pill>
{{-- Bidirectional toggle: Aktivieren when the feature is off, Deaktivieren when on. --}}
<x-btn :variant="$check['secure'] ? 'ghost' : 'accent'" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
</x-btn>
@else
<x-status-pill :status="$check['status']" class="shrink-0">
{{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }}
</x-status-pill>
@endif
</div>
</div>
@endforeach
</div>