feat(firewall): full UFW rule management + read-only firewalld view
The server-detail page gains a "Firewall-Regeln" panel — addressing "ich kann nur aktivieren, sonst nichts": list rules, add, and delete, with hard SSH-lockout guards. FirewallService: - status(): one privileged read — installed/active, default policies, and the rule list. ufw rules come from `ufw show added` (add-syntax, works active OR inactive), defaults from /etc/default/ufw; a CLUSEV_FWREAD_OK sentinel + a "Status:" check distinguish a real read from a failed/empty one. - addRule(): validated action/proto/port/from (proto before `from`, ufw grammar); refuses deny/reject on the SSH port or portless (would block all inbound). - deleteRule(): whitelists the spec against rules ufw actually reported (injection- proof — a forged spec can't match), deletes by spec (race-free, no rule number), guards SSH-port allow rules incl. ranges and trailing comments, distinguishes not_found from a real deletion so the audit only records true deletions. firewalld is READ-ONLY this release: status() reads the runtime state of every active zone (ports/services/rich rules, zone-attributed), rule mutation is refused with a German note. on/off + hardening (Phase A) still work. UI: x-panel with default-policy badges, "+ Regel" (modal modals.firewall-rule), per- rule delete via R5 ConfirmAction (audit deferred to the real outcome), graceful read-error / not-installed / inactive / firewalld-read-only states. Fix: the firewall row's tone variable was renamed $tone -> $ruleTone — the page-level $tone is a closure used by the gauges/Volumes panel; reusing the name clobbered it and 500'd the page below the firewall block. R12 now confirms the full loaded page. Default-policy EDITING was intentionally not exposed (highest lockout risk). Pint clean; Codex review clean; R12 — server-detail HTTP 200, 0 console errors, firewall panel + rules + volumes all render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
4bfa367ccf
commit
efecd26e31
|
|
@ -39,6 +39,8 @@ class ConfirmAction extends ModalComponent
|
||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
public array $params = [];
|
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 string $notify = 'Aktion ausgeführt.';
|
||||||
|
|
||||||
public static function modalMaxWidth(): string
|
public static function modalMaxWidth(): string
|
||||||
|
|
@ -63,7 +65,10 @@ class ConfirmAction extends ModalComponent
|
||||||
$this->dispatch($this->event, ...$this->params);
|
$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();
|
$this->closeModal();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Modals;
|
||||||
|
|
||||||
|
use App\Models\AuditEvent;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\FirewallService;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a firewall rule. For ufw the operator picks action/protocol/port/source; for
|
||||||
|
* firewalld (an allow-list default zone) it opens a port. All validation happens in
|
||||||
|
* FirewallService — this modal only collects and forwards. Adding is non-destructive,
|
||||||
|
* so no R5 confirm (deleting a rule is the guarded path).
|
||||||
|
*/
|
||||||
|
class FirewallRule extends ModalComponent
|
||||||
|
{
|
||||||
|
public int $serverId;
|
||||||
|
|
||||||
|
public string $tool = 'ufw';
|
||||||
|
|
||||||
|
public string $action = 'allow';
|
||||||
|
|
||||||
|
public string $proto = 'tcp';
|
||||||
|
|
||||||
|
public string $port = '';
|
||||||
|
|
||||||
|
public string $from = '';
|
||||||
|
|
||||||
|
public ?string $error = null;
|
||||||
|
|
||||||
|
public function mount(int $serverId, string $tool = 'ufw'): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,12 @@ namespace App\Livewire\Servers;
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
use App\Models\AuditEvent;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Services\FirewallService;
|
||||||
use App\Services\FleetService;
|
use App\Services\FleetService;
|
||||||
use App\Services\HardeningService;
|
use App\Services\HardeningService;
|
||||||
use App\Support\Os\OsDetector;
|
use App\Support\Os\OsDetector;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
|
|
@ -33,6 +35,9 @@ class Show extends Component
|
||||||
|
|
||||||
public array $hardening = [];
|
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. */
|
/** Detected OS tooling (family/package manager/firewall) for the identity panel. */
|
||||||
public array $os = [];
|
public array $os = [];
|
||||||
|
|
||||||
|
|
@ -102,6 +107,13 @@ class Show extends Component
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$this->hardening = [];
|
$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->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
|
||||||
$this->connected = true;
|
$this->connected = true;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
|
|
@ -272,6 +284,86 @@ class Show extends Component
|
||||||
$this->load($fleet);
|
$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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.servers.show');
|
return view('livewire.servers.show');
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,211 @@ class FirewallService
|
||||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
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 (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 <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
|
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
|
||||||
* back to 22 when nothing is set explicitly.
|
* back to 22 when nothing is set explicitly.
|
||||||
|
|
@ -66,4 +271,164 @@ class FirewallService
|
||||||
|
|
||||||
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ namespace App\Support\Os;
|
||||||
*/
|
*/
|
||||||
final class FirewallTool
|
final class FirewallTool
|
||||||
{
|
{
|
||||||
|
private const MARK = '===CLUSEV:';
|
||||||
|
|
||||||
/** @param 'ufw'|'firewalld' $tool */
|
/** @param 'ufw'|'firewalld' $tool */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private string $tool,
|
private string $tool,
|
||||||
|
|
@ -84,4 +86,77 @@ final class FirewallTool
|
||||||
? 'systemctl is-active --quiet firewalld'
|
? 'systemctl is-active --quiet firewalld'
|
||||||
: 'ufw status 2>/dev/null | grep -qi "Status: active"';
|
: '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 <p>` 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
<div class="p-5 sm:p-6">
|
||||||
|
<div class="flex items-start gap-3.5">
|
||||||
|
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||||
|
<x-icon name="shield" class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="font-display text-base font-semibold text-ink">Firewall-Regel hinzufügen</h2>
|
||||||
|
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||||
|
@if ($tool === 'firewalld')
|
||||||
|
Öffnet einen Port in der Standard-Zone von firewalld.
|
||||||
|
@else
|
||||||
|
Neue UFW-Regel. Quelle leer lassen = von überall.
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($error)
|
||||||
|
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||||
|
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||||
|
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
@if ($tool !== 'firewalld')
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">Aktion</label>
|
||||||
|
<select wire:model="action" class="{{ $select }}">
|
||||||
|
<option value="allow">Erlauben (allow)</option>
|
||||||
|
<option value="deny">Verwerfen (deny)</option>
|
||||||
|
<option value="reject">Ablehnen (reject)</option>
|
||||||
|
<option value="limit">Begrenzen (limit)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">Port {{ $tool === 'firewalld' ? '' : '(optional)' }}</label>
|
||||||
|
<input wire:model="port" type="number" min="1" max="65535" placeholder="z. B. 8080" class="{{ $field }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">Protokoll</label>
|
||||||
|
<select wire:model="proto" class="{{ $select }}">
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
@if ($tool !== 'firewalld')
|
||||||
|
<option value="any">Beide</option>
|
||||||
|
@endif
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($tool !== 'firewalld')
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">Quelle (optional)</label>
|
||||||
|
<input wire:model="from" type="text" placeholder="IP oder CIDR, z. B. 10.0.0.0/8" class="{{ $field }}" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-2">
|
||||||
|
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||||
|
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||||
|
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
|
||||||
|
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
|
Hinzufügen
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -224,6 +224,117 @@
|
||||||
</x-panel>
|
</x-panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- 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
|
||||||
|
<x-panel title="Firewall-Regeln"
|
||||||
|
:subtitle="$fwSupported ? ($fwToolLabel . ($fwInstalled ? ($fwActive ? ' · aktiv' : ' · inaktiv') : ' · nicht installiert')) : 'Nicht verfügbar'"
|
||||||
|
:padded="false">
|
||||||
|
@if ($fwSupported && $fwManageable)
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-btn variant="accent" size="sm"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'modals.firewall-rule', arguments: { serverId: {{ $server->id }}, tool: '{{ $fwTool }}' } })">
|
||||||
|
<x-icon name="plus" class="h-3.5 w-3.5" /> Regel
|
||||||
|
</x-btn>
|
||||||
|
</x-slot:actions>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (! $fwSupported)
|
||||||
|
<div class="px-4 py-4 sm:px-5">
|
||||||
|
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $fw['reason'] ?? 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.' }}</p>
|
||||||
|
</div>
|
||||||
|
@elseif ($fwReadError)
|
||||||
|
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
|
||||||
|
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||||
|
<p class="font-mono text-[11px] text-ink-3">Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.</p>
|
||||||
|
</div>
|
||||||
|
@elseif (! $fwInstalled)
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||||||
|
<p class="font-mono text-[11px] text-ink-3">{{ $fwToolLabel }} ist nicht installiert.</p>
|
||||||
|
<x-btn variant="secondary" size="sm"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
|
||||||
|
Aktivieren
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
@elseif ($fwTool === 'firewalld' && ! $fwActive)
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||||||
|
<p class="font-mono text-[11px] text-ink-3">firewalld ist installiert, aber inaktiv.</p>
|
||||||
|
<x-btn variant="secondary" size="sm"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
|
||||||
|
Aktivieren
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5 sm:px-5">
|
||||||
|
@if ($fwTool === 'ufw')
|
||||||
|
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Standard</span>
|
||||||
|
<x-badge tone="neutral">eingehend: {{ $fwDefaults['incoming'] ?? '—' }}</x-badge>
|
||||||
|
<x-badge tone="neutral">ausgehend: {{ $fwDefaults['outgoing'] ?? '—' }}</x-badge>
|
||||||
|
@else
|
||||||
|
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Zone</span>
|
||||||
|
<x-badge tone="neutral">{{ $fwDefaults['zone'] ?? '—' }}</x-badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($fwReadOnly)
|
||||||
|
<div class="flex items-start gap-2.5 border-b border-line bg-inset px-4 py-2.5 sm:px-5">
|
||||||
|
<x-icon name="lock" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" />
|
||||||
|
<p class="font-mono text-[11px] leading-relaxed text-ink-3">firewalld: nur Anzeige — Regeln bitte direkt am Server verwalten.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="divide-y divide-line">
|
||||||
|
@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
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
|
||||||
|
<span @class([
|
||||||
|
'h-2 w-2 shrink-0 rounded-full',
|
||||||
|
'bg-online' => $ruleTone === 'online',
|
||||||
|
'bg-warning' => $ruleTone === 'warning',
|
||||||
|
'bg-offline' => $ruleTone === 'offline',
|
||||||
|
])></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate font-mono text-sm text-ink">{{ $rule['label'] ?? ($rule['to'] ?? $rule['raw']) }}</p>
|
||||||
|
@if ($fwTool === 'ufw' && ($rule['from'] ?? '') !== '' && ! \Illuminate\Support\Str::contains($rule['from'], 'Anywhere'))
|
||||||
|
<p class="truncate font-mono text-[11px] text-ink-3">von {{ $rule['from'] }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<span @class([
|
||||||
|
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
||||||
|
'text-online' => $ruleTone === 'online',
|
||||||
|
'text-warning' => $ruleTone === 'warning',
|
||||||
|
'text-offline' => $ruleTone === 'offline',
|
||||||
|
])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
|
||||||
|
@unless ($fwReadOnly)
|
||||||
|
<x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="Regel entfernen"
|
||||||
|
wire:click="confirmDeleteRule({{ $loop->index }})">
|
||||||
|
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||||||
|
</x-btn>
|
||||||
|
@endunless
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="px-4 py-4 sm:px-5">
|
||||||
|
<p class="font-mono text-[11px] text-ink-3">Keine Regeln definiert.</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
{{-- Volumes + Netzwerk-Interfaces --}}
|
{{-- Volumes + Netzwerk-Interfaces --}}
|
||||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
|
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue