308 lines
16 KiB
PHP
308 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Server;
|
|
use App\Support\Os\FirewallTool;
|
|
use App\Support\Os\OsDetector;
|
|
use App\Support\Os\OsProfile;
|
|
use App\Support\Os\PackageManager;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Applies the dashboard server-hardening toggles over SSH (as root), OS-aware
|
|
* across apt/dnf/zypper + ufw/firewalld (resolved per host via OsDetector). Every
|
|
* item is BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying
|
|
* feature; package items (fail2ban, auto-updates, firewall) install on demand.
|
|
*
|
|
* apply() runs the exact command built by commandFor() (the single source of truth);
|
|
* title()/description() supply the German confirm-modal copy for each direction.
|
|
* Each state() row carries `supported`/`reason`, so a feature unavailable on the
|
|
* host's OS degrades with a German explanation instead of failing opaquely.
|
|
* Disabling SSH password auth is GUARDED so the operator can never lock themselves out.
|
|
*/
|
|
class HardeningService
|
|
{
|
|
// 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';
|
|
|
|
// 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';
|
|
|
|
public function __construct(
|
|
private FleetService $fleet,
|
|
private FirewallService $firewall,
|
|
private OsDetector $detector,
|
|
) {}
|
|
|
|
/**
|
|
* Live state of every hardening item, read PRIVILEGED in one shot. SSH uses
|
|
* `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks); the
|
|
* package/firewall/auto-update probes are built per-OS (dpkg vs rpm, ufw vs
|
|
* firewalld, apt periodic vs dnf-automatic).
|
|
*
|
|
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string, neutral:bool}>
|
|
*/
|
|
public function state(Server $server): array
|
|
{
|
|
$os = $this->detector->detect($server);
|
|
$pm = $os->managesPackages() ? PackageManager::for($os) : null;
|
|
$fw = FirewallTool::for($os);
|
|
|
|
// Shell CONDITIONS (exit 0 = true); `false` where the host lacks the tool.
|
|
// "Installed" is read CONSISTENTLY with Fail2banService/FirewallTool: the package
|
|
// manager (dpkg ^ii / rpm), OR the client binary on PATH, OR the service already
|
|
// running — ANY one proves the component is present. So a fail2ban/ufw installed
|
|
// from source or left in dpkg `rc` (config-only) state no longer reads as "nicht
|
|
// installiert" while its own live panel reports it active. A component can never be
|
|
// active without being installed, so this only removes FALSE negatives.
|
|
$anyOf = fn (array $tests): string => '('.implode(' || ', array_filter($tests)).')';
|
|
|
|
$f2bActive = 'systemctl is-active --quiet fail2ban';
|
|
$f2bInst = $anyOf([
|
|
$pm?->isInstalledTest('fail2ban'),
|
|
'command -v fail2ban-client >/dev/null 2>&1',
|
|
$f2bActive,
|
|
]);
|
|
$fwInst = $anyOf([
|
|
$pm?->isInstalledTest($os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw'),
|
|
$fw->installedTest(),
|
|
$fw->activeTest(),
|
|
]);
|
|
|
|
$auInst = $pm ? $pm->isInstalledTest($os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades') : 'false';
|
|
$auActive = match ($os->packageManager) {
|
|
'apt' => 'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\'',
|
|
'dnf' => 'systemctl is-enabled --quiet dnf-automatic.timer',
|
|
default => 'false',
|
|
};
|
|
|
|
// NOTE the space after each `$(` that precedes a `(`: `$( (` is a command
|
|
// substitution of a subshell, whereas `$((` would be ARITHMETIC expansion.
|
|
$cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; '
|
|
.'echo "fail2ban $( '.$f2bInst.' && echo 1 || echo 0) $('.$f2bActive.' && echo active || echo inactive)"; '
|
|
.'echo "firewall $( '.$fwInst.' && echo 1 || echo 0) $('.$fw->activeTest().' && echo active || echo inactive)"; '
|
|
.'echo "autoupdate $('.$auInst.' && echo 1 || echo 0) $('.$auActive.' && echo active || echo inactive)"';
|
|
|
|
$res = $this->fleet->runPrivileged($server, $cmd);
|
|
if (! $res['ok']) {
|
|
throw new RuntimeException(__('backend.hardening_status_read_failed'));
|
|
}
|
|
|
|
return $this->parseState($res['output'], $os);
|
|
}
|
|
|
|
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string}> */
|
|
private function parseState(string $out, OsProfile $os): 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', 'firewall', 'autoupdate'], true)) {
|
|
$pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active'];
|
|
}
|
|
}
|
|
}
|
|
|
|
$f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false];
|
|
$fw = $pkg['firewall'] ?? ['installed' => false, 'active' => false];
|
|
$upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false];
|
|
|
|
$detail = fn (string $name, array $st): string => $name.' · '
|
|
.(! $st['installed'] ? __('backend.item_not_installed') : ($st['active'] ? __('common.active') : __('common.inactive')));
|
|
// A package feature is only effectively ON when 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'];
|
|
|
|
$row = fn (string $key, string $label, string $detail, bool $featureOn, bool $secure, ?string $reason, bool $neutral = false): array => [
|
|
'key' => $key, 'label' => $label, 'detail' => $detail,
|
|
'featureOn' => $featureOn, 'secure' => $secure,
|
|
'supported' => $reason === null, 'reason' => $reason, 'neutral' => $neutral,
|
|
];
|
|
|
|
$fwName = $os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw';
|
|
$auName = $os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades';
|
|
|
|
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
|
|
return [
|
|
$row('ssh_root', __('backend.label_ssh_root'), 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
|
|
$row('ssh_password', __('backend.label_ssh_password'), 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
|
|
$row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')),
|
|
$row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')),
|
|
// Auto-updates are an operator PREFERENCE, not a security gate: never an
|
|
// insecure verdict (secure = true), and flagged `neutral` so the UI shows a
|
|
// muted aktiv/inaktiv state instead of SICHER/OFFEN. featureOn still drives
|
|
// the Aktivieren/Deaktivieren toggle from the real host state.
|
|
$row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), true, $os->supports('auto_updates'), true),
|
|
];
|
|
}
|
|
|
|
/** Modal title, reflecting the toggle direction. */
|
|
public function title(string $action, bool $enable): string
|
|
{
|
|
return match ($action) {
|
|
'ssh_root' => $enable ? __('backend.title_ssh_root_enable') : __('backend.title_ssh_root_disable'),
|
|
'ssh_password' => $enable ? __('backend.title_ssh_password_enable') : __('backend.title_ssh_password_disable'),
|
|
'fail2ban' => $enable ? __('backend.title_fail2ban_enable') : __('backend.title_fail2ban_disable'),
|
|
'firewall' => $enable ? __('backend.title_firewall_enable') : __('backend.title_firewall_disable'),
|
|
'unattended' => $enable ? __('backend.title_unattended_enable') : __('backend.title_unattended_disable'),
|
|
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
|
};
|
|
}
|
|
|
|
public function description(string $action, bool $enable): string
|
|
{
|
|
return match ($action) {
|
|
'ssh_root' => $enable
|
|
? __('backend.desc_ssh_root_enable')
|
|
: __('backend.desc_ssh_root_disable'),
|
|
'ssh_password' => $enable
|
|
? __('backend.desc_ssh_password_enable')
|
|
: __('backend.desc_ssh_password_disable'),
|
|
'fail2ban' => $enable
|
|
? __('backend.desc_fail2ban_enable')
|
|
: __('backend.desc_fail2ban_disable'),
|
|
'firewall' => $enable
|
|
? __('backend.desc_firewall_enable')
|
|
: __('backend.desc_firewall_disable'),
|
|
'unattended' => $enable
|
|
? __('backend.desc_unattended_enable')
|
|
: __('backend.desc_unattended_disable'),
|
|
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
|
};
|
|
}
|
|
|
|
/** @return array{ok: bool, output: string} */
|
|
public function apply(Server $server, string $action, bool $enable): array
|
|
{
|
|
$os = $this->detector->detect($server);
|
|
|
|
// Gracefully refuse a feature the host's OS does not support.
|
|
$feature = match ($action) {
|
|
'ssh_root', 'ssh_password' => 'ssh',
|
|
'fail2ban' => 'fail2ban',
|
|
'firewall' => 'firewall',
|
|
'unattended' => 'auto_updates',
|
|
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
|
};
|
|
if ($reason = $os->supports($feature)) {
|
|
return ['ok' => false, 'output' => $reason];
|
|
}
|
|
|
|
if ($action === 'firewall') {
|
|
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
|
|
|
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') {
|
|
return ['ok' => false, 'output' => __('backend.ssh_password_self_lockout')];
|
|
}
|
|
if (! $this->hasAuthorizedKey($server)) {
|
|
return ['ok' => false, 'output' => __('backend.ssh_password_no_key')];
|
|
}
|
|
}
|
|
|
|
// package 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($os, $action, $enable), $timeout);
|
|
|
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
}
|
|
|
|
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
|
private function commandFor(OsProfile $os, string $action, bool $enable): string
|
|
{
|
|
$pm = PackageManager::for($os);
|
|
|
|
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')
|
|
.' && 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])),
|
|
};
|
|
}
|
|
|
|
/** Auto-update mechanism per family: apt periodic + timers, or dnf-automatic.timer. */
|
|
private function autoUpdateScript(OsProfile $os, PackageManager $pm, bool $enable): string
|
|
{
|
|
if ($os->packageManager === 'dnf') {
|
|
return $enable
|
|
? $pm->ensureInstalledScript('dnf-automatic')
|
|
.' && sed -i \'s/^apply_updates.*/apply_updates = yes/\' /etc/dnf/automatic.conf'
|
|
.' && systemctl enable --now dnf-automatic.timer'
|
|
: 'systemctl disable --now dnf-automatic.timer';
|
|
}
|
|
|
|
// apt (default): the periodic drop-in + service/timers.
|
|
return $enable
|
|
? $pm->ensureInstalledScript('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)';
|
|
}
|
|
|
|
/** True if the SSH user has at least one authorized key. */
|
|
private function hasAuthorizedKey(Server $server): bool
|
|
{
|
|
try {
|
|
if (count($this->fleet->sshKeys($server)) > 0) {
|
|
return true;
|
|
}
|
|
} catch (Throwable) {
|
|
// fall through to the raw check
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$key = strtok($directive, ' ');
|
|
|
|
return 'mkdir -p /etc/ssh/sshd_config.d'
|
|
.' && touch '.self::SSHD_DROPIN
|
|
.' && 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
|
|
.' && (systemctl reload ssh || systemctl reload sshd)';
|
|
}
|
|
}
|