diff --git a/app/Livewire/Modals/ConfirmAction.php b/app/Livewire/Modals/ConfirmAction.php index 4c617f0..0970abc 100644 --- a/app/Livewire/Modals/ConfirmAction.php +++ b/app/Livewire/Modals/ConfirmAction.php @@ -39,6 +39,8 @@ class ConfirmAction extends ModalComponent /** @var array */ public array $params = []; + /** Toast shown on confirm. Pass '' to defer the notification to the handler + * (e.g. when the real outcome is only known after a remote command runs). */ public string $notify = 'Aktion ausgeführt.'; public static function modalMaxWidth(): string @@ -63,7 +65,10 @@ class ConfirmAction extends ModalComponent $this->dispatch($this->event, ...$this->params); } - $this->dispatch('notify', message: $this->notify); + // Empty notify = the handler will report the real outcome itself. + if ($this->notify !== '') { + $this->dispatch('notify', message: $this->notify); + } $this->closeModal(); } diff --git a/app/Livewire/Modals/FirewallRule.php b/app/Livewire/Modals/FirewallRule.php new file mode 100644 index 0000000..91dd8eb --- /dev/null +++ b/app/Livewire/Modals/FirewallRule.php @@ -0,0 +1,108 @@ +serverId = $serverId; + $this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw'; + } + + public static function modalMaxWidth(): string + { + return 'lg'; + } + + public function save(FirewallService $firewall): void + { + $this->error = null; + + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + $port = trim($this->port) !== '' ? (int) $this->port : null; + + try { + $res = $firewall->addRule($server, $this->action, $this->proto, $port, $this->from); + } catch (Throwable $e) { + $this->error = $e->getMessage(); + + return; + } + + if (! $res['ok']) { + $this->error = $res['output'] !== '' ? $res['output'] : 'Regel konnte nicht hinzugefügt werden.'; + + return; + } + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'firewall.rule_add', + 'target' => $server->name.' · '.$this->summary($port), + 'ip' => request()->ip(), + ]); + + $this->dispatch('firewallChanged'); + $this->dispatch('notify', message: 'Firewall-Regel hinzugefügt.'); + $this->closeModal(); + } + + /** Short human summary of the rule for the audit log. */ + private function summary(?int $port): string + { + if ($this->tool === 'firewalld') { + return 'Port '.$port.'/'.($this->proto === 'udp' ? 'udp' : 'tcp').' geöffnet'; + } + + $parts = [$this->action]; + if ($port !== null) { + $parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : ''); + } + if (trim($this->from) !== '') { + $parts[] = 'von '.trim($this->from); + } + + return implode(' ', $parts); + } + + public function render() + { + return view('livewire.modals.firewall-rule'); + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index a70a480..c45d20b 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -4,10 +4,12 @@ namespace App\Livewire\Servers; use App\Models\AuditEvent; use App\Models\Server; +use App\Services\FirewallService; use App\Services\FleetService; use App\Services\HardeningService; use App\Support\Os\OsDetector; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; @@ -33,6 +35,9 @@ class Show extends Component public array $hardening = []; + /** Firewall state (installed/active/defaults/rules) for the Firewall-Regeln panel. */ + public array $firewall = []; + /** Detected OS tooling (family/package manager/firewall) for the identity panel. */ public array $os = []; @@ -102,6 +107,13 @@ class Show extends Component } catch (Throwable) { $this->hardening = []; } + + // Firewall rules/defaults (separate PRIVILEGED read; empty on failure). + try { + $this->firewall = app(FirewallService::class)->status($this->server); + } catch (Throwable) { + $this->firewall = []; + } $this->loadAvg = (float) ($snap['metrics']['load'] ?? 0); $this->connected = true; } catch (Throwable) { @@ -272,6 +284,86 @@ class Show extends Component $this->load($fleet); } + /** + * Delete a firewall rule (R5): opens the confirm modal, which writes the + * AuditEvent and re-dispatches `firewallRuleDelete` (handled below). + */ + public function confirmDeleteRule(int $index): void + { + $rule = $this->firewall['rules'][$index] ?? null; + if ($rule === null) { + return; + } + $spec = $rule['spec'] ?? ''; + $label = $rule['label'] ?? ($rule['raw'] ?? $spec); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => 'Firewall-Regel entfernen', + 'body' => "Die Regel „{$label}“ wird aus der Firewall von {$this->server->name} entfernt.", + 'confirmLabel' => 'Entfernen', + 'danger' => true, + 'icon' => 'trash', + // No premature audit/notify — the handler records the real outcome + // only after the remote command returns (audit reflects success). + 'event' => 'firewallRuleDelete', + 'params' => ['spec' => $spec, 'label' => $label], + 'notify' => '', + ], + ); + } + + /** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */ + #[On('firewallRuleDelete')] + public function deleteFirewallRule(string $spec, string $label, FirewallService $firewall): void + { + try { + $res = $firewall->deleteRule($this->server, $spec); + } catch (Throwable $e) { + $this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($e->getMessage(), 90)); + + return; + } + + // The rule was already gone — inform, refresh, but do NOT audit a non-event. + if (! empty($res['notFound'])) { + $this->dispatch('notify', message: 'Regel existiert nicht mehr.'); + $this->reloadFirewall($firewall); + + return; + } + + if (! $res['ok']) { + $this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90)); + + return; + } + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $this->server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'firewall.rule_delete', + 'target' => $label.' · '.$this->server->name, + 'ip' => request()->ip(), + ]); + + $this->dispatch('notify', message: 'Firewall-Regel entfernt.'); + $this->reloadFirewall($firewall); + } + + /** Re-read the firewall state after an add/delete/default change. */ + #[On('firewallChanged')] + public function reloadFirewall(FirewallService $firewall): void + { + try { + $this->firewall = $firewall->status($this->server); + } catch (Throwable) { + // keep the current state on failure + } + } + public function render() { return view('livewire.servers.show'); diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php index 8e0bc15..febd3c6 100644 --- a/app/Services/FirewallService.php +++ b/app/Services/FirewallService.php @@ -50,6 +50,211 @@ class FirewallService 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' => '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 (1–65535).']; + } + 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 ` 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 "/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. @@ -66,4 +271,164 @@ class FirewallService 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; + } } diff --git a/app/Support/Os/FirewallTool.php b/app/Support/Os/FirewallTool.php index 73cedcb..4a03a66 100644 --- a/app/Support/Os/FirewallTool.php +++ b/app/Support/Os/FirewallTool.php @@ -12,6 +12,8 @@ namespace App\Support\Os; */ final class FirewallTool { + private const MARK = '===CLUSEV:'; + /** @param 'ufw'|'firewalld' $tool */ public function __construct( private string $tool, @@ -84,4 +86,77 @@ final class FirewallTool ? 'systemctl is-active --quiet firewalld' : 'ufw status 2>/dev/null | grep -qi "Status: active"'; } + + /** One privileged read of the firewall state, marker-sectioned for parsing. */ + public function statusScript(): string + { + if ($this->isFirewalld()) { + // firewalld is READ-ONLY in this release (rule mutation is ufw-only), so we + // read the RUNTIME state of the active default zone — exactly what is being + // enforced right now — which also sidesteps any runtime/permanent zone drift. + // Enumerate EVERY active zone (an interface/source may be bound to a + // non-default zone), so the read-only view reflects all enforced rules. + $zones = 'firewall-cmd --get-active-zones 2>/dev/null | grep -v "^[[:space:]]"'; + + return 'echo CLUSEV_FWREAD_OK; ' + .'echo '.self::MARK.'inst===; command -v firewall-cmd >/dev/null 2>&1 && echo yes || echo no; ' + .'echo '.self::MARK.'active===; systemctl is-active --quiet firewalld && echo active || echo inactive; ' + .'echo '.self::MARK.'zone===; firewall-cmd --get-default-zone 2>/dev/null; ' + // emit "zone|value" per line so each rule keeps its zone attribution. + .'echo '.self::MARK.'ports===; for z in $('.$zones.'); do for p in $(firewall-cmd --zone="$z" --list-ports 2>/dev/null); do echo "$z|$p"; done; done; ' + .'echo '.self::MARK.'services===; for z in $('.$zones.'); do for s in $(firewall-cmd --zone="$z" --list-services 2>/dev/null); do echo "$z|$s"; done; done; ' + .'echo '.self::MARK.'rich===; for z in $('.$zones.'); do firewall-cmd --zone="$z" --list-rich-rules 2>/dev/null | while IFS= read -r r; do [ -n "$r" ] && echo "$z|$r"; done; done'; + } + + return 'echo CLUSEV_FWREAD_OK; ' + .'echo '.self::MARK.'inst===; command -v ufw >/dev/null 2>&1 && echo yes || echo no; ' + .'echo '.self::MARK.'verbose===; ufw status verbose 2>/dev/null; ' + // `ufw show added` lists user rules in add-syntax and works whether ufw is + // active OR inactive (status only shows rules while active); the spec + // doubles as the delete argument, so deletes need no rule NUMBER (race-free). + .'echo '.self::MARK.'added===; ufw show added 2>/dev/null; ' + // defaults from the config file so they are correct even when inactive. + .'echo '.self::MARK.'defaults===; grep -E "^DEFAULT_(INPUT|OUTPUT)_POLICY" /etc/default/ufw 2>/dev/null'; + } + + /** + * Build the ufw add command from already-validated parts (action/proto/port/from). + * Rule mutation is ufw-only in this release; firewalld is read-only. + */ + public function addRuleScript(string $action, string $proto, ?int $port, ?string $from): string + { + $spec = $action; + if ($from !== null && $from !== '') { + // ufw extended syntax wants `proto

` BEFORE the `from` clause; the + // trailing-proto form is rejected. proto is kept even without a port so a + // source-only rule constrains the protocol instead of allowing all. + if ($proto !== 'any') { + $spec .= ' proto '.$proto; + } + $spec .= ' from '.$from; + if ($port !== null) { + $spec .= ' to any port '.$port; + } + } elseif ($port !== null) { + $spec .= ' '.$port.($proto !== 'any' ? '/'.$proto : ''); + } + + return 'ufw '.$spec; + } + + /** + * Delete a ufw rule by its add-syntax spec (e.g. "allow 80/tcp"). Race-free: + * no rule number is involved, so a concurrent change cannot shift the target. + * Deleting by rule does not prompt (unlike delete-by-number). + */ + public function deleteUfwScript(string $spec): string + { + // Routed rules use the route grammar: "ufw route delete allow …" (NOT "ufw + // delete route …", which ufw rejects). + if (preg_match('/^route\s+(.+)$/i', $spec, $m)) { + return 'ufw route delete '.$m[1]; + } + + return 'ufw delete '.$spec; + } } diff --git a/resources/views/livewire/modals/firewall-rule.blade.php b/resources/views/livewire/modals/firewall-rule.blade.php new file mode 100644 index 0000000..58b73f0 --- /dev/null +++ b/resources/views/livewire/modals/firewall-rule.blade.php @@ -0,0 +1,76 @@ +@php + $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $select = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none'; +@endphp +

+
+ + + +
+

Firewall-Regel hinzufügen

+

+ @if ($tool === 'firewalld') + Öffnet einen Port in der Standard-Zone von firewalld. + @else + Neue UFW-Regel. Quelle leer lassen = von überall. + @endif +

+
+
+ + @if ($error) +
+ +

{{ $error }}

+
+ @endif + +
+ @if ($tool !== 'firewalld') +
+ + +
+ @endif + +
+
+ + +
+
+ + +
+
+ + @if ($tool !== 'firewalld') +
+ + +
+ @endif +
+ +
+ Abbrechen + + + + Hinzufügen + +
+
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 4beec97..d5ac7ec 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -224,6 +224,117 @@ + {{-- Firewall-Regeln --}} + @php + $fw = $firewall ?? []; + $fwTool = $fw['tool'] ?? 'ufw'; + $fwToolLabel = $fwTool === 'firewalld' ? 'firewalld' : 'UFW'; + $fwSupported = $fw['supported'] ?? false; + $fwInstalled = $fw['installed'] ?? false; + $fwActive = $fw['active'] ?? false; + $fwManageable = $fw['manageable'] ?? false; + $fwReadOnly = $fw['readOnly'] ?? false; + $fwReadError = $fw['readError'] ?? false; + $fwRules = $fw['rules'] ?? []; + $fwDefaults = $fw['defaults'] ?? []; + $actTone = ['ALLOW' => 'online', 'LIMIT' => 'warning', 'DENY' => 'offline', 'REJECT' => 'offline']; + @endphp + + @if ($fwSupported && $fwManageable) + + + Regel + + + @endif + + @if (! $fwSupported) +
+

{{ $fw['reason'] ?? 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.' }}

+
+ @elseif ($fwReadError) +
+ +

Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.

+
+ @elseif (! $fwInstalled) +
+

{{ $fwToolLabel }} ist nicht installiert.

+ + Aktivieren + +
+ @elseif ($fwTool === 'firewalld' && ! $fwActive) +
+

firewalld ist installiert, aber inaktiv.

+ + Aktivieren + +
+ @else +
+ @if ($fwTool === 'ufw') + Standard + eingehend: {{ $fwDefaults['incoming'] ?? '—' }} + ausgehend: {{ $fwDefaults['outgoing'] ?? '—' }} + @else + Zone + {{ $fwDefaults['zone'] ?? '—' }} + @endif +
+ + @if ($fwReadOnly) +
+ +

firewalld: nur Anzeige — Regeln bitte direkt am Server verwalten.

+
+ @endif + +
+ @forelse ($fwRules as $rule) + {{-- $ruleTone, NOT $tone: the page-level $tone is a closure used by the + gauges/Volumes panel; reusing the name here would clobber it. --}} + @php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp +
+ $ruleTone === 'online', + 'bg-warning' => $ruleTone === 'warning', + 'bg-offline' => $ruleTone === 'offline', + ])> +
+

{{ $rule['label'] ?? ($rule['to'] ?? $rule['raw']) }}

+ @if ($fwTool === 'ufw' && ($rule['from'] ?? '') !== '' && ! \Illuminate\Support\Str::contains($rule['from'], 'Anywhere')) +

von {{ $rule['from'] }}

+ @endif +
+ $ruleTone === 'online', + 'text-warning' => $ruleTone === 'warning', + 'text-offline' => $ruleTone === 'offline', + ])>{{ strtolower($rule['action'] ?? 'allow') }} + @unless ($fwReadOnly) + + + + @endunless +
+ @empty +
+

Keine Regeln definiert.

+
+ @endforelse +
+ @endif +
+ {{-- Volumes + Netzwerk-Interfaces --}}