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 */ 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' => __('backend.firewalld_read_only')]; } $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' => __('backend.invalid_port')]; } if ($from !== null && ! $this->validIpOrCidr($from)) { return ['ok' => false, 'output' => __('backend.invalid_source')]; } if ($port === null && $from === null) { return ['ok' => false, 'output' => __('backend.port_or_source_required')]; } // 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 ? __('backend.deny_without_port_blocks_ssh', ['action' => $action]) : __('backend.deny_on_ssh_port_blocks_ssh', ['action' => $action, 'port' => $sshPort])]; } $tool = FirewallTool::for($os); if ($tool->isFirewalld() && $port === null) { return ['ok' => false, 'output' => __('backend.firewalld_port_required')]; } $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' => __('backend.firewalld_read_only')]; } $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 ` 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' => __('backend.firewall_status_read_failed')]; } $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' => __('backend.rule_gone')]; } // 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 "/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 __('backend.ssh_rule_guard', ['port' => $port]); } // 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 $s * @param array $base * @return array */ 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 $s * @param array $base * @return array */ 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; } }