clusev/app/Services/FirewallService.php

435 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services;
use App\Models\Server;
use App\Support\Os\FirewallTool;
use App\Support\Os\OsDetector;
/**
* Host firewall control over SSH (as root), abstracted across ufw and firewalld
* (resolved per OS via OsDetector/FirewallTool).
*
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd Port
* (detected from sshd_config, default 22) plus 80/443 BEFORE turning the firewall
* on — so enabling can never sever the operator's own SSH session.
*
* Both mutating methods return array{ok: bool, output: string}.
*/
class FirewallService
{
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
/**
* GUARD: allow the real sshd Port + 80/443 first, THEN enable the firewall.
* Never enable a firewall that would drop the current SSH session. Installs the
* firewall package first when supported and missing.
*
* @return array{ok: bool, output: string}
*/
public function enable(Server $server): array
{
$os = $this->detector->detect($server);
if ($reason = $os->supports('firewall')) {
return ['ok' => false, 'output' => $reason];
}
// long timeout: may install the firewall package first.
$script = FirewallTool::for($os)->enableScript($this->sshPort($server));
$res = $this->fleet->runPrivileged($server, $script, 600);
return ['ok' => $res['ok'], 'output' => $res['output']];
}
/** Turn the firewall off (clean stop, never a panic/drop-all). @return array{ok: bool, output: string} */
public function disable(Server $server): array
{
$os = $this->detector->detect($server);
$res = $this->fleet->runPrivileged($server, FirewallTool::for($os)->disableScript());
return ['ok' => $res['ok'], 'output' => $res['output']];
}
/**
* Read the full firewall state in one privileged round-trip: installed/active,
* default policies (ufw) or zone+target (firewalld), and the rule list. Returns
* `supported=false` + a German reason on an OS without a supported firewall.
*
* @return array<string, mixed>
*/
public function status(Server $server): array
{
$os = $this->detector->detect($server);
$reason = $os->supports('firewall');
$tool = FirewallTool::for($os);
$base = [
'supported' => $reason === null,
'reason' => $reason,
'tool' => $tool->isFirewalld() ? 'firewalld' : 'ufw',
'installed' => false,
'active' => false,
'defaults' => [],
'rules' => [],
];
if ($reason !== null) {
return $base;
}
$res = $this->fleet->runPrivileged($server, $tool->statusScript());
// The sentinel proves the privileged read actually RAN (sudo/SSH ok). A sudo
// failure emits error text (non-empty) but no sentinel — so we must not parse
// that as an empty firewall (which would make a delete falsely "succeed").
if (! str_contains($res['output'], 'CLUSEV_FWREAD_OK')) {
return $base + ['readError' => true];
}
$s = $this->sections($res['output']);
// ufw: a successful read ALWAYS prints a "Status:" line. If the tool is installed
// but that line is missing, the privileged ufw call itself failed (malformed
// config, etc.) — treat as a read error rather than an empty (no-rules) firewall,
// so a later delete can't falsely report "already gone".
if (! $tool->isFirewalld()
&& trim($s['inst'] ?? '') === 'yes'
&& ! preg_match('/Status:/i', $s['verbose'] ?? '')) {
return $base + ['readError' => true];
}
return $tool->isFirewalld() ? $this->parseFirewalld($s, $base) : $this->parseUfw($s, $base);
}
/**
* Add a firewall rule from validated parts. ufw: action/proto/port/from;
* firewalld: opens a port in the default zone.
*
* @return array{ok: bool, output: string}
*/
public function addRule(Server $server, string $action, string $proto, ?int $port, ?string $from): array
{
$os = $this->detector->detect($server);
if ($reason = $os->supports('firewall')) {
return ['ok' => false, 'output' => $reason];
}
// Rule mutation is ufw-only this release; firewalld is read-only.
if ($os->firewallTool === 'firewalld') {
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
}
$action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow';
$proto = in_array($proto, ['tcp', 'udp', 'any'], true) ? $proto : 'tcp';
$from = ($from !== null && trim($from) !== '') ? trim($from) : null;
if ($port !== null && ($port < 1 || $port > 65535)) {
return ['ok' => false, 'output' => 'Ungültiger Port (165535).'];
}
if ($from !== null && ! $this->validIpOrCidr($from)) {
return ['ok' => false, 'output' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.'];
}
if ($port === null && $from === null) {
return ['ok' => false, 'output' => 'Port oder Quelle ist erforderlich.'];
}
// LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever
// every future management session. Over TCP/any, refuse it when it targets the
// SSH port OR has NO port (a portless deny blocks ALL inbound, incl. SSH) —
// regardless of source, since a source may include Clusev's own address. Use
// fail2ban to block individual IPs from SSH instead.
$sshPort = $this->sshPort($server);
if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true)
&& ($port === null || $port === $sshPort)) {
return ['ok' => false, 'output' => ($port === null
? 'Eine '.$action.'-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. '
: 'Eine '.$action.'-Regel auf den SSH-Port ('.$sshPort.') ist nicht erlaubt (Aussperrgefahr). ')
.'Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.'];
}
$tool = FirewallTool::for($os);
if ($tool->isFirewalld() && $port === null) {
return ['ok' => false, 'output' => 'firewalld: Bitte einen Port angeben.'];
}
$res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60);
return ['ok' => $res['ok'], 'output' => $res['output']];
}
/**
* Delete a rule. ufw: $spec is the rule's raw line content — we re-read NOW and
* delete by the CURRENT number that matches, side-stepping the renumber race.
* firewalld: $spec is "port:80/tcp" or "service:ssh".
*
* @return array{ok: bool, output: string}
*/
public function deleteRule(Server $server, string $spec): array
{
$os = $this->detector->detect($server);
if ($reason = $os->supports('firewall')) {
return ['ok' => false, 'output' => $reason];
}
// Rule mutation is ufw-only this release; firewalld is read-only.
if ($os->firewallTool === 'firewalld') {
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
}
$tool = FirewallTool::for($os);
$sshPort = $this->sshPort($server);
// ufw: $spec is a public-method argument, so it is UNTRUSTED. Whitelist it
// against the specs ufw itself reported (which contain no shell metacharacters)
// — a forged spec like "allow 80/tcp; rm -rf /" can never match, so the raw
// concatenation into `ufw delete <spec>` can never inject. Delete-by-spec is
// also race-free (no rule number to shift).
$status = $this->status($server);
if (! empty($status['readError'])) {
return ['ok' => false, 'output' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.'];
}
$known = array_column($status['rules'], 'spec');
if (! in_array($spec, $known, true)) {
// The rule vanished between confirm and apply — report not_found so the
// caller does NOT audit a deletion that never happened.
return ['ok' => true, 'notFound' => true, 'output' => 'Regel existiert nicht mehr.'];
}
// LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open.
if ($this->ufwSpecOpensSsh($spec, $sshPort)) {
return ['ok' => false, 'output' => $this->sshGuardMessage($sshPort)];
}
$res = $this->fleet->runPrivileged($server, $tool->deleteUfwScript($spec));
return ['ok' => $res['ok'], 'output' => $res['output']];
}
/**
* True when a ufw allow/limit spec keeps the SSH port (or an SSH app profile)
* open. The port is matched ONLY in the destination position — the simple form
* ("allow 22/tcp") or after "to any port" — never inside a source IP like
* 192.0.2.22, which must stay deletable.
*/
private function ufwSpecOpensSsh(string $spec, int $sshPort): bool
{
$spec = trim($spec);
if (! preg_match('/^(allow|limit)\b/i', $spec)) {
return false;
}
if (stripos($spec, 'OpenSSH') !== false || preg_match('/(^|\s)SSH(\s|$)/i', $spec)) {
return true;
}
// SSH is TCP — ignore explicitly UDP-only rules (simple "<port>/udp" or "proto udp").
if (preg_match('/^(allow|limit)\s+\d+(?::\d+)?\/udp\b/i', $spec) || preg_match('/proto\s+udp/i', $spec)) {
return false;
}
// simple form: "allow 22", "allow 22/tcp", or a range "allow 20:30/tcp".
if (preg_match('/^(allow|limit)\s+(\d+)(?::(\d+))?(\/tcp)?(\s|$)/i', $spec, $m)
&& $this->portInRange($sshPort, (int) $m[2], ($m[3] ?? '') !== '' ? (int) $m[3] : null)) {
return true;
}
// destination-port form: "... port 22 ..." or a range "... port 20:30 ...".
if (preg_match('/\bport\s+(\d+)(?::(\d+))?/i', $spec, $m)
&& $this->portInRange($sshPort, (int) $m[1], ($m[2] ?? '') !== '' ? (int) $m[2] : null)) {
return true;
}
return false;
}
/** True when $needle equals the single port, or falls inside the [low, high] range. */
private function portInRange(int $needle, int $low, ?int $high): bool
{
return $high === null ? $needle === $low : ($needle >= $low && $needle <= $high);
}
/** German refusal shown when a delete would sever Clusev's own SSH access. */
private function sshGuardMessage(int $port): string
{
return "Diese Regel hält den SSH-Zugang (Port {$port}) offen und kann nicht entfernt werden — "
.'sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.';
}
// NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT
// exposed — it is the highest-lockout-risk firewall operation (a restrictive
// default with a missing/source-mismatched SSH rule severs Clusev). Defaults are
// shown READ-ONLY in the panel; activating the firewall via the hardening toggle
// applies safe defaults while guaranteeing ssh/80/443 stay open.
/**
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
* back to 22 when nothing is set explicitly.
*/
private function sshPort(Server $server): int
{
$res = $this->fleet->runPrivileged(
$server,
'grep -rhiE "^[[:space:]]*Port[[:space:]]+[0-9]+" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null '
.'| awk \'{print $2}\' | head -1'
);
$port = (int) trim($res['output']);
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
}
/**
* @param array<string, string> $s
* @param array<string, mixed> $base
* @return array<string, mixed>
*/
private function parseUfw(array $s, array $base): array
{
// Defaults from /etc/default/ufw — correct whether ufw is active or inactive
// (verbose only prints the Default: line while active).
$map = ['DROP' => 'deny', 'REJECT' => 'reject', 'ACCEPT' => 'allow'];
$defaults = ['incoming' => null, 'outgoing' => null, 'routed' => null];
if (preg_match('/DEFAULT_INPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mi)) {
$defaults['incoming'] = $map[strtoupper($mi[1])] ?? strtolower($mi[1]);
}
if (preg_match('/DEFAULT_OUTPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mo)) {
$defaults['outgoing'] = $map[strtoupper($mo[1])] ?? strtolower($mo[1]);
}
// Rules from `ufw show added` (add-syntax). The spec IS the delete argument.
$rules = [];
foreach (preg_split('/\R/', $s['added'] ?? '') ?: [] as $line) {
if (! preg_match('/^ufw\s+(.+\S)\s*$/', trim($line), $m)) {
continue;
}
$spec = trim(preg_replace('/\s+/', ' ', $m[1]) ?? '');
// Routed/forwarding rules look like "route allow in on eth0 ..." — the action
// is the SECOND token; keep the whole spec (delete needs "route allow …").
$first = (string) strtok($spec, ' ');
$routed = strcasecmp($first, 'route') === 0;
$afterFirst = trim((string) substr($spec, strlen($first)));
$action = strtoupper($routed ? (string) strtok($afterFirst, ' ') : $first);
if (! in_array($action, ['ALLOW', 'DENY', 'REJECT', 'LIMIT'], true)) {
continue;
}
$label = $routed ? $spec : (trim((string) substr($spec, strlen($first))) ?: $spec);
$rules[] = ['raw' => $spec, 'label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $spec];
}
$installed = trim($s['inst'] ?? '') === 'yes';
return array_merge($base, [
'installed' => $installed,
'active' => (bool) preg_match('/Status:\s*active/i', $s['verbose'] ?? ''),
// ufw stores rules in files, so they are manageable even while inactive.
'manageable' => $installed,
'readOnly' => false,
'defaults' => $defaults,
'rules' => $rules,
]);
}
/**
* @param array<string, string> $s
* @param array<string, mixed> $base
* @return array<string, mixed>
*/
private function parseFirewalld(array $s, array $base): array
{
// Each line is "zone|value"; the zone is shown when it is not the default zone,
// so a port enforced in a non-default zone is attributed correctly and identical
// rules in different zones stay distinct. firewalld is read-only, so `spec` is
// cosmetic here (never used for deletion).
$default = trim($s['zone'] ?? '');
$rules = [];
$seen = [];
$add = function (string $line, string $kind) use (&$rules, &$seen, $default): void {
$line = trim($line);
if ($line === '') {
return;
}
[$zone, $value] = array_pad(explode('|', $line, 2), 2, '');
if ($value === '') {
[$zone, $value] = [$default, $zone];
}
$key = $kind.'|'.$zone.'|'.$value;
if (isset($seen[$key])) {
return;
}
$seen[$key] = true;
$suffix = ($zone !== '' && $zone !== $default) ? ' · '.$zone : '';
$action = 'ALLOW';
$label = $value.$suffix;
if ($kind === 'service') {
$label = $value.' (Dienst)'.$suffix;
} elseif ($kind === 'rich') {
$action = stripos($value, 'accept') !== false ? 'ALLOW'
: (stripos($value, 'reject') !== false ? 'REJECT' : (stripos($value, 'drop') !== false ? 'DENY' : 'ALLOW'));
}
$rules[] = ['label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $kind.':'.$value];
};
foreach (preg_split('/\R/', $s['ports'] ?? '') ?: [] as $l) {
$add($l, 'port');
}
foreach (preg_split('/\R/', $s['services'] ?? '') ?: [] as $l) {
$add($l, 'service');
}
foreach (preg_split('/\R/', $s['rich'] ?? '') ?: [] as $l) {
$add($l, 'rich');
}
$installed = trim($s['inst'] ?? '') === 'yes';
$active = trim($s['active'] ?? '') === 'active';
return array_merge($base, [
'installed' => $installed,
'active' => $active,
// firewalld is READ-ONLY this release: rules are displayed (runtime state)
// but not mutated from Clusev, so it is never "manageable" for add/delete.
'manageable' => false,
'readOnly' => true,
'defaults' => ['zone' => trim($s['zone'] ?? '')],
'rules' => $rules,
]);
}
/** Split a marker-sectioned (===CLUSEV:name===) output into [name => body]. */
private function sections(string $out): array
{
$sections = [];
$current = null;
$buffer = [];
foreach (preg_split('/\R/', $out) ?: [] as $line) {
if (str_starts_with($line, '===CLUSEV:')) {
if ($current !== null) {
$sections[$current] = implode("\n", $buffer);
}
$current = rtrim(substr($line, strlen('===CLUSEV:')), '=');
$buffer = [];
} elseif ($current !== null) {
$buffer[] = $line;
}
}
if ($current !== null) {
$sections[$current] = implode("\n", $buffer);
}
return $sections;
}
/** Accept a bare IP (v4/v6) or an IP/CIDR with a valid prefix length. */
private function validIpOrCidr(string $v): bool
{
if (filter_var($v, FILTER_VALIDATE_IP)) {
return true;
}
if (! str_contains($v, '/')) {
return false;
}
[$ip, $mask] = explode('/', $v, 2);
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
return false;
}
$max = str_contains($ip, ':') ? 128 : 32;
return (int) $mask >= 0 && (int) $mask <= $max;
}
}