Compare commits

...

122 Commits

Author SHA1 Message Date
boban af369acbf6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:57:05 +01:00
boban d81c3bc07c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:52:54 +01:00
boban 821a2bde33 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:43:52 +01:00
boban 8e68051fde Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-04 18:40:39 +01:00
boban afb8d09db3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 23:20:53 +01:00
boban fc04ef44d0 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 23:17:06 +01:00
boban a7d84899fb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:58:18 +01:00
boban e3dc81ef73 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:53:37 +01:00
boban 9acea7b89b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:20:53 +01:00
boban 6c3cde5f65 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:10:01 +01:00
boban 77f22518c8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:05:14 +01:00
boban 9aa9475387 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 18:17:27 +01:00
boban d4255b08fa Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 17:43:14 +01:00
boban 94aec78d4c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 16:52:41 +01:00
boban d3783e1717 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 16:41:54 +01:00
boban dcf9a8d3e9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:32:24 +01:00
boban 595828c5f6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:29:30 +01:00
boban 834f173bb9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:26:34 +01:00
boban a3a4ec4d06 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:13:39 +01:00
boban 2f390af9ed Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:42:36 +01:00
boban 530faf6b45 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:33:30 +01:00
boban 8058f9b814 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:27:36 +01:00
boban c4b906223c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:18:53 +01:00
boban 81860d1851 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:14:26 +01:00
boban 792f0e3528 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:01:17 +01:00
boban 46591669d6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:38:00 +01:00
boban 8690067d9c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:21:22 +01:00
boban beb2f863a3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:14:54 +01:00
boban e833033074 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:08:26 +01:00
boban 02e558bf4b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:59:51 +01:00
boban d9867db546 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:43:16 +01:00
boban e77d9f64bb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:27:23 +01:00
boban ee44ff3def Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:23:48 +01:00
boban 4d1fd64158 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:15:55 +01:00
boban 6b0dd7d176 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:05:18 +01:00
boban 67b6e1fa02 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-30 23:55:07 +01:00
boban 8b4f2d9fe8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 19:32:25 +01:00
boban e3c7e8de33 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 19:24:36 +01:00
boban 385b67c3c5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 19:13:10 +01:00
boban 251f2d9c8f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 18:34:08 +01:00
boban aaae226c8d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 04:23:41 +01:00
boban 3c1093311c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 04:10:49 +01:00
boban 0cb7212d4b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:54:39 +01:00
boban ab13bab984 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:44:49 +01:00
boban 47bca4c8de Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:26:32 +01:00
boban 9074904683 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:10:11 +01:00
boban 659f3cb7ae Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 02:45:40 +01:00
boban c8cae445c5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:53:41 +01:00
boban 2ef27b5e8f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:26:47 +01:00
boban d200e3e73f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:22:23 +01:00
boban e23713a5c6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:11:12 +01:00
boban 56e7453f8d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:55:38 +01:00
boban 8790cffeb4 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:34:06 +01:00
boban 2ed1d1cd36 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:31:18 +01:00
boban 10b4872a04 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:28:43 +01:00
boban 4645b168f7 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 20:20:30 +01:00
boban 3152dc94e2 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:51:33 +01:00
boban 59c495af84 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:38:37 +01:00
boban a5d3ac08c6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:27:52 +01:00
boban 4197b61905 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:05:01 +01:00
boban dd645aed68 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 18:53:11 +01:00
boban 703843a9c2 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 18:18:22 +01:00
boban 3108d521a5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 18:07:37 +01:00
boban dd3f413e6a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 17:25:02 +01:00
boban 09117fe1e9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 19:57:15 +01:00
boban ddd96eb9f2 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 19:37:30 +01:00
boban da30b80056 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 19:05:11 +01:00
boban 074d2da4ec Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 18:25:09 +01:00
boban d76ea0b703 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 18:00:20 +01:00
boban adea3c5275 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 02:18:35 +01:00
boban 9d3ca94b87 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 01:50:34 +01:00
boban 520617d9b3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:59:59 +01:00
boban d6f0c5d7cb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:54:57 +01:00
boban 3b816e2198 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:44:12 +01:00
boban 8e35c617b8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:33:53 +01:00
boban d65aaf9a5d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:03:50 +01:00
boban a5e745ca4a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:43:47 +01:00
boban cdb16fc4a5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:31:52 +01:00
boban 988de01e82 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:27:41 +01:00
boban 3bf7db585a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:25:52 +01:00
boban 93c87b8d89 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:04:45 +01:00
boban 18447dbf21 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 18:57:23 +01:00
boban c11d330c38 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 18:41:54 +01:00
boban a943b42fec Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 18:19:02 +01:00
boban e997d5374d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 18:04:27 +01:00
boban ecbe123088 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 18:00:40 +01:00
boban 7a636fb496 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 17:34:03 +01:00
boban f99790b1a5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 17:13:25 +01:00
boban 1bf41063ae Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 17:08:35 +01:00
boban 1443a693bd Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 16:54:40 +01:00
boban 9d2e8d2606 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 16:50:03 +01:00
boban ea12b97497 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 16:46:16 +01:00
boban 64afb9d9af Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 16:08:40 +01:00
boban 0d41b6b658 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 15:53:40 +01:00
boban 8d5cf8b3c5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 15:43:56 +01:00
boban 7e5a9a42d5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 15:19:18 +01:00
boban b8caf3506a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 15:12:41 +01:00
boban 16f3db7dcc Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 15:02:23 +01:00
boban ca934a8588 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 14:54:47 +01:00
boban c84843a4a1 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 15:45:18 +02:00
boban e66c7809de Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 15:35:18 +02:00
boban 3504ca59c8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 15:31:50 +02:00
boban f4e03fab82 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 14:56:01 +02:00
boban da54fbc7f3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 14:18:34 +02:00
boban 1f2634b054 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 14:09:59 +02:00
boban b8013d198b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 14:00:00 +02:00
boban facf4bf844 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 13:59:34 +02:00
boban d3a949ea80 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 13:43:38 +02:00
boban ee2c0dfd79 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 13:29:48 +02:00
boban 2e40e109a3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 13:23:46 +02:00
boban a9609d358b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-25 13:10:06 +02:00
boban e67c8613b3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-24 23:25:14 +02:00
boban 812f91202f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-24 16:31:01 +02:00
boban 42dce7bde9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-24 14:14:35 +02:00
boban c1ecc90ec3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-24 01:38:03 +02:00
boban 3396aab47f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-24 01:08:49 +02:00
boban 56736a648b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-22 16:59:07 +02:00
boban 020f55f53d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-22 16:48:46 +02:00
boban 529979f078 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-22 03:12:02 +02:00
boban a8e7aedadf Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-21 21:38:04 +02:00
boban 300928851a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-21 20:54:00 +02:00
boban 57b01654cc Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-21 20:41:15 +02:00
116 changed files with 15660 additions and 3629 deletions

View File

@ -11,29 +11,82 @@ class CheckUpdates extends Command
public function handle(): int
{
$currentNorm = $this->readInstalledVersionNorm();
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
$appPath = base_path();
$current = trim(@file_get_contents(base_path('VERSION'))) ?: '0.0.0';
// newest tag from origin (sorted semver-friendly)
$latest = trim(shell_exec(
"cd {$appPath} && git fetch --tags --quiet origin && git tag --list | sort -V | tail -n1"
) ?? '');
$cmd = <<<BASH
set -e
cd {$appPath}
git fetch --tags --force --quiet origin +refs/tags/*:refs/tags/*
(git tag -l 'v*' --sort=-v:refname | head -n1) || true
BASH;
// Tags haben usually ein 'v' Prefix entfernen
$latest = ltrim($latest, 'v');
$latestTagRaw = trim((string) shell_exec($cmd));
if ($latestTagRaw === '') {
$latestTagRaw = trim((string) shell_exec("cd {$appPath} && git tag -l --sort=-v:refname | head -n1"));
}
if (!$latest) {
$latestNorm = $this->normalizeVersion($latestTagRaw);
// Nichts gefunden -> alles leeren
if (!$latestNorm) {
cache()->forget('updates:latest');
cache()->forget('updates:latest_raw');
cache()->forget('mailwolt.update_available'); // legacy
$this->warn('Keine Release-Tags gefunden.');
cache()->forget('mailwolt.update_available');
return 0;
return self::SUCCESS;
}
if (version_compare($latest, $current, '>')) {
cache()->forever('mailwolt.update_available', $latest);
$this->info("Update verfügbar: {$latest} (installiert: {$current})");
// Nur wenn wirklich neuer als installiert -> Keys setzen
if ($currentNorm && version_compare($latestNorm, $currentNorm, '>')) {
cache()->forever('updates:latest', $latestNorm);
cache()->forever('updates:latest_raw', $latestTagRaw ?: ('v'.$latestNorm));
cache()->forever('mailwolt.update_available', $latestNorm); // legacy-kompat
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
} else {
cache()->forget('mailwolt.update_available');
$this->info("Aktuell (installiert: {$current}).");
// Kein Update -> Keys löschen
cache()->forget('updates:latest');
cache()->forget('updates:latest_raw');
cache()->forget('mailwolt.update_available'); // legacy
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
}
return 0;
cache()->put('updates:last_checked_at', now(), now()->addMinutes(10));
return self::SUCCESS;
}
/* ===== Helpers ===== */
private function readInstalledVersionNorm(): ?string
{
$paths = [
'/var/lib/mailwolt/version', // vom Wrapper (normiert)
base_path('VERSION'), // App-Fallback
];
foreach ($paths as $p) {
$raw = @trim(@file_get_contents($p) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);
}
// Noch ein Fallback aus RAW-Datei
$raw = $this->readInstalledVersionRaw();
return $raw ? $this->normalizeVersion($raw) : null;
}
private function readInstalledVersionRaw(): ?string
{
$p = '/var/lib/mailwolt/version_raw'; // vom Wrapper (z.B. "v1.0.25" oder "v1.0.25-3-gabcd")
$raw = @trim(@file_get_contents($p) ?: '');
return $raw !== '' ? $raw : null;
}
private function normalizeVersion(?string $v): ?string
{
if (!$v) return null;
$v = trim($v);
if ($v === '') return null;
$v = ltrim($v, "vV \t\n\r\0\x0B"); // führendes v entfernen
$v = preg_replace('/-.*$/', '', $v); // Build-/dirty-Suffix abschneiden
return $v !== '' ? $v : null;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Events\HealthUpdated;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class CollectHealth extends Command
{
protected $signature = 'health:collect';
protected $description = 'Sammelt Systemmetriken und broadcastet sie';
public function handle(): int
{
$system = [
// CPU-Last (1/5/15) aus PHP
'cpu_load_1' => $this->loadavg(0),
'cpu_load_5' => $this->loadavg(1),
'cpu_load_15' => $this->loadavg(2),
// Kerne
'cores' => $this->cpuCores(),
// RAM % + Details aus /proc/meminfo (used = MemTotal - MemAvailable)
'ram' => $this->ramInfo(),
// Uptime in Sekunden aus /proc/uptime
'uptime_seconds' => $this->uptimeSeconds(),
// IPs aus ENV (falls gesetzt)
'ipv4' => env('SERVER_PUBLIC_IPV4'),
'ipv6' => env('SERVER_PUBLIC_IPV6'),
];
// Optional: eine “CPU-%”-Schätzung aus Load(1m)/Cores (damit dein UI einen %-Wert hat)
if (is_numeric($system['cpu_load_1']) && $system['cores'] > 0) {
$system['cpu_percent'] = (int) round(
100 * max(0, min(1, $system['cpu_load_1'] / $system['cores']))
);
}
$meta = [
'system' => $system,
'updated_at' => now()->toIso8601String(),
];
Cache::put('health:meta', $meta, 60);
event(new HealthUpdated($meta));
$this->info('Health aktualisiert & gesendet.');
return self::SUCCESS;
}
private function loadavg(int $index): ?float
{
$la = @sys_getloadavg();
return (is_array($la) && isset($la[$index])) ? (float) $la[$index] : null;
}
private function cpuCores(): int
{
$n = (int) @shell_exec('nproc 2>/dev/null');
return $n > 0 ? $n : 1;
// Alternativ: count(preg_grep('/^processor\s*:/', @file('/proc/cpuinfo') ?: [])) ?: 1;
}
private function ramInfo(): array
{
$mem = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [];
$kv = [];
foreach ($mem as $ln) {
if (preg_match('/^(\w+):\s+(\d+)/', $ln, $m)) {
$kv[$m[1]] = (int) $m[2]; // kB
}
}
$totalKB = $kv['MemTotal'] ?? 0;
$availKB = $kv['MemAvailable'] ?? 0; // besser als “free”
$usedKB = max(0, $totalKB - $availKB);
$totalGB = $totalKB / 1024 / 1024;
$usedGB = $usedKB / 1024 / 1024;
$percent = $totalKB > 0 ? (int) round(100 * $usedKB / $totalKB) : null;
return [
'percent' => $percent,
'used_gb' => (int) round($usedGB),
'total_gb' => (int) round($totalGB),
];
}
private function uptimeSeconds(): ?int
{
$s = @file_get_contents('/proc/uptime');
if (!$s) return null;
$parts = explode(' ', trim($s));
return isset($parts[0]) ? (int) floor((float) $parts[0]) : null;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MailwoltRestart extends Command
{
protected $signature = 'mailwolt:restart-services';
protected $description = 'Restart or reload MailWolt-related system services';
public function handle(): int
{
$units = config('mailwolt.units', []);
foreach ($units as $u) {
$base = (string)($u['name'] ?? '');
$unit = str_ends_with($base, '.service') ? $base : $base . '.service';
$action = (string)($u['action'] ?? 'try-reload-or-restart');
$this->info("{$unit} ({$action})");
// Existiert die Unit?
$existsCmd = sprintf('systemctl status %s >/dev/null 2>&1', escapeshellarg($unit));
exec($existsCmd, $_o, $existsRc);
if ($existsRc !== 0) {
$this->line(" {$unit} existiert nicht übersprungen");
continue;
}
// Restart/Reload via sudo (ohne Passwort)
$cmd = sprintf('sudo -n /usr/bin/systemctl %s %s', escapeshellarg($action), escapeshellarg($unit));
exec($cmd . ' 2>&1', $out, $rc);
foreach ($out as $line) {
$this->line(" $line");
}
$rc === 0 ? $this->line(' [✓] Erfolgreich') : $this->warn(" [!] Fehler (rc={$rc})");
}
return self::SUCCESS;
}
}
//class MailwoltRestart extends Command
//{
// protected $signature = 'mailwolt:restart-services';
// protected $description = 'Restart or reload MailWolt-related system services';
//
// public function handle(): int
// {
// $units = config('mailwolt.units', []);
// $allowed = ['reload','restart','try-reload-or-restart'];
//
// foreach ($units as $u) {
// $base = (string)($u['name'] ?? '');
// $unit = str_ends_with($base, '.service') ? $base : $base . '.service';
// $action = $u['action'] ?? 'try-reload-or-restart';
// if (!in_array($action, $allowed, true)) $action = 'try-reload-or-restart';
//
// // existiert Unit überhaupt?
// $probe = Process::fromShellCommandline("systemctl status $unit >/dev/null 2>&1");
// $probe->run();
// if ($probe->getExitCode() !== 0) {
// $this->warn("→ {$unit} existiert nicht übersprungen");
// continue;
// }
//
// $this->info("→ {$unit} ({$action})");
// $p = new Process(['sudo','-n','/usr/bin/systemctl',$action,$unit]);
// $p->setTimeout(15);
// $p->run();
//
// if (!$p->isSuccessful()) {
// $this->warn(" [!] Fehler (rc={$p->getExitCode()})");
// Log::warning('service restart failed', [
// 'unit'=>$unit,'action'=>$action,
// 'out'=>$p->getOutput(), 'err'=>$p->getErrorOutput()
// ]);
// } else {
// $this->line(" [✓] Erfolgreich");
// }
// }
// return self::SUCCESS;
// }
//}
//
//use Illuminate\Console\Command;
//
//class MailwoltRestart extends Command
//{
// protected $signature = 'mailwolt:restart-services';
// protected $description = 'Restart or reload MailWolt-related system services';
//
// public function handle(): int
// {
// $units = config('mailwolt.units', []);
//
// foreach ($units as $u) {
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';
// $action = $u['action'] ?? 'try-reload-or-restart';
//
// $cmd = sprintf('sudo -n /usr/bin/systemctl %s %s', escapeshellarg($action), escapeshellarg($unit));
// $this->info("→ {$unit} ({$action})");
//
// exec($cmd . ' 2>&1', $out, $rc);
// foreach ($out as $line) {
// $this->line(" $line");
// }
//
// if ($rc !== 0) {
// $this->warn(" [!] Fehler beim Neustart von {$unit} (rc={$rc})");
// } else {
// $this->line(" [✓] Erfolgreich");
// }
// }
//
// return self::SUCCESS;
// }
//}

View File

@ -0,0 +1,168 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use App\Models\Setting;
class ProbeRbl extends Command
{
protected $signature = 'rbl:probe {--force : Ignoriert Intervalle und prüft sofort}';
protected $description = 'Prüft öffentliche RBLs und speichert das Ergebnis in settings:health.rbl';
// Intervalle
private int $minIntervalDays = 7; // frühestens alle 7 Tage neu prüfen
private int $ttlDays = 14; // Ergebnis 14 Tage gültig
public function handle(): int
{
$now = now();
$existing = (array) Setting::get('health.rbl', []) ?: [];
$lastAt = isset($existing['checked_at']) ? \Illuminate\Support\Carbon::parse($existing['checked_at']) : null;
$nextDue = $lastAt ? $lastAt->copy()->addDays($this->minIntervalDays) : null;
if (!$this->option('force') && $nextDue && $now->lt($nextDue)) {
$this->info("Übersprungen: nächste Prüfung erst ab {$nextDue->toIso8601String()} (force mit --force).");
return self::SUCCESS;
}
// IPs ermitteln (Installer-ENV bevorzugt)
[$ipv4, $ipv6] = $this->resolvePublicIpsFromInstallerEnv();
$ipv4 = $ipv4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
$ipv6 = $ipv6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
// Kandidat für RBL (nur IPv4)
$ip = $this->validIPv4($ipv4) ? $ipv4 : null;
if (!$ip) {
$file = trim((string) @file_get_contents('/etc/mailwolt/public_ip'));
if ($this->validIPv4($file)) $ip = $file;
}
if (!$ip) {
$curl = trim((string) @shell_exec('curl -fsS --max-time 2 ifconfig.me 2>/dev/null'));
if ($this->validIPv4($curl)) $ip = $curl;
}
if (!$ip) $ip = '0.0.0.0';
// Abfragen (DNS)
[$lists, $meta] = $this->queryRblLists($ip);
$payload = [
'ip' => $ip,
'ipv4' => $ipv4,
'ipv6' => $ipv6,
'hits' => count($lists),
'lists' => array_values($lists), // nur die tatsächlich gelisteten Zonen
'meta' => $meta, // {zone:{status, txt?}}
'checked_at' => $now->toIso8601String(),
'valid_until' => $now->copy()->addDays($this->ttlDays)->toIso8601String(),
'min_next' => $now->copy()->addDays($this->minIntervalDays)->toIso8601String(),
];
// Persistieren (DB) + in Redis spiegeln
Setting::set('health.rbl', $payload);
Cache::put('health.rbl', $payload, now()->addDays($this->ttlDays));
$this->info(sprintf(
'RBL: ip=%s hits=%d lists=[%s]',
$payload['ip'], $payload['hits'], implode(',', $payload['lists'])
));
return self::SUCCESS;
}
/* ---------- Helpers ---------- */
private function resolvePublicIpsFromInstallerEnv(): array
{
$file = '/etc/mailwolt/installer.env';
if (!is_readable($file)) return [null, null];
$ipv4 = $ipv6 = null;
foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, '=')) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
$v = trim($v, " \t\n\r\0\x0B\"'");
if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
}
return [ $ipv4, $ipv6 ];
}
private function validIPv4(?string $ip): bool
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
}
private function validIPv6(?string $ip): bool
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
}
/**
* Gibt [listedZones, meta] zurück.
* meta[zone] = ['status'=>'listed|clean|blocked|nx', 'txt'=>?string]
*/
private function queryRblLists(string $ip): array
{
if (!$this->validIPv4($ip)) return [[], []];
$rev = implode('.', array_reverse(explode('.', $ip)));
// Kuratierte, erreichbare Zonen (ohne kaputte Subdomains)
$zones = [
// Spamhaus ZEN rate-limitiert, liefert „blocked“ bei Open Resolver
'zen.spamhaus.org',
// PSBL
'psbl.surriel.com',
// UCEPROTECT Level 1
'dnsbl-1.uceprotect.net',
// s5h
'bl.s5h.net',
];
$listed = [];
$meta = [];
foreach ($zones as $zone) {
$q = "{$rev}.{$zone}.";
$txt = @dns_get_record($q, DNS_TXT) ?: [];
$a = @dns_get_record($q, DNS_A) ?: [];
// Spamhaus „blocked“ Heuristik
$blocked = false;
if ($zone === 'zen.spamhaus.org') {
foreach ($a as $rec) {
if (!empty($rec['ip']) && in_array($rec['ip'], ['127.255.255.254','127.255.255.255'], true)) {
$blocked = true; break;
}
}
if (!$blocked) {
foreach ($txt as $rec) {
$t = implode('', $rec['txt'] ?? []);
if (stripos($t, 'open resolver') !== false) { $blocked = true; break; }
}
}
}
if ($blocked) {
$meta[$zone] = ['status' => 'blocked', 'txt' => 'Spamhaus blockt nutze privaten Resolver'];
continue;
}
$hasA = !empty($a);
$hasTXT = !empty($txt);
if ($hasA || $hasTXT) {
$listed[] = $zone;
$meta[$zone] = ['status' => 'listed', 'txt' => $hasTXT ? ($txt[0]['txt'][0] ?? null) : null];
} else {
// NXDOMAIN / sauber
$meta[$zone] = ['status' => 'clean', 'txt' => null];
}
}
return [$listed, $meta];
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use App\Models\Setting;
class SpamAvCollectCommand extends Command
{
protected $signature = 'spamav:collect';
protected $description = 'Collect Rspamd/ClamAV metrics and persist to Settings (DB→Redis)';
public function handle(): int
{
$this->info('Collecting Spam/AV metrics…');
$rpC = '/usr/bin/rspamc';
$rpA = '/usr/bin/rspamadm';
// --- RSPAMD: erst 'stat' (sicher), sonst 'counters' als Fallback ---
$ham = $spam = $reject = 0;
$stat = trim(@shell_exec("$rpC -h 127.0.0.1:11334 stat 2>/dev/null") ?? '');
if ($stat !== '') {
$ham = preg_match('/Messages treated as ham:\s*(\d+)/i', $stat, $m) ? (int)$m[1] : 0;
$spam = preg_match('/Messages treated as spam:\s*(\d+)/i', $stat, $m) ? (int)$m[1] : 0;
$reject = preg_match('/Messages with action reject:\s*(\d+)/i', $stat, $m) ? (int)$m[1] : 0;
} else {
$cnt = trim(@shell_exec("$rpC -h 127.0.0.1:11334 counters 2>/dev/null") ?? '');
$ham = preg_match('/\bham\s*:\s*(\d+)/i', $cnt, $m) ? (int)$m[1] : 0;
$spam = preg_match('/\bspam\s*:\s*(\d+)/i', $cnt, $m) ? (int)$m[1] : 0;
$reject = preg_match('/\breject\s*:\s*(\d+)/i', $cnt, $m) ? (int)$m[1] : 0;
}
// Rspamd Version
$rspamdVer = trim(@shell_exec("$rpA --version 2>/dev/null") ?? '');
if ($rspamdVer === '') {
$rspamdVer = trim(@shell_exec("dpkg-query -W -f='\${Version}\n' rspamd 2>/dev/null") ?? '');
}
// $clamLine = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '';
// $clamVer = $clamLine;
//
// // Versuch, Datum aus der Versionzeile zu ziehen (kein Zugriff auf freshclam.log nötig)
// $sigUpdated = null;
// if (preg_match('#/([^/]+ [0-9]{2} [0-9:]{8} [0-9]{4})$#', $clamLine, $m)) {
// $sigUpdated = $m[1]; // z.B. "Sun Oct 26 09:42:43 2025"
// }
// --- CLAMAV: Versionzeile inkl. Signaturdatum ausgeben ---
$clamLine = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '';
$parts = explode('/', $clamLine, 3);
$clamVer = $parts[0] ?? 'ClamAV';
$sigUpdated = null;
if (isset($parts[2])) {
$sigUpdated = trim($parts[2]);
} else {
// Fallback: journalctl (falls Gruppe adm)
$jl = trim(@shell_exec('journalctl -u freshclam -n 50 --no-pager 2>/dev/null | grep -i "Database updated" | tail -n1') ?? '');
if ($jl) $sigUpdated = $jl;
}
$data = [
'ts' => time(),
'ham' => $ham,
'spam' => $spam,
'reject' => $reject,
'rspamdVer' => $rspamdVer ?: '',
'clamVer' => $clamVer,
'sigUpdated' => $sigUpdated,
];
Setting::set('spamav.metrics', $data);
Cache::put('dash.spamav', $data, now()->addMinutes(10));
$this->info(sprintf(
'ham=%d spam=%d reject=%d | rspamd=%s | clam=%s',
$ham, $spam, $reject, $data['rspamdVer'], $clamVer
));
return self::SUCCESS;
}
}
//
//namespace App\Console\Commands;
//
//use Illuminate\Console\Command;
//use Illuminate\Support\Facades\Cache;
//use App\Models\Setting;
//
//class SpamAvCollectCommand extends Command
//{
// protected $signature = 'spamav:collect';
// protected $description = 'Collect Rspamd/ClamAV metrics and persist to Settings (DB→Redis)';
//
// public function handle(): int
// {
// $this->info('Collecting Spam/AV metrics…');
//
// // Rspamd counters (kein Root nötig)
// $out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
// $ham = preg_match('/\bham\s*:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
// $spam = preg_match('/\bspam\s*:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
// $reject = preg_match('/\breject\s*:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
//
// // ClamAV Version + Signatur-Datum robust ohne Datei-Zugriff
// $clamLine = trim((string) @shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null'));
// $clamVer = $clamLine !== '' ? $clamLine : '';
//
// // Aus clamd/clamscan-Output das Datum am Ende herausziehen (Format: ".../Sun Oct 26 09:42:43 2025")
// $sigUpdated = null;
// if ($clamLine && preg_match('#/([^/]+\d{4})$#', $clamLine, $m)) {
// // $m[1] ist z.B. "Sun Oct 26 09:42:43 2025"
// $sigUpdated = $m[1];
// }
//
// $data = [
// 'ts' => time(),
// 'ham' => $ham,
// 'spam' => $spam,
// 'reject' => $reject,
// 'rspamdVer' => trim((string) @shell_exec('rspamadm version 2>/dev/null')) ?: '',
// 'clamVer' => $clamVer,
// 'sigUpdated' => $sigUpdated,
// ];
//
// // Persistieren (DB→Redis) + kurzer UI-Cache
// Setting::set('spamav.metrics', $data);
// Cache::put('dash.spamav', $data, 60);
//
// $this->info(sprintf(
// 'ham=%d spam=%d reject=%d | rspamd=%s | clam=%s',
// $data['ham'], $data['spam'], $data['reject'], $data['rspamdVer'], $data['clamVer']
// ));
//
// return self::SUCCESS;
// }
//}

View File

@ -0,0 +1,410 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Setting;
class StorageProbe extends Command
{
protected $signature = 'health:probe-disk {target=/}';
protected $description = 'Speichert Storage-Werte (inkl. Breakdown) in settings:health.disk';
// Quelle für vorberechnete Mail-Summen (kommt aus mail:update-stats)
private const MAILBOX_TOTALS_KEY = 'mailbox.totals';
// Wie lange dürfen Mail-Summen alt sein, bevor wir auf du-Fallback gehen (Sekunden)
private const MAILBOX_TOTALS_STALE = 900; // 15 Min
public function handle(): int
{
$target = $this->argument('target') ?: '/';
$data = $this->probe($target);
Setting::set('health.disk', $data);
Setting::set('health.disk_updated_at', now()->toIso8601String());
// hübsche Konsole
$hb = function (int $bytes): string {
$b = max(0, $bytes);
if ($b >= 1024 ** 3) return number_format($b / 1024 ** 3, 1) . ' GB';
if ($b >= 1024 ** 2) return number_format($b / 1024 ** 2, 2) . ' MiB';
if ($b >= 1024) return number_format($b / 1024, 0) . ' KiB';
return $b . ' B';
};
$bd = $data['breakdown_bytes'] ?? ['system' => 0, 'mails' => 0, 'backup' => 0];
$this->info(sprintf(
'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%s mails=%s backups=%s',
$data['mount'],
$data['total_gb'],
$data['used_gb'],
$data['free_gb'],
$data['free_plus_reserve_gb'],
$data['percent_used_total'],
$hb((int)$bd['system']), $hb((int)$bd['mails']), $hb((int)$bd['backup'])
));
return self::SUCCESS;
}
protected function probe(string $target): array
{
// ── 1) df lesen (Gesamt/Frei inkl. Reserve) ──────────────────────────
$line = trim((string)@shell_exec('LC_ALL=C df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
$device = $mount = '';
$totalKb = $usedKb = $availKb = 0;
if ($line !== '') {
$p = preg_split('/\s+/', $line);
if (count($p) >= 6) {
$device = $p[0];
$totalKb = (int)$p[1]; // TOTAL (inkl. Reserve)
$usedKb = (int)$p[2]; // Used
$availKb = (int)$p[3]; // Avail (User-sicht)
$mount = $p[5];
}
}
$toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // Ganzzahl für Kopfzahlen
$totalGb = $toGiB_i($totalKb);
$freeGb = $toGiB_i($availKb);
$usedGb = max(0, $totalGb - $freeGb);
$res5Gb = (int)round($totalGb * 0.05);
$freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
$percentUsed = $totalGb > 0 ? (int)round($usedGb * 100 / $totalGb) : 0;
// Bytes für Breakdown rechnen
$totalBytes = (int)$totalKb * 1024;
$freeBytes = (int)$availKb * 1024;
$usedBytes = max(0, $totalBytes - $freeBytes);
// ── 2) Mails: bevorzugt aus Cache (mail:update-stats) ────────────────
[$mailUsersBytes, $mailSystemBytes] = $this->readMailTotals();
// ── 3) Backups schnell messen ────────────────────────────────────────
$bytesBackup = $this->duBytesDir('/var/backups/mailwolt');
// ── 4) Breakdown auflösen und konsistent machen ─────────────────────
// alles, was nicht Mails/Backups ist, dem System zuordnen
$bytesMails = max(0, (int)$mailUsersBytes); // nur user_mail in "Mails"
$bytesSystem = max(0, $usedBytes - ($bytesMails + $bytesBackup));
// Wenn Vorberechnung system_mail existiert, zähle sie explizit zum System.
$bytesSystem += max(0, (int)$mailSystemBytes);
// negativ verhindern (Messrauschen)
if ($bytesSystem < 0) {
$bytesSystem = 0;
}
return [
'device' => $device ?: 'unknown',
'mount' => $mount ?: $target,
'total_gb' => $totalGb,
'used_gb' => $usedGb,
'free_gb' => $freeGb,
'reserve5_gb' => $res5Gb,
'free_plus_reserve_gb' => $freePlusReserveGb,
'percent_used_total' => $percentUsed,
'breakdown_bytes' => [
'system' => $bytesSystem,
'mails' => $bytesMails,
'backup' => $bytesBackup,
],
];
}
/**
* Liest vorberechnete Mail-Summen aus settings: mail.totals.
* Fallback: wenn nicht vorhanden/zu alt, ermittelt Mails per du (nur dann).
*
* @return array{0:int,1:int} [users_bytes, system_bytes]
*/
private function readMailTotals(): array
{
$totals = (array)(Setting::get(self::MAILBOX_TOTALS_KEY, []) ?: []);
$ts = isset($totals['updated_at']) ? strtotime((string)$totals['updated_at']) : null;
$fresh = $ts && (time() - $ts) <= self::MAILBOX_TOTALS_STALE;
if ($fresh) {
$users = (int)($totals['users_bytes'] ?? 0);
$system = (int)($totals['system_bytes'] ?? 0);
return [max(0, $users), max(0, $system)];
}
// Fallback EINMAL: grob mails via detectMailRoot() messen
$root = $this->detectMailRoot();
$usersBytes = $root ? $this->duBytesDir($root) : 0;
return [max(0, $usersBytes), 0];
}
/**
* Versucht, das Wurzelverzeichnis der Maildaten zu finden (Dovecot/Postfix),
* ohne auf konkrete Setups festgenagelt zu sein.
*/
private function detectMailRoot(): ?string
{
// Dovecot bevorzugt
$ml = trim((string)@shell_exec('doveconf -n 2>/dev/null | awk -F= \'/^mail_location/ {print $2}\''));
if ($ml !== '') {
if (preg_match('~^(?:maildir|mdbox|sdbox):([^%]+)~i', $ml, $m)) {
$root = rtrim($m[1]);
foreach (['/Maildir', '/mdbox', '/sdbox'] as $suffix) {
if (str_ends_with($root, $suffix)) {
$root = dirname($root);
break;
}
}
if (is_dir($root)) return $root;
}
}
// Postfix-Konfiguration
$vmb = trim((string)@shell_exec('postconf -n 2>/dev/null | awk -F= \'/^virtual_mailbox_base/ {print $2}\''));
if ($vmb !== '' && is_dir($vmb)) return $vmb;
// Fallbacks
foreach (['/var/vmail', '/var/mail/vhosts', '/srv/mail/vhosts', '/home/vmail'] as $cand) {
if (is_dir($cand)) return $cand;
}
return null;
}
/** Summe in Bytes für ein Verzeichnisbaum; robust & schnell genug. */
private function duBytesDir(string $path): int
{
if (!is_dir($path)) return 0;
$out = @shell_exec('LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
return max(0, (int)trim((string)$out));
}
}
//namespace App\Console\Commands;
//
//use Illuminate\Console\Command;
//use App\Models\Setting;
//
//class StorageProbe extends Command
//{
// protected $signature = 'health:probe-disk {target=/}';
// protected $description = 'Speichert Storage-Werte (inkl. Breakdown) in settings:health.disk';
//
// public function handle(): int
// {
// $target = $this->argument('target') ?: '/';
// $data = $this->probe($target);
//
// Setting::set('health.disk', $data);
// Setting::set('health.disk_updated_at', now()->toIso8601String());
//
// $hb = function (int $bytes): string {
// $b = max(0, $bytes);
// if ($b >= 1024**3) return number_format($b / 1024**3, 1).' GB';
// if ($b >= 1024**2) return number_format($b / 1024**2, 2).' MiB';
// if ($b >= 1024) return number_format($b / 1024, 0).' KiB';
// return $b.' B';
// };
//
// $this->info(sprintf(
// 'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%s mails=%s backups=%s',
// $data['mount'],
// $data['total_gb'],
// $data['used_gb'],
// $data['free_gb'],
// $data['free_plus_reserve_gb'],
// $data['percent_used_total'],
// $hb((int)$data['breakdown_bytes']['system']),
// $hb((int)$data['breakdown_bytes']['mails']),
// $hb((int)$data['breakdown_bytes']['backup']),
// ));
// return self::SUCCESS;
// }
//
// private function detectMailRoot(): ?string
// {
// // Dovecot
// $ml = trim((string) @shell_exec('doveconf -n 2>/dev/null | awk -F= \'/^mail_location/ {print $2}\''));
// if ($ml !== '') {
// if (preg_match('~^(?:maildir|mdbox|sdbox):([^%]+)~i', $ml, $m)) {
// $root = rtrim($m[1]);
// foreach (['/Maildir', '/mdbox', '/sdbox'] as $suffix) {
// if (str_ends_with($root, $suffix)) { $root = dirname($root); break; }
// }
// if (is_dir($root)) return $root;
// }
// }
// // Postfix
// $vmb = trim((string) @shell_exec('postconf -n 2>/dev/null | awk -F= \'/^virtual_mailbox_base/ {print $2}\''));
// if ($vmb !== '' && is_dir($vmb)) return $vmb;
//
// // Fallbacks
// foreach (['/var/vmail', '/var/mail/vhosts', '/srv/mail/vhosts', '/home/vmail'] as $cand) {
// if (is_dir($cand)) return $cand;
// }
// return null;
// }
//
// private function duBytesDir(string $path): int
// {
// if (!is_dir($path)) return 0;
// $out = @shell_exec('LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
// return max(0, (int) trim((string) $out));
// }
//
// protected function probe(string $target): array
// {
// // --- df: Gesamtdaten des Filesystems (inkl. Reserve) -----------------
// $line = trim((string)@shell_exec('LC_ALL=C df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
//
// $device = $mount = '';
// $totalKb = $usedKb = $availKb = 0;
//
// if ($line !== '') {
// $p = preg_split('/\s+/', $line);
// if (count($p) >= 6) {
// $device = $p[0];
// $totalKb = (int)$p[1]; // TOTAL (inkl. Reserve)
// $usedKb = (int)$p[2]; // Used
// $availKb = (int)$p[3]; // Avail (User-sicht)
// $mount = $p[5];
// }
// }
//
// $toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // ganzzahlig (UI: Gesamt/Genutzt/Frei)
// $toGiB_f = static fn($kb) => round(max(0, (int)$kb) / (1024 * 1024), 1); // eine Nachkommastelle (Breakdown/Legende)
//
// $totalGb = $toGiB_i($totalKb);
// $freeGb = $toGiB_i($availKb); // user-verfügbar
// $usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
// $res5Gb = (int)round($totalGb * 0.05); // 5% von Gesamt
// $freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
// $percentUsed = $totalGb > 0 ? (int)round($usedGb * 100 / $totalGb) : 0;
//
// $duBytes = function (string $path): int {
// if (!is_dir($path)) return 0;
// $b = (int) trim((string) @shell_exec(
// 'LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1'
// ));
// return max(0, $b);
// };
//
// $mailRoot = $this->detectMailRoot();
// $bytesMails = $mailRoot ? $this->duBytesDir($mailRoot) : 0;
// $bytesBackup = $duBytes('/var/backups/mailwolt');
//
// $totalBytes = (int) $totalKb * 1024;
// $freeBytes = (int) $availKb * 1024;
// $usedBytes = max(0, $totalBytes - $freeBytes);
//
// $bytesSystem = max(0, $usedBytes - ($bytesMails + $bytesBackup));
//
// return [
// 'device' => $device ?: 'unknown',
// 'mount' => $mount ?: $target,
//
// 'total_gb' => $totalGb,
// 'used_gb' => $usedGb, // inkl. Reserve
// 'free_gb' => $freeGb, // User-sicht
// 'reserve5_gb' => $res5Gb, // Info
// 'free_plus_reserve_gb' => $freePlusReserveGb, // Anzeige „Frei“
//
// 'percent_used_total' => $percentUsed,
//
// // Reale Breakdown-Werte
// 'breakdown_bytes' => [
// 'system' => $bytesSystem,
// 'mails' => $bytesMails,
// 'backup' => $bytesBackup,
// ],
// ];
// }
//}
//
//namespace App\Console\Commands;
//
//use Illuminate\Console\Command;
//use App\Models\Setting;
//
//class StorageProbe extends Command
//{
// protected $signature = 'health:probe-disk {target=/}';
// protected $description = 'Speichert Storage-Werte (inkl. Frei+5%) in settings:health.disk';
//
// public function handle(): int
// {
// $target = $this->argument('target') ?: '/';
// $data = $this->probe($target);
//
// // Persistiert (DB + Redis) über dein Settings-Model
// Setting::set('health.disk', $data);
// Setting::set('health.disk_updated_at', now()->toIso8601String());
//
// $this->info(sprintf(
// 'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d)',
// $data['mount'],
// $data['total_gb'],
// $data['used_gb'],
// $data['free_gb'],
// $data['free_plus_reserve_gb'],
// $data['percent_used_total'],
// ));
//
// return self::SUCCESS;
// }
//
// protected function probe(string $target): array
// {
// $line = trim((string) @shell_exec('df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
//
// $device = $mount = '';
// $totalKb = $usedKb = $availKb = 0;
//
// if ($line !== '') {
// $p = preg_split('/\s+/', $line);
// if (count($p) >= 6) {
// $device = $p[0];
// $totalKb = (int) $p[1]; // TOTAL (inkl. Reserve)
// $usedKb = (int) $p[2]; // Used
// $availKb = (int) $p[3]; // Avail (User-sicht)
// $mount = $p[5];
// }
// }
//
// $toGiB = static fn($kb) => (int) round(max(0, (int)$kb) / (1024*1024));
//
// $totalGb = $toGiB($totalKb);
// $freeGb = $toGiB($availKb); // user-verfügbar
// $usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
// $res5Gb = (int) round($totalGb * 0.05); // 5% von Gesamt
// $freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
//
// $percentUsed = $totalGb > 0 ? (int) round($usedGb * 100 / $totalGb) : 0;
//
// return [
// 'device' => $device ?: 'unknown',
// 'mount' => $mount ?: $target,
//
// 'total_gb' => $totalGb,
// 'used_gb' => $usedGb, // inkl. Reserve
// 'free_gb' => $freeGb, // User-sicht
// 'reserve5_gb' => $res5Gb, // Info
// 'free_plus_reserve_gb' => $freePlusReserveGb, // ← das willst du anzeigen
//
// 'percent_used_total' => $percentUsed, // fürs Donut (~15%)
// 'breakdown' => [
// 'system_gb' => 5.2, // OS, App, Logs …
// 'mails_gb' => 2.8, // /var/mail/vhosts
// 'backup_gb' => 1.0, // /var/backups/mailwolt (oder wohin du sicherst)
// ],
// ];
// }
//}

View File

@ -1,5 +1,6 @@
<?php
namespace App\Console\Commands;
use App\Models\MailUser;
@ -12,83 +13,184 @@ use RecursiveIteratorIterator;
class UpdateMailboxStats extends Command
{
protected $signature = 'mail:update-stats {--user=}';
protected $description = 'Aktualisiert Quota & Nachrichtenzahl (Settings/Redis; DB-frei)';
protected $description = 'Aktualisiert Quota & Nachrichtenzahl (Settings/Redis; ohne DB-Spalten).';
// public function handle(): int
// {
// $log = Log::channel('mailstats');
// $onlyUser = trim((string)$this->option('user')) ?: null;
// $t0 = microtime(true);
//
// // Basis-Query: nur aktive, keine System-Mailboxen und keine System-Domains
// $base = MailUser::query()
// ->select(['id', 'domain_id', 'localpart', 'email', 'is_active', 'is_system'])
// ->with(['domain:id,domain,is_system'])
// ->where('is_active', true)
// ->where('is_system', false)
// ->whereHas('domain', fn($d) => $d->where('is_system', false));
//
// if ($onlyUser) {
// $base->where('email', $onlyUser);
// }
//
// $checked = 0;
// $changed = 0;
//
// $log->info('mail:update-stats START', ['only' => $onlyUser]);
//
// $base->orderBy('id')->chunkById(200, function ($users) use (&$checked, &$changed, $log) {
// foreach ($users as $u) {
// $checked++;
//
// // Email robust bestimmen (raw -> accessor -> zusammengesetzt)
// $raw = (string)($u->getRawOriginal('email') ?? '');
// $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? null);
//
// if (!is_string($email) || !preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) {
// // still kein Log-Spam
// continue;
// }
//
// [$local, $domain] = explode('@', $email, 2);
// $maildir = "/var/mail/vhosts/{$domain}/{$local}";
//
// // Größe in Bytes (rekursiv)
// $usedBytes = 0;
// if (is_dir($maildir)) {
// $it = new RecursiveIteratorIterator(
// new RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
// );
// foreach ($it as $f) {
// if ($f->isFile()) $usedBytes += $f->getSize();
// }
// }
//
// // Message-Count
// $messageCount = $this->countViaDoveadm($email);
// if ($messageCount === null) {
// $messageCount = $this->countViaFilesystem($maildir);
// }
//
// $key = "mailbox.{$email}";
// $prev = (array)(Setting::get($key, []) ?: []);
// $new = [
// 'used_bytes' => (int)$usedBytes,
// 'message_count' => (int)$messageCount,
// 'updated_at' => now()->toDateTimeString(),
// ];
//
// if (($prev['used_bytes'] ?? null) !== $new['used_bytes']
// || ($prev['message_count'] ?? null) !== $new['message_count']) {
// Setting::set($key, $new);
// $changed++;
//
// // kurze Ausgabe & Info-Log NUR bei Änderung
// $this->line(sprintf("%-35s %7.1f MiB %5d msgs",
// $email, $usedBytes / 1048576, $messageCount));
// $log->info('updated', ['email' => $email, 'used_bytes' => $new['used_bytes'], 'message_count' => $new['message_count']]);
// }
// }
// });
//
// $ms = (int)((microtime(true) - $t0) * 1000);
// $log->info('mail:update-stats DONE', compact('checked', 'changed', 'ms'));
// $this->info('Mailbox-Statistiken aktualisiert.');
// return self::SUCCESS;
// }
public function handle(): int
{
Log::info('=== mail:update-stats START ===', ['userOpt' => $this->option('user')]);
$log = Log::channel('mailstats');
$onlyUser = trim((string)$this->option('user')) ?: null;
$t0 = microtime(true);
$q = MailUser::query()
->with('domain:id,domain')
->active();
// Summen
$sumUserBytes = 0;
$sumSystemBytes = 0;
if ($only = trim((string)$this->option('user'))) {
$q->byEmail($only);
// aktiver Benutzerbestand (inkl. Domains, um system/non-system zu unterscheiden)
$base = MailUser::query()
->select(['id','domain_id','localpart','email','is_active','is_system'])
->with(['domain:id,domain,is_system'])
->where('is_active', true);
if ($onlyUser) {
$base->where('email', $onlyUser);
}
$users = $q->get();
if ($users->isEmpty()) {
Log::warning('Keine passenden Mailboxen gefunden.');
$this->warn('Keine passenden Mailboxen gefunden.');
return self::SUCCESS;
}
$checked = 0; $changed = 0;
$log->info('mail:update-stats START', ['only' => $onlyUser]);
$base->orderBy('id')->chunkById(200, function ($users) use (&$checked,&$changed,&$sumUserBytes,&$sumSystemBytes,$log) {
foreach ($users as $u) {
// 1) sichere E-Mail ermitteln: roher DB-Wert → Accessor → zusammengesetzt
$raw = (string)($u->getRawOriginal('email') ?? '');
$email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? '');
$checked++;
if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', (string)$email)) {
Log::warning('Ungültige effektive Adresse skip', [
'user_id' => $u->id, 'raw' => $raw, 'computed' => $email
]);
$raw = (string)($u->getRawOriginal('email') ?? '');
$email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? null);
if (!is_string($email) || !preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) {
continue;
}
[$local, $domain] = explode('@', $email, 2);
$maildir = "/var/mail/vhosts/{$domain}/{$local}";
// 2) Größe (Dateisystem)
// Größe in Bytes (rekursiv)
$usedBytes = 0;
if (is_dir($maildir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if ($f->isFile()) $usedBytes += $f->getSize();
}
foreach ($it as $f) if ($f->isFile()) $usedBytes += $f->getSize();
}
// 3) Message-Count prefer doveadm als vmail; Fallback: Files
$isSystemDomain = (bool)($u->domain->is_system ?? false);
if ($isSystemDomain || $u->is_system) {
// systemische Mailboxen fließen nur in die System-Summe ein
$sumSystemBytes += $usedBytes;
continue; // KEIN per-user Setting
}
// Message-Count
$messageCount = $this->countViaDoveadm($email);
if ($messageCount === null) {
$messageCount = $this->countViaFilesystem($maildir);
}
if ($messageCount === null) $messageCount = $this->countViaFilesystem($maildir);
// 4) In Settings (→ Redis + DB-Backup) persistieren
Setting::set("mailbox.{$email}", [
$sumUserBytes += $usedBytes;
$key = "mailbox.{$email}";
$prev = (array)(Setting::get($key, []) ?: []);
$new = [
'used_bytes' => (int)$usedBytes,
'message_count' => (int)$messageCount,
'updated_at' => now()->toDateTimeString(),
]);
];
$this->line(sprintf("%-35s %7.1f MiB %5d msgs",
$email, $usedBytes / 1024 / 1024, $messageCount));
Log::info('Mailbox-Stat OK', [
'email' => $email,
'maildir' => $maildir,
'used_bytes' => $usedBytes,
'message_count' => $messageCount,
]);
if (($prev['used_bytes'] ?? null) !== $new['used_bytes']
|| ($prev['message_count'] ?? null) !== $new['message_count']) {
Setting::set($key, $new);
$changed++;
$this->line(sprintf("%-35s %7.2f MiB %5d msgs",
$email, $usedBytes / 1048576, $messageCount));
$log->info('updated', ['email'=>$email]+$new);
}
}
});
Log::info('=== mail:update-stats DONE ===');
// Totals persistieren (Nutzen wir später im StorageProbe)
Setting::set('mailbox.totals', [
'users_bytes' => (int)$sumUserBytes, // alle nicht-systemischen Mailboxen
'system_bytes' => (int)$sumSystemBytes, // systemische Mailboxen
'updated_at' => now()->toIso8601String(),
]);
$ms = (int)((microtime(true)-$t0)*1000);
$log->info('mail:update-stats DONE', compact('checked','changed','ms','sumUserBytes','sumSystemBytes'));
$this->info('Mailbox-Statistiken aktualisiert.');
return self::SUCCESS;
}
protected function countViaDoveadm(string $email): ?int
private function countViaDoveadm(string $email): ?int
{
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
. escapeshellarg($email) . " messages INBOX 2>&1";
@ -105,7 +207,7 @@ class UpdateMailboxStats extends Command
return null;
}
protected function countViaFilesystem(string $maildir): int
private function countViaFilesystem(string $maildir): int
{
$n = 0;
foreach (['cur', 'new'] as $sub) {
@ -123,6 +225,291 @@ class UpdateMailboxStats extends Command
}
}
//namespace App\Console\Commands;
//
//use App\Models\MailUser;
//use App\Models\Setting;
//use Illuminate\Console\Command;
//use Illuminate\Support\Facades\Log;
//use RecursiveDirectoryIterator;
//use RecursiveIteratorIterator;
//
//class UpdateMailboxStats extends Command
//{
// protected $signature = 'mail:update-stats {--user=}';
// protected $description = 'Aktualisiert Quota & Nachrichtenzahl (Settings/Redis; ohne MailUser-DB-Felder).';
//
// public function handle(): int
// {
// $log = Log::channel('mailstats');
//
// $onlyUser = trim((string)$this->option('user')) ?: null;
// $started = microtime(true);
//
// $q = MailUser::query()
// ->with('domain:id,domain,is_system')
// ->where('is_active', true)
// ->where('is_system', false) // keine System-Mailboxen
// ->whereHas('domain', fn($d) => $d->where('is_system', false)); // keine Systemdomains
//
// if ($onlyUser) {
// $q->where('email', $onlyUser);
// }
//
// $users = $q->get();
//
// $log->info('mail:update-stats START', [
// 'only' => $onlyUser,
// 'users' => $users->count(),
// ]);
//
// if ($users->isEmpty()) {
// $this->warn('Keine passenden Mailboxen gefunden.');
// $log->info('mail:update-stats DONE (no users)', ['ms' => (int)((microtime(true) - $started) * 1000)]);
// return self::SUCCESS;
// }
//
// $changed = 0;
// $checked = 0;
//
// foreach ($users as $u) {
// $checked++;
//
// // Email robust ermitteln
// $raw = (string)($u->getRawOriginal('email') ?? '');
// $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? null);
//
// if (!is_string($email) || !preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) {
// // nur debug vermeidet Spam
// $log->debug('skip invalid email', ['user_id' => $u->id, 'raw' => $raw, 'computed' => $email]);
// continue;
// }
//
// [$local, $domain] = explode('@', $email, 2);
// $maildir = "/var/mail/vhosts/{$domain}/{$local}";
//
// // Größe (Dateisystem)
// $usedBytes = 0;
// if (is_dir($maildir)) {
// $it = new RecursiveIteratorIterator(
// new RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
// );
// foreach ($it as $f) {
// if ($f->isFile()) {
// $usedBytes += $f->getSize();
// }
// }
// }
//
// // Nachrichten zählen
// $messageCount = $this->countViaDoveadm($email);
// if ($messageCount === null) {
// $messageCount = $this->countViaFilesystem($maildir);
// }
//
// // Nur bei Änderungen persistieren + loggen
// $key = "mailbox.{$email}";
// $prev = Setting::get($key, null) ?: [];
//
// $new = [
// 'used_bytes' => (int)$usedBytes,
// 'message_count' => (int)$messageCount,
// 'updated_at' => now()->toDateTimeString(),
// ];
//
// if (
// !is_array($prev) ||
// ($prev['used_bytes'] ?? null) !== $new['used_bytes'] ||
// ($prev['message_count'] ?? null) !== $new['message_count']
// ) {
// Setting::set($key, $new);
// $changed++;
//
// // kurze, nützliche Info nur bei Änderung
// $this->line(sprintf("%-35s %7.1f MiB %5d msgs",
// $email, $usedBytes / 1024 / 1024, $messageCount));
//
// $log->info('updated', [
// 'email' => $email,
// 'used_bytes' => $new['used_bytes'],
// 'message_count' => $new['message_count'],
// ]);
// } else {
// // keine Änderung → kein Info-Log
// $log->debug('unchanged', ['email' => $email]);
// }
// }
//
// $ms = (int)((microtime(true) - $started) * 1000);
// $log->info('mail:update-stats DONE', [
// 'checked' => $checked,
// 'changed' => $changed,
// 'ms' => $ms,
// ]);
//
// $this->info('Mailbox-Statistiken aktualisiert.');
// return self::SUCCESS;
// }
//
// protected function countViaDoveadm(string $email): ?int
// {
// $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
// . escapeshellarg($email) . " messages INBOX 2>&1";
// $out = [];
// $rc = 0;
// exec($cmd, $out, $rc);
//
// if ($rc !== 0) return null;
//
// foreach ($out as $line) {
// if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) {
// return (int)$m[1];
// }
// }
// return null;
// }
//
// protected function countViaFilesystem(string $maildir): int
// {
// $n = 0;
// foreach (['cur', 'new'] as $sub) {
// $dir = "{$maildir}/{$sub}";
// if (!is_dir($dir)) continue;
// $h = opendir($dir);
// if (!$h) continue;
// while (($fn = readdir($h)) !== false) {
// if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue;
// $n++;
// }
// closedir($h);
// }
// return $n;
// }
//}
//namespace App\Console\Commands;
//
//use App\Models\MailUser;
//use App\Models\Setting;
//use Illuminate\Console\Command;
//use Illuminate\Support\Facades\Log;
//use RecursiveDirectoryIterator;
//use RecursiveIteratorIterator;
//
//class UpdateMailboxStats extends Command
//{
// protected $signature = 'mail:update-stats {--user=}';
// protected $description = 'Aktualisiert Quota & Nachrichtenzahl (Settings/Redis; DB-frei)';
//
// public function handle(): int
// {
// Log::info('=== mail:update-stats START ===', ['userOpt' => $this->option('user')]);
//
// $q = MailUser::query()
// ->with('domain:id,domain')
// ->active();
//
// if ($only = trim((string)$this->option('user'))) {
// $q->byEmail($only);
// }
//
// $users = $q->get();
// if ($users->isEmpty()) {
// Log::warning('Keine passenden Mailboxen gefunden.');
// $this->warn('Keine passenden Mailboxen gefunden.');
// return self::SUCCESS;
// }
//
// foreach ($users as $u) {
// // 1) sichere E-Mail ermitteln: roher DB-Wert → Accessor → zusammengesetzt
// $raw = (string)($u->getRawOriginal('email') ?? '');
// $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? '');
//
// if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', (string)$email)) {
// Log::warning('Ungültige effektive Adresse skip', [
// 'user_id' => $u->id, 'raw' => $raw, 'computed' => $email
// ]);
// continue;
// }
//
// [$local, $domain] = explode('@', $email, 2);
// $maildir = "/var/mail/vhosts/{$domain}/{$local}";
//
// // 2) Größe (Dateisystem)
// $usedBytes = 0;
// if (is_dir($maildir)) {
// $it = new RecursiveIteratorIterator(
// new RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
// );
// foreach ($it as $f) {
// if ($f->isFile()) $usedBytes += $f->getSize();
// }
// }
//
// // 3) Message-Count prefer doveadm als vmail; Fallback: Files
// $messageCount = $this->countViaDoveadm($email);
// if ($messageCount === null) {
// $messageCount = $this->countViaFilesystem($maildir);
// }
//
// // 4) In Settings (→ Redis + DB-Backup) persistieren
// Setting::set("mailbox.{$email}", [
// 'used_bytes' => (int)$usedBytes,
// 'message_count' => (int)$messageCount,
// 'updated_at' => now()->toDateTimeString(),
// ]);
//
// $this->line(sprintf("%-35s %7.1f MiB %5d msgs",
// $email, $usedBytes / 1024 / 1024, $messageCount));
//
// Log::info('Mailbox-Stat OK', [
// 'email' => $email,
// 'maildir' => $maildir,
// 'used_bytes' => $usedBytes,
// 'message_count' => $messageCount,
// ]);
// }
//
// Log::info('=== mail:update-stats DONE ===');
// $this->info('Mailbox-Statistiken aktualisiert.');
// return self::SUCCESS;
// }
//
// protected function countViaDoveadm(string $email): ?int
// {
// $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
// . escapeshellarg($email) . " messages INBOX 2>&1";
// $out = [];
// $rc = 0;
// exec($cmd, $out, $rc);
// if ($rc !== 0) return null;
//
// foreach ($out as $line) {
// if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) {
// return (int)$m[1];
// }
// }
// return null;
// }
//
// protected function countViaFilesystem(string $maildir): int
// {
// $n = 0;
// foreach (['cur', 'new'] as $sub) {
// $dir = "{$maildir}/{$sub}";
// if (!is_dir($dir)) continue;
// $h = opendir($dir);
// if (!$h) continue;
// while (($fn = readdir($h)) !== false) {
// if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue;
// $n++;
// }
// closedir($h);
// }
// return $n;
// }
//}
//namespace App\Console\Commands;
//
//use Illuminate\Console\Command;

View File

@ -1,20 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
class CertPing implements ShouldBroadcastNow
{
public function __construct(public string $message) {}
public function broadcastOn(): Channel
{ return new Channel('system.tasks'); }
public function broadcastAs(): string
{ return 'cert.ping'; }
public function broadcastWith(): array
{ return ['message' => $this->message]; }
}

View File

@ -0,0 +1,27 @@
<?php
// App/Events/HealthUpdated.php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HealthUpdated implements ShouldBroadcastNow
{
use Dispatchable, SerializesModels;
public function __construct(public array $meta) {}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('health');
}
// Wichtig: garantiert, dass "data" ein Objekt mit "meta" ist (kein JSON-String)
public function broadcastWith(): array
{
return ['meta' => $this->meta];
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class Test
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Session\TokenMismatchException;
use Throwable;
class Handler extends ExceptionHandler
{
public function render($request, \Throwable $e)
{
if ($e instanceof TokenMismatchException) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'session_expired',
'redirect' => route('login'),
], 419);
}
return redirect()
->route('login')
->with('warning', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
return parent::render($request, $e);
}
}

View File

@ -31,7 +31,6 @@ if (!function_exists('webmail_host')) {
if (!function_exists('mta_host')) {
function mta_host(?int $domainId = null): string
{
// 1⃣ Vorrang: Datenbankwert (z. B. aus der domains-Tabelle)
if ($domainId) {
try {
$domain = \App\Models\Domain::find($domainId);
@ -39,17 +38,25 @@ if (!function_exists('mta_host')) {
return $domain->mta_host;
}
} catch (\Throwable $e) {
// DB evtl. noch nicht migriert — fallback auf env
// DB evtl. noch nicht migriert — fallback auf env
}
}
// 2⃣ ENV-Variante (z. B. MTA_SUB=mail01)
$sub = env('MTA_SUB');
if ($sub) {
return domain_host($sub);
}
// 3⃣ Notfall: statischer Fallback
return domain_host('mx');
}
}
if (! function_exists('countryFlag')) {
function countryFlag(string $code): string
{
$code = strtoupper($code);
return implode('', array_map(
fn($char) => mb_chr(ord($char) + 127397, 'UTF-8'),
str_split($code)
));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class AuthenticatedMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (!Auth::check()) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
return redirect()->route('login');
}
return $next($request);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class GuestOnlyMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
// Eingeloggt → z. B. Dashboard weiterleiten
return redirect()->route('ui.dashboard');
}
return $next($request);
}
}

View File

@ -2,9 +2,11 @@
namespace App\Jobs;
use App\Models\Setting as SettingsModel;
use App\Support\CacheVer;
use App\Support\WoltGuard\Probes;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -13,53 +15,33 @@ use Symfony\Component\Finder\Finder;
class RunHealthChecks implements ShouldQueue
{
use Queueable;
use Queueable, Probes;
public int $timeout = 10; // safety
public int $timeout = 10;
public int $tries = 1;
public function handle(): void
{
try {
$services = [
$this->safe(fn() => $this->service('postfix'), ['name'=>'postfix']),
$this->safe(fn() => $this->service('dovecot'), ['name'=>'dovecot']),
$this->safe(fn() => $this->service('rspamd'), ['name'=>'rspamd']),
$this->safe(fn() => $this->tcp('127.0.0.1', 6379), ['name'=>'redis']),
$this->safe(fn() => $this->db(), ['name'=>'db']),
// $this->safe(fn() => $this->queueWorkers(), ['name'=>'queue']),
$this->safe(fn() => $this->tcp('127.0.0.1', 8080), ['name'=>'reverb']),
];
$meta = [
'app_version' => config('app.version', app()->version()),
'pending_migs' => $this->safe(fn() => $this->pendingMigrationsCount(), 0),
'cert_soon' => $this->safe(fn() => $this->certificatesDue(30), ['count'=>0,'nearest_days'=>null]),
'disk' => $this->safe(fn() => $this->diskUsage(), ['percent'=>null,'free_gb'=>null]),
'system' => $this->systemLoad(),
'updated_at' => now()->toIso8601String(),
];
Cache::put('health:services', array_values($services), 300);
Cache::put('health:meta', $meta, 300);
Cache::put('metrics:queues', [
'outgoing' => 19,
'incoming' => 5,
'today_ok' => 834,
'today_err'=> 12,
'trend' => [
'outgoing' => [2,1,0,4,3,5,4,0,0,0], // letzte 10 Zeitfenster
'incoming' => [1,0,0,1,0,2,1,0,0,0],
'ok' => [50,62,71,88,92,110,96,120,130,115],
'err' => [1,0,0,2,1,0,1,3,2,2],
],
], 120);
Cache::put('events:recent', $this->safe(fn() => $this->recentAlerts(), []), 300);
} catch (\Throwable $e) {
// Last-resort catch: never allow the job to fail hard
Log::error('RunHealthChecks fatal', ['ex' => $e]);
$cards = config('woltguard.cards', []);
$svcRows = [];
foreach ($cards as $key => $card) {
$ok = false;
foreach ($card['sources'] as $src) {
if ($this->check($src)) {
$ok = true;
break;
}
}
$svcRows[] = ['name' => $key, 'ok' => $ok]; // labels brauchst du im UI
}
$payload = ['ts' => time(), 'rows' => $svcRows];
Cache::put(CacheVer::k('health:services'), $payload, 300);
Log::info('WG: writing services', ['count'=>count($svcRows)]);
SettingsModel::set('woltguard.services', $payload);
Cache::forget('health:services');
}
/** Wraps a probe; logs and returns fallback on error */
protected function safe(callable $fn, $fallback = null)
@ -94,9 +76,16 @@ class RunHealthChecks implements ShouldQueue
protected function queueWorkers(): array
{
$r = Process::run("systemctl is-active supervisor");
$raw = trim($r->output() ?: $r->errorOutput());
return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
$okQueue = $this->probeSystemd('mailwolt-queue.service');
$okSched = $this->probeSystemd('mailwolt-schedule.service');
$ok = $okQueue && $okSched;
$raw = sprintf('queue:%s sched:%s', $okQueue ? 'active' : 'down', $okSched ? 'active' : 'down');
return ['name' => 'queue', 'ok' => $ok, 'raw' => $raw];
// $r = Process::run("systemctl is-active supervisor");
// $raw = trim($r->output() ?: $r->errorOutput());
// return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
}
protected function diskUsage(): array

View File

@ -1,292 +1,351 @@
<?php
namespace App\Livewire\Ui\Dashboard;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class HealthCard extends Component
{
// Rohdaten
public array $services = [];
public array $meta = [];
// UI: Balken/Segmente
public int $barSegments = 24;
public array $cpuSeg = [];
public array $ramSeg = [];
// UI: Load & Uptime
public string $loadBadgeText = '';
public array $loadDots = [];
public array $uptimeChips = [];
public string $uptimeIcon = 'ph ph-clock';
// UI: Services kompakt
public array $servicesCompact = [];
// Storage
public ?int $diskUsedGb = null;
public ?int $diskTotalGb = null;
public ?int $diskPercent = null;
public ?int $diskFreeGb = null;
public array $diskSegments = [];
public int $diskInnerSize = 160; // grauer Kreis
public int $diskSegOuterRadius = 92; // Abstand der Ticks
public array $diskCenterText = ['percent'=>'%','label'=>'Speicher belegt'];
// Anzeige oben
public ?string $ramSummary = null;
public ?string $loadText = null;
public ?string $uptimeText = null;
public ?int $cpuPercent = null;
public ?int $ramPercent = null;
public ?string $updatedAtHuman = null;
public function mount(): void
{
$this->loadData();
}
public function loadData(): void
{
$this->services = Cache::get('health:services', []);
$this->meta = Cache::get('health:meta', []);
$this->hydrateSystem();
$this->hydrateDisk();
$this->hydrateUpdatedAt();
$this->decorateServicesCompact();
$this->decorateDisk();
}
public function render()
{
return view('livewire.ui.dashboard.health-card');
}
/* ---------------- Aufbereitung ---------------- */
protected function hydrateSystem(): void
{
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
// RAM %
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
}
// RAM Summary "used / total"
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
// Load (1/5/15)
$load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
$load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
$load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
if (is_array($loadMixed)) {
$vals = array_values($loadMixed);
$load1 ??= $this->numFloat($vals[0] ?? null);
$load5 ??= $this->numFloat($vals[1] ?? null);
$load15 ??= $this->numFloat($vals[2] ?? null);
}
$this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
// CPU %
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
if ($this->cpuPercent === null && $load1 !== null) {
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
$this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
}
// Uptime
$this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
if (!$this->uptimeText) {
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
}
// Segment-Balken
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
$this->ramSeg = $this->buildSegments($this->ramPercent);
// Load: Dots (1/5/15 relativ zu Kernen)
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
$ratio1 = $load1 !== null ? $load1 / $cores : null;
$ratio5 = $load5 !== null ? $load5 / $cores : null;
$ratio15 = $load15 !== null ? $load15 / $cores : null;
$this->loadBadgeText = $this->loadText ?? '';
$this->loadDots = [
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
];
// Uptime Chips
$chips = [];
if ($this->uptimeText) {
$d = $h = $m = null;
if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
}
$this->uptimeChips = $chips ?: [['v'=>'','u'=>'']];
}
protected function hydrateDisk(): void
{
$disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
$this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
$this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
// total/used berechnen, falls nicht geliefert
if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
$p = max(0, min(100, $this->diskPercent)) / 100;
$estTotal = (int) round($this->diskFreeGb / (1 - $p));
$this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
}
if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
$u = $this->diskTotalGb - $this->diskFreeGb;
$this->diskUsedGb = $u >= 0 ? $u : null;
}
}
protected function hydrateUpdatedAt(): void
{
$updated = $this->meta['updated_at'] ?? null;
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '';
} catch (\Throwable) { $this->updatedAtHuman = ''; }
}
protected function decorateServicesCompact(): void
{
$nameMap = [
'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
'127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
'127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
];
$this->servicesCompact = collect($this->services)
->map(function ($srv) use ($nameMap) {
$key = (string)($srv['name'] ?? '');
$ok = (bool) ($srv['ok'] ?? false);
$label = $nameMap[$key]['label'] ?? ucfirst($key);
$hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
return [
'label' => $label,
'hint' => $hint,
'ok' => $ok,
'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
'pillText' => $ok ? 'ok' : 'down',
'pillClass' => $ok
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
];
})
->values()
->all();
}
protected function decorateDisk(): void
{
$percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
$this->diskCenterText = [
'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '%',
'label' => 'Speicher belegt',
];
$count = 48;
$activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
$step = 360 / $count;
$activeClass = match (true) {
$percent >= 90 => 'bg-rose-400',
$percent >= 70 => 'bg-amber-300',
default => 'bg-emerald-400',
};
$this->diskSegments = [];
for ($i = 0; $i < $count; $i++) {
$angle = ($i * $step) - 90; // Start bei 12 Uhr
$this->diskSegments[] = [
'angle' => $angle,
'class' => $i < $activeN ? $activeClass : 'bg-white/16',
];
}
}
/* ---------------- Helpers ---------------- */
protected function pick(array $arr, array $keys)
{
foreach ($keys as $k) {
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
return $arr[$k];
}
}
return null;
}
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
{
if ($l1 !== null || $l5 !== null || $l15 !== null) {
$fmt = fn($x) => $x === null ? '' : number_format($x, 2);
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
}
return $fallback ?: null;
}
protected function secondsToHuman(int $s): string
{
$d = intdiv($s, 86400); $s %= 86400;
$h = intdiv($s, 3600); $s %= 3600;
$m = intdiv($s, 60);
if ($d > 0) return "{$d}d {$h}h";
if ($h > 0) return "{$h}h {$m}m";
return "{$m}m";
}
protected function toneByPercent(?int $p): string {
if ($p === null) return 'white';
if ($p >= 90) return 'rose';
if ($p >= 70) return 'amber';
return 'emerald';
}
protected function buildSegments(?int $percent): array {
$n = max(6, $this->barSegments);
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
$tone = $this->toneByPercent($percent);
$fillCls = match($tone) {
'rose' => 'bg-rose-500/80',
'amber' => 'bg-amber-400/80',
'emerald'=> 'bg-emerald-500/80',
default => 'bg-white/20',
};
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
}
protected function loadDotClass(?float $ratio): string {
if ($ratio === null) return 'bg-white/25';
if ($ratio >= 0.9) return 'bg-rose-500';
if ($ratio >= 0.7) return 'bg-amber-400';
return 'bg-emerald-400';
}
}
//
//namespace App\Livewire\Ui\Dashboard;
//
//use Carbon\Carbon;
//use Illuminate\Support\Facades\Cache;
//use Livewire\Component;
//
//class HealthCard extends Component
//{
// // Rohdaten
// public array $services = [];
// public array $meta = [];
//
// // UI: Balken/Segmente
// public int $barSegments = 24;
// public array $cpuSeg = [];
// public array $ramSeg = [];
//
// // UI: Load & Uptime
// public string $loadBadgeText = '';
// public array $loadDots = [];
// public array $uptimeChips = [];
// public string $uptimeIcon = 'ph ph-clock';
//
// // UI: Services kompakt
// public array $servicesCompact = [];
//
// // Storage
// public ?int $diskUsedGb = null;
// public ?int $diskTotalGb = null;
// public ?int $diskPercent = null;
// public ?int $diskFreeGb = null;
// public array $diskSegments = [];
// public int $diskInnerSize = 160; // grauer Kreis
// public int $diskSegOuterRadius = 92; // Abstand der Ticks
// public array $diskCenterText = ['percent'=>'%','label'=>'Speicher belegt'];
//
// // Anzeige oben
// public ?string $ramSummary = null;
// public ?string $loadText = null;
// public ?string $uptimeText = null;
// public ?int $cpuPercent = null;
// public ?int $ramPercent = null;
// public ?string $updatedAtHuman = null;
//
// public $guardOk = [];
// public function mount(): void
// {
// $this->loadData();
// }
//
// public function loadData(): void
// {
// $this->services = Cache::get('health:services', []);
// $this->meta = Cache::get('health:meta', []);
//
// $this->hydrateSystem();
// $this->hydrateDisk();
// $this->hydrateUpdatedAt();
// $this->decorateServicesCompact();
// $this->decorateDisk();
// }
//
// public function render()
// {
// return view('livewire.ui.dashboard.health-card');
// }
//
// /* ---------------- Aufbereitung ---------------- */
//
// protected function hydrateSystem(): void
// {
// $sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
//
// // RAM %
// $this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
// if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
// $this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
// }
//
// // RAM Summary "used / total"
// $used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
// $tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
// $this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
//
// // Load (1/5/15)
// $load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
// $load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
// $load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
//
// $loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
// if (is_array($loadMixed)) {
// $vals = array_values($loadMixed);
// $load1 ??= $this->numFloat($vals[0] ?? null);
// $load5 ??= $this->numFloat($vals[1] ?? null);
// $load15 ??= $this->numFloat($vals[2] ?? null);
// }
// $this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
//
// // CPU %
// $this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
// if ($this->cpuPercent === null && $load1 !== null) {
// $cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
// $this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
// }
//
// // Uptime
// $this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
// if (!$this->uptimeText) {
// $uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
// if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
// }
//
// // Segment-Balken
// $this->cpuSeg = $this->buildSegments($this->cpuPercent);
// $this->ramSeg = $this->buildSegments($this->ramPercent);
//
// // Load: Dots (1/5/15 relativ zu Kernen)
// $cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
// $ratio1 = $load1 !== null ? $load1 / $cores : null;
// $ratio5 = $load5 !== null ? $load5 / $cores : null;
// $ratio15 = $load15 !== null ? $load15 / $cores : null;
//
// $this->loadBadgeText = $this->loadText ?? '';
// $this->loadDots = [
// ['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
// ['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
// ['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
// ];
//
// // Uptime Chips
// $chips = [];
// if ($this->uptimeText) {
// $d = $h = $m = null;
// if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
// if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
// if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
// if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
// if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
// if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
// }
// $this->uptimeChips = $chips ?: [['v'=>'','u'=>'']];
// }
//
// protected function hydrateDisk(): void
// {
// $disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
//
// $this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
// $this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
//
// // total/used berechnen, falls nicht geliefert
// if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
// $p = max(0, min(100, $this->diskPercent)) / 100;
// $estTotal = (int) round($this->diskFreeGb / (1 - $p));
// $this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
// }
// if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
// $u = $this->diskTotalGb - $this->diskFreeGb;
// $this->diskUsedGb = $u >= 0 ? $u : null;
// }
// }
//
// protected function hydrateUpdatedAt(): void
// {
// $updated = $this->meta['updated_at'] ?? null;
// try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '';
// } catch (\Throwable) { $this->updatedAtHuman = ''; }
// }
//
// protected function decorateServicesCompact(): void
// {
// $nameMap = [
// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
// ];
//
//// $nameMap = [
//// // --- Mail ---
//// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA / Versand'],
//// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP / POP3'],
//// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
//// 'clamav' => ['label' => 'ClamAV', 'hint' => 'Virenscanner'],
////
//// // --- Daten & Cache ---
//// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL / MariaDB'],
//// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache / Queue'],
////
//// // --- Web / PHP ---
//// 'php8.2-fpm' => ['label' => 'PHP-FPM', 'hint' => 'PHP Runtime'],
//// 'nginx' => ['label' => 'Nginx', 'hint' => 'Webserver'],
////
//// // --- MailWolt spezifisch ---
//// 'mailwolt-queue' => ['label' => 'MailWolt Queue', 'hint' => 'Job Worker'],
//// 'mailwolt-schedule'=> ['label' => 'MailWolt Schedule','hint' => 'Task Scheduler'],
////
//// // --- Sonstige Infrastruktur ---
//// 'fail2ban' => ['label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection'],
//// 'systemd-journald'=> ['label' => 'System Logs', 'hint' => 'Logging / Journal'],
////
//// // --- WebSocket & Echtzeit ---
//// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket Server'],
//// ];
//
// $existing = collect($this->services)->keyBy('name');
//
// $this->servicesCompact = collect($nameMap)
// ->map(function ($meta, $key) use ($existing) {
// $srv = $existing->get($key, []);
// $ok = (bool)($srv['ok'] ?? false);
//
// return [
// 'label' => $meta['label'],
// 'hint' => $meta['hint'],
// 'ok' => $ok,
//
// // Punktfarbe
// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
//
// // ✅ Bessere Status-Texte
// 'pillText' => $ok ? 'Aktiv' : 'Offline',
//
// // Farbe für Pill
// 'pillClass' => $ok
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
// ];
// })
// ->values()
// ->all();
//
// $this->guardOk = collect($this->services)->every(
// fn($s) => (bool)($s['ok'] ?? false)
// );
//// $this->servicesCompact = collect($this->services)
//// ->map(function ($srv) use ($nameMap) {
//// $key = (string)($srv['name'] ?? '');
//// $ok = (bool) ($srv['ok'] ?? false);
//// $label = $nameMap[$key]['label'] ?? ucfirst($key);
//// $hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
////
//// var_dump($srv);
//// return [
//// 'label' => $label,
//// 'hint' => $hint,
//// 'ok' => $ok,
//// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
//// 'pillText' => $ok ? 'Läuft' : 'Down',
//// 'pillClass' => $ok
//// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
//// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
//// ];
//// })
//// ->values()
//// ->all();
// }
//
// protected function decorateDisk(): void
// {
// $percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
// $this->diskCenterText = [
// 'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '%',
// 'label' => 'Speicher belegt',
// ];
//
// $count = 48;
// $activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
// $step = 360 / $count;
//
// $activeClass = match (true) {
// $percent >= 90 => 'bg-rose-400',
// $percent >= 70 => 'bg-amber-300',
// default => 'bg-emerald-400',
// };
//
// $this->diskSegments = [];
// for ($i = 0; $i < $count; $i++) {
// $angle = ($i * $step) - 90; // Start bei 12 Uhr
// $this->diskSegments[] = [
// 'angle' => $angle,
// 'class' => $i < $activeN ? $activeClass : 'bg-white/16',
// ];
// }
// }
//
// /* ---------------- Helpers ---------------- */
//
// protected function pick(array $arr, array $keys)
// {
// foreach ($keys as $k) {
// if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
// return $arr[$k];
// }
// }
// return null;
// }
//
// protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
// protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
//
// protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
// {
// if ($l1 !== null || $l5 !== null || $l15 !== null) {
// $fmt = fn($x) => $x === null ? '' : number_format($x, 2);
// return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
// }
// return $fallback ?: null;
// }
//
// protected function secondsToHuman(int $s): string
// {
// $d = intdiv($s, 86400); $s %= 86400;
// $h = intdiv($s, 3600); $s %= 3600;
// $m = intdiv($s, 60);
// if ($d > 0) return "{$d}d {$h}h";
// if ($h > 0) return "{$h}h {$m}m";
// return "{$m}m";
// }
//
// protected function toneByPercent(?int $p): string {
// if ($p === null) return 'white';
// if ($p >= 90) return 'rose';
// if ($p >= 70) return 'amber';
// return 'emerald';
// }
//
// protected function buildSegments(?int $percent): array {
// $n = max(6, $this->barSegments);
// $filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
// $tone = $this->toneByPercent($percent);
// $fillCls = match($tone) {
// 'rose' => 'bg-rose-500/80',
// 'amber' => 'bg-amber-400/80',
// 'emerald'=> 'bg-emerald-500/80',
// default => 'bg-white/20',
// };
// return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
// }
//
// protected function loadDotClass(?float $ratio): string {
// if ($ratio === null) return 'bg-white/25';
// if ($ratio >= 0.9) return 'bg-rose-500';
// if ($ratio >= 0.7) return 'bg-amber-400';
// return 'bg-emerald-400';
// }
//}

View File

@ -28,7 +28,7 @@ class TopBar extends Component
public function mount(): void
{
// Domains + Zertifikate (passe an deinen Storage an)
$this->domainsCount = Domain::count(); // oder aus Cache/Repo
$this->domainsCount = Domain::where('is_server', false)->where('is_system', false)->count(); // oder aus Cache/Repo
// Beispiel: Domain::select('cert_expires_at','cert_issuer')...
// -> hier simple Annahme: im Cache 'domains:certs' liegt [{domain, days_left, type}]

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,7 @@ class AliasList extends Component
$domains = Domain::query()
->where('is_system', false)
->where('is_server', false)
->withCount(['mailAliases'])
->with([
'mailAliases' => fn ($q) => $q

View File

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Ui\Mail;
use Livewire\Component;
class BounceCard extends Component
{
public int $bounces24h = 0;
public array $topCodes = []; // [['code'=>'550', 'count'=>12], ...]
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.mail.bounce-card'); }
public function refresh(): void { $this->load(true); }
protected function load(bool $force=false): void
{
// Parse last 2000 lines of mail log for status=bounced / defer / reject
$log = @shell_exec('tail -n 2000 /var/log/mail.log 2>/dev/null') ?? '';
$this->bounces24h = preg_match_all('/status=bounced/i', $log);
$counts = [];
if ($log) {
if (preg_match_all('/\s([45]\d\d)\s/m', $log, $m)) {
foreach ($m[1] as $c) $counts[$c] = ($counts[$c] ?? 0) + 1;
}
}
arsort($counts);
$this->topCodes = collect($counts)->take(5)->map(fn($v,$k)=>['code'=>$k,'count'=>$v])->values()->all();
}
}

View File

@ -0,0 +1,544 @@
<?php
namespace App\Livewire\Ui\Mail;
use Livewire\Component;
use App\Models\Domain;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class DnsHealthCard extends Component
{
public array $rows = []; // [{id,name,ok,missing:[...]}]
public string $mtaHost = '';
public bool $tlsa = false;
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.mail.dns-health-card');
}
public function refresh(): void
{
$this->load();
}
public function openDnsModal(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $domainId]);
}
// protected function load(bool $force = false): void
// {
// [$this->mtaHost, $this->tlsa, $this->rows] = Cache::remember('dash.dnshealth.v2', $force ? 1 : 600, function () {
//
// $base = trim((string) env('BASE_DOMAIN', ''));
// $mtaSub = trim((string) env('MTA_SUB', 'mx'));
// $mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub; // z.B. mx.nexlab.at
//
// // ▼ gewünschter Filter:
// $domains = Domain::query()
// ->where('is_active', true)
// ->where('is_server', false) // <<< Server-Domain sauber ausschließen
// ->orderBy('domain')
// ->get(['id', 'domain']);
//
// $rows = [];
// foreach ($domains as $d) {
// $dom = $d->domain;
//
// // DKIM-Selector ermitteln: .env > DB > Fallback null
// $selector = trim((string) env('DKIM_SELECTOR', ''));
// if ($selector === '') {
// $selector = (string) DB::table('dkim_keys')
// ->where('domain_id', $d->id)
// ->where('is_active', 1)
// ->orderByDesc('id')
// ->value('selector') ?? '';
// }
//
// $missing = [];
//
// if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
// if (!$this->hasSpf($dom)) $missing[] = 'SPF';
// if (!$this->hasDkim($dom, $selector)) $missing[] = 'DKIM';
// if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
//
// $rows[] = [
// 'id' => (int) $d->id,
// 'name' => $dom,
// 'ok' => empty($missing),
// 'missing' => $missing,
// ];
// }
//
// // Hostweites TLSA (nur Hinweis)
// $tlsa = $this->hasTlsa("_25._tcp.$mtaHost") || $this->hasTlsa("_465._tcp.$mtaHost") || $this->hasTlsa("_587._tcp.$mtaHost");
//
// return [$mtaHost, $tlsa, $rows];
// });
// }
protected function load(): void
{
$base = trim((string) env('BASE_DOMAIN', ''));
$mtaSub = trim((string) env('MTA_SUB', 'mx'));
$mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub; // z.B. mx.nexlab.at
// nur aktive, NICHT-Server-Domains (System + Custom, solange is_server = false)
$domains = Domain::query()
->where('is_active', true)
->where('is_server', false)
->orderBy('domain')
->get(['id','domain']);
$rows = [];
foreach ($domains as $d) {
$dom = $d->domain;
// DKIM-Selector: .env > DB > leer
$selector = trim((string) env('DKIM_SELECTOR', ''));
if ($selector === '') {
$selector = (string) DB::table('dkim_keys')
->where('domain_id', $d->id)
->where('is_active', 1)
->orderByDesc('id')
->value('selector') ?? '';
}
$missing = [];
if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
if (!$this->hasSpf($dom)) $missing[] = 'SPF';
if (!$this->hasDkim($dom, $selector)) $missing[] = 'DKIM';
if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
$rows[] = [
'id' => (int) $d->id,
'name' => $dom,
'ok' => empty($missing),
'missing' => $missing,
];
}
// Hostweites TLSA (nur Info)
$tlsa = $this->hasTlsa("_25._tcp.$mtaHost")
|| $this->hasTlsa("_465._tcp.$mtaHost")
|| $this->hasTlsa("_587._tcp.$mtaHost");
$this->mtaHost = $mtaHost;
$this->tlsa = $tlsa;
$this->rows = $rows;
}
/* ── DNS Helpers ───────────────────────────────────────────────────── */
protected function digShort(string $type, string $name): string
{
$cmd = "timeout 2 dig +short " . escapeshellarg($name) . ' ' . escapeshellarg(strtoupper($type)) . " 2>/dev/null";
return (string) @shell_exec($cmd) ?: '';
}
protected function hasTxt(string $name): bool
{
return trim($this->digShort('TXT', $name)) !== '';
}
protected function hasTlsa(string $name): bool
{
return trim($this->digShort('TLSA', $name)) !== '';
}
protected function hasSpf(string $domain): bool
{
$out = $this->digShort('TXT', $domain);
foreach (preg_split('/\R+/', trim($out)) as $line) {
if (stripos($line, 'v=spf1') !== false) return true;
}
return false;
}
// ▼ DKIM: bevorzugt konkreten Selector prüfen; wenn leer, versuche Policy (_domainkey)
protected function hasDkim(string $domain, string $selector = ''): bool
{
if ($selector !== '' && $this->hasTxt("{$selector}._domainkey.$domain")) {
return true;
}
// Fallback: irgendein _domainkey-TXT vorhanden
return $this->hasTxt("_domainkey.$domain");
}
// protected function hasDkim(string $domain, string $selector = ''): bool
// {
// if ($selector !== '') {
// return $this->hasTxt("{$selector}._domainkey.$domain");
// }
// // Manche Betreiber veröffentlichen eine Policy auf _domainkey.<dom>
// return $this->hasTxt("_domainkey.$domain");
// }
protected function mxPointsTo(string $domain, array $allowedHosts): bool
{
$out = $this->digShort('MX', $domain);
if ($out === '') return false;
$targets = [];
foreach (preg_split('/\R+/', trim($out)) as $line) {
if (preg_match('~\s+([A-Za-z0-9\.\-]+)\.?$~', trim($line), $m)) {
$targets[] = strtolower($m[1]);
}
}
if (!$targets) return false;
$allowed = array_map(fn ($h) => strtolower(rtrim($h, '.')), $allowedHosts);
foreach ($targets as $t) {
$t = rtrim($t, '.');
if (in_array($t, $allowed, true)) return true;
}
return false;
}
}
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $rows = []; // [{id,name,ok,missing:[...]}]
// public string $mtaHost = ''; // z.B. mx.nexlab.at
// public bool $tlsa = false; // hostweit
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.mail.dns-health-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function openDnsModal(int $domainId): void
// {
// $this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $domainId]);
// }
//
// protected function load(bool $force = false): void
// {
// [$this->mtaHost, $this->tlsa, $this->rows] = Cache::remember('dash.dnshealth.v1', $force ? 1 : 600, function () {
//
// $base = trim((string)env('BASE_DOMAIN', ''));
// $mtaSub = trim((string)env('MTA_SUB', 'mx'));
// $mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub;
//
// $rows = [];
// $domains = Domain::query()
// ->where('is_system', false)
// ->where('is_active', true)
// ->orderBy('domain')
// ->get(['id', 'domain']);
//
// foreach ($domains as $d) {
// $dom = $d->domain;
//
// $missing = [];
//
// // Pflicht-Checks
// if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
// if (!$this->hasSpf($dom)) $missing[] = 'SPF';
// if (!$this->hasDkim($dom)) $missing[] = 'DKIM';
// if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
//
// $rows[] = [
// 'id' => (int)$d->id,
// 'name' => $dom,
// 'ok' => empty($missing),
// 'missing' => $missing,
// ];
// }
//
// // TLSA (hostweit, nur Info)
// $tlsa = $this->hasTlsa("_25._tcp.$mtaHost") || $this->hasTlsa("_465._tcp.$mtaHost") || $this->hasTlsa("_587._tcp.$mtaHost");
//
// return [$mtaHost, $tlsa, $rows];
// });
// }
//
// /* ── DNS Helpers (mit Timeout, damit UI nicht hängt) ───────────────── */
//
// protected function digShort(string $type, string $name): string
// {
// $cmd = "timeout 2 dig +short " . escapeshellarg($name) . " " . escapeshellarg(strtoupper($type)) . " 2>/dev/null";
// return (string)@shell_exec($cmd) ?: '';
// }
//
// protected function hasTxt(string $name): bool
// {
// return trim($this->digShort('TXT', $name)) !== '';
// }
//
// protected function hasTlsa(string $name): bool
// {
// return trim($this->digShort('TLSA', $name)) !== '';
// }
//
// protected function hasSpf(string $domain): bool
// {
// $out = $this->digShort('TXT', $domain);
// foreach (preg_split('/\R+/', trim($out)) as $line) {
// if (stripos($line, 'v=spf1') !== false) return true;
// }
// return false;
// }
//
// // DKIM: wenn spezifischer Selector vorhanden → prüfe den, sonst akzeptiere _domainkey-Policy als “vorhanden”
// protected function hasDkim(string $domain): bool
// {
// $sel = trim((string)env('DKIM_SELECTOR', ''));
// if ($sel !== '' && $this->hasTxt("{$sel}._domainkey.$domain")) return true;
// return $this->hasTxt("_domainkey.$domain");
// }
//
// protected function mxPointsTo(string $domain, array $allowedHosts): bool
// {
// $out = $this->digShort('MX', $domain);
// if ($out === '') return false;
//
// $targets = [];
// foreach (preg_split('/\R+/', trim($out)) as $line) {
// // Format: "10 mx.example.com."
// if (preg_match('~\s+([A-Za-z0-9\.\-]+)\.?$~', trim($line), $m)) {
// $targets[] = strtolower($m[1]);
// }
// }
// if (!$targets) return false;
//
// $allowed = array_map('strtolower', $allowedHosts);
// foreach ($targets as $t) {
// if (in_array($t, $allowed, true)) return true;
// }
// return false;
// }
//}
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $domains = []; // [['name'=>..., 'dkim'=>bool, 'dmarc'=>bool], ...]
// public string $host = ''; // z.B. mx.nexlab.at
// public bool $tlsa = false; // hostbasiert (einmalig)
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.mail.dns-health-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// protected function load(bool $force = false): void
// {
// [$this->host, $this->tlsa, $this->domains, $this->ipv4, $this->ipv6] =
// Cache::remember('dash.dnshealth', $force ? 1 : 900, function () {
//
// // ── ENV lesen ────────────────────────────────────────────────
// $base = trim((string)env('BASE_DOMAIN', ''));
// $mtaSub = trim((string)env('MTA_SUB', 'mx'));
// $host = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub;
//
// $ipv4 = trim((string)env('SERVER_PUBLIC_IPV4', '')) ?: null;
// $ipv6 = trim((string)env('SERVER_PUBLIC_IPV6', '')) ?: null;
//
// // ── Domains laden (nur aktive, nicht-system) ────────────────
// $rows = [];
// $domains = Domain::query()
// ->where('is_system', false)
// ->where('is_active', true)
// ->orderBy('domain')
// ->get(['domain']);
//
// foreach ($domains as $d) {
// $dom = $d->domain;
// $rows[] = [
// 'name' => $dom,
// 'dkim' => $this->hasTxt("_domainkey.$dom"),
// 'dmarc' => $this->hasTxt("_dmarc.$dom"),
// ];
// }
//
// // ── TLSA nur hostbasiert prüfen (25/465/587) ────────────────
// $tlsa = $this->hasTlsa("_25._tcp.$host")
// || $this->hasTlsa("_465._tcp.$host")
// || $this->hasTlsa("_587._tcp.$host");
//
// return [$host, $tlsa, $rows, $ipv4, $ipv6];
// });
// }
//
// /* ───────────────────────── DNS Helpers ───────────────────────── */
//
// protected function hasTxt(string $name): bool
// {
// $out = @shell_exec("timeout 2 dig +short TXT " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//
// protected function hasTlsa(string $name): bool
// {
// $out = @shell_exec("timeout 2 dig +short TLSA " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//}
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $rows = []; // pro Domain: ['dom','dkim','dmarc']
// public string $host = ''; // z.B. mx.nexlab.at
// public bool $tlsa = false; // TLSA-Status für den Host
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.mail.dns-health-card'); }
// public function refresh(): void { $this->load(true); }
//
// protected function load(bool $force = false): void
// {
// // Werte aus .env
// $this->ipv4 = trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
// $this->ipv6 = trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
//
// $base = trim((string) env('BASE_DOMAIN', ''));
// $mta = trim((string) env('MTA_SUB', 'mx'));
// $host = $base ? "{$mta}.{$base}" : $mta; // z.B. mx.nexlab.at
// $this->host = $host;
//
// // Neuer Cache-Key, damit altes Format keinen Crash verursacht
// [$calcHost, $calcTlsa, $calcRows] = Cache::remember(
// 'dash.dnshealth.v2',
// $force ? 1 : 900,
// function () use ($host) {
// // TLSA: nur 1× pro Host prüfen
// $tlsa = $this->hasTlsa("_25._tcp.{$host}")
// || $this->hasTlsa("_465._tcp.{$host}")
// || $this->hasTlsa("_587._tcp.{$host}");
//
// // Domains: nur DKIM/DMARC
// $rows = [];
// $domains = Domain::query()
// ->where('is_system', false)
// ->where('is_active', true)
// ->get(['domain']);
//
// foreach ($domains as $d) {
// $dom = $d->domain;
// $dkim = $this->hasTxt("_domainkey.{$dom}");
// $dmarc = $this->hasTxt("_dmarc.{$dom}");
// $rows[] = compact('dom','dkim','dmarc');
// }
//
// return [$host, $tlsa, $rows];
// }
// );
//
// // Defensive: falls mal falsches Datenformat im Cache war
// if (!is_string($calcHost) || !is_bool($calcTlsa) || !is_array($calcRows)) {
// Cache::forget('dash.dnshealth.v2');
// $this->load(true);
// return;
// }
//
// $this->host = $calcHost;
// $this->tlsa = $calcTlsa;
// $this->rows = $calcRows;
// }
//
// protected function hasTxt(string $name): bool
// {
// $out = @shell_exec("dig +short TXT " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//
// protected function hasTlsa(string $name): bool
// {
// $out = @shell_exec("dig +short TLSA " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//}
//
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $rows = []; // [ ['domain'=>..., 'dkim'=>bool, 'dmarc'=>bool, 'tlsa'=>bool], ... ]
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.mail.dns-health-card'); }
// public function refresh(): void { $this->load(true); }
//
// protected function load(bool $force=false): void
// {
// $this->rows = Cache::remember('dash.dnshealth', $force ? 1 : 600, function () {
// $rows = [];
// $domains = Domain::query()->where('is_system', false)->where('is_active', true)->get(['domain']);
// foreach ($domains as $d) {
// $dom = $d->domain;
// $dkim = $this->hasTxt("_domainkey.$dom"); // rough: just any dkim TXT exists
// $dmarc = $this->hasTxt("_dmarc.$dom");
// $tlsa = $this->hasTlsa("_25._tcp.$dom") || $this->hasTlsa("_465._tcp.$dom") || $this->hasTlsa("_587._tcp.$dom");
// $rows[] = compact('dom','dkim','dmarc','tlsa');
// }
// return $rows;
// });
// }
//
// protected function hasTxt(string $name): bool
// {
// $out = @shell_exec("dig +short TXT ".escapeshellarg($name)." 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
// protected function hasTlsa(string $name): bool
// {
// $out = @shell_exec("dig +short TLSA ".escapeshellarg($name)." 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//}

View File

@ -6,7 +6,6 @@ namespace App\Livewire\Ui\Mail;
use App\Models\Domain;
use App\Models\Setting;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
use Livewire\Component;
@ -14,7 +13,6 @@ use Livewire\Component;
class MailboxList extends Component
{
public string $search = '';
public bool $showSystemCard = false;
#[On('mailbox:updated')]
#[On('mailbox:deleted')]
@ -24,20 +22,6 @@ class MailboxList extends Component
$this->dispatch('$refresh');
}
#[On('focus:domain')]
public function focusDomain(int $id): void
{
// z. B. Domain nach oben holen / scrollen / highlighten
// oder direkt den "+ Postfach" Dialog:
// $this->openMailboxCreate($id);
}
#[On('focus:user')]
public function focusUser(int $id): void
{
// später: Benutzerseite / Filter setzen ...
}
public function openMailboxCreate(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
@ -45,143 +29,119 @@ class MailboxList extends Component
]);
}
public function openMailboxEdit(int $domainId): void
public function openMailboxEdit(int $mailUserId): void
{
// $domainId == mailbox_id
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
$domainId, // <— nur der Wert, kein Key!
$mailUserId,
]);
}
public function openMailboxDelete(int $domainId): void
public function openMailboxDelete(int $mailUserId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
$domainId, // <— nur der Wert, kein Key!
$mailUserId,
]);
}
public function updateMailboxStats()
public function updateMailboxStats(): void
{
$started = microtime(true);
// läuft asynchron → UI blockiert nicht
dispatch(fn() => Artisan::call('mail:update-stats'));
Log::channel('mailstats')->info('UI: updateMailboxStats() geklickt', [
'actor' => 'web',
'ip' => request()->ip() ?? null,
]);
// Command ausführen
$rc = Artisan::call('mail:update-stats');
$output = Artisan::output();
Log::channel('mailstats')->info('UI: Command beendet', [
'rc' => $rc,
'ms' => (int)((microtime(true) - $started) * 1000),
'output' => trim($output),
]);
// UI auffrischen
$this->dispatch('$refresh');
// Ergebnis toaster
$this->dispatch('toast',
type: $rc === 0 ? 'done' : 'warn',
badge: 'Mailbox',
title: $rc === 0 ? 'Mailbox aktualisiert' : 'Aktualisierung fehlgeschlagen',
text: $rc === 0 ? 'Statistiken wurden aktualisiert.' : 'Siehe logs/mailstats.log',
duration: 6000,
);
}
public function updateMailboxStatsOne(string $email)
{
Artisan::call('mail:update-stats', ['--user' => $email]);
$this->dispatch('$refresh');
$this->dispatch('toast',
type: 'done',
badge: 'Mailbox',
title: 'Mailbox aktualisiert',
text: 'Die Mailbox-Statistiken wurden aktualisiert.',
duration: 6000
duration: 6000,
);
}
public function render()
{
$system = Domain::query()->where('is_system', true)->first();
$term = trim($this->search);
$hasTerm = $term !== '';
$needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
$needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
// Nur Domains, die NICHT system und NICHT server sind
$domains = Domain::query()
->when($system, fn ($q) => $q->whereKeyNot($system->id))
// Domain selbst ODER MailUser müssen matchen
->where('is_system', false)
->where('is_server', false)
->when($hasTerm, function ($q) use ($needle) {
$q->where(function ($w) use ($needle) {
$w->where('domain', 'like', $needle)
->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
->orWhereHas('mailUsers', fn($u) => $u
->where('is_active', true)
->where('is_system', false)
->where('localpart', 'like', $needle)
);
});
})
->withCount(['mailUsers'])
// Relationen zunächst ggf. gefiltert laden
->with([
'mailUsers' => function ($q) use ($hasTerm, $needle) {
if ($hasTerm) $q->where('localpart', 'like', $needle);
$q->orderBy('localpart');
},
->withCount(['mailUsers as mail_users_count' => fn($u) => $u
->where('is_active', true)
->where('is_system', false)
])
->with(['mailUsers' => function ($q) use ($hasTerm, $needle) {
$q->where('is_active', true)
->where('is_system', false);
if ($hasTerm) {
$q->where('localpart', 'like', $needle);
}
$q->orderBy('localpart');
}])
->orderBy('domain')
->get();
// Wenn der Domainname selbst matched → alle Mailboxen/Aliasse vollständig nachladen
// Wenn Domain direkt matcht → alle Benutzer dieser Domain zeigen
if ($hasTerm) {
$lower = Str::lower($term);
foreach ($domains as $d) {
if (Str::contains(Str::lower($d->domain), $lower)) {
$d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
$d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
$d->setRelation('mailUsers', $d->mailUsers()
->where('is_active', true)
->where('is_system', false)
->orderBy('localpart')
->get()
);
}
}
}
// Vorbereitung für Blade
// ---- Anzeige vorbereiten ----
foreach ($domains as $d) {
$prepared = [];
$domainActive = (bool)($d->is_active ?? true);
foreach ($d->mailUsers as $u) {
$email = trim($u->email ?? '') !== ''
? $u->email
: ($u->localpart !== '' ? ($u->localpart.'@'.$d->domain) : null);
$email = $u->email ?? ($u->address ?? null);
$stats = $email ? (Setting::get("mailbox.{$email}", []) ?: []) : [];
$stats = $email ? Setting::get("mailbox.$email") : null;
$usedBytes = (int)($stats['used_bytes'] ?? 0);
$messageCount = (int)($stats['message_count'] ?? 0);
$usedMiB = round($usedBytes / 1048576, 2);
$quotaMiB = (int)($u->quota_mb ?? 0);
$usedBytes = is_array($stats) && isset($stats['used_bytes']) ? (int)$stats['used_bytes'] : (int)($u->used_bytes ?? 0);
$messageCount = is_array($stats) && isset($stats['message_count']) ? (int)$stats['message_count'] : (int)($u->message_count ?? 0);
$usedMB = (int) round($usedBytes / 1024 / 1024);
$quota = (int)($u->quota_mb ?? 0);
$usage = $quota > 0 ? min(100, (int) round($usedMB / max(1, $quota) * 100)) : 0;
$usage = $quotaMiB > 0
? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100))
: 0;
$mailboxActive = (bool)($u->is_active ?? true);
$effective = $domainActive && $mailboxActive;
$reason = null;
if (!$effective) {
$reason = !$domainActive ? 'Domain inaktiv'
$reason = !$domainActive
? 'Domain inaktiv'
: (!$mailboxActive ? 'Postfach inaktiv' : null);
}
$prepared[] = [
'id' => $u->id,
'localpart' => (string)$u->localpart,
'quota_mb' => $quota,
'quota_mb' => $quotaMiB,
'used_mb' => $usedMiB,
'usage_percent' => $usage,
'used_mb' => $usedMB, // MiB fürs UI
'message_count' => $messageCount,
'is_active' => $mailboxActive,
'is_effective_active' => $effective,
@ -194,9 +154,275 @@ class MailboxList extends Component
return view('livewire.ui.mail.mailbox-list', [
'domains' => $domains,
'system' => $this->showSystemCard ? $system : null,
]);
}
}
//namespace App\Livewire\Ui\Mail;
//
//use App\Models\Domain;
//use App\Models\Setting;
//use Illuminate\Support\Facades\Artisan;
//use Illuminate\Support\Str;
//use Livewire\Attributes\On;
//use Livewire\Component;
//
//class MailboxList extends Component
//{
// public string $search = '';
// public bool $showSystemCard = false;
//
// #[On('mailbox:updated')]
// #[On('mailbox:deleted')]
// #[On('mailbox:created')]
// public function refreshMailboxList(): void
// {
// $this->dispatch('$refresh');
// }
//
// #[On('focus:domain')]
// public function focusDomain(int $id): void
// {
// // optional: Domain hervorheben
// }
//
// #[On('focus:user')]
// public function focusUser(int $id): void
// {
// // optional
// }
//
// public function openMailboxCreate(int $domainId): void
// {
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
// 'domainId' => $domainId,
// ]);
// }
//
// public function openMailboxEdit(int $domainId): void
// {
// // $domainId == mailbox_id
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
// $domainId,
// ]);
// }
//
// public function openMailboxDelete(int $domainId): void
// {
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
// $domainId,
// ]);
// }
//
// public function updateMailboxStats(): void
// {
//// Artisan::call('mail:update-stats');
// dispatch(fn () => Artisan::call('mail:update-stats'));
//
// $this->dispatch('$refresh');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Mailbox',
// title: 'Mailbox aktualisiert',
// text: 'Die Mailbox-Statistiken wurden aktualisiert.',
// duration: 6000,
// );
// }
//
// public function render()
// {
// $system = Domain::query()->where('is_system', true)->first();
// $term = trim($this->search);
// $hasTerm = $term !== '';
// $needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
//
// $domains = Domain::query()
// ->when($system, fn($q) => $q->whereKeyNot($system->id))
// ->when($hasTerm, function ($q) use ($needle) {
// $q->where(function ($w) use ($needle) {
// $w->where('domain', 'like', $needle)
// ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
// });
// })
// ->withCount(['mailUsers'])
// ->with([
// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
// if ($hasTerm) {
// $q->where('localpart', 'like', $needle);
// }
// $q->orderBy('localpart');
// },
// ])
// ->orderBy('domain')
// ->get();
//
// if ($hasTerm) {
// $lower = Str::lower($term);
// foreach ($domains as $d) {
// if (Str::contains(Str::lower($d->domain), $lower)) {
// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
// }
// }
// }
//
// // ---- Anzeige vorbereiten (mit korrekter Prozent-Berechnung) ----
// foreach ($domains as $d) {
// $prepared = [];
// $domainActive = (bool)($d->is_active ?? true);
//
// foreach ($d->mailUsers as $u) {
// // Stats aus Settings (Redis → DB Fallback)
// $stats = Setting::get("mailbox.{$u->email}", []);
// $usedBytes = (int)($stats['used_bytes'] ?? ($u->used_bytes ?? 0));
// $messageCount = (int)($stats['message_count'] ?? ($u->message_count ?? 0));
//
// // Anzeige in MiB (nur fürs UI runden)
// $usedMiB = (float)round($usedBytes / 1048576, 2);
// $quotaMiB = (int)($u->quota_mb ?? 0);
//
// // Prozent aus Bytes/Quota (ohne Vorab-Rundung auf MiB!)
// $usage = $quotaMiB > 0
// ? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100))
// : 0;
//
// $mailboxActive = (bool)($u->is_active ?? true);
// $effective = $domainActive && $mailboxActive;
//
// $reason = null;
// if (!$effective) {
// $reason = !$domainActive ? 'Domain inaktiv'
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
// }
//
// $prepared[] = [
// 'id' => $u->id,
// 'localpart' => (string)$u->localpart,
// 'quota_mb' => $quotaMiB,
// 'used_mb' => $usedMiB,
// 'usage_percent' => $usage,
// 'message_count' => $messageCount,
// 'is_active' => $mailboxActive,
// 'is_effective_active' => $effective,
// 'inactive_reason' => $reason,
// ];
// }
//
// $d->prepared_mailboxes = $prepared;
// }
//
// return view('livewire.ui.mail.mailbox-list', [
// 'domains' => $domains,
// 'system' => $this->showSystemCard ? $system : null,
// ]);
// }
//}
//namespace App\Livewire\Ui\Mail;
//
//use App\Models\Domain;
//use App\Models\Setting;
//use Illuminate\Support\Facades\Artisan;
//use Illuminate\Support\Facades\Log;
//use Illuminate\Support\Str;
//use Livewire\Attributes\On;
//use Livewire\Component;
//
//class MailboxList extends Component
//{
// public string $search = '';
// public bool $showSystemCard = false;
//
// #[On('mailbox:updated')]
// #[On('mailbox:deleted')]
// #[On('mailbox:created')]
// public function refreshMailboxList(): void
// {
// $this->dispatch('$refresh');
// }
//
// #[On('focus:domain')]
// public function focusDomain(int $id): void
// {
// // z. B. Domain nach oben holen / scrollen / highlighten
// // oder direkt den "+ Postfach" Dialog:
// // $this->openMailboxCreate($id);
// }
//
// #[On('focus:user')]
// public function focusUser(int $id): void
// {
// // später: Benutzerseite / Filter setzen ...
// }
//
// public function openMailboxCreate(int $domainId): void
// {
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
// 'domainId' => $domainId,
// ]);
// }
//
// public function openMailboxEdit(int $domainId): void
// {
// // $domainId == mailbox_id
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
// $domainId, // <— nur der Wert, kein Key!
// ]);
// }
//
// public function openMailboxDelete(int $domainId): void
// {
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
// $domainId, // <— nur der Wert, kein Key!
// ]);
// }
//
// public function updateMailboxStats()
// {
// $started = microtime(true);
//
// Log::channel('mailstats')->info('UI: updateMailboxStats() geklickt', [
// 'actor' => 'web',
// 'ip' => request()->ip() ?? null,
// ]);
//
// // Command ausführen
// $rc = Artisan::call('mail:update-stats');
// $output = Artisan::output();
//
// Log::channel('mailstats')->info('UI: Command beendet', [
// 'rc' => $rc,
// 'ms' => (int)((microtime(true) - $started) * 1000),
// 'output' => trim($output),
// ]);
//
// // UI auffrischen
// $this->dispatch('$refresh');
//
// // Ergebnis toaster
// $this->dispatch('toast',
// type: $rc === 0 ? 'done' : 'warn',
// badge: 'Mailbox',
// title: $rc === 0 ? 'Mailbox aktualisiert' : 'Aktualisierung fehlgeschlagen',
// text: $rc === 0 ? 'Statistiken wurden aktualisiert.' : 'Siehe logs/mailstats.log',
// duration: 6000,
// );
// }
//
//
// public function updateMailboxStatsOne(string $email)
// {
// Artisan::call('mail:update-stats', ['--user' => $email]);
// $this->dispatch('$refresh');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Mailbox',
// title: 'Mailbox aktualisiert',
// text: 'Die Mailbox-Statistiken wurden aktualisiert.',
// duration: 6000
// );
// }
//
// public function render()
// {
// $system = Domain::query()->where('is_system', true)->first();
@ -207,7 +433,7 @@ class MailboxList extends Component
// $domains = Domain::query()
// ->when($system, fn ($q) => $q->whereKeyNot($system->id))
//
// // Domain selbst ODER MailUser/ Aliasse müssen matchen
// // Domain selbst ODER MailUser müssen matchen
// ->when($hasTerm, function ($q) use ($needle) {
// $q->where(function ($w) use ($needle) {
// $w->where('domain', 'like', $needle)
@ -217,7 +443,7 @@ class MailboxList extends Component
//
// ->withCount(['mailUsers'])
//
// // Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
// // Relationen zunächst ggf. gefiltert laden
// ->with([
// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
// if ($hasTerm) $q->where('localpart', 'like', $needle);
@ -228,35 +454,35 @@ class MailboxList extends Component
// ->orderBy('domain')
// ->get();
//
// // Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
// // Wenn der Domainname selbst matched → alle Mailboxen/Aliasse vollständig nachladen
// if ($hasTerm) {
// $lower = Str::lower($term);
// foreach ($domains as $d) {
// if (Str::contains(Str::lower($d->domain), $lower)) {
// // volle Relationen nachladen (überschreibt die gefilterten)
// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
// }
// }
// }
//
// // Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
// // Vorbereitung für Blade
// foreach ($domains as $d) {
// $prepared = [];
// $domainActive = (bool)($d->is_active ?? true);
//
// foreach ($d->mailUsers as $u) {
// $stats = Setting::get("mailbox.{$u->email}");
// $usedBytes = $stats['used_bytes'] ?? ($u->used_bytes ?? 0);
// $messageCount = $stats['message_count'] ?? ($u->message_count ?? 0);
// $usedMB = (int) round(($usedBytes) / 1024 / 1024);
// $email = trim($u->email ?? '') !== ''
// ? $u->email
// : ($u->localpart !== '' ? ($u->localpart.'@'.$d->domain) : null);
//
// $stats = $email ? Setting::get("mailbox.$email") : null;
//
// $usedBytes = is_array($stats) && isset($stats['used_bytes']) ? (int)$stats['used_bytes'] : (int)($u->used_bytes ?? 0);
// $messageCount = is_array($stats) && isset($stats['message_count']) ? (int)$stats['message_count'] : (int)($u->message_count ?? 0);
//
// $usedMB = (int) round($usedBytes / 1024 / 1024);
// $quota = (int)($u->quota_mb ?? 0);
// $usage = $quota > 0 ? min(100, (int) round($usedMB / max(1,$quota) * 100)) : 0;
//
//
//// $quota = (int)($u->quota_mb ?? 0);
//// $used = (int)($u->used_mb ?? 0);
//// $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
// $usage = $quota > 0 ? min(100, (int) round($usedMB / max(1, $quota) * 100)) : 0;
//
// $mailboxActive = (bool)($u->is_active ?? true);
// $effective = $domainActive && $mailboxActive;
@ -272,7 +498,7 @@ class MailboxList extends Component
// 'localpart' => (string)$u->localpart,
// 'quota_mb' => $quota,
// 'usage_percent' => $usage,
// 'used_mb' => $usedMB,
// 'used_mb' => $usedMB, // MiB fürs UI
// 'message_count' => $messageCount,
// 'is_active' => $mailboxActive,
// 'is_effective_active' => $effective,
@ -280,7 +506,6 @@ class MailboxList extends Component
// ];
// }
//
// // für Blade
// $d->prepared_mailboxes = $prepared;
// }
//
@ -289,136 +514,228 @@ class MailboxList extends Component
// 'system' => $this->showSystemCard ? $system : null,
// ]);
// }
// public function render()
// {
// $system = Domain::query()->where('is_system', true)->first();
// $term = trim($this->search);
//// public function render()
//// {
//// $system = Domain::query()->where('is_system', true)->first();
//// $term = trim($this->search);
//// $hasTerm = $term !== '';
//// $needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
////
//// $domains = Domain::query()
//// ->when($system, fn ($q) => $q->whereKeyNot($system->id))
////
//// // Domain selbst ODER MailUser/ Aliasse müssen matchen
//// ->when($hasTerm, function ($q) use ($needle) {
//// $q->where(function ($w) use ($needle) {
//// $w->where('domain', 'like', $needle)
//// ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
//// });
//// })
////
//// ->withCount(['mailUsers'])
////
//// // Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
//// ->with([
//// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
//// if ($hasTerm) $q->where('localpart', 'like', $needle);
//// $q->orderBy('localpart');
//// },
//// ])
////
//// ->orderBy('domain')
//// ->get();
////
//// // Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
//// if ($hasTerm) {
//// $lower = Str::lower($term);
//// foreach ($domains as $d) {
//// if (Str::contains(Str::lower($d->domain), $lower)) {
//// // volle Relationen nachladen (überschreibt die gefilterten)
//// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
//// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
//// }
//// }
//// }
////
//// // Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
//// foreach ($domains as $d) {
//// $prepared = [];
//// $domainActive = (bool)($d->is_active ?? true);
////
//// foreach ($d->mailUsers as $u) {
//// $stats = Setting::get("mailbox.{$u->email}");
//// $usedBytes = $stats['used_bytes'] ?? ($u->used_bytes ?? 0);
//// $messageCount = $stats['message_count'] ?? ($u->message_count ?? 0);
//// $usedMB = (int) round(($usedBytes) / 1024 / 1024);
//// $quota = (int)($u->quota_mb ?? 0);
//// $usage = $quota > 0 ? min(100, (int) round($usedMB / max(1,$quota) * 100)) : 0;
////
////
////// $quota = (int)($u->quota_mb ?? 0);
////// $used = (int)($u->used_mb ?? 0);
////// $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
////
//// $mailboxActive = (bool)($u->is_active ?? true);
//// $effective = $domainActive && $mailboxActive;
////
//// $reason = null;
//// if (!$effective) {
//// $reason = !$domainActive ? 'Domain inaktiv'
//// : (!$mailboxActive ? 'Postfach inaktiv' : null);
//// }
////
//// $prepared[] = [
//// 'id' => $u->id,
//// 'localpart' => (string)$u->localpart,
//// 'quota_mb' => $quota,
//// 'usage_percent' => $usage,
//// 'used_mb' => $usedMB,
//// 'message_count' => $messageCount,
//// 'is_active' => $mailboxActive,
//// 'is_effective_active' => $effective,
//// 'inactive_reason' => $reason,
//// ];
//// }
////
//// // für Blade
//// $d->prepared_mailboxes = $prepared;
//// }
////
//// return view('livewire.ui.mail.mailbox-list', [
//// 'domains' => $domains,
//// 'system' => $this->showSystemCard ? $system : null,
//// ]);
//// }
//
// $domains = Domain::query()
// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
// ->withCount(['mailUsers','mailAliases'])
// ->with([
// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
// 'mailAliases' => fn ($q) => $q->orderBy('local'),
// ])
// ->when($term !== '', function ($q) use ($term) {
// $q->where(function ($w) use ($term) {
// $w->where('domain', 'like', "%{$term}%")
// ->orWhereHas('mailUsers', fn($u) =>
// $u->where('localpart', 'like', "%{$term}%")
// );
// });
// })
// ->orderBy('domain')
// ->get();
//// public function render()
//// {
//// $system = Domain::query()->where('is_system', true)->first();
//// $term = trim($this->search);
////
//// $domains = Domain::query()
//// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
//// ->withCount(['mailUsers','mailAliases'])
//// ->with([
//// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
//// 'mailAliases' => fn ($q) => $q->orderBy('local'),
//// ])
//// ->when($term !== '', function ($q) use ($term) {
//// $q->where(function ($w) use ($term) {
//// $w->where('domain', 'like', "%{$term}%")
//// ->orWhereHas('mailUsers', fn($u) =>
//// $u->where('localpart', 'like', "%{$term}%")
//// );
//// });
//// })
//// ->orderBy('domain')
//// ->get();
////
//// // Vorbereitung für Blade (unverändert)
//// foreach ($domains as $d) {
//// $prepared = [];
//// $domainActive = (bool)($d->is_active ?? true);
////
//// foreach ($d->mailUsers as $u) {
//// $quota = (int) ($u->quota_mb ?? 0);
//// $used = (int) ($u->used_mb ?? 0);
//// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
////
//// $mailboxActive = (bool)($u->is_active ?? true);
//// $effective = $domainActive && $mailboxActive;
////
//// $reason = null;
//// if (!$effective) {
//// $reason = !$domainActive ? 'Domain inaktiv'
//// : (!$mailboxActive ? 'Postfach inaktiv' : null);
//// }
////
//// $prepared[] = [
//// 'id' => $u->id,
//// 'localpart' => (string) $u->localpart,
//// 'quota_mb' => $quota,
//// 'used_mb' => $used,
//// 'usage_percent' => $usage,
//// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
//// 'is_active' => $mailboxActive,
//// 'is_effective_active' => $effective,
//// 'inactive_reason' => $reason,
//// ];
//// }
////
//// $d->prepared_mailboxes = $prepared;
//// }
////
//// return view('livewire.ui.mail.mailbox-list', [
//// 'domains' => $domains,
//// 'system' => $this->showSystemCard ? $system : null,
//// ]);
//// }
//
// // Vorbereitung für Blade (unverändert)
// foreach ($domains as $d) {
// $prepared = [];
// $domainActive = (bool)($d->is_active ?? true);
//
// foreach ($d->mailUsers as $u) {
// $quota = (int) ($u->quota_mb ?? 0);
// $used = (int) ($u->used_mb ?? 0);
// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
//
// $mailboxActive = (bool)($u->is_active ?? true);
// $effective = $domainActive && $mailboxActive;
//
// $reason = null;
// if (!$effective) {
// $reason = !$domainActive ? 'Domain inaktiv'
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
// }
//
// $prepared[] = [
// 'id' => $u->id,
// 'localpart' => (string) $u->localpart,
// 'quota_mb' => $quota,
// 'used_mb' => $used,
// 'usage_percent' => $usage,
// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
// 'is_active' => $mailboxActive,
// 'is_effective_active' => $effective,
// 'inactive_reason' => $reason,
// ];
// }
//
// $d->prepared_mailboxes = $prepared;
// }
//
// return view('livewire.ui.mail.mailbox-list', [
// 'domains' => $domains,
// 'system' => $this->showSystemCard ? $system : null,
// ]);
// }
// public function render()
// {
// $system = Domain::query()->where('is_system', true)->first();
//
// $term = trim($this->search);
//
// $domains = Domain::query()
// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
// ->withCount(['mailUsers','mailAliases'])
// ->with([
// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
// 'mailAliases' => fn ($q) => $q->orderBy('source'),
// ])
// ->when($term !== '', function ($q) use ($term) {
// $q->where(function ($w) use ($term) {
// $w->where('domain', 'like', "%{$term}%")
// ->orWhereHas('mailUsers', fn($u) =>
// $u->where('localpart', 'like', "%{$term}%")
// );
// });
// })
// ->orderBy('domain')
// ->get();
//
// // Für das Blade vorbereiten (ohne Relations zu mutieren)
// foreach ($domains as $d) {
// $prepared = [];
// $domainActive = (bool)($d->is_active ?? true);
//
// foreach ($d->mailUsers as $u) {
// $quota = (int) ($u->quota_mb ?? 0);
// $used = (int) ($u->used_mb ?? 0);
// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
//
// $mailboxActive = (bool)($u->is_active ?? true);
// $effective = $domainActive && $mailboxActive;
//
// $reason = null;
// if (!$effective) {
// $reason = !$domainActive ? 'Domain inaktiv'
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
// }
//
// $prepared[] = [
// 'id' => $u->id,
// 'localpart' => (string) $u->localpart,
// 'quota_mb' => $quota,
// 'used_mb' => $used,
// 'usage_percent' => $usage,
// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
// 'is_active' => $mailboxActive, // ursprünglicher Flag (falls du ihn brauchst)
// 'is_effective_active' => $effective, // ← NEU: Domain & Mailbox aktiv?
// 'inactive_reason' => $reason, // ← NEU: warum gesperrt
// ];
// }
//
// $d->prepared_mailboxes = $prepared;
// }
//
// return view('livewire.ui.mail.mailbox-list', [
// 'domains' => $domains,
// 'system' => $this->showSystemCard ? $system : null,
// ]);
// }
}
//// public function render()
//// {
//// $system = Domain::query()->where('is_system', true)->first();
////
//// $term = trim($this->search);
////
//// $domains = Domain::query()
//// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
//// ->withCount(['mailUsers','mailAliases'])
//// ->with([
//// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
//// 'mailAliases' => fn ($q) => $q->orderBy('source'),
//// ])
//// ->when($term !== '', function ($q) use ($term) {
//// $q->where(function ($w) use ($term) {
//// $w->where('domain', 'like', "%{$term}%")
//// ->orWhereHas('mailUsers', fn($u) =>
//// $u->where('localpart', 'like', "%{$term}%")
//// );
//// });
//// })
//// ->orderBy('domain')
//// ->get();
////
//// // Für das Blade vorbereiten (ohne Relations zu mutieren)
//// foreach ($domains as $d) {
//// $prepared = [];
//// $domainActive = (bool)($d->is_active ?? true);
////
//// foreach ($d->mailUsers as $u) {
//// $quota = (int) ($u->quota_mb ?? 0);
//// $used = (int) ($u->used_mb ?? 0);
//// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
////
//// $mailboxActive = (bool)($u->is_active ?? true);
//// $effective = $domainActive && $mailboxActive;
////
//// $reason = null;
//// if (!$effective) {
//// $reason = !$domainActive ? 'Domain inaktiv'
//// : (!$mailboxActive ? 'Postfach inaktiv' : null);
//// }
////
//// $prepared[] = [
//// 'id' => $u->id,
//// 'localpart' => (string) $u->localpart,
//// 'quota_mb' => $quota,
//// 'used_mb' => $used,
//// 'usage_percent' => $usage,
//// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
//// 'is_active' => $mailboxActive, // ursprünglicher Flag (falls du ihn brauchst)
//// 'is_effective_active' => $effective, // ← NEU: Domain & Mailbox aktiv?
//// 'inactive_reason' => $reason, // ← NEU: warum gesperrt
//// ];
//// }
////
//// $d->prepared_mailboxes = $prepared;
//// }
////
//// return view('livewire.ui.mail.mailbox-list', [
//// 'domains' => $domains,
//// 'system' => $this->showSystemCard ? $system : null,
//// ]);
//// }
//}

File diff suppressed because it is too large Load Diff

View File

@ -92,7 +92,7 @@ class MailboxCreateModal extends ModalComponent
{
// alle Nicht-System-Domains in Select
$this->domains = Domain::query()
->where('is_system', false)
->where('is_system', false)->where('is_server', false)
->orderBy('domain')->get(['id', 'domain'])->toArray();
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
@ -291,251 +291,3 @@ class MailboxCreateModal extends ModalComponent
return view('livewire.ui.mail.modal.mailbox-create-modal');
}
}
//namespace App\Livewire\Ui\Mail\Modal;
//
//use App\Models\Domain;
//use App\Models\MailUser;
//use Illuminate\Database\QueryException;
//use Illuminate\Support\Facades\Hash;
//use Illuminate\Validation\Rule;
//use Livewire\Attributes\On;
//use LivewireUI\Modal\ModalComponent;
//
//class MailboxCreateModal extends ModalComponent
//{
// // optional vorselektierte Domain
// public ?int $domain_id = null;
//
// // Anzeige
// public string $domain_name = '';
// /** @var array<int,array{id:int,domain:string}> */
// public array $domains = [];
// public string $email_preview = '';
//
// public string $localpart = '';
// public ?string $display_name = null;
// public ?string $password = null;
// public int $quota_mb = 0;
// public ?int $rate_limit_per_hour = null;
// public bool $is_active = true;
// public bool $must_change_pw = true;
//
// // Limits / Status
// public ?int $limit_max_mailboxes = null;
// public ?int $limit_default_quota_mb = null;
// public ?int $limit_max_quota_per_mb = null;
// public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
// public ?int $limit_domain_rate_per_hour = null;
// public bool $allow_rate_limit_override = false;
//
// public int $mailbox_count_used = 0;
// public int $domain_storage_used_mb = 0;
//
// // Hints/Flags
// public string $quota_hint = '';
// public bool $rate_limit_readonly = false;
// public bool $no_mailbox_slots = false;
// public bool $no_storage_left = false;
// public bool $can_create = true;
// public string $block_reason = '';
//
// /* ---------- Validation ---------- */
// protected function rules(): array
// {
// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
// $remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
// ? PHP_INT_MAX
// : max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
// $cap = min($maxPerMailbox, $remainingByTotal);
//
// return [
// 'domain_id' => ['required', Rule::exists('domains', 'id')],
// 'localpart' => [
// 'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
// Rule::unique('mail_users', 'localpart')->where(fn($q) => $q->where('domain_id', $this->domain_id)),
// ],
// 'display_name' => ['nullable', 'max:191'],
// 'password' => ['nullable', 'min:8'],
// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
// 'is_active' => ['boolean'],
// 'must_change_pw' => ['boolean'],
// ];
// }
//
// /* ---------- Lifecycle ---------- */
// public function mount(?int $domainId = null): void
// {
// // alle Nicht-System-Domains in Select
// $this->domains = Domain::query()
// ->where('is_system', false)
// ->orderBy('domain')->get(['id', 'domain'])->toArray();
//
// // vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
// $this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
//
// // Limits + Anzeige laden
// $this->syncDomainContext();
// }
//
// public function updatedDomainId(): void
// {
// $this->resetErrorBag(); // scoped unique etc.
// $this->syncDomainContext();
// }
//
// public function updatedLocalpart(): void
// {
// $this->localpart = strtolower(trim($this->localpart));
// $this->rebuildEmailPreview();
// }
//
// public function updatedQuotaMb(): void
// {
// $this->recomputeQuotaHints();
// $this->recomputeBlockers();
// }
//
// /* ---------- Helpers ---------- */
// private function syncDomainContext(): void
// {
// if (!$this->domain_id) return;
//
// $d = Domain::query()
// ->withCount('mailUsers')
// ->withSum('mailUsers as used_storage_mb', 'quota_mb')
// ->findOrFail($this->domain_id);
//
// $this->domain_name = $d->domain;
// $this->limit_max_mailboxes = (int)$d->max_mailboxes;
// $this->limit_default_quota_mb = (int)$d->default_quota_mb;
// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
// $this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
// $this->allow_rate_limit_override = (bool)$d->rate_limit_override;
//
// $this->mailbox_count_used = (int)$d->mail_users_count;
// $this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
//
// // Defaults
// $this->quota_mb = $this->limit_default_quota_mb ?? 0;
// if (!$this->allow_rate_limit_override) {
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
// $this->rate_limit_readonly = true;
// } else {
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
// $this->rate_limit_readonly = false;
// }
//
// $this->rebuildEmailPreview();
// $this->recomputeQuotaHints();
// $this->recomputeBlockers();
// }
//
// private function rebuildEmailPreview(): void
// {
// $this->email_preview = $this->localpart && $this->domain_name
// ? ($this->localpart . '@' . $this->domain_name) : '';
// }
//
// private function recomputeQuotaHints(): void
// {
// $parts = [];
//
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
// $remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
// $remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
// $parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
// $parts[] = "nach Speichern: {$remainingAfter} MiB";
// }
// if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
// if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
//
// $this->quota_hint = implode(' · ', $parts);
// }
//
// private function recomputeBlockers(): void
// {
// // Slots
// $this->no_mailbox_slots = false;
// if (!is_null($this->limit_max_mailboxes)) {
// $free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
// if ($free <= 0) $this->no_mailbox_slots = true;
// }
//
// // Speicher
// $this->no_storage_left = false;
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
// $remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
// if ($remaining <= 0) $this->no_storage_left = true;
// }
//
// $reasons = [];
// if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
// if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
// $this->block_reason = implode(' ', $reasons);
// $this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
// }
//
// /* ---------- Save ---------- */
// #[On('mailbox:create')]
// public function save(): void
// {
// $this->recomputeBlockers();
// if (!$this->can_create) {
// $this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
// return;
// }
//
// $data = $this->validate();
// $email = $data['localpart'] . '@' . $this->domain_name;
//
// try {
// $u = new MailUser();
// $u->domain_id = $data['domain_id'];
// $u->localpart = $data['localpart'];
// $u->email = $email;
// $u->display_name = $this->display_name ?: null;
// $u->password_hash = $this->password ? Hash::make($this->password) : null;
// $u->is_system = false;
// $u->is_active = (bool)$data['is_active'];
// $u->must_change_pw = (bool)$data['must_change_pw'];
// $u->quota_mb = (int)$data['quota_mb'];
// $u->rate_limit_per_hour = $data['rate_limit_per_hour'];
// $u->save();
// } catch (QueryException $e) {
// $msg = strtolower($e->getMessage());
// if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
// $this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
// return;
// }
// if (str_contains($msg, 'mail_users_email_unique')) {
// $this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
// return;
// }
// throw $e;
// }
//
// $this->dispatch('mailbox:created');
// $this->dispatch('closeModal');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Postfach',
// title: 'Postfach angelegt',
// text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
// duration: 6000
// );
//
// }
//
// public static function modalMaxWidth(): string
// {
// return '3xl';
// }
//
// public function render()
// {
// return view('livewire.ui.mail.modal.mailbox-create-modal');
// }
//}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Ui\Mail;
use Livewire\Component;
class QueueCard extends Component
{
public int $active = 0;
public int $deferred = 0;
public ?string $oldestAge = null;
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.mail.queue-card'); }
public function refresh(): void { $this->load(); }
protected function load(): void
{
$out = trim(@shell_exec('postqueue -p 2>/dev/null') ?? '');
$this->active = preg_match_all('/^[A-F0-9]{10}\*?\s+/mi', $out); // grob
$this->deferred = preg_match_all('/\s+(\(deferred\))/mi', $out);
// älteste Mail grob: erste Queue-ID-Zeile → Zeit parsen (optional)
$this->oldestAge = $this->active + $this->deferred > 0 ? '~'.date('H:i') : '';
}
public function flush(): void
{
@shell_exec('postqueue -f >/dev/null 2>&1 &');
$this->dispatch('toast', type:'info', title:'Queue flush gestartet');
}
}

View File

@ -0,0 +1,964 @@
<?php
namespace App\Livewire\Ui\Security;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Livewire\Component;
class Fail2BanCard extends Component
{
public bool $available = true;
public bool $permDenied = false;
public bool $error = false;
public int $activeBans = 0;
public array $jails = [];
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.security.fail2-ban-card');
}
#[On('f2b:refresh-banlist')]
public function refresh(): void
{
$this->load(true);
}
public function openDetails(string $jail): void
{
$this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
}
/* ---------------- intern ---------------- */
protected function load(bool $force = false): void
{
$this->available = $this->permDenied = $this->error = false;
$this->activeBans = 0;
$this->jails = [];
$bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
if ($bin === '') {
$this->available = false;
return;
}
$this->available = true;
[, $ping] = $this->f2b('ping');
if ($this->looksDenied($ping)) {
$this->permDenied = true;
return;
}
[, $status] = $this->f2b('status');
if ($this->looksDenied($status)) {
$this->permDenied = true;
return;
}
if (!preg_match('/Jail list:\s*(.+)$/mi', $status, $mm)) {
$this->error = true;
Log::warning('Fail2BanCard: unexpected status output', ['status' => $status]);
return;
}
$jails = array_filter(array_map('trim', preg_split('/\s*,\s*/', $mm[1] ?? '')));
$sum = 0;
$rows = [];
foreach ($jails as $j) {
$jEsc = escapeshellarg($j);
[, $s] = $this->f2b("status {$jEsc}");
if ($this->looksDenied($s)) {
$this->permDenied = true;
return;
}
$banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
$bantime = $this->getBantime($j);
$rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime, 'ips' => []];
$sum += $banned;
}
$this->activeBans = $sum;
$this->jails = $rows;
}
private function f2b(string $args): array
{
$sudo = $this->bin('sudo');
$f2b = $this->bin('fail2ban-client');
$cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
$out = (string)@shell_exec($cmd);
$ok = stripos($out, 'Status') !== false
|| stripos($out, 'Jail list') !== false
|| stripos($out, 'pong') !== false;
return [$ok, $out];
}
private function getBantime(string $jail): int
{
[, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
if ($this->looksDenied($out)) {
$this->permDenied = true;
return 600;
}
if (preg_match('/-?\d+/', trim($out), $m)) return (int)$m[0];
return 600;
}
private function looksDenied(string $out): bool
{
return (bool)preg_match('/(permission denied|not allowed to execute|a password is required)/i', $out);
}
private function firstMatch(string $pattern, string $haystack): ?string
{
return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
}
private function bin(string $name): string
{
$p = trim((string)@shell_exec("command -v " . escapeshellarg($name) . " 2>/dev/null"));
return $p !== '' ? $p : $name;
}
}
//namespace App\Livewire\Ui\Security;
//
//use Illuminate\Support\Facades\Log;
//use Livewire\Attributes\On;
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // sudo / Socket-Rechte fehlen?
// public bool $error = false; // anderer Fehler (Output unerwartet)
// public int $activeBans = 0;
// public array $jails = []; // [['name','banned','bantime','ips'=>[...]]]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// #[On('f2b:refresh-banlist')]
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function openDetails(string $jail): void
// {
// // wire-elements/modal (v2): Event-Namen + Component + Params
// $this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
// }
//
// /* ------------------- intern ------------------- */
//
// protected function load(bool $force = false): void
// {
// $this->available = true;
// $this->permDenied = false;
// $this->error = false;
// $this->activeBans = 0;
// $this->jails = [];
//
// // existiert fail2ban-client?
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// return;
// }
//
// // Rechte / Erreichbarkeit
// [, $ping] = $this->f2b('ping');
// if ($this->looksDenied($ping)) {
// $this->permDenied = true;
// return;
// }
//
// // Jails lesen
// [, $status] = $this->f2b('status');
// if ($this->looksDenied($status)) {
// $this->permDenied = true;
// return;
// }
// if (!preg_match('/Jail list:\s*(.+)$/mi', $status, $mm)) {
// // etwas stimmt nicht loggen und „error“ zeigen
// $this->error = true;
// Log::warning('Fail2BanCard: unexpected status output', ['status' => $status]);
// return;
// }
//
// $jails = array_filter(array_map('trim', preg_split('/\s*,\s*/', $mm[1] ?? '')));
// $sum = 0;
// $rows = [];
//
// foreach ($jails as $j) {
// $jEsc = escapeshellarg($j);
// [, $s] = $this->f2b("status {$jEsc}");
// if ($this->looksDenied($s)) {
// $this->permDenied = true;
// return;
// }
//
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j);
// $ipLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipLine)))) : [];
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'bantime' => $bantime,
// // wir zeigen IPs NICHT mehr in der Card; Details sind im Modal
// 'ips' => [],
// ];
// $sum += $banned;
// }
//
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// if ($this->looksDenied($out)) {
// $this->permDenied = true;
// return 600;
// }
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600;
// }
//
// private function looksDenied(string $out): bool
// {
// return preg_match('/(permission denied|not allowed to execute|a password is required)/i', $out) === 1;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true;
// public bool $permDenied = false;
// public int $activeBans = 0;
// public array $jails = [];
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// #[On('f2b:refresh-banlist')]
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function openDetails(string $jail): void
// {
// // KORREKTER DISPATCH für wire-elements/modal
// $this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
// }
//
// /* ------------------- intern ------------------- */
//
// protected function load(bool $force = false): void
// {
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Rechte prüfen
// [$ok, $raw] = $this->f2b('ping');
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Jail-Liste
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $rows = [];
// $sum = 0;
//
// foreach ($jails as $j) {
// $jEsc = escapeshellarg($j);
// [, $s] = $this->f2b("status {$jEsc}");
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j);
// $ipListLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipListLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipListLine)))) : [];
//
// // Details inkl. Restzeit je IP
// $ipDetails = $this->buildIpDetails($j, $ips, $bantime);
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'bantime' => $bantime, // Sek. (-1 = permanent)
// 'ips' => $ipDetails, // [['ip'=>..., 'remaining'=>..., 'until'=>...], ...]
// ];
// $sum += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// /** konfig. Bantime des Jails in Sekunden (-1 = permanent) */
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600; // konservativer Fallback
// }
//
// /** Letzten Ban-Zeitpunkt (Unix-Timestamp) aus /var/log/fail2ban.log ermitteln. */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// $file = '/var/log/fail2ban.log';
// if (!is_readable($file)) return null;
//
// // nur das Ende der Datei lesen (Performance, auch bei Rotation groß genug wählen)
// $tailBytes = 400000; // 400 KB
// $size = @filesize($file) ?: 0;
// $seek = max(0, $size - $tailBytes);
//
// $fh = @fopen($file, 'rb');
// if (!$fh) return null;
// if ($seek > 0) fseek($fh, $seek);
// $data = stream_get_contents($fh) ?: '';
// fclose($fh);
//
// // Beispielzeile:
// // 2025-10-30 22:34:20,797 fail2ban.actions [...] NOTICE [sshd] Ban 193.46.255.244
// $j = preg_quote($jail, '/');
// $p = preg_quote($ip, '/');
// $pattern = '/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}),\d+.*\['.$j.'\]\s+Ban\s+'.$p.'\s*$/m';
//
// if (preg_match_all($pattern, $data, $m) && !empty($m[1])) {
// $date = end($m[1]); // YYYY-MM-DD
// $time = end($m[2]); // HH:MM:SS
// $dt = \DateTime::createFromFormat('Y-m-d H:i:s', "$date $time", new \DateTimeZone(date_default_timezone_get()));
// return $dt ? $dt->getTimestamp() : null;
// }
// return null;
// }
//
// /** Baut Details inkl. Restzeit (Sekunden; -1 = permanent). */
// private function buildIpDetails(string $jail, array $ips, int $bantime): array
// {
// $now = time();
// $out = [];
//
// foreach ($ips as $ip) {
// $banAt = $this->lastBanTimestamp($jail, $ip);
// $remaining = null;
// $until = null;
//
// if ($bantime === -1) {
// $remaining = -1; // permanent
// } elseif ($banAt !== null) {
// $remaining = max(0, $bantime - ($now - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantime) : null;
// }
//
// $out[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, null = Ban-Zeitpunkt nicht gefunden, >=0 = Sekunden
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
// return $out;
// }
//
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs
// /** @var array<int,array{name:string,banned:int,bantime:int}> */
// public array $jails = [];
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// // Optional: öffnet später dein Detail-Modal/Tab
// public function openDetails(string $jail): void
// {
// $this->dispatch('openModal', 'ui.security.modal.fail2-ban-jail-modal', ['jail' => $jail]);
// }
// /* ---------------- intern ---------------- */
//
// protected function load(bool $force = false): void
// {
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Rechtecheck
// [$ok, $raw] = $this->f2b('ping');
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Jails laden
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $rows = [];
// $sum = 0;
//
// foreach ($jails as $j) {
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j); // Sek.; -1 = permanent
// $rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime];
// $sum += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// /** sudo + fail2ban-client ausführen; [ok, output] */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $out = (string)@shell_exec("timeout 2 $sudo -n $f2b $args 2>&1");
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600; // defensiver Default
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
// public array $jails = []; // [['name','banned','bantime','ips'=>[['ip','remaining','until'],...]],...]
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// /** Button „Neu prüfen“ */
// public function refresh(): void
// {
// $this->load(true);
// }
//
// /* --------------------- intern --------------------- */
//
// protected function load(bool $force = false): void
// {
// // existiert fail2ban-client?
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = [];
// return;
// }
//
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = $this->collectTopIps();
// return;
// }
//
// // Jails auflisten
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $total = 0;
// $rows = [];
//
// foreach ($jails as $j) {
// $bantimeSecs = $this->getBantime($j); // Sek., -1 = permanent
//
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
// $ipDetails = [];
// foreach (array_slice($ips, 0, 50) as $ip) {
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
// $remaining = null;
// $until = null;
//
// if ($banAt !== null) {
// if ((int)$bantimeSecs === -1) {
// $remaining = -1; // permanent
// } else {
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
// }
// }
//
// $ipDetails[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'ips' => $ipDetails,
// 'bantime' => (int)$bantimeSecs,
// ];
// $total += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $total;
// $this->jails = $rows;
// $this->topIps = $this->collectTopIps();
// }
//
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) {
// return (int)$m[0];
// }
// return 600; // defensiver Default
// }
//
// /** letzte Ban-Zeile aus /var/log/fail2ban.log → Unix-Timestamp */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
// $line = trim((string)@shell_exec($cmd));
// if ($line === '') return null;
//
// // "YYYY-MM-DD HH:MM:SS,mmm ..."
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
// $ts = strtotime($m[1] . ' ' . $m[2]);
// return $ts ?: null;
// }
// return null;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//
// /** Top-IPs grob zählen (aus der aktuellen Jail-Liste; Fallback: Log) */
// private function collectTopIps(): array
// {
// $map = [];
// foreach ($this->jails as $jail) {
// foreach ($jail['ips'] as $row) {
// $ip = $row['ip'] ?? null;
// if (!$ip) continue;
// $map[$ip] = ($map[$ip] ?? 0) + 1;
// }
// }
//
// if (!empty($map)) {
// arsort($map);
// $out = [];
// foreach (array_slice($map, 0, 5, true) as $ip => $count) {
// $out[] = ['ip' => $ip, 'count' => $count];
// }
// return $out;
// }
//
// // Fallback: aus fail2ban.log
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
// . ' | sort | uniq -c | sort -nr | head -5';
// $log = (string)@shell_exec($cmd);
// $rows = [];
// if ($log !== '') {
// foreach (preg_split('/\R+/', trim($log)) as $l) {
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
// $rows[] = ['ip' => $m[2], 'count' => (int)$m[1]];
// }
// }
// }
// return $rows;
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
// public array $jails = []; // [['name'=>'sshd','banned'=>2,'ips'=>['1.2.3.4',...]], ...]
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// // Wird vom Button "Neu prüfen" genutzt
// public function refresh(): void
// {
// $this->load(true);
// }
//
// /* --------------------- intern --------------------- */
//
// protected function load(bool $force = false): void
// {
// // existiert fail2ban-client?
// $bin = trim((string) @shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = [];
// return;
// }
//
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = $this->collectTopIps();
// return;
// }
//
// // Jails auflisten
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $total = 0; $rows = [];
//// ... in load() NACH dem Einlesen der Jail-Liste:
// $rows = [];
// foreach ($jails as $j) {
// $bantimeSecs = $this->getBantime($j); // konfigurierter Wert (Sekunden, -1 = permanent)
//
// [, $s] = $this->f2b('status '.escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
// $ipDetails = [];
// foreach (array_slice($ips, 0, 50) as $ip) {
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
// $remaining = null;
// $until = null;
//
// if ($banAt !== null) {
// if ((int)$bantimeSecs === -1) {
// $remaining = -1; // permanent
// } else {
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
// }
// }
//
// $ipDetails[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'ips' => $ipDetails, // jetzt mit Details
// 'bantime' => (int)$bantimeSecs,
// ];
// $total += $banned;
// }
//
// // foreach ($jails as $j) {
//// [, $s] = $this->f2b('status '.escapeshellarg($j));
//// $banned = (int) ($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
//// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
//// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//// $rows[] = ['name'=>$j,'banned'=>$banned,'ips'=>array_slice($ips, 0, 8)];
//// $total += $banned;
//// }
//
//
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $total;
// $this->jails = $rows;
// $this->topIps = $this->collectTopIps();
// }
//
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
// $out = (string) @shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
// // fail2ban liefert Seconds als Zahl (oder mit Newline)
// $val = trim($out);
// // Fallback: manche Versionen geben nur Zahl ohne Kontext zurück,
// // sonst aus jail.local ermitteln wäre overkill -> einfache Zahl extrahieren:
// if (preg_match('/-?\d+/', $val, $m)) {
// return (int)$m[0];
// }
// // wenn nicht ermittelbar: 600 Sekunden als conservative default
// return 600;
// }
//
// /** Sucht die letzte "Ban <IP>"-Zeile für Jail in /var/log/fail2ban.log und gibt Unix-Timestamp zurück. */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// // Beispiel-Logzeilen:
// // 2025-10-29 18:07:11,436 fail2ban.actions [12345]: NOTICE [sshd] Ban 1.2.3.4
// // Wir holen die letzte passende Zeile (tail mit grep), dann parsen Datum.
// $pattern = escapeshellarg(sprintf('\\[%s\\] Ban %s', $jail, $ip));
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
// $line = (string)@shell_exec($cmd);
// $line = trim($line);
// if ($line === '') {
// return null;
// }
// // Datumsformat am Anfang: "YYYY-MM-DD HH:MM:SS,mmm"
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
// $ts = strtotime($m[1].' '.$m[2]);
// return $ts ?: null;
// }
// return null;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//
// /** Zählt die häufigsten IPs aus den letzten Fail2Ban-Logs (ban/unban Events) */
// private function collectTopIps(): array
// {
// // 1. Versuch: IPs direkt aus den Jails
// $rows = [];
// foreach ($this->jails as $jail) {
// foreach ($jail['ips'] as $ip) {
// $rows[$ip] = ($rows[$ip] ?? 0) + 1;
// }
// }
//
// if (!empty($rows)) {
// arsort($rows);
// return collect($rows)
// ->map(fn($count, $ip) => ['ip' => $ip, 'count' => $count])
// ->values()
// ->take(5)
// ->toArray();
// }
//
// // 2. Fallback: Falls keine Jails/IPs → Logdatei
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
// . ' | sort | uniq -c | sort -nr | head -5';
// $log = (string) @shell_exec($cmd);
//
// $rows = [];
// if ($log !== '') {
// foreach (preg_split('/\R+/', trim($log)) as $l) {
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
// $rows[] = ['ip'=>$m[2],'count'=>(int)$m[1]];
// }
// }
// }
// return $rows;
// }
//}

View File

@ -0,0 +1,200 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\On;
use Livewire\Component;
class Fail2banBanlist extends Component
{
/**
* null oder '*' => alle Jails
* 'recidive' => nur dieses Jail
* 'mailwolt-blacklist' etc.
*/
public ?string $jail = null;
/**
* Struktur für Blade (reine Ausgabe, keine Logik im Blade):
* [
* [
* 'ip' => '1.2.3.4',
* 'jail' => 'recidive',
* 'permanent' => false,
* 'label' => 'Temporär', // oder 'Permanent'
* 'box' => 'border-amber-400/20 bg-white/3', // Kartenstil
* 'badge' => 'border-amber-400/30 bg-amber-500/10 text-amber-200',
* 'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
* ],
* ...
* ]
*
* @var array<int,array{
* ip:string,jail:string,permanent:bool,label:string,box:string,badge:string,btn:string
* }>
*/
public array $rows = [];
#[On('f2b:refresh')]
public function refreshList(): void
{
$this->loadBanned();
}
public function mount(?string $jail = null): void
{
$this->jail = $jail;
$this->loadBanned();
}
public function render()
{
return view('livewire.ui.security.fail2ban-banlist');
}
/* ================= core ================= */
private function loadBanned(): void
{
$jails = $this->jailList();
// ggf. nur ein bestimmtes Jail
if (is_string($this->jail) && $this->jail !== '' && $this->jail !== '*') {
$jails = in_array($this->jail, $jails, true) ? [$this->jail] : [];
}
$rows = [];
foreach ($jails as $j) {
$out = $this->f2b("status " . escapeshellarg($j));
if (!preg_match('/IP list:\s*(.+)$/mi', $out, $m)) {
continue;
}
$ips = preg_split('/\s+/', trim($m[1])) ?: [];
foreach ($ips as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
continue;
}
$permanent = $this->isPermanent($j, $ip);
if ($permanent) {
$box = 'border-rose-400/30 bg-rose-500/5';
$badge = 'border-rose-400/30 bg-rose-500/10 text-rose-200';
$label = 'Permanent';
$style = 'permanent';
$dot = 'bg-rose-500';
} else {
$box = 'border-amber-400/20 bg-white/3';
$badge = 'border-amber-400/30 bg-amber-500/10 text-amber-200';
$label = 'Temporär';
$style = 'temporary';
$dot = 'bg-amber-400';
}
$rows[] = [
'ip' => $ip,
'jail' => $j,
'permanent' => $permanent,
'style' => $style,
'label' => $label,
'box' => $box,
'badge' => $badge,
'dot' => $dot,
'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
];
}
}
// Sortierung: permanent oben, dann nach Jail, dann IP
usort($rows, function ($a, $b) {
if ($a['permanent'] !== $b['permanent']) return $a['permanent'] ? -1 : 1;
if ($a['jail'] !== $b['jail']) return strcmp($a['jail'], $b['jail']);
return strcmp($a['ip'], $b['ip']);
});
$this->rows = $rows;
}
/** Entbannt eine IP **im angegebenen Jail** (Button gibt Jail mit) */
public function unban(string $ip, string $jail): void
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) return;
$cmd = sprintf(
'sudo -n /usr/bin/fail2ban-client set %s unbanip %s 2>&1',
escapeshellarg($jail),
escapeshellarg($ip)
);
@shell_exec($cmd);
$this->loadBanned();
$this->dispatch('toast',
type: 'done',
badge: 'Fail2Ban',
title: 'IP entbannt',
text: "IP {$ip} in Jail „{$jail}“ entbannt.",
duration: 5000,
);
}
/* ================= helpers ================= */
/** Prüft via SQLite, ob der **letzte** Ban für (jail, ip) permanent ist (bantime < 0). */
private function isPermanent(string $jail, string $ip): bool
{
$db = $this->getDbFile();
if ($db === '' || !is_readable($db)) {
// Fallback: Blacklist-Jail ist per Design permanent
return $jail === 'mailwolt-blacklist';
}
$q = <<<SQL
WITH last AS (
SELECT MAX(timeofban) AS t
FROM bans
WHERE jail = '$jail' AND ip = '$ip'
)
SELECT bantime
FROM bans, last
WHERE jail = '$jail' AND ip = '$ip' AND timeofban = last.t
LIMIT 1;
SQL;
$cmd = sprintf(
'sudo -n /usr/bin/sqlite3 -readonly %s %s 2>&1',
escapeshellarg($db),
escapeshellarg($q)
);
$out = trim((string)@shell_exec($cmd));
if ($out === '') return ($jail === 'mailwolt-blacklist'); // Fallback
return ((int)$out) < 0;
}
/** Liste aller Jails */
private function jailList(): array
{
$out = $this->f2b('status');
if (preg_match('/Jail list:\s*(.+)$/mi', $out, $m)) {
$jails = array_map('trim', preg_split('/\s*,\s*/', trim($m[1])));
return array_values(array_filter($jails, fn($v) => $v !== ''));
}
return [];
}
/** fail2ban-client über sudo aufrufen */
private function f2b(string $args): string
{
return (string) @shell_exec('sudo -n /usr/bin/fail2ban-client '.$args.' 2>&1');
}
/** Pfad zur Fail2Ban-SQLite-DB holen */
private function getDbFile(): string
{
$out = $this->f2b('get dbfile');
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
$path = end($lines) ?: '';
$path = preg_replace('/^`?-?\s*/', '', $path);
return $path ?: '/var/lib/fail2ban/fail2ban.sqlite3';
}
}

View File

@ -0,0 +1,543 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\On;
use Livewire\Component;
use App\Models\Fail2banSetting;
use App\Models\Fail2banIpList;
use Illuminate\Validation\ValidationException;
class Fail2banSettings extends Component
{
// Formfelder
public int $bantime;
public int $max_bantime;
public bool $bantime_increment;
public float $bantime_factor;
public int $max_retry;
public int $findtime;
public int $cidr_v4;
public int $cidr_v6;
public bool $external_mode;
public array $whitelist = [];
public array $blacklist = [];
public Fail2banSetting $settings;
#[On('f2b:refresh')]
public function refreshLists(): void
{
$this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray();
$this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray();
}
public function mount(): void
{
$this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
'bantime' => 3600,
'max_bantime' => 43200,
'bantime_increment' => true,
'bantime_factor' => 1.5,
'max_retry' => 3,
'findtime' => 600,
'cidr_v4' => 32,
'cidr_v6' => 128,
'external_mode' => false,
]);
$this->fill([
'bantime' => (int)$this->settings->bantime,
'max_bantime' => (int)$this->settings->max_bantime,
'bantime_increment' => (bool)$this->settings->bantime_increment,
'bantime_factor' => (float)$this->settings->bantime_factor,
'max_retry' => (int)$this->settings->max_retry,
'findtime' => (int)$this->settings->findtime,
'cidr_v4' => (int)$this->settings->cidr_v4,
'cidr_v6' => (int)$this->settings->cidr_v6,
'external_mode' => (bool)$this->settings->external_mode,
]);
$this->refreshLists();
}
public function save(): void
{
$this->validate([
'bantime' => 'required|integer|min:60',
'max_bantime' => 'required|integer|min:60',
'bantime_factor' => 'required|numeric|min:1',
'max_retry' => 'required|integer|min:1',
'findtime' => 'required|integer|min:60',
'cidr_v4' => 'required|integer|min:8|max:32',
'cidr_v6' => 'required|integer|min:8|max:128',
]);
try {
// Einstellungen speichern
$this->settings->update([
'bantime' => $this->bantime,
'max_bantime' => $this->max_bantime,
'bantime_increment' => $this->bantime_increment,
'bantime_factor' => $this->bantime_factor,
'max_retry' => $this->max_retry,
'findtime' => $this->findtime,
'cidr_v4' => $this->cidr_v4,
'cidr_v6' => $this->cidr_v6,
'external_mode' => $this->external_mode,
]);
// Config-Dateien schreiben
$this->writeDefaultsConfig();
$this->writeWhitelistConfig();
// Fail2Ban reload
$this->runCommand('sudo -n /usr/bin/fail2ban-client reload');
$this->dispatch('toast',
type: 'success',
badge: 'Fail2Ban',
title: 'Einstellungen gespeichert',
text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
duration: 6000,
);
} catch (\Throwable $e) {
$this->dispatch('toast',
type: 'error',
badge: 'Fail2Ban',
title: 'Fehler beim Anwenden',
text: 'Die neuen Einstellungen konnten nicht angewendet werden: ' . $e->getMessage(),
duration: 8000,
);
}
}
/* ---------------- Config-Dateien ---------------- */
protected function writeDefaultsConfig(): void
{
$s = $this->settings;
$content = <<<CONF
[DEFAULT]
bantime = {$s->bantime}
findtime = {$s->findtime}
maxretry = {$s->max_retry}
bantime.increment = {$this->boolToStr($s->bantime_increment)}
bantime.factor = {$s->bantime_factor}
bantime.maxtime = {$s->max_bantime}
CONF;
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
}
protected function writeWhitelistConfig(): void
{
// zieht System + User-Whitelist
$ips = Fail2banIpList::allWhitelistForConfig();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
}
/* ---------------- Helper ---------------- */
private function writeRootFileViaTee(string $target, string $content): void
{
if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
throw new \RuntimeException("Illegal path: $target");
}
$cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $desc, $pipes);
if (!is_resource($proc)) {
throw new \RuntimeException('tee start fehlgeschlagen');
}
fwrite($pipes[0], $content);
fclose($pipes[0]);
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
throw new \RuntimeException("tee failed writing to {$target}");
}
}
private function runCommand(string $cmd): void
{
$output = [];
$return = 0;
exec($cmd . ' 2>&1', $output, $return);
if ($return !== 0) {
throw new \RuntimeException("Command failed ($return): {$cmd}\n" . implode("\n", $output));
}
}
private function boolToStr(bool $v): string
{
return $v ? 'true' : 'false';
}
public function render()
{
return view('livewire.ui.security.fail2ban-settings');
}
}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Fail2banSetting;
//use App\Models\Fail2banIpList;
//
//class Fail2banSettings extends Component
//{
// // Formfelder
// public int $bantime;
// public int $max_bantime;
// public bool $bantime_increment;
// public float $bantime_factor;
// public int $max_retry;
// public int $findtime;
// public int $cidr_v4;
// public int $cidr_v6;
// public bool $external_mode;
//
// public array $whitelist = [];
// public array $blacklist = [];
//
// public Fail2banSetting $settings;
//
// #[On('f2b:refresh')]
// public function refreshLists(): void
// {
// $this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray();
// }
//
// public function mount(): void
// {
// // Setting holen oder Defaults anlegen
// $this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
// 'bantime' => 3600,
// 'max_bantime' => 43200,
// 'bantime_increment' => true,
// 'bantime_factor' => 1.5,
// 'max_retry' => 3,
// 'findtime' => 600,
// 'cidr_v4' => 32,
// 'cidr_v6' => 128,
// 'external_mode' => false,
// ]);
//
// // Properties befüllen
// $this->fill([
// 'bantime' => (int)$this->settings->bantime,
// 'max_bantime' => (int)$this->settings->max_bantime,
// 'bantime_increment' => (bool)$this->settings->bantime_increment,
// 'bantime_factor' => (float)$this->settings->bantime_factor,
// 'max_retry' => (int)$this->settings->max_retry,
// 'findtime' => (int)$this->settings->findtime,
// 'cidr_v4' => (int)$this->settings->cidr_v4,
// 'cidr_v6' => (int)$this->settings->cidr_v6,
// 'external_mode' => (bool)$this->settings->external_mode,
// ]);
//
// $this->refreshLists();
// }
//
// public function save(): void
// {
// $this->validate([
// 'bantime' => 'required|integer|min:60',
// 'max_bantime' => 'required|integer|min:60',
// 'bantime_factor' => 'required|numeric|min:1',
// 'max_retry' => 'required|integer|min:1',
// 'findtime' => 'required|integer|min:60',
// 'cidr_v4' => 'required|integer|min:8|max:32',
// 'cidr_v6' => 'required|integer|min:8|max:128',
// ]);
//
// // Einstellungen speichern
// $this->settings->update([
// 'bantime' => $this->bantime,
// 'max_bantime' => $this->max_bantime,
// 'bantime_increment' => $this->bantime_increment,
// 'bantime_factor' => $this->bantime_factor,
// 'max_retry' => $this->max_retry,
// 'findtime' => $this->findtime,
// 'cidr_v4' => $this->cidr_v4,
// 'cidr_v6' => $this->cidr_v6,
// 'external_mode' => $this->external_mode,
// ]);
//
// // Config-Dateien schreiben
// $this->writeDefaultsConfig();
// $this->writeWhitelistConfig();
//
// // Fail2Ban reload
// $this->runCommand('sudo -n /usr/bin/fail2ban-client reload');
//
// $this->dispatch('toast',
// type: 'done',
// badge: 'Fail2Ban',
// title: 'Einstellungen gespeichert',
// text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
// duration: 6000,
// );
// }
//
// protected function writeDefaultsConfig(): void
// {
// $s = $this->settings;
//
// $content = <<<CONF
//[DEFAULT]
//bantime = {$s->bantime}
//findtime = {$s->findtime}
//maxretry = {$s->max_retry}
//bantime.increment = {$this->boolToStr($s->bantime_increment)}
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
//
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// }
//
// /**
// * Schreibt Root-Dateien sicher via `sudo tee`
// */
// private function writeRootFileViaTee(string $target, string $content): void
// {
// if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
// throw new \RuntimeException("Illegal path: $target");
// }
//
// $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
//
// $descriptorspec = [
// 0 => ['pipe', 'r'],
// 1 => ['pipe', 'w'],
// 2 => ['pipe', 'w'],
// ];
//
// $proc = proc_open($cmd, $descriptorspec, $pipes, null, null);
// if (!is_resource($proc)) {
// throw new \RuntimeException('Failed to start tee');
// }
//
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// stream_get_contents($pipes[1]);
// stream_get_contents($pipes[2]);
// $exitCode = proc_close($proc);
//
// if ($exitCode !== 0) {
// throw new \RuntimeException("tee failed writing to {$target}");
// }
// }
//
// /**
// * Führt Systembefehle aus und wirft Exception bei Fehlern
// */
// private function runCommand(string $cmd): void
// {
// $output = [];
// $return = 0;
// exec($cmd . ' 2>&1', $output, $return);
//
// if ($return !== 0) {
// throw new \RuntimeException("Command failed ($return): {$cmd}\n" . implode("\n", $output));
// }
// }
//
// private function boolToStr(bool $v): string
// {
// return $v ? 'true' : 'false';
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2ban-settings');
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Fail2banSetting;
//use App\Models\Fail2banIpList;
//
//class Fail2banSettings extends Component
//{
// // Formfelder
// public int $bantime;
// public int $max_bantime;
// public bool $bantime_increment;
// public float $bantime_factor;
// public int $max_retry;
// public int $findtime;
// public int $cidr_v4;
// public int $cidr_v6;
// public bool $external_mode;
//
// public array $whitelist = [];
// public array $blacklist = [];
//
// public Fail2banSetting $settings;
//
// #[On('f2b:refresh')]
// public function refreshLists(): void
// {
// $this->whitelist = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::where('type', 'blacklist')->pluck('ip')->toArray();
// }
//
// public function mount(): void
// {
// // Setting holen oder mit Defaults anlegen
// $this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
// 'bantime' => 3600, 'max_bantime' => 43200, 'bantime_increment' => true,
// 'bantime_factor' => 1.5, 'max_retry' => 3, 'findtime' => 600,
// 'cidr_v4' => 32, 'cidr_v6' => 128, 'external_mode' => false,
// ]);
//
// // Properties füllen (KEINE Mixed-Objekte in Inputs binden)
// $this->fill([
// 'bantime' => (int)$this->settings->bantime,
// 'max_bantime' => (int)$this->settings->max_bantime,
// 'bantime_increment' => (bool)$this->settings->bantime_increment,
// 'bantime_factor' => (float)$this->settings->bantime_factor,
// 'max_retry' => (int)$this->settings->max_retry,
// 'findtime' => (int)$this->settings->findtime,
// 'cidr_v4' => (int)$this->settings->cidr_v4,
// 'cidr_v6' => (int)$this->settings->cidr_v6,
// 'external_mode' => (bool)$this->settings->external_mode,
// ]);
//
// $this->whitelist = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::where('type','blacklist')->pluck('ip')->toArray();
// }
//
// public function save(): void
// {
// $this->validate([
// 'bantime' => 'required|integer|min:60',
// 'max_bantime' => 'required|integer|min:60',
// 'bantime_factor' => 'required|numeric|min:1',
// 'max_retry' => 'required|integer|min:1',
// 'findtime' => 'required|integer|min:60',
// 'cidr_v4' => 'required|integer|min:8|max:32',
// 'cidr_v6' => 'required|integer|min:8|max:128',
// ]);
//
// $this->settings->update([
// 'bantime' => $this->bantime,
// 'max_bantime' => $this->max_bantime,
// 'bantime_increment' => $this->bantime_increment,
// 'bantime_factor' => $this->bantime_factor,
// 'max_retry' => $this->max_retry,
// 'findtime' => $this->findtime,
// 'cidr_v4' => $this->cidr_v4,
// 'cidr_v6' => $this->cidr_v6,
// 'external_mode' => $this->external_mode,
// ]);
//
// $this->writeDefaultsConfig();
// $this->writeWhitelistConfig();
//
// @shell_exec('sudo fail2ban-client reload');
// $this->dispatch('notify', message: 'Gespeichert & Fail2Ban neu geladen.');
// }
//
// protected function writeDefaultsConfig(): void
// {
// $s = $this->settings;
// $content = <<<CONF
//[DEFAULT]
//bantime = {$s->bantime}
//findtime = {$s->findtime}
//maxretry = {$s->max_retry}
//bantime.increment = {$this->boolToStr($s->bantime_increment)}
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
// file_put_contents('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
// file_put_contents('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void
// {
// // Nur erlaubte Pfade (Hardening)
// if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
// throw new \RuntimeException("Illegal path: $target");
// }
//
// $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
//
// $descriptorspec = [
// 0 => ['pipe', 'r'], // stdin -> tee
// 1 => ['pipe', 'w'], // stdout
// 2 => ['pipe', 'w'], // stderr
// ];
//
// $proc = proc_open($cmd, $descriptorspec, $pipes, null, null);
// if (!is_resource($proc)) {
// throw new \RuntimeException('Failed to start tee');
// }
//
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
// $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
//
// $code = proc_close($proc);
// if ($code !== 0) {
// throw new \RuntimeException("tee failed (code $code): $stderr $stdout");
// }
// }
//
// private function boolToStr(bool $v): string
// {
// return $v ? 'true' : 'false';
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2ban-settings');
// }
//}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security\Modal\Fail2BanJailModal;
use Livewire\Component;
class Php extends Component
{
public function render()
{
return view('livewire.ui.security.modal.fail2-ban-jail-modal.php');
}
}

View File

@ -0,0 +1,567 @@
<?php
namespace App\Livewire\Ui\Security\Modal;
use LivewireUI\Modal\ModalComponent;
use App\Models\Fail2banIpList;
use Illuminate\Validation\ValidationException;
class Fail2banIpModal extends ModalComponent
{
/** 'whitelist' | 'blacklist' */
public string $type = 'whitelist';
/** 'add' | 'remove' */
public string $mode = 'add';
/** IP/CIDR im Formular */
public string $ip = '';
/** Für "remove" vorbefüllt */
public ?string $prefill = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
{
$type = strtolower($type);
$mode = strtolower($mode);
if (!in_array($type, ['whitelist', 'blacklist'], true)) {
throw new \InvalidArgumentException('Invalid type');
}
if (!in_array($mode, ['add', 'remove'], true)) {
throw new \InvalidArgumentException('Invalid mode');
}
$this->type = $type;
$this->mode = $mode;
$this->ip = $ip ?? '';
$this->prefill = $ip;
}
public function render()
{
return view('livewire.ui.security.modal.fail2ban-ip-modal');
}
/* ---------------- actions ---------------- */
public function save(): void
{
$this->assertAddMode();
$ip = trim($this->ip);
if (!Fail2banIpList::isValidIpOrCidr($ip)) {
throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
}
// Schutz: System-/Loopback-IPs darf der User nicht manuell pflegen
if (Fail2banIpList::isLoopback($ip)) {
throw ValidationException::withMessages(['ip' => 'Loopback/localhost ist bereits systemseitig erlaubt und kann nicht geändert werden.']);
}
// Duplikate abfangen
$exists = Fail2banIpList::where('ip', $ip)->where('type', $this->type)->exists();
if ($exists) {
throw ValidationException::withMessages(['ip' => ucfirst($this->type) . ' enthält diese IP bereits.']);
}
// DB schreiben
Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
if ($this->type === 'whitelist') {
// Whitelist-Datei aktualisieren + Fail2Ban reload
$this->writeWhitelistConfig();
$this->reloadFail2ban();
// UI aktualisieren & Toast
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'success',
badge: 'Fail2Ban',
title: 'Whitelist aktualisiert',
text: 'Die IP wurde erfolgreich zur Whitelist hinzugefügt und ist nun freigegeben.',
duration: 6000,
);
} else {
// Blacklist = sofort bannen
$this->banIp($ip);
// UI aktualisieren & Toast
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'warning',
badge: 'Fail2Ban',
title: 'Blacklist aktualisiert',
text: 'Die IP wurde zur Blacklist hinzugefügt und umgehend blockiert.',
duration: 6000,
);
}
// Modal bewusst am Ende schließen (Toast bleibt sichtbar)
$this->closeModal();
}
public function remove(): void
{
$this->assertRemoveMode();
$ip = trim($this->prefill ?? $this->ip);
if ($ip === '') return;
// System-Whitelist darf nicht entfernt werden
$row = Fail2banIpList::where('type', $this->type)->where('ip', $ip)->first();
if ($row && $row->is_system) {
throw ValidationException::withMessages(['ip' => 'Systemeintrag kann nicht entfernt werden.']);
}
Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
if ($this->type === 'whitelist') {
$this->writeWhitelistConfig();
$this->reloadFail2ban();
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'info',
badge: 'Fail2Ban',
title: 'Whitelist geändert',
text: 'Die IP wurde aus der Whitelist entfernt.',
duration: 6000,
);
} else {
$this->unbanIp($ip);
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'info',
badge: 'Fail2Ban',
title: 'Blacklist geändert',
text: 'Die IP wurde aus der Blacklist entfernt und ist wieder freigegeben.',
duration: 6000,
);
}
$this->closeModal();
}
/* ---------------- helper ---------------- */
private function assertAddMode(): void
{
if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
}
private function assertRemoveMode(): void
{
if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
}
private function writeWhitelistConfig(): void
{
// WICHTIG: inkl. System-IPs (unsichtbar in der UI)
$ips = Fail2banIpList::allWhitelistForConfig();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
}
private function writeRootFileViaTee(string $target, string $content): void
{
if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
throw new \RuntimeException("Illegal path: $target");
}
$cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $desc, $pipes);
if (!is_resource($proc)) {
throw new \RuntimeException('tee start fehlgeschlagen');
}
fwrite($pipes[0], $content);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
throw new \RuntimeException("tee failed (code $code): $stderr $stdout");
}
}
private function reloadFail2ban(): void
{
@shell_exec('sudo -n /usr/bin/fail2ban-client reload 2>&1');
}
private function banIp(string $ip): void
{
$ipEsc = escapeshellarg($ip);
@shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1");
}
private function unbanIp(string $ip): void
{
$ipEsc = escapeshellarg($ip);
@shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1");
}
}
//namespace App\Livewire\Ui\Security\Modal;
//
//use LivewireUI\Modal\ModalComponent;
//use App\Models\Fail2banIpList;
//use Illuminate\Validation\ValidationException;
//
//class Fail2banIpModal extends ModalComponent
//{
// /** 'whitelist' | 'blacklist' */
// public string $type = 'whitelist';
//
// /** 'add' | 'remove' */
// public string $mode = 'add';
//
// /** IP/CIDR im Formular */
// public string $ip = '';
//
// /** Für "remove" vorbefüllt */
// public ?string $prefill = null;
//
// public static function modalMaxWidth(): string
// {
// return 'lg';
// }
//
// public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
// {
// $type = strtolower($type);
// $mode = strtolower($mode);
//
// if (!in_array($type, ['whitelist', 'blacklist'], true)) {
// throw new \InvalidArgumentException('Invalid type');
// }
// if (!in_array($mode, ['add', 'remove'], true)) {
// throw new \InvalidArgumentException('Invalid mode');
// }
//
// $this->type = $type;
// $this->mode = $mode;
// $this->ip = $ip ?? '';
// $this->prefill = $ip;
// }
//
// public function render()
// {
// return view('livewire.ui.security.modal.fail2ban-ip-modal');
// }
//
// /* ---------------- actions ---------------- */
//
// public function save(): void
// {
// $this->assertAddMode();
// $ip = trim($this->ip);
//
// if (!Fail2banIpList::isValidIpOrCidr($ip)) {
// throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
// }
//
// // Schutz: System-/Loopback-IPs darf der User nicht manuell pflegen
// if (Fail2banIpList::isLoopback($ip)) {
// throw ValidationException::withMessages(['ip' => 'Loopback/localhost ist bereits systemseitig erlaubt und kann nicht geändert werden.']);
// }
//
// // Duplikate abfangen (es gibt einen Unique-Index ip+type; trotzdem user-freundlich)
// $exists = Fail2banIpList::where('ip', $ip)->where('type', $this->type)->exists();
// if ($exists) {
// throw ValidationException::withMessages(['ip' => ucfirst($this->type) . ' enthält diese IP bereits.']);
// }
//
// // DB schreiben
// Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/mailwolt-whitelist.local
// $this->reloadFail2ban(); // f2b neu laden
// } else {
// // Blacklist = sofort bannen im dedizierten Jail
// $this->banIp($ip);
// }
//
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// }
//
// public function remove(): void
// {
// $this->assertRemoveMode();
// $ip = trim($this->prefill ?? $this->ip);
// if ($ip === '') return;
//
// // System-Whitelist darf nicht entfernt werden
// $row = Fail2banIpList::where('type', $this->type)->where('ip', $ip)->first();
// if ($row && $row->is_system) {
// throw ValidationException::withMessages(['ip' => 'Systemeintrag kann nicht entfernt werden.']);
// }
//
// Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig();
// $this->reloadFail2ban();
// } else {
// $this->unbanIp($ip);
// }
//
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Fail2Ban',
// title: 'Einstellungen gespeichert',
// text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
// duration: 6000,
// );
// }
//
// /* ---------------- helper ---------------- */
//
// private function assertAddMode(): void
// {
// if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
// }
//
// private function assertRemoveMode(): void
// {
// if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
// }
//
// private function writeWhitelistConfig(): void
// {
// // WICHTIG: inkl. System-IPs
// $ips = Fail2banIpList::allWhitelistForConfig();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// // sicher in Root-Pfad schreiben (sudo tee)
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void
// {
// if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
// throw new \RuntimeException("Illegal path: $target");
// }
//
// $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
// $desc = [
// 0 => ['pipe', 'r'],
// 1 => ['pipe', 'w'],
// 2 => ['pipe', 'w'],
// ];
// $proc = proc_open($cmd, $desc, $pipes);
// if (!is_resource($proc)) {
// throw new \RuntimeException('tee start fehlgeschlagen');
// }
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// $stdout = stream_get_contents($pipes[1]);
// fclose($pipes[1]);
// $stderr = stream_get_contents($pipes[2]);
// fclose($pipes[2]);
// $code = proc_close($proc);
// if ($code !== 0) {
// throw new \RuntimeException("tee failed (code $code): $stderr $stdout");
// }
// }
//
// private function reloadFail2ban(): void
// {
// @shell_exec('sudo -n /usr/bin/fail2ban-client reload 2>&1');
// }
//
// private function banIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1");
// }
//
// private function unbanIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1");
// }
//}
//
//namespace App\Livewire\Ui\Security\Modal;
//
//use LivewireUI\Modal\ModalComponent;
//use App\Models\Fail2banIpList;
//use Illuminate\Validation\ValidationException;
//
//class Fail2banIpModal extends ModalComponent
//{
// /** 'whitelist' | 'blacklist' */
// public string $type = 'whitelist';
//
// /** 'add' | 'remove' */
// public string $mode = 'add';
//
// /** IP/CIDR im Formular */
// public string $ip = '';
//
// /** Für "remove" vorbefüllt */
// public ?string $prefill = null;
//
// public static function modalMaxWidth(): string { return 'lg'; }
//
// public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
// {
// $type = strtolower($type);
// $mode = strtolower($mode);
//
// if (!in_array($type, ['whitelist', 'blacklist'], true)) {
// throw new \InvalidArgumentException('Invalid type');
// }
// if (!in_array($mode, ['add', 'remove'], true)) {
// throw new \InvalidArgumentException('Invalid mode');
// }
//
// $this->type = $type;
// $this->mode = $mode;
// $this->ip = $ip ?? '';
// $this->prefill = $ip;
// }
//
// public function render()
// {
// return view('livewire.ui.security.modal.fail2ban-ip-modal');
// }
//
// /* ---------------- actions ---------------- */
//
// public function save(): void
// {
// $this->assertAddMode();
// $ip = trim($this->ip);
//
// if (!$this->isValidIpOrCidr($ip)) {
// throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
// }
//
// // DB schreiben
// Fail2banIpList::firstOrCreate(['ip' => $ip, 'type' => $this->type]);
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig();
// $this->reloadFail2ban();
// } else {
// // Blacklist = sofort bannen im dedizierten Jail
// $this->banIp($ip);
// }
//
// $this->dispatch('f2b:refresh');
// $this->dispatch('notify', message: ucfirst($this->type).' aktualisiert.');
// $this->closeModal();
// $this->dispatch('f2b:refresh'); // falls du eine Liste neu laden willst
// }
//
// public function remove(): void
// {
// $this->assertRemoveMode();
// $ip = trim($this->prefill ?? $this->ip);
//
// if ($ip === '') return;
//
// Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig();
// $this->reloadFail2ban();
// } else {
// // aus Blacklist-Jail entbannen, falls noch aktiv
// $this->unbanIp($ip);
// }
//
// $this->dispatch('f2b:refresh');
// $this->dispatch('notify', message: ucfirst($this->type).' Eintrag entfernt.');
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// }
//
// /* ---------------- helper ---------------- */
//
// private function assertAddMode(): void
// {
// if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
// }
//
// private function assertRemoveMode(): void
// {
// if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
// }
//
// private function isValidIpOrCidr(string $s): bool
// {
// // IP
// if (filter_var($s, FILTER_VALIDATE_IP)) return true;
//
// // CIDR
// if (strpos($s, '/') !== false) {
// [$ip, $mask] = explode('/', $s, 2);
// if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
// if (strpos($ip, ':') !== false) {
// // IPv6
// return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 128;
// }
// // IPv4
// return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 32;
// }
// return false;
// }
//
// private function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $file = '/etc/fail2ban/jail.d/mailwolt-whitelist.local';
// $tmp = $file.'.tmp';
// @file_put_contents($tmp, $content, LOCK_EX);
// @chmod($tmp, 0644);
// @rename($tmp, $file);
// }
//
// private function reloadFail2ban(): void
// {
// @shell_exec('sudo fail2ban-client reload 2>&1');
// }
//
// private function banIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1");
// // optional: in DB zusätzlich behalten, damit UI konsistent ist (bereits oben getan)
// }
//
// private function unbanIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1");
// }
//}

View File

@ -0,0 +1,391 @@
<?php
// App\Livewire\Ui\Security\RblCard.php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Artisan;
use App\Models\Setting;
class RblCard extends Component
{
public string $ip = '';
public ?string $ipv4 = null;
public ?string $ipv6 = null;
public int $hits = 0;
public array $lists = []; // nur gelistete Zonen
public array $meta = []; // status je Zone
public ?string $checkedAt = null;
public ?string $validUntil = null;
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.security.rbl-card'); }
public function refresh(): void
{
// Manuelles Re-Check via Command (asynchron, damit UI nicht blockiert)
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
// Sofortige UI-Aktualisierung aus Settings (altes Ergebnis) …
$this->load(true);
// … und kurzer Hinweis
$this->dispatch('toast', type:'info', title:'RBL-Prüfung gestartet', text:'Ergebnis wird aktualisiert, sobald verfügbar.', duration:2500);
}
protected function load(bool $force = false): void
{
$payload = $force
? (array) Setting::get('health.rbl', []) // direkt aus DB
: (array) (Cache::get('health.rbl') ?: Setting::get('health.rbl', []));
$this->ip = (string)($payload['ip'] ?? '');
$this->ipv4 = $payload['ipv4'] ?? null;
$this->ipv6 = $payload['ipv6'] ?? null;
$this->hits = (int)($payload['hits'] ?? 0);
$this->lists = (array)($payload['lists'] ?? []);
$this->meta = (array)($payload['meta'] ?? []);
$this->checkedAt = $payload['checked_at'] ?? null;
$this->validUntil = $payload['valid_until'] ?? null;
}
}
//namespace App\Livewire\Ui\Security;
//
//use Illuminate\Support\Facades\Cache;
//use Livewire\Component;
//
//class RblCard extends Component
//{
// public string $ip = '';
// public int $hits = 0;
// public array $lists = [];
//
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// // Schalte registrierungspflichtige Listen (Barracuda etc.) optional zu
// private bool $includeRegistered = false; // env('RBL_INCLUDE_REGISTERED', false) wenn du willst
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.rbl-card');
// }
//
// public function refresh(): void
// {
// Cache::forget('dash.rbl');
// $this->load(true);
// }
//
// protected function load(bool $force = false): void
// {
// [$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
//
// $this->ipv4 = $ip4 ?: trim((string)env('SERVER_PUBLIC_IPV4', '')) ?: '';
// $this->ipv6 = $ip6 ?: trim((string)env('SERVER_PUBLIC_IPV6', '')) ?: '';
//
// $data = Cache::remember('dash.rbl', $force ? 1 : 6 * 3600, function () {
// $candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
//
// if (!$candidate) {
// $fromFile = trim((string)@file_get_contents('/etc/mailwolt/public_ip'));
// if ($this->validIPv4($fromFile)) $candidate = $fromFile;
// }
// if (!$candidate) {
// $curl = trim((string)@shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null"));
// if ($this->validIPv4($curl)) $candidate = $curl;
// }
//
// $ip = $candidate ?: '0.0.0.0';
// $lists = $this->queryRblLists($ip);
//
// return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
// });
//
// foreach ($data as $k => $v) $this->$k = $v;
// }
//
// /** bevorzugt Installer-ENV */
// private function resolvePublicIpsFromInstallerEnv(): array
// {
// $file = '/etc/mailwolt/installer.env';
// if (!is_readable($file)) return [null, null];
//
// $ipv4 = $ipv6 = null;
// $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
// foreach ($lines as $line) {
// if (preg_match('/^\s*#/', $line) || !str_contains($line, '=')) continue;
// [$k, $v] = array_map('trim', explode('=', $line, 2));
// $v = trim($v, " \t\n\r\0\x0B\"'");
// if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
// if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
// }
// return [$ipv4, $ipv6];
// }
//
// private function validIPv4(?string $ip): bool
// {
// return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
// }
//
// private function validIPv6(?string $ip): bool
// {
// return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
// }
//
// /**
// * Prüft die IP gegen gängige **öffentliche** RBLs.
// * @return array<string> gelistete RBL-Zonen
// */
// private function queryRblLists(string $ip): array
// {
// if (!$this->validIPv4($ip)) return [];
//
// $rev = implode('.', array_reverse(explode('.', $ip)));
//
// // nur Zonen prüfen, die es wirklich gibt
// $zones = [
// 'zen.spamhaus.org',
// 'psbl.surriel.com',
// 'dnsbl-1.uceprotect.net',
// 'all.s5h.net',
// ];
// $zones = array_values(array_filter($zones, fn($z) => @checkdnsrr($z.'.','NS')));
//
// $listed = [];
// foreach ($zones as $zone) {
// $q = "{$rev}.{$zone}.";
//
// $a = @dns_get_record($q, DNS_A) ?: [];
// if (!count($a)) continue;
//
// $ips = array_column($a, 'ip');
//
// // --- WICHTIG: Spamhaus "blocked" / Ratelimit ignorieren
// if (array_intersect($ips, ['127.255.255.254','127.255.255.255'])) {
// // optional: merk dir, dass Spamhaus blockt -> UI-Hinweis
// $listed[] = ['zone'=>$zone, 'code'=>'blocked', 'txt'=>null];
// continue;
// }
//
// $txtRecs = @dns_get_record($q, DNS_TXT) ?: [];
// $txt = $txtRecs[0]['txt'] ?? null;
//
// $listed[] = ['zone'=>$zone, 'code'=>$ips[0] ?? null, 'txt'=>$txt];
// }
//
// // Nur echte Treffer zurückgeben; „blocked“ separat signalisieren
// $real = array_values(array_filter($listed, fn($e) => ($e['code'] ?? null) !== 'blocked'));
//
// // Falls alles nur "blocked" war, gib leere Liste zurück
// return array_map(fn($e) => $e['zone'].($e['code'] ? " ({$e['code']})" : ''), $real);
// }
//}
////declare(strict_types=1);
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//use Illuminate\Support\Facades\Cache;
//
//class RblCard extends Component
//{
// public string $ip = '';
// public int $hits = 0;
// public array $lists = [];
//
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.rbl-card');
// }
//
// public function refresh(): void
// {
// Cache::forget('dash.rbl');
// $this->load(true);
// }
//
// protected function load(bool $force = false): void
// {
// // 1) IPv4/IPv6 bevorzugt aus /etc/mailwolt/installer.env
// [$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
//
// // 2) Fallback auf .env
// $this->ipv4 = $ip4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: '';
// $this->ipv6 = $ip6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: '';
//
// // 3) RBL-Ermittlung (cached)
// $data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
// // bevorzugt eine valide IPv4 für den RBL-Check
// $candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
//
// if (!$candidate) {
// $fromFile = @file_get_contents('/etc/mailwolt/public_ip') ?: '';
// $fromFile = trim($fromFile);
// if ($this->validIPv4($fromFile)) {
// $candidate = $fromFile;
// }
// }
//
// if (!$candidate) {
// // letzter Fallback kann auf Hardened-Systemen geblockt sein
// $curl = @shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?: '';
// $curl = trim($curl);
// if ($this->validIPv4($curl)) {
// $candidate = $curl;
// }
// }
//
// $ip = $candidate ?: '0.0.0.0';
// $lists = $this->queryRblLists($ip);
//
// return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
// });
//
// // 4) Werte ins Component-State
// foreach ($data as $k => $v) {
// $this->$k = $v;
// }
// }
//
// /** Bevorzugt Installer-ENV; gibt [ipv4, ipv6] zurück oder [null, null]. */
// private function resolvePublicIpsFromInstallerEnv(): array
// {
// $file = '/etc/mailwolt/installer.env';
// if (!is_readable($file)) {
// return [null, null];
// }
//
// $ipv4 = null;
// $ipv6 = null;
//
// $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
// foreach ($lines as $line) {
// // Kommentare überspringen
// if (preg_match('/^\s*#/', $line)) {
// continue;
// }
// // KEY=VALUE (VALUE evtl. in "..." oder '...')
// if (!str_contains($line, '=')) {
// continue;
// }
// [$k, $v] = array_map('trim', explode('=', $line, 2));
// $v = trim($v, " \t\n\r\0\x0B\"'");
//
// if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) {
// $ipv4 = $v;
// } elseif ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) {
// $ipv6 = $v;
// }
// }
//
// return [$ipv4, $ipv6];
// }
//
// private function validIPv4(?string $ip): bool
// {
// return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
// }
//
// private function validIPv6(?string $ip): bool
// {
// return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
// }
//
// /**
// * Prüft die IP gegen ein paar gängige RBLs.
// * Nutzt PHP-DNS (checkdnsrr), keine externen Tools.
// *
// * @return array<string> gelistete RBL-Zonen
// */
// private function queryRblLists(string $ip): array
// {
// // Nur IPv4 prüfen (die meisten Listen hier sind v4)
// if (!$this->validIPv4($ip)) {
// return [];
// }
//
// $rev = implode('.', array_reverse(explode('.', $ip)));
// $sources = [
// 'zen.spamhaus.org',
// 'bl.spamcop.net',
// 'dnsbl.sorbs.net',
// 'b.barracudacentral.org',
// ];
//
// $listed = [];
// foreach ($sources as $zone) {
// $qname = "{$rev}.{$zone}";
// // A-Record oder TXT deuten auf Listing hin
// if (@checkdnsrr($qname . '.', 'A') || @checkdnsrr($qname . '.', 'TXT')) {
// $listed[] = $zone;
// }
// }
//
// return $listed;
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//use Illuminate\Support\Facades\Cache;
//
//class RblCard extends Component
//{
// public string $ip = '';
// public int $hits = 0;
// public array $lists = [];
//
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.security.rbl-card'); }
// public function refresh(): void { $this->load(true); }
//
// protected function load(bool $force=false): void
// {
// $this->ipv4 = trim(env('SERVER_PUBLIC_IPV4')) ?: '';
// $this->ipv6 = trim(env('SERVER_PUBLIC_IPV6')) ?: '';
//
// $data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
// $ip = trim(@file_get_contents('/etc/mailwolt/public_ip') ?: '');
// if ($ip === '') $ip = trim(@shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?? '');
// if (!preg_match('/^\d+\.\d+\.\d+\.\d+$/', $ip)) $ip = '0.0.0.0';
//
// $rev = implode('.', array_reverse(explode('.', $ip)));
// $sources = [
// 'zen.spamhaus.org',
// 'bl.spamcop.net',
// 'dnsbl.sorbs.net',
// 'b.barracudacentral.org',
// ];
//
// $lists = [];
// foreach ($sources as $s) {
// $q = "$rev.$s";
// $res = trim(@shell_exec("dig +short ".escapeshellarg($q)." A 2>/dev/null") ?? '');
// if ($res !== '') $lists[] = $s;
// }
//
// return ['ip'=>$ip, 'hits'=>count($lists), 'lists'=>$lists];
// });
//
// foreach ($data as $k=>$v) $this->$k = $v;
// }
//}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
use Illuminate\Support\Facades\Cache;
use App\Models\Setting;
class SpamAvCard extends Component
{
public int $ham = 0;
public int $spam = 0;
public int $reject = 0;
public string $rspamdVer = '';
public string $clamVer = '';
public ?string $sigUpdated = null;
// wie frisch müssen Zahlen mindestens sein (Sek.)
protected int $maxAge = 300;
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.security.spam-av-card');
}
public function refresh(): void
{
$this->load(true);
}
protected function load(bool $force = false): void
{
// 1) Versuche erst aus Settings (DB/Redis), optional mit Cache-Shortcuts
$data = $force ? null : Cache::get('dash.spamav');
if (!$data) {
$data = Setting::get('spamav.metrics');
}
// 2) Wenn keine Daten oder zu alt ⇒ neu sammeln
$stale = !is_array($data) || (time() - (int)($data['ts'] ?? 0) > $this->maxAge);
if ($force || $stale) {
$data = $this->collectMetrics();
// Persistenz: Settings (DB→Redis) + UI-Cache (kurz)
Setting::set('spamav.metrics', $data);
Cache::put('dash.spamav', $data, 60);
}
// 3) Auf Properties mappen
$this->ham = (int)($data['ham'] ?? 0);
$this->spam = (int)($data['spam'] ?? 0);
$this->reject = (int)($data['reject'] ?? 0);
$this->rspamdVer = (string)($data['rspamdVer'] ?? ''); // <- wichtig
$this->clamVer = (string)($data['clamVer'] ?? '');
$this->sigUpdated = $data['sigUpdated'] ?? null;
}
/** Sammelt live von rspamd/clamav robust und schnell */
protected function collectMetrics(): array
{
// rspamd counters
$out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
$ham = preg_match('/\bham\s*:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
$spam = preg_match('/\bspam\s*:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
$reject = preg_match('/\breject\s*:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
// Versionen
$clamLine = trim((string) @shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null'));
$clamVer = $clamLine !== '' ? $clamLine : '';
$sigUpdated = null;
if ($clamLine && preg_match('#/([^/]+\d{4})$#', $clamLine, $m)) {
$sigUpdated = $m[1];
}
return [
'ts' => time(),
'ham' => $ham,
'spam' => $spam,
'reject' => $reject,
'clamLine' => $clamLine,
'clamVer' => $clamVer,
'sigUpdated' => $sigUpdated,
];
}
}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//use Illuminate\Support\Facades\Cache;
//
//class SpamAvCard extends Component
//{
// public int $ham = 0;
// public int $spam = 0;
// public int $reject = 0;
// public string $rspamdVer = '';
// public string $clamVer = '';
// public ?string $sigUpdated = null;
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.security.spam-av-card'); }
// public function refresh(): void { $this->load(true); }
//
// protected function load(bool $force = false): void
// {
// $data = Cache::remember('dash.spamav', $force ? 1 : 60, function () {
// $out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
// // very rough counters (adapt to your setup)
// $ham = preg_match('/ham:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
// $spam = preg_match('/spam:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
// $reject = preg_match('/reject:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
//
// $rspamdVer = trim(@shell_exec('rspamadm version 2>/dev/null') ?? '') ?: '';
// $clamVer = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '';
//
// // last signatures update (freshclam log)
// $sigUpdated = null;
// $log = @shell_exec('grep -i "Database updated" /var/log/clamav/freshclam.log | tail -n1 2>/dev/null');
// if ($log) $sigUpdated = trim($log);
//
// return compact('ham','spam','reject','rspamdVer','clamVer','sigUpdated');
// });
//
// foreach ($data as $k => $v) $this->$k = $v;
// }
//}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
use Illuminate\Support\Facades\Cache;
class AlertsCard extends Component
{
/** Example structure you can fill from your scheduler/commands */
public array $alerts = []; // [['level'=>'warn|error','msg'=>'text','when'=>'...'], ...]
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.system.alerts-card'); }
public function refresh(): void { $this->load(true); }
protected function load(bool $force=false): void
{
$this->alerts = Cache::remember('dash.alerts', $force ? 1 : 60, function () {
$a = [];
// examples: push items based on simple heuristics/files you already have
if (is_file('/var/lib/mailwolt/update/state') && trim(@file_get_contents('/var/lib/mailwolt/update/state')) === 'running') {
$a[] = ['level'=>'info','msg'=>'Update läuft …','when'=>date('H:i')];
}
$cert = '/etc/ssl/ui/fullchain.pem';
if (is_file($cert)) {
$end = trim(@shell_exec("openssl x509 -enddate -noout -in ".escapeshellarg($cert)." 2>/dev/null") ?? '');
if (preg_match('/notAfter=(.+)/', $end, $m)) {
$ts = strtotime($m[1] ?? '');
if ($ts && $ts - time() < 14*86400) {
$days = max(0, floor(($ts-time())/86400));
$a[] = ['level'=>'warn','msg'=>"UI-Zertifikat läuft in {$days} Tagen ab",'when'=>date('H:i')];
}
}
}
return $a;
});
}
}

View File

@ -0,0 +1,750 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
use Illuminate\Support\Str;
use Carbon\Carbon;
class BackupStatusCard extends Component
{
// Anzeige-Felder (nur Ausgabe im Blade)
public ?string $lastAt = null; // "27.10.2025 18:27:35"
public ?string $lastSize = null; // "93.0 MB"
public ?string $lastDuration = null; // "11s" / "2m 03s"
public ?bool $ok = null;
// Progress (nur wenn state=running)
public bool $running = false;
public int $percent = 0;
public ?string $step = null; // z.B. "compress"
protected string $statusFile = '/var/lib/mailwolt/backup.status';
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.system.backup-status-card');
}
public function refresh(): void
{
$this->load(true);
}
public function runNow(): void
{
// asynchron starten (sudoers vorausgesetzt)
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// Sofort UI auf „läuft“ setzen Poll holt Echtstatus
$this->running = true;
$this->percent = 1;
$this->step = 'start';
}
private function load(bool $force = false): void
{
$state = $this->readStatus();
// Progress
$this->running = ($state['state'] ?? null) === 'running';
$this->percent = (int)($state['percent'] ?? 0);
$this->step = $state['step'] ?? null;
// Abschlusswerte
$fin = $state['finished_at'] ?? $state['start_at'] ?? null;
$this->lastAt = $fin ? $this->fmtDate($fin) : null;
$sizeB = isset($state['size']) ? (int)$state['size'] : null;
$this->lastSize = $sizeB !== null ? $this->fmtBytes($sizeB) : null;
$durS = isset($state['duration']) ? (int)$state['duration'] : null;
$this->lastDuration = $durS !== null ? $this->fmtDuration($durS) : null;
$this->ok = isset($state['ok']) ? ((string)$state['ok'] === '1') : null;
// Wenn fertig → Balken aus
if (!$this->running) {
$this->percent = 0;
$this->step = null;
}
}
private function readStatus(): array
{
if (!is_readable($this->statusFile)) {
return [];
}
$out = [];
foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (!str_contains($line, '=')) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
// nur erwartete Keys
if (in_array($k, ['state', 'pid', 'start_at', 'finished_at', 'step', 'percent', 'size', 'duration', 'ok'], true)) {
$out[$k] = $v;
}
}
return $out;
}
private function fmtDate(string $iso): string
{
// in App-Zeitzone anzeigen
$tz = config('app.timezone', 'UTC');
try {
return Carbon::parse($iso)->setTimezone($tz)->format('d.m.Y H:i:s');
} catch (\Throwable) {
return $iso;
}
}
private function fmtBytes(int $bytes): string
{
$u = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$n = (float)$bytes;
while ($n >= 1024 && $i < count($u) - 1) {
$n /= 1024;
$i++;
}
return number_format($n, ($i <= 1 ? 0 : 1), ',', '.') . ' ' . $u[$i];
}
private function fmtDuration(int $sec): string
{
if ($sec < 60) return $sec . 's';
$m = intdiv($sec, 60);
$s = $sec % 60;
return sprintf('%dm %02ds', $m, $s);
}
}
//namespace App\Livewire\Ui\System;
//
//use Illuminate\Support\Str;
//use Livewire\Component;
//use Carbon\Carbon;
//
//class BackupStatusCard extends Component
//{
// // Anzeige-Felder (fertig formatiert)
// public ?string $lastAt = null;
// public ?string $lastSize = null;
// public ?string $lastDuration = null;
// public ?bool $ok = null;
//
// // Laufstatus für Progress (nur zur Sichtbarkeit)
// public bool $running = false;
// public int $percent = 0;
// public string $progressText = '';
//
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.system.backup-status-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function runNow(): void
// {
// // asynchron starten sudoers wie bereits gesetzt
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// // UI direkt "laufend" schalten; echte Werte kommen über Poll
// $this->running = true;
// $this->percent = 1;
// $this->progressText = 'Backup gestartet …';
// }
//
// protected function load(bool $force = false): void
// {
// $s = $this->readStatus();
//
// // Progress
// $state = $s['state'] ?? null;
// $this->running = in_array($state, ['running'], true);
// $this->percent = (int)($s['percent'] ?? 0);
// $step = $s['step'] ?? '';
// $this->progressText = $this->mapStepText($step, $state);
//
// // Abschlusswerte
// $finishedAt = $s['finished_at'] ?? ($state === 'done' ? ($s['start_at'] ?? null) : null);
// $sizeBytes = isset($s['size']) ? (int)$s['size'] : null;
// $durSec = isset($s['duration']) ? (int)$s['duration'] : null;
// $okStr = $s['ok'] ?? null;
//
// $this->lastAt = $finishedAt ? $this->fmtDate($finishedAt) : null;
// $this->lastSize = $sizeBytes !== null ? $this->fmtBytes($sizeBytes) : null;
// $this->lastDuration = $durSec !== null ? $this->fmtDuration($durSec) : null;
// $this->ok = $okStr !== null ? ($okStr === '1' || $okStr === 'true') : null;
// }
//
// protected function readStatus(): array
// {
// if (!is_readable($this->statusFile)) {
// return [];
// }
// $lines = @file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [];
// $out = [];
// foreach ($lines as $ln) {
// if (!str_contains($ln, '=')) continue;
// [$k, $v] = array_map('trim', explode('=', $ln, 2));
// $out[$k] = $v;
// }
// return $out;
// }
//
// private function mapStepText(string $step, ?string $state): string
// {
// if ($state === 'done') return 'Backup abgeschlossen.';
// if ($state === 'failed') return 'Backup fehlgeschlagen.';
// return match ($step) {
// 'start' => 'Backup wird vorbereitet …',
// 'mysqldump' => 'Datenbank wird gesichert …',
// 'maildir' => 'Maildir wird gesichert …',
// 'app' => 'Applikation wird gesichert …',
// 'configs' => 'Konfigurationen werden gesichert …',
// 'compress' => 'Archiv wird komprimiert …',
// 'retention' => 'Alte Backups werden aufgeräumt …',
// default => $step ? Str::headline($step) . ' …' : 'Backup läuft …',
// };
// }
//
// private function fmtDate(string $iso): string
// {
// return Carbon::parse($iso)->timezone(config('app.timezone', 'Europe/Berlin'))->format('d.m.Y H:i:s');
// }
//
// private function fmtBytes(int $b): string
// {
// $units = ['B', 'KB', 'MB', 'GB', 'TB'];
// $i = 0;
// $val = $b;
// while ($val >= 1024 && $i < count($units) - 1) {
// $val /= 1024;
// $i++;
// }
// return number_format($val, $val >= 10 ? 0 : ($val >= 1 ? 1 : 0)) . ' ' . $units[$i];
// }
//
// private function fmtDuration(int $s): string
// {
// if ($s < 60) return $s . 's';
// $m = intdiv($s, 60);
// $r = $s % 60;
// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
// $h = intdiv($m, 60);
// $m = $m % 60;
// return sprintf('%dh %02dm', $h, $m);
// }
//}
//namespace App\Livewire\Ui\System;
//
//use Carbon\Carbon;
//use Livewire\Component;
//
//class BackupStatusCard extends Component
//{
// public string $lastAt = '';
// public string $lastSize = '';
// public string $lastDuration = '';
// public string $statusText = 'unbekannt';
// public string $statusColor = 'text-white/60 border-white/20 bg-white/5';
//
// public string $progressText = '';
// public string $progressPercent = '0';
// public string $progressVisibleClass = 'hidden'; // <- Sichtbarkeit
//
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.system.backup-status-card'); }
// public function refresh(): void { $this->load(true); }
//
// public function runNow(): void
// {
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// // UI sofort auf "läuft" setzen
// $this->progressText = 'Vorbereitung läuft...';
// $this->progressPercent = '1';
// $this->progressVisibleClass = 'block';
// }
//
// protected function load(bool $force = false): void
// {
// $kv = [];
// if (is_file($this->statusFile)) {
// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
// $p = strpos($ln, '='); if ($p !== false) $kv[substr($ln,0,$p)] = substr($ln,$p+1);
// }
// }
//
// $state = $kv['state'] ?? null; // running | done | failed
// $step = $kv['step'] ?? null;
// $percent = isset($kv['percent']) ? (int)$kv['percent'] : 0;
// $ok = isset($kv['ok']) ? ((int)$kv['ok'] === 1) : null;
//
// // Anzeigeformatierungen wie gehabt …
// // (deine bestehenden formatBytes/formatDuration/Timezone-Logik)
//
// $this->progressPercent = (string)max(0, min(100, $percent));
// $this->progressText = $this->mapStep($step);
//
// // Sichtbarkeit steuern KEINE Blade-Logik nötig
// if ($state === 'running') {
// $this->progressVisibleClass = 'block';
// } else {
// // bei done/failed: Balken verstecken und auf 100% / finalen Text setzen
// $this->progressVisibleClass = 'hidden';
// if ($percent >= 100 || $step === 'done') {
// $this->progressPercent = '100';
// $this->progressText = 'Backup abgeschlossen.';
// }
// }
//
// // Status-Badge (wie gehabt)
// if ($ok === true) {
// $this->statusText = 'erfolgreich';
// $this->statusColor = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
// } elseif ($ok === false) {
// $this->statusText = 'fehlgeschlagen';
// $this->statusColor = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
// } else {
// $this->statusText = 'unbekannt';
// $this->statusColor = 'text-white/60 border-white/20 bg-white/5';
// }
// }
//
// private function mapStep(?string $step): string
// {
// return match($step) {
// 'mysqldump' => 'Datenbank wird gesichert...',
// 'maildir' => 'Mail-Verzeichnis wird archiviert...',
// 'app' => 'Anwendungsdaten werden gesichert...',
// 'configs' => 'Konfigurationen werden gesichert...',
// 'compress' => 'Backup wird komprimiert...',
// 'retention' => 'Alte Backups werden gelöscht...',
// 'done' => 'Backup abgeschlossen.',
// default => 'Vorbereitung läuft...',
// };
// }
//}
//
//class BackupStatusCard extends Component
//{
// public string $lastAt = '';
// public string $lastSize = '';
// public string $lastDuration = '';
// public string $statusText = 'unbekannt';
// public string $statusColor = 'text-white/60 border-white/20 bg-white/5';
// public string $progressText = '';
// public string $progressPercent = '0';
// public bool $running = false;
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.system.backup-status-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function runNow(): void
// {
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// $this->running = true;
// $this->progressText = 'Starte Backup...';
// $this->progressPercent = '1';
// }
//
// protected function load(bool $force = false): void
// {
// if (!is_file($this->statusFile)) {
// $this->running = false;
// return;
// }
//
// $kv = [];
// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
// $p = strpos($ln, '=');
// if ($p !== false) {
// $kv[substr($ln, 0, $p)] = substr($ln, $p + 1);
// }
// }
//
// $state = $kv['state'] ?? null;
// $step = $kv['step'] ?? null;
// $percent = isset($kv['percent']) ? (int)$kv['percent'] : 0;
// $ok = isset($kv['ok']) ? ((int)$kv['ok'] === 1) : null;
//
// // Formatierung
// $tz = config('app.timezone', 'Europe/Berlin');
// $finished = $kv['finished_at'] ?? $kv['start_at'] ?? null;
// $this->lastAt = $finished
// ? Carbon::parse($finished)->setTimezone($tz)->format('d.m.Y H:i:s')
// : '';
//
// $this->lastSize = isset($kv['size'])
// ? $this->formatBytes((int)$kv['size'])
// : '';
//
// $this->lastDuration = isset($kv['duration'])
// ? $this->formatDuration((int)$kv['duration'])
// : '';
//
// // Fortschritt
// $this->progressPercent = (string)$percent;
// $this->progressText = $this->mapStep($step);
//
// // Status
// $this->running = ($state === 'running');
//
// if ($ok === true) {
// $this->statusText = 'erfolgreich';
// $this->statusColor = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
// } elseif ($ok === false) {
// $this->statusText = 'fehlgeschlagen';
// $this->statusColor = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
// } else {
// $this->statusText = 'unbekannt';
// $this->statusColor = 'text-white/60 border-white/20 bg-white/5';
// }
// }
//
// private function formatBytes(int $b): string
// {
// if ($b >= 1024 * 1024 * 1024) return number_format($b / (1024 * 1024 * 1024), 1) . ' GB';
// if ($b >= 1024 * 1024) return number_format($b / (1024 * 1024), 1) . ' MB';
// if ($b >= 1024) return number_format($b / 1024, 0) . ' KB';
// return $b . ' B';
// }
//
// private function formatDuration(int $s): string
// {
// if ($s < 60) return $s . 's';
// $m = intdiv($s, 60);
// $r = $s % 60;
// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
// $h = intdiv($m, 60);
// $m = $m % 60;
// return sprintf('%dh %02dm %02ds', $h, $m, $r);
// }
//
// private function mapStep(?string $step): string
// {
// return match ($step) {
// 'mysqldump' => 'Datenbank wird gesichert...',
// 'maildir' => 'Mail-Verzeichnis wird archiviert...',
// 'app' => 'Anwendungsdaten werden gesichert...',
// 'configs' => 'Konfigurationen werden gesichert...',
// 'compress' => 'Backup wird komprimiert...',
// 'retention' => 'Alte Backups werden gelöscht...',
// 'done' => 'Backup abgeschlossen.',
// default => 'Vorbereitung läuft...',
// };
// }
//}
//
////
////
////namespace App\Livewire\Ui\System;
////
////use Carbon\CarbonImmutable;
////use Livewire\Component;
////
////class BackupStatusCard extends Component
////{
//// public ?string $lastAt = null; // finale Zeit
//// public ?string $lastSize = null; // menschenlesbar
//// public ?string $lastDuration = null; // menschenlesbar
//// public ?bool $ok = null;
////
//// // Live-Status
//// public bool $running = false;
//// public ?string $step = null;
//// public int $percent = 0;
////
//// public function mount(): void
//// {
//// $this->load();
//// }
////
//// public function render()
//// {
//// return view('livewire.ui.system.backup-status-card');
//// }
////
//// public function refresh(): void
//// {
//// $this->load(true);
//// }
////
//// public function runNow(): void
//// {
//// // Script asynchron starten (sudoers muss gesetzt sein)
//// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
//// // Sofort UI auf "läuft" stellen Poll holt echten Status nach
//// $this->running = true;
//// $this->step = 'start';
//// $this->percent = 1;
//// }
////
//// protected function load(bool $force = false): void
//// {
//// $f = '/var/lib/mailwolt/backup.status';
//// if (!is_file($f)) {
//// return;
//// }
////
//// $data = [];
//// foreach (@file($f, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
//// if (strpos($ln, '=') !== false) {
//// [$k, $v] = explode('=', $ln, 2);
//// $data[$k] = $v;
//// }
//// }
////
//// $state = $data['state'] ?? null;
//// $this->running = ($state === 'running');
////
//// // Progress
//// $this->step = $data['step'] ?? null;
//// $this->percent = (int)($data['percent'] ?? 0);
////
//// // Finale Werte
//// if ($state === 'done' || $state === 'failed') {
//// $this->ok = ($data['ok'] ?? '') === '1';
//// $ts = $data['finished_at'] ?? $data['start_at'] ?? null;
//// $this->lastAt = $ts ? $this->fmtTs($ts) : null;
////
//// $bytes = (int)($data['size'] ?? 0);
//// $this->lastSize = $bytes ? $this->fmtBytes($bytes) : null;
////
//// $dur = (int)($data['duration'] ?? 0);
//// $this->lastDuration = $dur ? $this->fmtDuration($dur) : null;
//// }
//// }
////
//// protected function fmtTs(string $iso): string
//// {
//// return CarbonImmutable::parse($iso)->tz(config('app.timezone'))
//// ->format('d.m.Y H:i:s');
//// }
////
//// protected function fmtBytes(int $b): string
//// {
//// $u = ['B', 'KB', 'MB', 'GB', 'TB'];
//// $i = 0;
//// while ($b >= 1024 && $i < count($u) - 1) {
//// $b /= 1024;
//// $i++;
//// }
//// return sprintf('%.1f %s', $b, $u[$i]);
//// }
////
//// protected function fmtDuration(int $s): string
//// {
//// if ($s < 60) return $s . 's';
//// $m = intdiv($s, 60);
//// $r = $s % 60;
//// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
//// $h = intdiv($m, 60);
//// $m %= 60;
//// return sprintf('%dh %02dm', $h, $m);
//// }
////}
////
//////
//////namespace App\Livewire\Ui\System;
//////
//////use Carbon\CarbonImmutable;
//////use Illuminate\Support\Str;
//////use Livewire\Component;
//////
//////class BackupStatusCard extends Component
//////{
////// public ?string $lastAt = null; // formatierte Zeit
////// public ?string $lastSize = null; // human readable
////// public ?string $lastDuration = null; // human readable
////// public ?bool $ok = null;
//////
////// // Laufzeit/Progress
////// public string $state = 'idle'; // idle|running|done|error
////// public string $step = ''; // aktueller Schritt
////// public array $steps = [
////// 'mysqldump' => 'Datenbank sichern',
////// 'maildir' => 'Maildir kopieren',
////// 'app' => 'App sichern',
////// 'configs' => 'Configs sichern',
////// 'archive' => 'Archiv erstellen',
////// 'compress' => 'Komprimieren',
////// 'retention' => 'Aufräumen',
////// 'finish' => 'Abschluss',
////// ];
//////
////// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//////
////// public function mount(): void
////// {
////// $this->load(true);
////// }
//////
////// public function render()
////// {
////// return view('livewire.ui.system.backup-status-card');
////// }
//////
////// public function refresh(): void
////// {
////// $this->load(true);
////// }
//////
////// public function runNow(): void
////// {
////// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
////// // Sofort in "running" gehen Poll übernimmt dann
////// $this->state = 'running';
////// $this->step = 'mysqldump';
////// }
//////
////// public function load(bool $force = false): void
////// {
////// $raw = $this->readStatus();
////// $this->state = $raw['state'] ?? 'idle';
////// $this->step = $raw['step'] ?? '';
//////
////// // Datum/Zeit hübsch
////// if (!empty($raw['time'])) {
////// $this->lastAt = $this->fmtTime($raw['time']);
////// } else {
////// $this->lastAt = null;
////// }
//////
////// // Größe/Dauer hübsch
////// $bytes = isset($raw['size_bytes']) ? (int)$raw['size_bytes'] : null;
////// $secs = isset($raw['dur_seconds']) ? (int)$raw['dur_seconds'] : null;
//////
////// $this->lastSize = $bytes !== null ? $this->humanBytes($bytes) : null;
////// $this->lastDuration = $secs !== null ? $this->humanDuration($secs) : null;
////// $this->ok = isset($raw['ok']) ? ((string)$raw['ok'] === '1') : null;
////// }
//////
////// protected function readStatus(): array
////// {
////// if (!is_file($this->statusFile)) return [];
////// $out = [];
////// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
////// if (!str_contains($ln, '=')) continue;
////// [$k, $v] = array_map('trim', explode('=', $ln, 2));
////// $out[$k] = $v;
////// }
//////
////// // Backward compatibility (alte Keys)
////// if (isset($out['size']) && !isset($out['size_bytes'])) {
////// $out['size_bytes'] = (int)$out['size'];
////// }
////// if (isset($out['dur']) && !isset($out['dur_seconds'])) {
////// $out['dur_seconds'] = (int)$out['dur'];
////// }
//////
////// return $out;
////// }
//////
////// protected function fmtTime(string $iso): string
////// {
////// try {
////// $tz = config('app.timezone', 'UTC');
////// // ISO aus Script ist idealerweise UTC (Z)
////// $dt = CarbonImmutable::parse($iso)->timezone($tz);
////// // z.B. 27.10.2025, 16:48:02 (CET)
////// return $dt->isoFormat('L, LTS') . ' ' . $dt->format('T');
////// } catch (\Throwable) {
////// return $iso;
////// }
////// }
//////
////// protected function humanBytes(int $bytes): string
////// {
////// $units = ['B', 'KB', 'MB', 'GB', 'TB'];
////// $i = 0;
////// while ($bytes >= 1024 && $i < count($units) - 1) {
////// $bytes /= 1024;
////// $i++;
////// }
////// return number_format($bytes, $i === 0 ? 0 : 1, ',', '.') . ' ' . $units[$i];
////// }
//////
////// protected function humanDuration(int $secs): string
////// {
////// if ($secs < 60) return $secs . ' s';
////// $m = intdiv($secs, 60);
////// $s = $secs % 60;
////// if ($m < 60) return sprintf('%d min %02d s', $m, $s);
////// $h = intdiv($m, 60);
////// $m = $m % 60;
////// return sprintf('%d h %02d min', $h, $m);
////// }
//////}
//////
////////
////////namespace App\Livewire\Ui\System;
////////
////////use Livewire\Component;
////////
////////class BackupStatusCard extends Component
////////{
//////// public ?string $lastAt = null;
//////// public ?string $lastSize = null;
//////// public ?string $lastDuration = null;
//////// public ?bool $ok = null;
////////
//////// public function mount(): void { $this->load(); }
//////// public function render() { return view('livewire.ui.system.backup-status-card'); }
//////// public function refresh(): void { $this->load(true); }
////////
//////// public function runNow(): void
//////// {
//////// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
////////// $this->dispatch('toast', type:'info', title:'Backup gestartet');
//////// }
////////
//////// protected function load(bool $force=false): void
//////// {
//////// // Example: parse a tiny status file your backup script writes.
//////// $f = '/var/lib/mailwolt/backup.status';
//////// if (is_file($f)) {
//////// $lines = @file($f, FILE_IGNORE_NEW_LINES) ?: [];
//////// foreach ($lines as $ln) {
//////// if (str_starts_with($ln,'time=')) $this->lastAt = substr($ln,5);
//////// if (str_starts_with($ln,'size=')) $this->lastSize = substr($ln,5);
//////// if (str_starts_with($ln,'dur=')) $this->lastDuration = substr($ln,4);
//////// if (str_starts_with($ln,'ok=')) $this->ok = (substr($ln,3) === '1');
//////// }
//////// }
//////// }
////////}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class DomainsSslForm extends Component
{
// fix / readonly aus ENV oder config
public string $mail_domain_readonly = '';
// editierbar
public string $ui_domain = '';
public string $webmail_domain = '';
protected function rules(): array
{
return [
'ui_domain' => 'nullable|string|max:190',
'webmail_domain' => 'nullable|string|max:190',
];
}
public function mount(): void
{
$this->mail_domain_readonly = (string) config('mailwolt.domain.mail', 'mx');
$this->ui_domain = Setting::get('ui_domain', $this->ui_domain);
$this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain);
}
public function save(): void
{
$this->validate();
Setting::put('ui_domain', $this->ui_domain);
Setting::put('webmail_domain', $this->webmail_domain);
$this->dispatch('toast',
type: 'done',
badge: 'System',
title: 'Domains gespeichert',
text: 'UI- und Webmail-Domain wurden übernommen.',
duration: 5000,
);
}
public function render() { return view('livewire.ui.system.form.domains-ssl-form'); }
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class GeneralForm extends Component
{
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
protected function rules(): array
{
return [
'locale' => 'required|string|max:10',
'timezone' => 'required|string|max:64',
];
}
public function mount(): void
{
// Defaults aus ENV nur für den allerersten Seed in Settings (Redis/DB)
$envLocale = env('APP_LOCALE') ?? env('APP_FALLBACK_LOCALE') ?? $this->locale;
$envTimezone = env('APP_TIMEZONE') ?? $this->timezone;
// Wenn (noch) nichts in Settings liegt, einmalig mit ENV-Werten befüllen
if (Setting::get('locale', null) === null) {
Setting::set('locale', $envLocale);
}
if (Setting::get('timezone', null) === null) {
Setting::set('timezone', $envTimezone);
}
// Ab hier ausschließlich aus Settings lesen (Redis → DB Fallback)
$this->locale = (string) Setting::get('locale', $envLocale);
$this->timezone = (string) Setting::get('timezone', $envTimezone);
// Sofort für die aktuelle Request anwenden
app()->setLocale($this->locale);
@date_default_timezone_set($this->timezone);
config([
'app.locale' => $this->locale,
'app.fallback_locale' => $this->locale,
'app.timezone' => $this->timezone,
]);
}
public function save(): void
{
$this->validate();
// Persistieren: DB → Redis (siehe Setting::set)
Setting::set('locale', $this->locale);
Setting::set('timezone', $this->timezone);
// Direkt in der laufenden Request aktivieren
app()->setLocale($this->locale);
@date_default_timezone_set($this->timezone);
config([
'app.locale' => $this->locale,
'app.fallback_locale' => $this->locale, // optional
'app.timezone' => $this->timezone,
]);
$this->dispatch('toast',
type: 'done',
badge: 'System',
title: 'Allgemein gespeichert',
text: 'Sprache und Zeitzone wurden übernommen.',
duration: 5000,
);
}
public function render()
{
return view('livewire.ui.system.form.general-form');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class SecurityForm extends Component
{
public bool $twofa_enabled = false;
public ?int $rate_limit = 5;
public ?int $password_min = 10;
protected function rules(): array
{
return [
'twofa_enabled' => 'boolean',
'rate_limit' => 'nullable|integer|min:1|max:100',
'password_min' => 'nullable|integer|min:6|max:128',
];
}
public function mount(): void
{
$this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled);
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
}
public function save(): void
{
$this->validate();
Setting::put('twofa_enabled', $this->twofa_enabled);
Setting::put('rate_limit', $this->rate_limit);
Setting::put('password_min', $this->password_min);
$this->dispatch('toast',
type: 'done',
badge: 'Sicherheit',
title: 'Sicherheit gespeichert',
text: '2FA/Rate-Limits/Passwortregeln wurden übernommen.',
duration: 5000,
);
}
public function render() { return view('livewire.ui.system.form.security-form'); }
}

View File

@ -0,0 +1,274 @@
<?php
namespace App\Livewire\Ui\System;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\On;
use Livewire\Component;
class HealthCard extends Component
{
// Inputquellen
public array $meta = [];
// UI: Balken/Segmente
public int $barSegments = 24;
public array $cpuSeg = [];
public array $ramSeg = [];
// Anzeige
public ?int $cpuPercent = null;
public ?int $ramPercent = null;
public ?string $ramSummary = null;
public ?string $loadText = null;
public array $loadDots = [];
public ?string $uptimeText = null;
public ?int $uptimeDays = null;
public ?int $uptimeHours = null;
public ?string $uptimeDaysLabel = null;
public ?string $uptimeHoursLabel = null;
public string $uptimeIcon = 'ph ph-clock';
public ?string $updatedAtHuman = null;
// NEW: IPs
public ?string $ip4 = null;
public ?string $ip6 = null;
public function mount(): void
{
$this->loadData();
}
public function loadData(): void
{
$this->meta = Cache::get('health:meta', []);
$this->hydrateSystem();
$this->hydrateIps();
$this->hydrateUpdatedAt();
}
public function getListeners(): array
{
// exakt auf die Event-Klasse hören (kein Alias)
return [
'echo-private:health,HealthUpdated' => 'onHealthEcho',
];
}
public function onHealthEcho($payload): void
{
// ① Bei manchen Treibern kommt $payload als JSON-String
if (is_string($payload)) {
$decoded = json_decode($payload, true);
if (json_last_error() === JSON_ERROR_NONE) {
$payload = $decoded;
}
}
// ② Manche Treiber kapseln erneut in "data"
if (isset($payload['data']) && is_string($payload['data'])) {
$decoded = json_decode($payload['data'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$payload = $decoded;
}
} elseif (isset($payload['data']) && is_array($payload['data'])) {
$payload = $payload['data'];
}
$meta = $payload['meta'] ?? [];
if (!is_array($meta)) $meta = [];
$this->meta = $meta;
$this->hydrateSystem();
$this->hydrateIps();
$this->hydrateUpdatedAt();
// optionales Debug-Log (kein JS nötig)
logger('Health WS received', ['meta' => $this->meta]);
}
public function render()
{
return view('livewire.ui.system.health-card');
}
/* --------------- Aufbereitung --------------- */
protected function hydrateSystem(): void
{
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
// RAM % + Summary
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
}
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
// Load (1/5/15)
$l1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
$l5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
$l15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
if (is_array($loadMixed)) {
$vals = array_values($loadMixed);
$l1 ??= $this->numFloat($vals[0] ?? null);
$l5 ??= $this->numFloat($vals[1] ?? null);
$l15 ??= $this->numFloat($vals[2] ?? null);
}
$this->loadText = $this->fmtLoad($l1, $l5, $l15, is_string($loadMixed) ? $loadMixed : null);
// CPU %
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
if ($this->cpuPercent === null && $l1 !== null) {
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
$this->cpuPercent = (int) round(min(100, max(0, ($l1 / max(1,$cores)) * 100)));
}
// Uptime
// $this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
// if (!$this->uptimeText) {
// $uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
// if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
// }
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
if ($uptimeSec !== null) {
$parsed = $this->parseUptime($uptimeSec);
$this->uptimeText = "{$parsed['days']}d {$parsed['hours']}h"; // bestehende Anzeige
$this->uptimeDays = $parsed['days'];
$this->uptimeHours = $parsed['hours'];
$this->uptimeDaysLabel = $parsed['days_text'];
$this->uptimeHoursLabel = $parsed['hours_text'];
}
// Segmente
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
$this->ramSeg = $this->buildSegments($this->ramPercent);
// Load Dots relativ zu Kernen
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
$ratio1 = $l1 !== null ? $l1 / $cores : null;
$ratio5 = $l5 !== null ? $l5 / $cores : null;
$ratio15 = $l15 !== null ? $l15 / $cores : null;
$this->loadDots = [
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
];
}
protected function hydrateIps(): void
{
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
// Versuche diverse mögliche Keys
$this->ip4 = $this->pick($sys, ['ipv4','ip4','public_ipv4','server_public_ipv4','lan_ipv4']);
$this->ip6 = $this->pick($sys, ['ipv6','ip6','public_ipv6','server_public_ipv6','lan_ipv6']);
// Fallback: aus installer.env lesen
if (!$this->ip4 || !$this->ip6) {
$env = @file('/etc/mailwolt/installer.env', FILE_IGNORE_NEW_LINES) ?: [];
foreach ($env as $ln) {
if (!$this->ip4 && str_starts_with($ln, 'SERVER_PUBLIC_IPV4=')) {
$this->ip4 = trim(substr($ln, strlen('SERVER_PUBLIC_IPV4=')));
}
if (!$this->ip6 && str_starts_with($ln, 'SERVER_PUBLIC_IPV6=')) {
$this->ip6 = trim(substr($ln, strlen('SERVER_PUBLIC_IPV6=')));
}
}
}
// Auf hübsch: dünne Zwischenräume (wir lassen Blade das stylen monospaced + tracking)
$this->ip4 = $this->ip4 ?: '';
$this->ip6 = $this->ip6 ?: '';
}
protected function hydrateUpdatedAt(): void
{
$updated = $this->meta['updated_at'] ?? null;
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '';
} catch (\Throwable) { $this->updatedAtHuman = ''; }
}
/* --------------- Helpers --------------- */
protected function pick(array $arr, array $keys)
{
foreach ($keys as $k) {
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
return $arr[$k];
}
}
return null;
}
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
protected function numFloat($v): ?float { return is_numeric($v) ? (float)$v : null; }
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
{
if ($l1 !== null || $l5 !== null || $l15 !== null) {
$fmt = fn($x) => $x === null ? '' : number_format($x, 2);
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
}
return $fallback ?: null;
}
protected function secondsToHuman(int $s): string
{
$d = intdiv($s, 86400); $s %= 86400;
$h = intdiv($s, 3600); $s %= 3600;
$m = intdiv($s, 60);
if ($d > 0) return "{$d}d {$h}h";
if ($h > 0) return "{$h}h {$m}m";
return "{$m}m";
}
protected function parseUptime(int $seconds): array
{
$days = intdiv($seconds, 86400);
$hours = intdiv($seconds % 86400, 3600);
$minutes = intdiv($seconds % 3600, 60);
return [
'days' => $days,
'hours' => $hours,
'minutes' => $minutes,
'days_text' => $days === 1 ? 'Tag' : 'Tage', // string
'hours_text' => $hours === 1 ? 'Stunde' : 'Stunden', // string
];
}
protected function toneByPercent(?int $p): string {
if ($p === null) return 'white';
if ($p >= 90) return 'rose';
if ($p >= 70) return 'amber';
return 'emerald';
}
protected function buildSegments(?int $percent): array {
$n = max(6, $this->barSegments);
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
$tone = $this->toneByPercent($percent);
$fillCls = match($tone) {
'rose' => 'bg-rose-500/80',
'amber' => 'bg-amber-400/80',
'emerald'=> 'bg-emerald-500/80',
default => 'bg-white/20',
};
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
}
protected function loadDotClass(?float $ratio): string {
if ($ratio === null) return 'bg-white/25';
if ($ratio >= 0.9) return 'bg-rose-500';
if ($ratio >= 0.7) return 'bg-amber-400';
return 'bg-emerald-400';
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
use Illuminate\Support\Str;
class UpdateModal extends ModalComponent
{
public string $state = 'unknown'; // running|done|unknown
public ?int $rc = null; // exit code
public ?string $line = null; // letzte Logzeile hübsch
public array $tail = []; // letzte N Logzeilen roh
public int $percent = 0; // heuristisch (optional)
public static bool $closingAllowed = false;
private const LOG = '/var/log/mailwolt-update.log';
private const STATE_DIR = '/var/lib/mailwolt/update';
public function mount(): void
{
$this->refresh();
}
public function render()
{
return view('livewire.ui.system.modal.update-modal');
}
#[On('update:modal-refresh')]
public function refresh(): void
{
$st = @trim(@file_get_contents(self::STATE_DIR.'/state') ?: '');
$rcRaw = @trim(@file_get_contents(self::STATE_DIR.'/rc') ?: '');
$this->state = $st ?: 'unknown';
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
// Log einlesen
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
$this->tail = array_slice($lines, -30);
$last = trim($this->tail ? end($this->tail) : '');
$last = preg_replace('/^\[\w\]\s*/', '', $last);
$last = preg_replace('/^=+ .*? =+\s*$/', 'Update beendet', $last);
$last = preg_replace('/^\d{4}-\d{2}-\d{2}T[^ ]+\s*::\s*/', '', $last);
$this->line = Str::limit($last, 160);
// ganz simple Fortschritts-Heuristik über bekannte Meilensteine
$text = implode("\n", $this->tail);
$pct = 5;
foreach ([
'Update gestartet' => 10,
'Composer' => 25,
'npm ci' => 40,
'npm run build' => 60,
'migrate' => 75,
'optimize' => 85,
'Version aktualisiert' => 95,
'Update beendet' => 100,
] as $needle => $val) {
if (stripos($text, $needle) !== false) { $pct = max($pct, $val); }
}
if ($this->state === 'done') { $pct = 100; }
$this->percent = $pct;
// Auto-Close vorbereiten
if ($this->state === 'done' && $this->rc === 0) {
static::$closingAllowed = true;
}
}
public static function modalMaxWidth(): string { return '2xl'; }
public static function closeModalOnEscape(): bool { return static::$closingAllowed; }
public static function closeModalOnClickAway(): bool { return static::$closingAllowed; }
}

View File

@ -0,0 +1,406 @@
<?php
namespace App\Livewire\Ui\System;
use App\Support\CacheVer;
use App\Support\WoltGuard\Probes;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Illuminate\Support\Collection;
class ServicesCard extends Component
{
use Probes;
/** Daten fürs Blade */
public array $servicesCompact = [];
public int $okCount = 0;
public int $totalCount = 0;
public bool $guardOk = false;
// Badge (rechts)
public string $badgeText = '';
public string $badgeClass = '';
public string $badgeIcon = 'ph ph-check-circle';
// optionales Polling im Blade (du hast es dort referenziert)
public int $pollSeconds = 15;
public function mount(): void
{
$this->load();
}
public function refresh(): void
{
// nur unsere eigene Services-Card neu aufbauen (Cache vom Job lassen wir in Ruhe)
$this->load();
}
public function render()
{
return view('livewire.ui.system.services-card');
}
public function load(): void
{
$cards = config('woltguard.cards', []);
// 1) Cache lesen (versionierter Key)
$raw = Cache::get(CacheVer::k('health:services'));
// 2) Neues Format { ts, rows } → entpacken
if (is_array($raw) && array_key_exists('rows', $raw)) {
$raw = $raw['rows'];
}
// 3) Fallback: Legacy-Key ODER Settings (DB/Redis) ebenfalls entpacken
if (empty($raw)) {
$legacy = Cache::get('health:services', []);
if (is_array($legacy) && array_key_exists('rows', $legacy)) {
$raw = $legacy['rows'];
} elseif (!empty($legacy)) {
$raw = $legacy;
} else {
// persistierter Payload aus DB (Settings)
$persist = \App\Support\Setting::get('woltguard.services', []);
$raw = (is_array($persist) && isset($persist['rows']) && is_array($persist['rows']))
? $persist['rows']
: [];
}
}
// Ab hier ist $raw eine Liste von {name, ok}
$cached = collect($raw)->keyBy('name');
$rows = [];
foreach ($cards as $key => $card) {
$isOk = (bool) ($cached->get($key)['ok'] ?? false);
// Nur wenn wirklich gar nichts im Cache ist, aktiv prüfen
if ($cached->isEmpty()) {
foreach ($card['sources'] as $src) {
if ($this->check($src)) { $isOk = true; break; }
}
}
$rows[] = [
'label' => $card['label'],
'hint' => $card['hint'],
'ok' => $isOk,
];
}
// Zähler / Badge / Compact wie vorher…
$this->totalCount = count($rows);
$this->okCount = collect($rows)->where('ok', true)->count();
$this->guardOk = $this->totalCount > 0 && $this->okCount === $this->totalCount;
[$this->badgeText, $this->badgeClass, $this->badgeIcon] =
$this->guardOk
? ['alle Dienste OK', 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30', 'ph ph-check-circle']
: ["{$this->okCount}/{$this->totalCount} aktiv", 'text-amber-200 bg-amber-500/10 border-amber-400/30', 'ph ph-warning'];
$this->servicesCompact = collect($rows)->map(fn($row) => [
'label' => $row['label'],
'hint' => $row['hint'],
'ok' => $row['ok'],
'dotClass' => $row['ok'] ? 'bg-emerald-400' : 'bg-rose-400',
'pillText' => $row['ok'] ? 'Online' : 'Offline',
'pillClass' => $row['ok']
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
])->all();
}
// public function load(): void
// {
// // 1) Karten aus Config laden
// $cards = config('woltguard.cards', []);
//
// if (empty($cards)) {
// // Config-Clear/-Cache synchron ausführen (kostet ~ms)
// try {
// Artisan::call('config:clear');
// Artisan::call('config:cache');
// } catch (\Throwable $e) {
// // ignoriere UI soll trotzdem laufen
// }
// $cards = config('woltguard.cards', []);
// }
//
// // 2) Service-Status aus Cache (versionierter Key)
// $key = \App\Support\CacheVer::k('health:services');
// $raw = Cache::get($key, []);
// // Legacy-Key als Fallback
// if (empty($raw)) $raw = Cache::get('health:services', []);
//
// $cached = collect($raw)->keyBy('name');
//
// $rows = [];
// $ok = 0;
//
// foreach ($cards as $name => $card) {
// $isOk = (bool) ($cached->get($name)['ok'] ?? false);
//
// // 3) Wenn Cache leer → aktiv prüfen
// if ($cached->isEmpty()) {
// foreach ($card['sources'] as $src) {
// if ($this->check($src)) { $isOk = true; break; }
// }
// }
//
// if ($isOk) $ok++;
//
// $rows[] = [
// 'name' => $name, // <— wichtig, damit der Cache keyBy('name') später klappt
// 'label' => $card['label'] ?? $name,
// 'hint' => $card['hint'] ?? null,
// 'ok' => $isOk,
// ];
// }
//
// // 4) Wenn wir aktiv geprüft haben (Cache war leer) → Cache auffüllen
// if ($cached->isEmpty() && !empty($rows)) {
// Cache::put($key, $rows, 600); // 10 Minuten
// Cache::forget('health:services'); // Legacy aufräumen
// }
//
// // 5) Kopfzahlen + UI-Daten
// $this->totalCount = count($rows);
// $this->okCount = $ok;
// $this->guardOk = $this->totalCount > 0 && $this->okCount === $this->totalCount;
//
// [$this->badgeText, $this->badgeClass, $this->badgeIcon] =
// $this->guardOk
// ? ['alle Dienste OK', 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30', 'ph ph-check-circle']
// : ["{$this->okCount}/{$this->totalCount} aktiv", 'text-amber-200 bg-amber-500/10 border-amber-400/30', 'ph ph-warning'];
//
// $this->servicesCompact = collect($rows)->map(fn($r) => [
// 'label' => $r['label'],
// 'hint' => $r['hint'],
// 'ok' => $r['ok'],
// 'dotClass' => $r['ok'] ? 'bg-emerald-400' : 'bg-rose-400',
// 'pillText' => $r['ok'] ? 'Online' : 'Offline',
// 'pillClass' => $r['ok']
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
// ])->all();
// }
// public function load(): void
// {
// $cards = config('woltguard.cards', []);
//
// $raw = Cache::get(CacheVer::k('health:services'), []);
//
// // einmaliger Fallback für ältere Deploys:
// if (empty($raw)) {
// $raw = Cache::get('health:services', []);
// }
//
// $cached = collect($raw)->keyBy('name');
//
// $rows = [];
// $ok = 0;
// $total = 0;
//
// foreach ($cards as $key => $card) {
// $total++;
// // 1) Cache-Hit?
// $isOk = (bool) ($cached->get($key)['ok'] ?? false);
//
// // 2) Fallback probe (falls Cache leer/alt)
// if ($cached->isEmpty()) {
// foreach ($card['sources'] as $src) {
// if ($this->check($src)) { $isOk = true; break; }
// }
// }
//
// if ($isOk) $ok++;
//
// $rows[] = [
// 'label' => $card['label'],
// 'hint' => $card['hint'],
// 'ok' => $isOk,
// ];
// }
//
// $this->totalCount = count($rows);
// $this->okCount = collect($rows)->where('ok', true)->count();
// $this->guardOk = $this->totalCount > 0 && $this->okCount === $this->totalCount;
//
// [$this->badgeText, $this->badgeClass, $this->badgeIcon] =
// $this->guardOk
// ? ['alle Dienste OK', 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30', 'ph ph-check-circle']
// : ["{$this->okCount}/{$this->totalCount} aktiv", 'text-amber-200 bg-amber-500/10 border-amber-400/30', 'ph ph-warning'];
//
// $this->servicesCompact = collect($rows)->map(fn($row) => [
// 'label' => $row['label'],
// 'hint' => $row['hint'],
// 'ok' => $row['ok'],
// 'dotClass' => $row['ok'] ? 'bg-emerald-400' : 'bg-rose-400',
// 'pillText' => $row['ok'] ? 'Online' : 'Offline',
// 'pillClass' => $row['ok']
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
// ])->all();
// }
// public function load(): void
// {
// // Cache nur als optionaler Beschleuniger für Einzel-Checks,
// // NICHT für Summen / Anzahl Services.
// $cached = collect(Cache::get('health:services', []))->keyBy('name');
//
// $rows = [];
// $ok = 0;
// $total = 0;
//
// foreach ($this->cards as $key => $card) {
// $total++;
//
// $isOk = false;
//
// // 1) Cache-Shortcut (wenn vorhanden)
// foreach ($card['sources'] as $src) {
// $hit = $this->isSourceOkFromCache($src, $cached);
// if ($hit === true) { $isOk = true; break; }
// }
//
// // 2) aktive Probes, wenn Cache nicht geholfen hat
// if (!$isOk) {
// foreach ($card['sources'] as $src) {
// if ($this->check($src)) { $isOk = true; break; }
// }
// }
//
// if ($isOk) $ok++;
//
// $rows[$key] = [
// 'label' => $card['label'],
// 'hint' => $card['hint'],
// 'ok' => $isOk,
// ];
// }
//
// // Zahlen für die Kopfzeile / Badge
// $this->totalCount = $total;
// $this->okCount = $ok;
// $this->guardOk = $total > 0 && $ok === $total;
//
// // Badge-Props
// if ($this->guardOk) {
// $this->badgeText = 'alle Dienste OK';
// $this->badgeClass = 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30';
// $this->badgeIcon = 'ph ph-check-circle';
// } else {
// $this->badgeText = "{$ok}/{$total} aktiv";
// $this->badgeClass = 'text-amber-200 bg-amber-500/10 border-amber-400/30';
// $this->badgeIcon = 'ph ph-warning';
// }
//
// // Liste für die Darstellung
// $this->servicesCompact = collect($rows)->map(function ($row) {
// $ok = (bool)$row['ok'];
// return [
// 'label' => $row['label'],
// 'hint' => $row['hint'],
// 'ok' => $ok,
// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
// 'pillText' => $ok ? 'Online' : 'Offline',
// 'pillClass' => $ok
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
// ];
// })->values()->all();
// }
/* ---------- Cache-Matcher ---------- */
protected function isSourceOkFromCache(string $source, Collection $idx): ?bool
{
if (str_starts_with($source, 'tcp:')) {
[$host, $port] = explode(':', substr($source, 4), 2);
$name = "{$host}:{$port}";
return $idx->has($name) ? (bool)($idx[$name]['ok'] ?? false) : null;
}
if ($source === 'db') {
return $idx->has('db') ? (bool)($idx['db']['ok'] ?? false) : null;
}
if (str_starts_with($source, 'systemd:')) {
$unit = substr($source, 8);
return $idx->has($unit) ? (bool)($idx[$unit]['ok'] ?? false) : null;
}
return null; // für socket:/ pid:/ proc: keine Cache-Infos
}
/* ---------- Probes ---------- */
protected function check(string $source): bool
{
if (str_starts_with($source, 'systemd:')) {
return $this->probeSystemd(substr($source, 8));
}
if (str_starts_with($source, 'tcp:')) {
[$host, $port] = explode(':', substr($source, 4), 2);
return $this->probeTcp($host, (int)$port);
}
if (str_starts_with($source, 'socket:')) {
$path = substr($source, 7);
return is_string($path) && @file_exists($path);
}
if (str_starts_with($source, 'pid:')) {
$path = substr($source, 4);
if (!@is_file($path)) return false;
$pid = (int)trim(@file_get_contents($path) ?: '');
return $pid > 1 && @posix_kill($pid, 0);
}
if (str_starts_with($source, 'proc:')) {
$regex = substr($source, 5);
return $this->probeProcessRegex($regex);
}
if ($source === 'db') {
return $this->probeDatabase();
}
return false;
}
protected function probeSystemd(string $unit): bool
{
$bin = file_exists('/bin/systemctl') ? '/bin/systemctl'
: (file_exists('/usr/bin/systemctl') ? '/usr/bin/systemctl' : 'systemctl');
$cmd = sprintf('%s is-active --quiet %s 2>/dev/null', escapeshellcmd($bin), escapeshellarg($unit));
$exit = null;
@exec($cmd, $_, $exit);
return $exit === 0;
}
protected function probeTcp(string $host, int $port, int $timeout = 1): bool
{
$fp = @fsockopen($host, $port, $e1, $e2, $timeout);
if (is_resource($fp)) { fclose($fp); return true; }
return false;
}
protected function probeProcessRegex(string $regex): bool
{
$regex = '#' . $regex . '#i';
foreach (@scandir('/proc') ?: [] as $d) {
if (!ctype_digit($d)) continue;
$cmd = @file_get_contents("/proc/$d/cmdline");
if ($cmd === false || $cmd === '') continue;
$cmd = str_replace("\0", ' ', $cmd);
if (preg_match($regex, $cmd)) return true;
}
return false;
}
protected function probeDatabase(): bool
{
try { DB::connection()->getPdo(); return true; }
catch (\Throwable) { return false; }
}
}

View File

@ -0,0 +1,476 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
use Illuminate\Support\Facades\Artisan;
use App\Models\Setting;
class StorageCard extends Component
{
public string $target = '/';
// Summen für die Zahlenanzeige (GB)
public ?int $diskTotalGb = null;
public ?int $diskUsedGb = null;
public ?int $diskFreeGb = null;
// Donut
public array $diskSegments = []; // [{angle,class}]
public int $diskSegOuterRadius = 92;
public int $diskInnerSize = 160;
public array $diskCenterText = ['percent' => '', 'label' => 'SPEICHER BELEGT'];
// Legende/Bar (GENAU wie dein Blade es erwartet)
// [{label,color,gb,percent}]
public array $barSegments = [];
public ?string $measuredAt = null;
protected int $segCount = 100; // Donut-Segmente
private int $legendMinBytes = 104858; // 0,1 MiB darunter blenden wir aus
public function mount(string $target = '/'): void
{
$this->target = $target ?: '/';
$this->loadFromSettings();
}
public function render()
{
return view('livewire.ui.system.storage-card');
}
public function refresh(): void
{
Artisan::call('health:probe-disk', ['target' => $this->target]);
$this->loadFromSettings();
}
// ─────────────────────────────────────────────────────────────
private function humanBytes(float|int $bytes): string
{
$b = max(0, (float)$bytes);
if ($b >= 1024 ** 3) return number_format($b / (1024 ** 3), 1) . ' GB';
if ($b >= 1024 ** 2) return number_format($b / (1024 ** 2), 2) . ' MiB';
if ($b >= 1024) return number_format($b / 1024, 0) . ' KiB';
return (string)((int)$b) . ' B';
}
protected function loadFromSettings(): void
{
$disk = Setting::get('health.disk', []);
if (!is_array($disk) || empty($disk)) {
$this->resetUi();
return;
}
// Summen (GB) so wie dein Blade sie zeigt
$this->diskTotalGb = self::intOrNull($disk['total_gb'] ?? null);
$this->diskUsedGb = self::intOrNull($disk['used_gb'] ?? null);
$this->diskFreeGb = self::intOrNull($disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? null));
$percent = $disk['percent_used_total'] ?? null;
$this->diskCenterText = [
'percent' => is_numeric($percent) ? (string)round($percent) . '%' : '',
'label' => 'SPEICHER BELEGT',
];
// Breakdown in BYTES (kommt aus StorageProbe)
$bdBytes = $disk['breakdown_bytes'] ?? ['system' => 0, 'mails' => 0, 'backup' => 0];
// Total/Free in BYTES ableiten
$totalBytes = (int)round(((int)($disk['total_gb'] ?? 0)) * 1024 ** 3);
$freeGb = $disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? 0);
$freeBytes = (int)round(((float)$freeGb) * 1024 ** 3);
// reportetes Used (Bytes)
$usedReportedBytes = max(0, $totalBytes - $freeBytes);
// Summe Breakdown angleichen (Rest → System), damit Donut/Prozente stimmig sind
$sumUsedBreakdown = max(0, (int)$bdBytes['system'] + (int)$bdBytes['mails'] + (int)$bdBytes['backup']);
if ($usedReportedBytes > $sumUsedBreakdown) {
$bdBytes['system'] += ($usedReportedBytes - $sumUsedBreakdown);
}
// Donut färben
$this->diskSegments = $this->buildDonutSegmentsFromBytes($bdBytes, $totalBytes, $freeBytes);
// Legende/Bar (GB + Prozent; KEIN „text“-Feld)
$this->barSegments = $this->buildLegendGbFromBytes($bdBytes, $totalBytes, $freeBytes);
$this->measuredAt = Setting::get('health.disk_updated_at', null);
}
protected function buildDonutSegmentsFromBytes(array $bdBytes, int $totalBytes, int $freeBytes): array
{
if ($totalBytes <= 0) return [];
$order = [
['key' => 'system', 'class' => 'bg-emerald-400'],
['key' => 'mails', 'class' => 'bg-rose-400'],
['key' => 'backup', 'class' => 'bg-sky-400'],
];
$counts = [];
foreach ($order as $d) {
$bytes = max(0, (int)($bdBytes[$d['key']] ?? 0));
$cnt = (int)round($this->segCount * ($bytes / $totalBytes));
if ($bytes > 0 && $cnt === 0) $cnt = 1; // min. 1 Segment, wenn vorhanden
$counts[$d['key']] = $cnt;
}
$usedCount = array_sum($counts);
$freeCount = max(0, $this->segCount - $usedCount);
$segments = [];
foreach ($order as $d) {
for ($i = 0; $i < $counts[$d['key']]; $i++) {
$segments[] = ['class' => $d['class']];
}
}
for ($i = 0; $i < $freeCount; $i++) {
$segments[] = ['class' => 'bg-white/15'];
}
while (count($segments) < $this->segCount) {
$segments[] = ['class' => 'bg-white/15'];
}
$out = [];
for ($i = 0; $i < $this->segCount; $i++) {
$angle = (360 / $this->segCount) * $i - 90;
$out[] = ['angle' => $angle, 'class' => $segments[$i]['class']];
}
return $out;
}
// protected function buildLegendGbFromBytes(array $bdBytes, int $totalBytes, int $freeBytes): array
// {
// $defs = [
// ['key' => 'system', 'label' => 'System', 'class' => 'bg-emerald-400'],
// ['key' => 'mails', 'label' => 'Mails', 'class' => 'bg-rose-400'],
// ['key' => 'backup', 'label' => 'Backups', 'class' => 'bg-sky-400'],
// ];
//
// $toPercent = function (int $bytes) use ($totalBytes): int {
// if ($totalBytes <= 0) return 0;
// return (int)max(0, min(100, round($bytes * 100 / $totalBytes)));
// };
// $toGb = fn(int $bytes) => round($bytes / (1024 ** 3), 1);
//
// $out = [];
// foreach ($defs as $d) {
// $val = max(0, (int)($bdBytes[$d['key']] ?? 0));
// if ($val <= 0) continue; // zu klein → nicht anzeigen
// $out[] = [
// 'label' => $d['label'],
// 'color' => $d['class'],
// 'gb' => $toGb($val), // ← genau das Feld, das dein Blade nutzt
// 'percent' => $toPercent($val),
// ];
// }
//
// // „Frei“ immer anzeigen
// $out[] = [
// 'label' => 'Frei',
// 'color' => 'bg-white/20',
// 'gb' => $toGb($freeBytes),
// 'percent' => $toPercent($freeBytes),
// ];
//
// return $out;
// }
protected function buildLegendGbFromBytes(array $bdBytes, int $totalBytes, int $freeBytes): array
{
$defs = [
['key' => 'system', 'label' => 'System', 'class' => 'bg-emerald-400'],
['key' => 'mails', 'label' => 'Mails', 'class' => 'bg-rose-400'],
['key' => 'backup', 'label' => 'Backups', 'class' => 'bg-sky-400'],
];
$toPercent = function (int $bytes) use ($totalBytes): int {
if ($totalBytes <= 0) return 0;
return (int)max(0, min(100, round($bytes * 100 / $totalBytes)));
};
$toGbNum = fn(int $bytes) => round($bytes / (1024 ** 3), 1);
$out = [];
foreach ($defs as $d) {
$val = max(0, (int)($bdBytes[$d['key']] ?? 0));
// < 1 MiB NICHT anzeigen
if ($val < $this->legendMinBytes) continue;
$out[] = [
'label' => $d['label'],
'color' => $d['class'],
// numerisch in GB lassen (falls du später rechnen willst)
'gb' => $toGbNum($val),
'percent' => $toPercent($val),
// FERTIG formatiert mit Einheit fürs Blade
'human' => $this->humanBytes($val),
];
}
// „Frei“ immer anzeigen
$out[] = [
'label' => 'Frei',
'color' => 'bg-white/20',
'gb' => $toGbNum($freeBytes),
'percent' => $toPercent($freeBytes),
'human' => $this->humanBytes($freeBytes),
];
return $out;
}
protected function resetUi(): void
{
$this->diskTotalGb = null;
$this->diskUsedGb = null;
$this->diskFreeGb = null;
$this->diskSegments = [];
$this->barSegments = [];
$this->diskCenterText = ['percent' => '', 'label' => 'SPEICHER BELEGT'];
$this->measuredAt = null;
}
protected static function intOrNull($v): ?int
{
return is_numeric($v) ? (int)round($v) : null;
}
}
//
//
//namespace App\Livewire\Ui\System;
//
//use Livewire\Component;
//use Illuminate\Support\Facades\Artisan;
//use App\Models\Setting;
//
//class StorageCard extends Component
//{
// public string $target = '/';
//
// // Summen
// public ?int $diskTotalGb = null;
// public ?int $diskUsedGb = null;
// public ?int $diskFreeGb = null;
//
// // Donut
// public array $diskSegments = [];
// public int $diskSegOuterRadius = 92;
// public int $diskInnerSize = 160;
// public array $diskCenterText = ['percent' => '', 'label' => 'SPEICHER BELEGT'];
//
// private int $legendMinBytes = 1 * 1024 * 1024; // 1 MiB Schwelle
//
// // Stacked-Bar + Legende
// public array $barSegments = []; // [{label,color,gb,percent}]
// public ?string $measuredAt = null;
//
// protected int $segCount = 100;
//
// public function mount(string $target = '/'): void
// {
// $this->target = $target ?: '/';
// $this->loadFromSettings();
// }
//
// public function render()
// {
// return view('livewire.ui.system.storage-card');
// }
//
// public function refresh(): void
// {
// Artisan::call('health:probe-disk', ['target' => $this->target]);
// $this->loadFromSettings();
// }
//
// private function humanSize(int $bytes): string
// {
// if ($bytes >= 1024**3) return number_format($bytes / 1024**3, 1) . ' GB';
// if ($bytes >= 1024**2) return number_format($bytes / 1024**2, 2) . ' MiB';
// if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KiB';
// return $bytes . ' B';
// }
//
// protected function loadFromSettings(): void
// {
// $disk = Setting::get('health.disk', []);
// if (!is_array($disk) || empty($disk)) { $this->resetUi(); return; }
//
// $this->diskTotalGb = self::intOrNull($disk['total_gb'] ?? null);
// $this->diskUsedGb = self::intOrNull($disk['used_gb'] ?? null);
// $this->diskFreeGb = self::intOrNull($disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? null));
//
// $percent = $disk['percent_used_total'] ?? null;
// $this->diskCenterText = [
// 'percent' => is_numeric($percent) ? (string)round($percent).'%' : '',
// 'label' => 'SPEICHER BELEGT',
// ];
//
// // Donut farbig aus Breakdown
// $this->diskSegments = $this->buildDonutSegmentsFromBreakdown($disk);
//
// // Legende unten (gleiche Farben wie Donut)
// $total = max(1, (float)($disk['total_gb'] ?? 1));
// $bd = $disk['breakdown'] ?? [];
// $sys = (float)($bd['system_gb'] ?? 0);
// $mails = (float)($bd['mails_gb'] ?? 0);
// $backup = (float)($bd['backup_gb'] ?? 0);
// $free = (float)($disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? 0));
// $p = fn(float $gb) => max(0, min(100, round($gb * 100 / $total)));
//
// $this->barSegments = [
// ['label' => 'System', 'gb' => round($sys,1), 'percent' => $p($sys), 'color' => 'bg-emerald-400'],
// ['label' => 'Mails', 'gb' => round($mails,1), 'percent' => $p($mails), 'color' => 'bg-rose-400'],
// ['label' => 'Backups', 'gb' => round($backup,1),'percent' => $p($backup), 'color' => 'bg-sky-400'],
// ['label' => 'Frei', 'gb' => round($free,1), 'percent' => $p($free), 'color' => 'bg-white/20'],
// ];
//
// $this->barSegments = array_values(array_filter(
// $this->barSegments,
// fn($b) => ($b['gb'] ?? 0) > 0 // nur Einträge mit realer Größe
// ));
//
// $this->measuredAt = Setting::get('health.disk_updated_at', null);
// }
//
// // NEU: ersetzt buildSegments() + nutzt Breakdown
// protected function buildDonutSegmentsFromBreakdown(array $disk): array
// {
// $total = (float)($disk['total_gb'] ?? 0);
// if ($total <= 0) return [];
//
// // Breakdown lesen
// $bd = $disk['breakdown'] ?? [];
// $sys = (float)($bd['system_gb'] ?? 0);
// $mails = (float)($bd['mails_gb'] ?? 0);
// $backup = (float)($bd['backup_gb'] ?? 0);
// $free = (float)($disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? 0));
//
// // Robust machen: falls used aus Settings größer ist als Breakdown-Summe → auf System draufschlagen
// $usedReported = (float)($disk['used_gb'] ?? ($total - $free));
// $sumUsedBd = $sys + $mails + $backup;
// if ($usedReported > $sumUsedBd && $usedReported <= $total) {
// $sys += ($usedReported - $sumUsedBd);
// }
// // Grenzen
// $sys = max(0, min($sys, $total));
// $mails = max(0, min($mails, $total));
// $backup = max(0, min($backup, $total));
// $free = max(0, min($free, $total));
//
// // Segmente verteilen
// $mkCount = function (float $gb) use ($total) {
// return (int) round($this->segCount * $gb / $total);
// };
//
// $segments = [];
// $add = function (int $count, string $class) use (&$segments) {
// for ($i = 0; $i < $count; $i++) {
// $segments[] = ['class' => $class];
// }
// };
//
// $add($mkCount($sys), 'bg-emerald-400'); // System
// $add($mkCount($mails), 'bg-rose-400'); // Mails
// $add($mkCount($backup), 'bg-sky-400'); // Backups
//
// // Rest = Frei (grau)
// while (count($segments) < $this->segCount) {
// $segments[] = ['class' => 'bg-white/15'];
// }
//
// // Winkel setzen (gleich wie vorher)
// $out = [];
// for ($i = 0; $i < $this->segCount; $i++) {
// $angle = (360 / $this->segCount) * $i - 90;
// $out[] = ['angle' => $angle, 'class' => $segments[$i]['class']];
// }
// return $out;
// }
//
//// protected function buildSegments(?int $percent): array
//// {
//// $segments = [];
//// $active = is_int($percent) ? (int)round($this->segCount * $percent / 100) : 0;
////
//// $activeClass = match (true) {
//// !is_int($percent) => 'bg-white/15',
//// $percent >= 90 => 'bg-rose-400',
//// $percent >= 70 => 'bg-amber-300',
//// default => 'bg-emerald-400',
//// };
////
//// for ($i = 0; $i < $this->segCount; $i++) {
//// $angle = (360 / $this->segCount) * $i - 90;
//// $segments[] = [
//// 'angle' => $angle,
//// 'class' => $i < $active ? $activeClass : 'bg-white/15'
//// ];
//// }
//// return $segments;
//// }
//
// protected function buildBar(array $disk): array
// {
// $total = (float)($disk['total_gb'] ?? 0);
// if ($total <= 0) return [];
//
// // Breakdown lesen + normalisieren
// [$sys, $mails, $backups] = $this->readBreakdown($disk);
//
// $free = max(0.0, (float)($disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? 0)));
// $used = min($total, $sys + $mails + $backups); // robust bei Messrauschen
//
// // Falls Breakdown kleiner ist als used_gb: Rest als “System” draufschlagen,
// // damit die Prozent-Summe 100 ergibt.
// $usedReported = (float)($disk['used_gb'] ?? ($total - $free));
// if ($usedReported > 0 && $used < $usedReported) {
// $sys += ($usedReported - $used);
// $used = $usedReported;
// }
//
// // Prozent berechnen
// $p = fn(float $gb) => max(0, min(100, round($gb * 100 / $total)));
//
// return [
// ['label' => 'System', 'gb' => round($sys, 1), 'percent' => $p($sys), 'color' => 'bg-emerald-400'],
// ['label' => 'Mails', 'gb' => round($mails, 1), 'percent' => $p($mails), 'color' => 'bg-rose-400'],
// ['label' => 'Backups', 'gb' => round($backups, 1), 'percent' => $p($backups), 'color' => 'bg-sky-400'],
// ['label' => 'Frei', 'gb' => round($free, 1), 'percent' => $p($free), 'color' => 'bg-white/20'],
// ];
// }
//
// protected function readBreakdown(array $disk): array
// {
// // Deine Keys ggf. hier mappen
// $bd = $disk['breakdown'] ?? [];
// $sys = (float)($bd['system_gb'] ?? 0);
// $mails = (float)($bd['mails_gb'] ?? 0);
// $backup = (float)($bd['backup_gb'] ?? 0);
//
// return [$sys, $mails, $backup];
// }
//
// protected function resetUi(): void
// {
// $this->diskTotalGb = null;
// $this->diskUsedGb = null;
// $this->diskFreeGb = null;
// $this->diskSegments = [];
// $this->barSegments = [];
// $this->diskCenterText = ['percent' => '', 'label' => 'SPEICHER BELEGT'];
// $this->measuredAt = null;
// }
//
// protected static function intOrNull($v): ?int
// {
// return is_numeric($v) ? (int)round($v) : null;
// }
//}

View File

@ -0,0 +1,601 @@
<?php
namespace App\Livewire\Ui\System;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Livewire\Component;
class UpdateCard extends Component
{
/* ===== Versionen / Status ===== */
public ?string $current = null; // normiert (ohne v)
public ?string $latest = null; // normiert (ohne v)
public ?string $displayCurrent = null; // hübsch mit v
public ?string $displayLatest = null; // hübsch mit v
public bool $hasUpdate = false;
public string $state = 'idle'; // idle | running
public bool $running = false; // low-level Wrapper-Status
public ?string $lowState = null; // 'running' | 'done' | '' (unbekannt)
public ?int $rc = null; // Rückgabecode vom Wrapper
/* ===== UI-Properties (nur fürs Blade) ===== */
public string $badgeText = '';
public string $badgeClass = '';
public string $badgeIcon = '';
public bool $showButton = false;
public bool $buttonDisabled = false;
public string $buttonLabel = '';
public ?string $progressLine = null; // Einzeilige Live-Statuszeile
public ?string $errorLine = null; // Fehlerzeile bei rc != 0
public ?string $message = null;
public bool $postActionsDone = false;
protected string $cacheStartedAtKey = 'mw.update.started_at';
protected int $failsafeSeconds = 20 * 60;
private const VERSION_FILE = '/var/lib/mailwolt/version'; // normiert
private const VERSION_FILE_RAW = '/var/lib/mailwolt/version_raw'; // raw (z.B. v1.0.35-dirty)
private const BUILD_INFO = '/etc/mailwolt/build.info'; // Fallback
private const UPDATE_LOG = '/var/log/mailwolt-update.log'; // Wrapper-Log
/* ========================================================= */
public function mount(): void
{
$this->reloadVersionsAndStatus();
$this->recompute();
$this->progressLine = '';
if ($this->running) {
$this->state = 'running';
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
}
$this->recomputeUi();
}
public function render()
{
return view('livewire.ui.system.update-card');
}
/* ================== Aktionen ================== */
public function runUpdate(): void
{
// evtl. alte Einträge aufräumen
Cache::forget('mailwolt.update_available');
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
// Sofort ins Running gehen
$this->latest = null;
$this->displayLatest = null;
$this->hasUpdate = false;
$this->state = 'running';
$this->running = true;
$this->errorLine = null;
$this->progressLine = 'Update gestartet …';
$this->recomputeUi();
}
public function pollUpdate(): void
{
$this->refreshLowLevelState();
if ($this->rc !== null) {
$this->running = false;
}
// 2) Failsafe
$started = (int) Cache::get($this->cacheStartedAtKey, 0);
if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
$this->running = false;
$this->lowState = 'done';
$this->rc ??= 0;
}
// Abschluss NUR wenn state=done
if ($this->lowState === 'done') {
Cache::forget($this->cacheStartedAtKey);
// kurze Gnadenzeit gegen Schreibpuffer
usleep(300_000);
$this->reloadVersionsAndStatus();
$this->recompute();
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan spamav:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
$this->postActionsDone = true;
$ver = $this->displayCurrent ?? 'aktuelle Version';
$this->progressLine = 'Update abgeschlossen: ' . $ver;
// Optional: NICHT sofort reloaden Nutzer entscheidet
// $this->dispatch('reload-page', delay: 6000);
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
$this->postActionsDone = true;
$this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
$this->dispatch('toast', type:'error', title:'Update fehlgeschlagen',
text:$this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
badge:'System', duration:0);
}
$this->state = 'idle';
}
$this->recomputeUi();
// 3) Abschluss?
// $started = (int)Cache::get($this->cacheStartedAtKey, 0);
// if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
// $this->running = false;
// $this->rc ??= 0; // wenn unklar, als erfolgreich werten
// }
// if (!$this->running) {
// Cache::forget($this->cacheStartedAtKey);
//
// usleep(500_000);
// // Nachlauf: Versionen & Vergleich neu aufbauen
// $this->reloadVersionsAndStatus();
// $this->recompute();
//
// if ($this->current && $this->latest && version_compare($this->current, $this->latest, '>=')) {
// $this->hasUpdate = false;
// }
//
// if ($this->rc === 0 && !$this->postActionsDone) {
//// @shell_exec('nohup php /var/www/mailwolt/artisan mailwolt:restart-services >/dev/null 2>&1 &');
// @shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
// @shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
// @shell_exec('nohup php /var/www/mailwolt/artisan spamav:collect >/dev/null 2>&1 &');
// $this->postActionsDone = true;
//
// $ver = $this->displayCurrent ?? 'aktuelle Version';
// $this->progressLine = 'Update abgeschlossen: ' . $ver;
//// $this->dispatch('reload-page', delay: 5000);
// $this->dispatch('toast',
// type: 'success',
// title: 'Update erfolgreich',
// text: "MailWolt wurde auf {$ver} aktualisiert.",
// badge: 'System',
// duration: 4000
// );
//
// $this->dispatch('reload-page', delay: 5000);
//
// } elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
// // Fehlerfall
// $this->postActionsDone = true;
// $this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
// $this->dispatch('toast',
// type: 'error',
// title: 'Update fehlgeschlagen',
// text: $this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
// badge: 'System',
// duration: 0
// );
// }
// $this->state = 'idle';
// }
// $this->recomputeUi();
}
/* ================== Helpers ================== */
protected function reloadVersionsAndStatus(): void
{
$this->current = $this->readCurrentVersion();
// Update-Checker schreibt:
// - updates:latest (normiert)
// - updates:latest_raw (original)
$latNorm = Cache::get('updates:latest');
$latRaw = Cache::get('updates:latest_raw');
// Legacy-Fallback
if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) {
$latNorm = $this->normalizeVersion($legacy);
$latRaw = $legacy;
}
$this->latest = $latNorm ?: null;
$this->displayLatest = $latRaw ?: ($latNorm ? 'v' . $latNorm : null);
$this->refreshLowLevelState();
}
protected function recompute(): void
{
$curNorm = $this->normalizeVersion($this->current);
$latNorm = $this->normalizeVersion($this->latest);
$this->hasUpdate = ($curNorm && $latNorm)
? version_compare($latNorm, $curNorm, '>')
: false;
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
if (!$this->displayLatest && $latNorm) {
$this->displayLatest = 'v' . $latNorm;
}
}
protected function recomputeUi(): void
{
if ($this->state === 'running') {
$this->badgeText = 'Update läuft';
$this->badgeIcon = 'ph-arrows-clockwise animate-spin';
$this->badgeClass = 'text-sky-200 bg-sky-500/10 border-sky-400/30';
$this->showButton = true;
$this->buttonDisabled = true;
$this->buttonLabel = 'Update läuft …';
return;
}
if ($this->rc !== null && $this->rc !== 0) {
$this->badgeText = 'Fehlgeschlagen';
$this->badgeIcon = 'ph-warning-circle';
$this->badgeClass = 'text-rose-200 bg-rose-500/10 border-rose-400/30';
$this->showButton = true;
$this->buttonDisabled = false;
$this->buttonLabel = 'Erneut versuchen';
return;
}
if ($this->hasUpdate) {
$this->badgeText = 'Update verfügbar';
$this->badgeIcon = 'ph-arrow-fat-line-up';
$this->badgeClass = 'text-yellow-200 bg-yellow-500/10 border-yellow-400/30';
$this->showButton = true;
$this->buttonDisabled = false;
$this->buttonLabel = 'Jetzt aktualisieren';
return;
}
// Aktuell
$this->badgeText = 'Aktuell';
$this->badgeIcon = 'ph-check-circle';
$this->badgeClass = 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30';
$this->showButton = false;
$this->buttonDisabled = true;
$this->buttonLabel = '';
}
protected function finishUiIfNoUpdate(): void
{
if (!$this->hasUpdate) {
$this->state = 'idle';
Cache::forget('mailwolt.update_available'); // Legacy-Key
}
}
// protected function refreshLowLevelState(): void
// {
// $state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
// $rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
//
// $this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
// $this->running = ($this->rc === null) && ($state !== 'done');
// $this->progressLine = $this->tailUpdateLog();
//
//// $state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
//// $this->running = ($state === 'running');
////
//// $rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
//// $this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
////
//// $this->progressLine = $this->tailUpdateLog();
// }
protected function refreshLowLevelState(): void
{
$state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
$rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
$this->lowState = $state !== '' ? $state : null;
// running: solange NICHT 'done'
$this->running = ($this->lowState !== 'done');
// rc erst freigeben, wenn wirklich done
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int)$rcRaw : null;
$this->progressLine = $this->tailUpdateLog();
}
protected function readCurrentVersion(): ?string
{
// 1) normierte Version vom Wrapper
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
if ($v !== '') return $v;
// 2) raw -> normieren
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);
// 3) build.info
$build = @file_get_contents(self::BUILD_INFO);
if ($build) {
foreach (preg_split('/\R+/', $build) as $line) {
if (str_starts_with($line, 'version=')) {
$v = $this->normalizeVersion(trim(substr($line, 8)));
if ($v) return $v;
}
}
}
// 4) Fallback auf config(app.version)
$v = $this->normalizeVersion(config('app.version') ?: '');
return $v ?: null;
}
protected function normalizeVersion(?string $v): ?string
{
if ($v === null) return null;
$v = trim($v);
if ($v === '') return null;
$v = ltrim($v, "vV \t\n\r\0\x0B"); // führendes v weg
$v = preg_replace('/-.*$/', '', $v); // Build-/dirty-Suffix kappen
return $v !== '' ? $v : null;
}
/** Einzeilige letzte Log-Zeile hübsch aufbereitet */
protected function tailUpdateLog(): ?string
{
$p = self::UPDATE_LOG;
if (!is_readable($p)) return null;
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines || !count($lines)) return null;
$last = trim(end($lines));
// Kosmetik
$last = preg_replace('/^\[\w\]\s*/', '', $last); // "[i] " etc.
$last = preg_replace('/^=+ .*? =+\s*$/', 'Update beendet', $last); // Banner zusammenfassen
$last = preg_replace('/^\d{4}-\d{2}-\d{2}T[^ ]+\s*::\s*/', '', $last); // "2025-.. :: " entfernen
return Str::limit($last, 120);
}
}
//namespace App\Livewire\Ui\System;
//
//use Illuminate\Support\Facades\Artisan;
//use Illuminate\Support\Facades\Cache;
//use Livewire\Component;
//
//class UpdateCard extends Component
//{
// public ?string $current = null;
// public ?string $latest = null;
// public ?string $displayCurrent = null;
// public ?string $displayLatest = null;
//
// public bool $hasUpdate = false;
// public string $state = 'idle';
// public ?string $message = null;
// public ?bool $messagePositive = null;
//
// public bool $running = false;
// public ?int $rc = null;
//
// // NEU: UI-Properties (nur fürs Blade ausgeben)
// public string $badgeText = '';
// public string $badgeClass = '';
// public string $badgeIcon = '';
// public bool $showButton = false;
// public bool $buttonDisabled = false;
// public string $buttonLabel = '';
// public ?string $progressLine = null; // Einzeiler unter der Version
// public ?string $errorLine = null; // optionaler Fehlertext
//
// public bool $postActionsDone = false;
//
// protected string $cacheStartedAtKey = 'mw.update.started_at';
// protected int $failsafeSeconds = 20 * 60;
// private const VERSION_FILE = '/var/lib/mailwolt/version'; // normiert, ohne "v"
// private const VERSION_FILE_RAW = '/var/lib/mailwolt/version_raw'; // original ("v1.0.25" o.ä.)
// private const BUILD_INFO = '/etc/mailwolt/build.info'; // Fallback
//
// public function mount(): void
// {
// $this->reloadVersionsAndStatus();
// $this->recompute();
//
// // falls ein Update bereits läuft (State-Datei existiert), gleich “running” zeigen
// if ($this->running) {
// $this->state = 'running';
// }
// }
//
// protected function reloadVersionsAndStatus(): void
// {
// $this->current = $this->readCurrentVersion();
//
// // Update-Checker soll beide Keys schreiben:
// // - updates:latest (normiert, ohne v)
// // - updates:latest_raw (Original)
// $latNorm = Cache::get('updates:latest');
// $latRaw = Cache::get('updates:latest_raw');
//
// // Falls dein alter Key noch benutzt wird, weiter kompatibel:
// if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) {
// $latNorm = $this->normalizeVersion($legacy);
// $latRaw = $legacy;
// }
//
// $this->latest = $latNorm ?: null; // für Vergleiche
// $this->displayLatest = $latRaw ?: ($latNorm ? 'v'.$latNorm : null);
//
// $this->refreshLowLevelState();
// }
//
// protected function recompute(): void
// {
// $curNorm = $this->normalizeVersion($this->current);
// $latNorm = $this->normalizeVersion($this->latest);
//
// $this->hasUpdate = ($curNorm && $latNorm)
// ? version_compare($latNorm, $curNorm, '>')
// : false;
//
// // Anzeige immer hübsch mit "v", Vergleich bleibt normiert
// $this->displayCurrent = $curNorm ? 'v'.$curNorm : null;
//
// // displayLatest NICHT mehr vom hasUpdate abhängig machen bleibt informativ sichtbar
// if (!$this->displayLatest && $latNorm) {
// $this->displayLatest = 'v'.$latNorm;
// }
// }
//
// protected function finishUiIfNoUpdate(): void
// {
// if (!$this->hasUpdate) {
// $this->state = 'idle';
// $cur = $this->displayCurrent ?? '';
// $this->message = "Du bist auf dem neuesten Stand ({$cur})";
// $this->messagePositive = true;
//
// // Nur den alten Legacy-Key aufräumen
// Cache::forget('mailwolt.update_available');
// }
// }
//
// protected function readCurrentVersion(): ?string
// {
// // 1) Wrapper-Datei (normiert, ohne „v“)
// $v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
// if ($v !== '') return $v;
//
// // 2) Raw -> normieren
// $raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
// if ($raw !== '') return $this->normalizeVersion($raw);
//
// // 3) Fallback build.info (format: "version=…")
// $build = @file_get_contents(self::BUILD_INFO);
// if ($build) {
// foreach (preg_split('/\R+/', $build) as $line) {
// if (str_starts_with($line, 'version=')) {
// $v = $this->normalizeVersion(trim(substr($line, 8)));
// if ($v) return $v;
// }
// }
// }
//
// // 4) Noch ein letzter Fallback
// $v = $this->normalizeVersion(config('app.version') ?: '');
// return $v ?: null;
// }
//
// protected function normalizeVersion(?string $v): ?string
// {
// if ($v === null) return null;
// $v = trim($v);
// if ($v === '') return null;
//
// // führendes v entfernen, Build-Suffixe (z.B. "-3-gabcd" oder "-dirty") kappen
// $v = ltrim($v, "vV \t\n\r\0\x0B");
// $v = preg_replace('/-.*$/', '', $v); // alles nach erster '-' weg
// return $v !== '' ? $v : null;
// }
//
// public function runUpdate(): void
// {
// Cache::forget('mailwolt.update_available');
// Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
//
// // Name korrigiert: Wrapper ist /usr/local/sbin/mailwolt-update
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
//
// $this->latest = null;
// $this->displayLatest = null;
// $this->hasUpdate = false;
// $this->state = 'running';
// $this->running = true;
// $this->message = 'Update läuft …';
// }
//
// protected function refreshLowLevelState(): void
// {
// // Wrapper-Status lesen
// $state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
//
// // Läuft, wenn Datei "running" enthält alles andere gilt als nicht laufend
// $this->running = ($state === 'running');
//
// // Rückgabecode (nur gesetzt, wenn Update beendet ist)
// $rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
// $this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
// }
//
// public function pollUpdate(): void
// {
// // 1) Low-Level Status vom Wrapper (/var/lib/mailwolt/update/{state,rc})
// $this->refreshLowLevelState();
//
// // 2) failsafe: wenn zu lange läuft, als beendet markieren
// $started = (int) Cache::get($this->cacheStartedAtKey, 0);
// if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
// $this->running = false;
// $this->rc ??= 0; // notfalls als “erfolgreich” werten
// }
//
// // 3) Wenn nicht mehr running ⇒ Abschluss behandeln
// if (!$this->running) {
// Cache::forget($this->cacheStartedAtKey);
//
// // Versionen neu laden und “hasUpdate” neu berechnen
// $this->reloadVersionsAndStatus();
// $this->recompute();
//
// // 3a) Erfolg: jetzt erst Dienste neu starten
// if ($this->rc === 0 && !$this->postActionsDone) {
// // Restart asynchron starten (dein Artisan-Command macht systemctl etc.)
// @shell_exec('nohup php /var/www/mailwolt/artisan mailwolt:restart-services >/dev/null 2>&1 &');
// $this->postActionsDone = true;
//
// // Toast
// $ver = $this->displayCurrent ?? 'aktuelle Version';
// $this->dispatch('toast',
// type: 'success',
// title: 'Update erfolgreich',
// text: "MailWolt wurde auf {$ver} aktualisiert. Dienste werden neu gestartet …",
// badge: 'System',
// duration: 5000
// );
//
// // Seite nach 5s neu laden
// $this->dispatch('reload-page', delay: 5000);
// }
// // 3b) Fehler: Meldung zeigen
// elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
// $this->postActionsDone = true;
// $this->dispatch('toast',
// type: 'error',
// title: 'Update fehlgeschlagen',
// text: "Update ist mit Rückgabecode {$this->rc} fehlgeschlagen. Bitte Logs prüfen.",
// badge: 'System',
// duration: 0
// );
// }
//
// // 4) UI zurück auf idle
// $this->state = 'idle';
// }
// }
//
//}

View File

@ -1,72 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
class UpdateManager extends Component
{
public bool $running = false;
public string $log = '';
public ?int $rc = null;
public ?string $latest = null; // optional: von Cache o.ä.
public function mount(): void
{
$this->refreshState();
// Optional: latest Tag/Version aus Cache anzeigen
$this->latest = cache('mailwolt.update_available');
}
public function refreshState(): void
{
$state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
$this->running = ($state === 'running');
$rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
// letzte 200 Zeilen Log
$this->log = @shell_exec('tail -n 200 /var/log/mailwolt-update.log 2>/dev/null') ?? '';
}
public function runUpdate(): void
{
// Hinweis „Update verfügbar“ ausblenden
cache()->forget('mailwolt.update_available');
// Update im Hintergrund starten
@shell_exec('nohup sudo -n /usr/local/sbin/mw-update >/dev/null 2>&1 &');
$this->running = true;
$this->dispatch('toast',
type: 'info',
badge: 'Update',
title: 'Update gestartet',
text: 'Das System wird aktualisiert …',
duration: 6000
);
}
public function poll(): void
{
$before = $this->running;
$this->refreshState();
if ($before && !$this->running && $this->rc !== null) {
$ok = ($this->rc === 0);
$this->dispatch('toast',
type: $ok ? 'done' : 'warn',
badge: 'Update',
title: $ok ? 'Update abgeschlossen' : 'Update fehlgeschlagen',
text: $ok ? 'Die neue Version ist aktiv.' : "Fehlercode: {$this->rc}. Log prüfen.",
duration: 8000
);
}
}
public function render()
{
return view('livewire.ui.system.update-manager');
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\Setting;
use App\Support\CacheVer;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class WoltguardCard extends Component
{
/** Gesamter Roh-Input aus dem Health-Cache */
public array $services = [];
/** UI: Status */
public bool $guardOk = false;
public int $okCount = 0;
public int $totalCount = 0;
public int $downCount = 0;
public string $badgeText = 'unbekannt';
public string $badgeIcon = 'ph ph-question';
public string $badgeClass = 'text-white/70 border-white/20 bg-white/10';
/** Liste der ausgefallenen Dienste (für Tooltip/Anzeige) */
public array $downServices = [];
/** Pollintervall (Sek.) */
public int $pollSeconds = 30;
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.system.woltguard-card');
}
/** Manuelles Refresh aus dem UI */
public function refresh(): void
{
$this->load();
}
/* ---------------- intern ---------------- */
protected function load(): void
{
$list = Cache::get(CacheVer::k('health:services'));
if (empty($list)) {
// Fallback: persistierter Wert aus DB/Redis
$list = \App\Support\Setting::get('woltguard.services', []);
}
$rows = [];
if (is_array($list)) {
$rows = isset($list['rows']) && is_array($list['rows'])
? $list['rows'] // neues Format
: (isset($list[0]) && is_array($list[0]) ? $list : []); // altes Format
}
$this->services = $rows;
$this->totalCount = count($rows);
$this->okCount = collect($rows)->where('ok', true)->count();
$this->downCount = $this->totalCount - $this->okCount;
$this->guardOk = $this->totalCount > 0 && $this->downCount === 0;
$this->downServices = collect($rows)
->filter(fn($s) => !($s['ok'] ?? false))
->map(fn($s) => (string)($s['name'] ?? 'unbekannt'))
->values()->all();
// Badge
if ($this->totalCount === 0) {
$this->badgeText = 'keine Daten';
$this->badgeIcon = 'ph ph-warning-circle';
$this->badgeClass = 'text-amber-300 border-amber-400/30 bg-amber-500/10';
return;
}
if ($this->guardOk) {
$this->badgeText = 'alle Dienste OK';
$this->badgeIcon = 'ph ph-check-circle';
$this->badgeClass = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
} else {
$this->badgeText = $this->downCount >= 3 ? "{$this->downCount} Dienste down" : 'Störung erkannt';
$this->badgeIcon = $this->downCount >= 3 ? 'ph ph-x-circle' : 'ph ph-warning-circle';
$this->badgeClass = $this->downCount >= 3
? 'text-rose-300 border-rose-400/30 bg-rose-500/10'
: 'text-amber-300 border-amber-400/30 bg-amber-500/10';
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BackupExclude extends Model
{
use HasFactory;
protected $table = 'backup_excludes';
protected $fillable = [
'policy_id','scope','pattern',
];
public function policy()
{
return $this->belongsTo(BackupPolicy::class, 'policy_id');
}
}

35
app/Models/BackupJob.php Normal file
View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BackupJob extends Model
{
use HasFactory;
protected $table = 'backup_jobs';
protected $fillable = [
'policy_id','status','started_at','finished_at',
'size_bytes','artifact_path','checksum','log_excerpt','error',
];
protected $casts = [
'policy_id' => 'integer',
'size_bytes' => 'integer',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
public function policy()
{
return $this->belongsTo(BackupPolicy::class, 'policy_id');
}
/* Scopes */
public function scopeOk($q) { return $q->where('status', 'ok'); }
public function scopeFailed($q) { return $q->where('status', 'failed'); }
public function scopeRunning($q) { return $q->where('status', 'running'); }
}

124
app/Models/BackupPolicy.php Normal file
View File

@ -0,0 +1,124 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class BackupPolicy extends Model
{
use HasFactory;
protected $table = 'backup_policies';
protected $fillable = [
'name','enabled','schedule_cron',
'include_db','include_maildirs','include_configs',
'target_type','target_path',
's3_bucket','s3_region','s3_endpoint','s3_key_enc','s3_secret_enc',
'sftp_host','sftp_port','sftp_user','sftp_password_enc','sftp_privkey_enc','sftp_path',
'webdav_url','webdav_user','webdav_password_enc',
'retention_count','retention_days',
'compression','encrypt','gpg_recipient','password_enc',
'last_run_at','last_status','last_size_bytes','last_error',
];
protected $casts = [
'enabled' => 'bool',
'include_db' => 'bool',
'include_maildirs' => 'bool',
'include_configs' => 'bool',
'encrypt' => 'bool',
'sftp_port' => 'integer',
'retention_count' => 'integer',
'retention_days' => 'integer',
'last_size_bytes' => 'integer',
'last_run_at' => 'datetime',
];
/* ---------- Beziehungen ---------- */
public function jobs()
{
return $this->hasMany(BackupJob::class, 'policy_id');
}
public function excludes()
{
return $this->hasMany(BackupExclude::class, 'policy_id');
}
/* ---------- Scopes ---------- */
public function scopeEnabled($q) { return $q->where('enabled', true); }
public function scopeLocal($q) { return $q->where('target_type', 'local'); }
public function scopeRemote($q) { return $q->whereIn('target_type', ['s3','sftp','webdav']); }
/* ---------- Secret-Accessors/Mutators (virtuelle Klartext-Attribute) ---------- */
// S3 Access Key
protected function s3Key(): Attribute
{
return Attribute::make(
get: fn () => $this->decryptNullable($this->s3_key_enc),
set: fn ($value) => ['s3_key_enc' => $this->encryptNullable($value)]
);
}
// S3 Secret
protected function s3Secret(): Attribute
{
return Attribute::make(
get: fn () => $this->decryptNullable($this->s3_secret_enc),
set: fn ($value) => ['s3_secret_enc' => $this->encryptNullable($value)]
);
}
// SFTP Passwort
protected function sftpPassword(): Attribute
{
return Attribute::make(
get: fn () => $this->decryptNullable($this->sftp_password_enc),
set: fn ($value) => ['sftp_password_enc' => $this->encryptNullable($value)]
);
}
// SFTP Private Key (optional)
protected function sftpPrivkey(): Attribute
{
return Attribute::make(
get: fn () => $this->decryptNullable($this->sftp_privkey_enc),
set: fn ($value) => ['sftp_privkey_enc' => $this->encryptNullable($value)]
);
}
// WebDAV Passwort
protected function webdavPassword(): Attribute
{
return Attribute::make(
get: fn () => $this->decryptNullable($this->webdav_password_enc),
set: fn ($value) => ['webdav_password_enc' => $this->encryptNullable($value)]
);
}
// Generischer Archiv-Passphrase (falls ohne GPG)
protected function password(): Attribute
{
return Attribute::make(
get: fn () => $this->decryptNullable($this->password_enc),
set: fn ($value) => ['password_enc' => $this->encryptNullable($value)]
);
}
/* ---------- Helpers ---------- */
protected function encryptNullable(?string $plain): ?string
{
if ($plain === null || $plain === '') return null;
return Crypt::encryptString($plain);
}
protected function decryptNullable(?string $encrypted): ?string
{
if ($encrypted === null || $encrypted === '') return null;
try { return Crypt::decryptString($encrypted); } catch (\Throwable) { return null; }
}
}

View File

@ -0,0 +1,278 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Fail2banIpList extends Model
{
protected $table = 'fail2ban_ip_lists';
protected $fillable = [
'ip',
'type',
'is_system',
];
protected $casts = [
'ip' => 'string',
'type' => 'string',
'is_system' => 'boolean',
];
public const TYPE_WHITELIST = 'whitelist';
public const TYPE_BLACKLIST = 'blacklist';
/* ===========================
Boot-Hooks (Schutz & Normalisierung)
=========================== */
protected static function booted()
{
// Normalisierung & Loopback-Flag setzen
static::saving(function (self $m) {
$m->ip = trim($m->ip);
if (!self::isValidIpOrCidr($m->ip)) {
throw new \InvalidArgumentException("Ungültige IP/CIDR: {$m->ip}");
}
// Loopback immer als System markieren
if (self::isLoopback($m->ip)) {
$m->is_system = true;
$m->type = self::TYPE_WHITELIST; // Loopback gehört auf die Whitelist
}
// Systemeinträge dürfen nicht in die Blacklist
if ($m->is_system && $m->type === self::TYPE_BLACKLIST) {
throw new \InvalidArgumentException("Systemeinträge dürfen nicht auf die Blacklist.");
}
});
// Systemeinträge sind unveränderlich (bis auf interne Seeds/Maintenance dann per DB direkt ändern)
static::updating(function (self $m) {
if ($m->getOriginal('is_system')) {
// Erlaube nur no-op Updates (z. B. Timestamps), aber blocke ip/type Änderungen
$blocked = $m->isDirty('ip') || $m->isDirty('type') || $m->isDirty('is_system');
if ($blocked) {
throw new \RuntimeException("Systemeinträge können nicht geändert werden.");
}
}
});
static::deleting(function (self $m) {
if ($m->is_system) {
throw new \RuntimeException("Systemeintrag kann nicht gelöscht werden.");
}
});
}
/* ===========================
Scopes
=========================== */
public function scopeWhitelist($q)
{
return $q->where('type', self::TYPE_WHITELIST);
}
public function scopeBlacklist($q)
{
return $q->where('type', self::TYPE_BLACKLIST);
}
// Für UI: blende Systemeinträge aus
public function scopeVisible($q)
{
return $q->where('is_system', false);
}
// Kombiniert: z. B. Fail2banIpList::visible()->whitelist()->get();
public function scopeVisibleWhitelist($q)
{
return $q->visible()->whitelist();
}
public function scopeVisibleBlacklist($q)
{
return $q->visible()->blacklist();
}
/* ===========================
Helper-Listen
=========================== */
// Für UI-Listen (ohne System)
public static function whitelistArray(): array
{
return static::where('type', self::TYPE_WHITELIST)
->where('is_system', false)
->pluck('ip')->all();
}
public static function blacklistArray(): array
{
return static::where('type', self::TYPE_BLACKLIST)
->where('is_system', false)
->pluck('ip')->all();
}
// Für das Schreiben der Fail2ban-Whitelist-Datei (inkl. System!)
public static function allWhitelistForConfig(): array
{
return static::where('type', self::TYPE_WHITELIST)
->pluck('ip')->all();
}
/* ===========================
Validierung
=========================== */
// Erlaubt IPv4/IPv6, optional mit CIDR (/0..32 bzw. /0..128)
public static function isValidIpOrCidr(string $value): bool
{
$value = trim($value);
// IP ohne CIDR
if (filter_var($value, FILTER_VALIDATE_IP)) {
return true;
}
// IP/CIDR
if (strpos($value, '/') !== false) {
[$ip, $prefix] = explode('/', $value, 2);
if (!ctype_digit($prefix)) {
return false;
}
$prefix = (int)$prefix;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $prefix >= 0 && $prefix <= 32;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return $prefix >= 0 && $prefix <= 128;
}
return false;
}
return false;
}
// Loopback-Erkennung (IPv4 127.0.0.0/8, IPv6 ::1/128)
public static function isLoopback(string $value): bool
{
$value = trim($value);
// Klartext-Fälle
if (in_array($value, ['127.0.0.1', '127.0.0.1/8', '::1', '::1/128'], true)) {
return true;
}
// IPv4 Loopback Bereich
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return str_starts_with($value, '127.');
}
// IPv4-CIDR Loopback
if (strpos($value, '/') !== false) {
[$ip, $prefix] = explode('/', $value, 2);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && ctype_digit($prefix)) {
$prefix = (int)$prefix;
// Prüfe, ob Netz 127.0.0.0/8 überlappt
return self::cidrOverlaps($ip, $prefix, '127.0.0.0', 8);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && ctype_digit($prefix)) {
$prefix = (int)$prefix;
// Prüfe, ob ::1/128 überlappt (nur exakt ::1)
return self::cidrOverlaps($ip, $prefix, '::1', 128);
}
}
// IPv6 single
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return $value === '::1';
}
return false;
}
// Simple Overlap-Check für IPv4/IPv6 Netze
private static function cidrOverlaps(string $ip, int $prefix, string $netIp, int $netPrefix): bool
{
$a = inet_pton($ip);
$b = inet_pton($netIp);
if ($a === false || $b === false || strlen($a) !== strlen($b)) {
return false;
}
$len = strlen($a);
$bytes = intdiv(max($prefix, $netPrefix), 8);
$bits = max($prefix, $netPrefix) % 8;
// Netzmaske anwenden (auf die längere Präfixlänge)
for ($i = 0; $i < $bytes; $i++) {
if ($a[$i] !== $b[$i]) return false;
}
if ($bits > 0) {
$mask = chr(0xFF << (8 - $bits));
if ((ord($a[$bytes]) & ord($mask)) !== (ord($b[$bytes]) & ord($mask))) {
return false;
}
}
return true;
}
}
//
//namespace App\Models;
//
//use Illuminate\Database\Eloquent\Model;
//
//class Fail2banIpList extends Model
//{
// protected $fillable = [
// 'ip',
// 'type',
// ];
//
// protected $casts = [
// 'ip' => 'string',
// 'type' => 'string',
// ];
//
// const TYPE_WHITELIST = 'whitelist';
// const TYPE_BLACKLIST = 'blacklist';
//
// /**
// * Scopes
// */
// public function scopeWhitelist($query)
// {
// return $query->where('type', self::TYPE_WHITELIST);
// }
//
// public function scopeBlacklist($query)
// {
// return $query->where('type', self::TYPE_BLACKLIST);
// }
//
// /**
// * Validiert grob die IP.
// */
// public function isValidIp(): bool
// {
// return filter_var($this->ip, FILTER_VALIDATE_IP) !== false;
// }
//
// /**
// * Gibt Liste aller Whitelist-IPs als Array zurück.
// */
// public static function whitelistArray(): array
// {
// return static::where('type', self::TYPE_WHITELIST)->pluck('ip')->all();
// }
//
// /**
// * Gibt Liste aller Blacklist-IPs als Array zurück.
// */
// public static function blacklistArray(): array
// {
// return static::where('type', self::TYPE_BLACKLIST)->pluck('ip')->all();
// }
//}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Fail2banSetting extends Model
{
protected $table = 'fail2ban_settings';
protected $fillable = [
'bantime','max_bantime','bantime_increment','bantime_factor',
'max_retry','findtime','cidr_v4','cidr_v6','external_mode',
];
protected $casts = [
'bantime' => 'integer',
'max_bantime' => 'integer',
'bantime_increment' => 'boolean',
'bantime_factor' => 'float',
'max_retry' => 'integer',
'findtime' => 'integer',
'cidr_v4' => 'integer',
'cidr_v6' => 'integer',
'external_mode' => 'boolean',
];
// /**
// * Gibt die erste Konfiguration oder Default-Werte zurück.
// */
// public static function current(): self
// {
// return static::first() ?? new static([
// 'bantime' => 3600,
// 'max_bantime' => 43200,
// 'bantime_increment' => true,
// 'bantime_factor' => 1.5,
// 'max_retry' => 5,
// 'findtime' => 600,
// 'cidr_v4' => 32,
// 'cidr_v6' => 128,
// 'external_mode' => false,
// ]);
// }
/**
* Konvertiert bool zu "true"/"false" (für Config-Dateien).
*/
public function boolToString(bool $val): string
{
return $val ? 'true' : 'false';
}
}

View File

@ -8,67 +8,95 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MailUser extends Model
{
protected $fillable = [
'domain_id', 'localpart', 'email', 'display_name', 'password_hash',
'is_active', 'quota_mb', 'is_system'
'domain_id',
'localpart',
'email',
'display_name',
'password_hash',
'is_system',
'is_active',
'can_login',
'quota_mb',
'rate_limit_per_hour',
];
protected $hidden = ['password_hash'];
protected $casts = [
'is_active' => 'bool',
'is_system' => 'bool',
'quota_mb' => 'int',
'is_system' => 'boolean',
'is_active' => 'boolean',
'can_login' => 'boolean',
'quota_mb' => 'integer',
'rate_limit_per_hour' => 'integer',
'last_login_at' => 'datetime',
'stats_refreshed_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/*
|--------------------------------------------------------------------------
| Beziehungen
|--------------------------------------------------------------------------
*/
public function domain(): BelongsTo
{
return $this->belongsTo(Domain::class);
}
// optional: virtueller Setter
/*
|--------------------------------------------------------------------------
| Accessors / Helper
|--------------------------------------------------------------------------
*/
/**
* Bevorzugt den DB-Wert aus 'email'.
* Fehlt dieser, wird aus localpart + domain.domain gebaut.
* Gibt niemals "@" zurück, sondern null, wenn unbestimmbar.
*/
public function getEmailAttribute($value): ?string
{
if (!empty($value)) {
return $value; // DB-Wert hat Vorrang
}
$local = (string)($this->attributes['localpart'] ?? '');
// Domainname wenn Relation geladen, daraus; sonst nichts (kein teurer Query hier).
$dom = $this->relationLoaded('domain')
? (string)($this->domain->domain ?? '')
: (string)($this->attributes['domain'] ?? ''); // nur falls bei Joins alias 'domain' selektiert wäre
if ($local !== '' && $dom !== '') {
return "{$local}@{$dom}";
}
return null;
}
/**
* „Adresse“ als bequemer Fallback (identisch zu email(), fällt aber am Ende auf '@dom' NICHT zurück).
*/
public function getAddressAttribute(): string
{
return (string)($this->getRawOriginal('email') ?: ($this->email ?? ''));
}
/**
* Optionaler Setter für Passwort über virtuelles Attribut "password".
*/
public function setPasswordAttribute(string $plain): void
{
$this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT);
}
/**
* KORREKT: DB-Wert hat Vorrang; falls leer Fallback localpart@domain.domain
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
*/
public function getEmailAttribute($value): ?string
{
if (!empty($value)) {
return $value;
}
$local = (string)($this->attributes['localpart'] ?? $this->localpart ?? '');
$dom = $this->relationLoaded('domain')
? (string)($this->domain->domain ?? '')
: (string)($this->attributes['domain'] ?? '');
if ($local !== '' && $dom !== '') {
return "{$local}@{$dom}";
}
// nichts zusammenfummeln → nicht "@"
return null;
}
/**
* Adresse ohne Accessor/DB-Fallback erzwingen (z.B. in Queries vor-joined)
*/
public function getAddressAttribute(): string
{
$raw = (string)($this->attributes['email'] ?? '');
if ($raw !== '') return $raw;
$local = (string)($this->attributes['localpart'] ?? $this->localpart ?? '');
$dom = $this->relationLoaded('domain')
? (string)($this->domain->domain ?? '')
: (string)($this->attributes['domain'] ?? '');
return ($local !== '' && $dom !== '') ? "{$local}@{$dom}" : '';
}
// Scopes
public function scopeActive($q)
{
return $q->where('is_active', true);
@ -86,6 +114,87 @@ class MailUser extends Model
}
//class MailUser extends Model
//{
// protected $fillable = [
// 'domain_id', 'localpart', 'email', 'display_name', 'password_hash',
// 'is_active', 'quota_mb', 'is_system'
// ];
//
// protected $hidden = ['password_hash'];
//
// protected $casts = [
// 'is_active' => 'bool',
// 'is_system' => 'bool',
// 'quota_mb' => 'int',
// 'last_login_at' => 'datetime',
// ];
//
// public function domain(): BelongsTo
// {
// return $this->belongsTo(Domain::class);
// }
//
// // optional: virtueller Setter
// public function setPasswordAttribute(string $plain): void
// {
// $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT);
// }
//
// /**
// * KORREKT: DB-Wert hat Vorrang; falls leer → Fallback localpart@domain.domain
// */
// public function getEmailAttribute($value): ?string
// {
// if (!empty($value)) {
// return $value;
// }
// $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? '');
// $dom = $this->relationLoaded('domain')
// ? (string)($this->domain->domain ?? '')
// : (string)($this->attributes['domain'] ?? '');
//
// if ($local !== '' && $dom !== '') {
// return "{$local}@{$dom}";
// }
// // nichts zusammenfummeln → nicht "@"
// return null;
// }
//
// /**
// * Adresse ohne Accessor/DB-Fallback erzwingen (z.B. in Queries vor-joined)
// */
// public function getAddressAttribute(): string
// {
// $raw = (string)($this->attributes['email'] ?? '');
// if ($raw !== '') return $raw;
//
// $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? '');
// $dom = $this->relationLoaded('domain')
// ? (string)($this->domain->domain ?? '')
// : (string)($this->attributes['domain'] ?? '');
//
// return ($local !== '' && $dom !== '') ? "{$local}@{$dom}" : '';
// }
//
// // Scopes
// public function scopeActive($q)
// {
// return $q->where('is_active', true);
// }
//
// public function scopeSystem($q)
// {
// return $q->where('is_system', true);
// }
//
// public function scopeByEmail($q, string $email)
// {
// return $q->where('email', $email);
// }
//}
//class MailUser extends Model
//{
//// protected $table = 'mail_users';

View File

@ -23,11 +23,20 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(\App\Support\SettingsRepository $settings): void
{
Domain::observe(DomainObserver::class);
$ver = trim(@file_get_contents(base_path('VERSION'))) ?: 'dev';
config(['app.version' => $ver]);
config(['app.version' => trim(@file_get_contents('/var/lib/mailwolt/version')) ?: 'dev']);
if (file_exists(base_path('.git/HEAD'))) {
$ref = trim(file_get_contents(base_path('.git/HEAD')));
if (str_starts_with($ref, 'ref:')) {
$refFile = base_path('.git/' . substr($ref, 5));
$commit = @file_get_contents($refFile);
} else {
$commit = $ref;
}
config(['app.git_commit' => substr($commit ?? '', 0, 7)]);
}
try {
$S = app(\App\Support\SettingsRepository::class);

View File

@ -0,0 +1,37 @@
<?php
namespace App\Providers;
use App\Support\BuildMeta;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class BuildMetaServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->singleton(BuildMeta::class, fn () => BuildMeta::detect());
}
/**
* Bootstrap services.
*/
public function boot(): void
{
$meta = $this->app->make(BuildMeta::class);
// zentral verfügbar
config([
'app.version' => $meta->version,
'app.git_rev' => $meta->rev,
'app.git_short' => $meta->short,
'app.build_time' => $meta->updated,
]);
// optional: in allen Views als $build
View::share('build', $meta);
}
}

77
app/Support/BuildMeta.php Normal file
View File

@ -0,0 +1,77 @@
<?php
namespace App\Support;
use Illuminate\Support\Str;
final class BuildMeta
{
public string $version = 'dev';
public string $rev = '';
public string $short = '';
public ?string $updated = null;
public static function detect(
string $buildFile = '/etc/mailwolt/build.info',
string $basePath = null
): self {
$m = new self();
$basePath ??= base_path();
// 1) /etc/mailwolt/build.info (vom Updater)
if (is_file($buildFile) && is_readable($buildFile)) {
$lines = @file($buildFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
if (!str_contains($line, '=')) continue;
[$k, $v] = explode('=', $line, 2);
$k = trim($k); $v = trim($v);
if ($k === 'version') $m->version = $v;
if ($k === 'rev') $m->rev = $v;
if ($k === 'updated') $m->updated = $v;
}
}
// 2) Fallback: .env APP_VERSION
// if ($m->version === 'dev' && ($envVer = env('APP_VERSION'))) {
// $m->version = trim($envVer);
// }
// 3) Fallback: Git (ohne "-dirty")
if ($m->rev === '' || $m->version === 'dev') {
$head = $basePath.'/.git/HEAD';
if (is_file($head)) {
$ref = trim((string)@file_get_contents($head));
if (Str::startsWith($ref, 'ref:')) {
$refFile = $basePath.'/.git/'.substr($ref, 5);
$commit = @file_get_contents($refFile);
} else {
$commit = $ref;
}
$m->rev = $m->rev ?: trim((string)$commit);
if ($m->version === 'dev') {
$tag = @shell_exec('git -C '.escapeshellarg($basePath).' describe --tags --abbrev=0 2>/dev/null');
$m->version = $tag ? trim($tag) : 'dev';
}
}
}
// Sauber: "-dirty" entfernen
$m->version = preg_replace('/-dirty$/', '', $m->version);
// Kurz-Commit
$m->short = $m->rev ? substr($m->rev, 0, 7) : '';
return $m;
}
public function toArray(): array
{
return [
'version' => $this->version,
'rev' => $this->rev,
'short' => $this->short,
'updated' => $this->updated,
];
}
}

28
app/Support/CacheVer.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
final class CacheVer
{
private const KEY = 'app:cache_v';
public static function get(): string
{
return (string) (Cache::get(self::KEY) ?? '1');
}
public static function bump(): string
{
$v = (string) Str::uuid(); // oder (int)+=1, UUID ist robust
Cache::forever(self::KEY, $v);
return $v;
}
public static function k(string $key): string
{
return 'v:'.self::get().':'.$key;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Support\WoltGuard;
use Illuminate\Support\Facades\DB;
trait Probes
{
protected function check(string $source): bool
{
if (str_starts_with($source, 'systemd:')) return $this->probeSystemd(substr($source, 8));
if (str_starts_with($source, 'tcp:')) return $this->probeTcp(...$this->splitTcp(substr($source, 4)));
if (str_starts_with($source, 'socket:')) return @file_exists(substr($source, 7));
if (str_starts_with($source, 'pid:')) return $this->probePid(substr($source, 4));
if (str_starts_with($source, 'proc:')) return $this->probeProcessRegex(substr($source, 5));
if ($source === 'db') return $this->probeDatabase();
return false;
}
protected function splitTcp(string $s): array { [$h,$p] = explode(':', $s, 2); return [$h,(int)$p]; }
protected function probeSystemd(string $unit): bool
{
$bin = file_exists('/bin/systemctl') ? '/bin/systemctl'
: (file_exists('/usr/bin/systemctl') ? '/usr/bin/systemctl' : 'systemctl');
$cmd = sprintf('%s is-active --quiet %s 2>/dev/null', escapeshellcmd($bin), escapeshellarg($unit));
$exit = null; @exec($cmd, $_, $exit);
return $exit === 0;
}
protected function probeTcp(string $host, int $port, int $timeout = 1): bool
{
$fp = @fsockopen($host, $port, $e1, $e2, $timeout);
if (is_resource($fp)) { fclose($fp); return true; }
return false;
}
protected function probePid(string $path): bool
{
if (!@is_file($path)) return false;
$pid = (int) trim(@file_get_contents($path) ?: '');
return $pid > 1 && @posix_kill($pid, 0);
}
protected function probeProcessRegex(string $regex): bool
{
$regex = '#' . $regex . '#i';
foreach (@scandir('/proc') ?: [] as $d) {
if (!ctype_digit($d)) continue;
$cmd = @file_get_contents("/proc/$d/cmdline");
if (!$cmd) continue;
$cmd = str_replace("\0", ' ', $cmd);
if (preg_match($regex, $cmd)) return true;
}
return false;
}
protected function probeDatabase(): bool
{
try { DB::connection()->getPdo(); return true; }
catch (\Throwable) { return false; }
}
}

View File

@ -8,6 +8,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
channels: __DIR__ . '/../routes/channels.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
@ -15,6 +16,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'ensure.setup' => \App\Http\Middleware\EnsureSetupCompleted::class,
'signup.open' => \App\Http\Middleware\SignupOpen::class,
'auth.user' => \App\Http\Middleware\AuthenticatedMiddleware::class,
'guest.only' => \App\Http\Middleware\GuestOnlyMiddleware::class,
]);
})

View File

@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\BuildMetaServiceProvider::class,
];

View File

@ -13,7 +13,7 @@ return [
|
*/
'name' => env('APP_NAME', 'Laravel'),
'name' => env('APP_NAME', 'MailWolt'),
/*
|--------------------------------------------------------------------------
@ -123,5 +123,8 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
'version' => env('APP_VERSION', '1.0.0')
'version' => env('APP_VERSION', 'dev'),
'git_rev' => null,
'git_short' => null,
'build_time' => null,
];

8
config/backup.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'default_cron' => '0 3 * * *',
'local_path' => '/var/backups/mailwolt',
'compression' => 'zstd',
'retention' => '7',
];

View File

@ -54,8 +54,9 @@ return [
'mailstats' => [
'driver' => 'daily',
'path' => storage_path('logs/mailstats.log'),
'level' => 'debug',
'days' => 14,
'level' => 'info',
'days' => 7,
'bubble' => false,
],
'stack' => [

View File

@ -5,7 +5,7 @@ return [
'platform_system_zone' => env('SYSMAIL_SUB', 'sysmail'),
'fixed_reserve_mb' => env('MAILPOOL_FIXED_RESERVE_MB', 2048), // 2 GB
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 15), // 15 %
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 10), // 10 %
'mail_data_path' => env('MAILPOOL_PATH', '/var/mail'),
'spf_tail' => env('MAILPOOL_SPF_TAIL', '~all'),

41
config/mailwolt.php Normal file
View File

@ -0,0 +1,41 @@
<?php
return [
'domain' => [
'base' => env('BASE_DOMAIN'),
'mail' => env('MTA_SUB'),
'ui' => env('UI_SUB'),
'webmail' => env('WEBMAIL_SUB'),
],
'language' => [
'de' => [
'label' => 'Deutsch',
'locale' => 'de',
'fallback_locale' => 'de',
'flag' => 'de',
],
'en' => [
'label' => 'English',
'locale' => 'en',
'fallback_locale' => 'en',
'flag' => 'gb',
],
],
'units' => [
['name' => 'nginx', 'action' => 'reload'],
['name' => 'postfix', 'action' => 'try-reload-or-restart'],
['name' => 'dovecot', 'action' => 'try-reload-or-restart'],
['name' => 'rspamd', 'action' => 'try-reload-or-restart'],
// nur drin lassen, wenn wirklich installiert:
['name' => 'opendkim', 'action' => 'try-reload-or-restart'],
['name' => 'opendmarc', 'action' => 'try-reload-or-restart'],
['name' => 'clamav-daemon', 'action' => 'try-reload-or-restart'],
['name' => 'redis-server', 'action' => 'try-reload-or-restart'],
],
];

View File

@ -10,8 +10,8 @@ return [
'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [
['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'],
['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'],
['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'],
['label' => 'Filter', 'route' => 'ui.mail.filters.index'],
// ['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'],
// ['label' => 'Filter', 'route' => 'ui.mail.filters.index'],
['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'],
['label' => 'Queues', 'route' => 'ui.mail.queues.index'],
],
@ -19,45 +19,59 @@ return [
[
'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [
['label' => 'Übersicht', 'route' => 'ui.domains.index'],
['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'],
['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'],
// ['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'],
// ['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'],
],
],
[
'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [
['label' => 'Allgemein', 'route' => 'ui.webmail.settings'],
['label' => 'Plugins', 'route' => 'ui.webmail.plugins'],
['label' => 'Allgemein', 'route' => 'ui.logout'],
['label' => 'Plugins', 'route' => 'ui.logout'],
// ['label' => 'Allgemein', 'route' => 'ui.webmail.settings'],
// ['label' => 'Plugins', 'route' => 'ui.webmail.plugins'],
],
],
[
'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [
['label' => 'Benutzer', 'route' => 'ui.users.index'],
['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'],
['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'],
['label' => 'Benutzer', 'route' => 'ui.logout'],
['label' => 'Rollen & Rechte', 'route' => 'ui.logout'],
['label' => 'Anmeldesicherheit', 'route' => 'ui.logout'],
// ['label' => 'Benutzer', 'route' => 'ui.users.index'],
// ['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'],
// ['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'],
],
],
[
'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [
['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
['label' => 'Fail2Ban', 'route' => 'ui.security.fail2ban'],
['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
['label' => 'Rspamd', 'route' => 'ui.security.rspamd'],
['label' => 'SSL', 'route' => 'ui.security.ssl'],
// ['label' => 'Ratelimits', 'route' => 'ui.security.audit'],
// ['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
// ['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
// ['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
],
],
[
'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [
['label' => 'Einstellungen', 'route' => 'ui.system.settings'],
['label' => 'Dienste & Status', 'route' => 'ui.system.services'],
['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'],
['label' => 'Logs', 'route' => 'ui.system.logs'],
['label' => 'Speicher', 'route' => 'ui.system.storage'],
['label' => 'Über', 'route' => 'ui.system.about'],
// ['label' => 'Dienste & Status', 'route' => 'ui.system.services'],
// ['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'],
// ['label' => 'Logs', 'route' => 'ui.system.logs'],
// ['label' => 'Speicher', 'route' => 'ui.system.storage'],
// ['label' => 'Über', 'route' => 'ui.system.about'],
],
],
[
'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [
['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'],
['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'],
['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'],
['label' => 'API-Schlüssel', 'route' => 'ui.logout'],
['label' => 'Webhooks', 'route' => 'ui.logout'],
['label' => 'Sandbox', 'route' => 'ui.logout'],
// ['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'],
// ['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'],
// ['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'],
],
],
];

View File

@ -18,25 +18,25 @@ return [
[
'title' => 'Security',
'icon' => 'icons.icon-security',
'route' => 'logout',
'route' => 'ui.logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Team',
'icon' => 'icons.icon-team',
'route' => 'logout',
'route' => 'ui.logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Settings',
'icon' => 'icons.icon-settings',
'route' => 'logout',
'route' => 'ui.logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Logout',
'icon' => 'icons.icon-logout',
'route' => 'logout',
'route' => 'ui.logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
]

76
config/woltguard.php Normal file
View File

@ -0,0 +1,76 @@
<?php
return [
'cards' => [
// Mail
'postfix' => [
'label' => 'Postfix', 'hint' => 'MTA / Versand',
'sources' => ['systemd:postfix'],
],
'dovecot' => [
'label' => 'Dovecot', 'hint' => 'IMAP / POP3',
'sources' => ['systemd:dovecot', 'tcp:127.0.0.1:993'],
],
'rspamd' => [
'label' => 'Rspamd', 'hint' => 'Spamfilter',
'sources' => ['systemd:rspamd', 'tcp:127.0.0.1:11333', 'tcp:127.0.0.1:11334'],
],
'clamav' => [
'label' => 'ClamAV', 'hint' => 'Virenscanner',
'sources' => [
'systemd:clamav-daemon', 'systemd:clamav-daemon@scan', 'systemd:clamd',
'socket:/run/clamav/clamd.ctl', 'pid:/run/clamav/clamd.pid', 'tcp:127.0.0.1:3310',
],
],
// Daten & Cache
'db' => [
'label' => 'Datenbank', 'hint' => 'MySQL / MariaDB',
'sources' => ['db'],
],
'redis' => [
'label' => 'Redis', 'hint' => 'Cache / Queue',
'sources' => ['tcp:127.0.0.1:6379', 'systemd:redis-server', 'systemd:redis'],
],
// Web / PHP
'php-fpm' => [
'label' => 'PHP-FPM', 'hint' => 'PHP Runtime',
'sources' => [
'systemd:php8.3-fpm', 'systemd:php8.2-fpm', 'systemd:php8.1-fpm', 'systemd:php-fpm',
'socket:/run/php/php8.3-fpm.sock', 'socket:/run/php/php8.2-fpm.sock',
'socket:/run/php/php8.1-fpm.sock', 'socket:/run/php/php-fpm.sock',
'tcp:127.0.0.1:9000',
],
],
'nginx' => [
'label' => 'Nginx', 'hint' => 'Webserver',
'sources' => ['systemd:nginx', 'tcp:127.0.0.1:80'],
],
// MailWolt
'mw-queue' => [
'label' => 'MailWolt Queue', 'hint' => 'Job Worker',
'sources' => ['systemd:mailwolt-queue', 'proc:/php.*artisan(\.php)?\s+queue:work'],
],
'mw-schedule' => [
'label' => 'MailWolt Schedule', 'hint' => 'Task Scheduler',
'sources' => ['systemd:mailwolt-schedule', 'proc:/php.*artisan(\.php)?\s+schedule:work'],
],
'mw-ws' => [
'label' => 'MailWolt WebSocket', 'hint' => 'Echtzeit Updates',
'sources' => ['systemd:mailwolt-ws', 'tcp:127.0.0.1:8080'],
],
// Sonstiges
'fail2ban' => [
'label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection',
'sources' => ['systemd:fail2ban'],
],
'journal' => [
'label' => 'System Logs', 'hint' => 'Journal',
'sources' => ['systemd:systemd-journald', 'systemd:rsyslog'],
],
],
];

View File

@ -0,0 +1,77 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backup_policies', function (Blueprint $table) {
$table->id();
$table->string('name')->default('Standard');
// Aktivierung & Zeitplan
$table->boolean('enabled')->default(false)->index();
$table->string('schedule_cron', 64)->default('0 3 * * *'); // täglich 03:00
// Umfang
$table->boolean('include_db')->default(true);
$table->boolean('include_maildirs')->default(true);
$table->boolean('include_configs')->default(true);
// Ziel
$table->enum('target_type', ['local','s3','sftp','webdav'])->default('local');
// common
$table->string('target_path')->nullable(); // /var/backups/mailwolt
// S3
$table->string('s3_bucket')->nullable();
$table->string('s3_region')->nullable();
$table->string('s3_endpoint')->nullable();
$table->string('s3_key_enc')->nullable(); // encrypt in app!
$table->string('s3_secret_enc')->nullable(); // encrypt in app!
// SFTP
$table->string('sftp_host')->nullable();
$table->unsignedSmallInteger('sftp_port')->nullable();
$table->string('sftp_user')->nullable();
$table->text('sftp_password_enc')->nullable(); // encrypt in app!
$table->text('sftp_privkey_enc')->nullable(); // encrypt in app!
$table->string('sftp_path')->nullable();
// WebDAV
$table->string('webdav_url')->nullable();
$table->string('webdav_user')->nullable();
$table->text('webdav_password_enc')->nullable(); // encrypt in app!
// Aufbewahrung
$table->unsignedInteger('retention_count')->default(7);
$table->unsignedInteger('retention_days')->nullable(); // optional
// Kompression/Encryption
$table->enum('compression', ['zstd','gzip','none'])->default('zstd');
$table->boolean('encrypt')->default(false);
$table->string('gpg_recipient')->nullable();
$table->text('password_enc')->nullable(); // alternative zu GPG (verschlüsselt)
// UI/Monitoring
$table->timestamp('last_run_at')->nullable();
$table->enum('last_status', ['ok','failed','running','queued','unknown'])
->default('unknown')->index();
$table->unsignedBigInteger('last_size_bytes')->default(0);
$table->text('last_error')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_policies');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backup_jobs', function (Blueprint $table) {
$table->id();
$table->foreignId('policy_id')->constrained('backup_policies')->cascadeOnDelete();
$table->enum('status', ['queued','running','ok','failed','canceled'])
->default('queued')->index();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->unsignedBigInteger('size_bytes')->default(0);
$table->string('artifact_path')->nullable(); // z.B. /var/backups/.../mailwolt_2025-10-23.tar.zst
$table->string('checksum')->nullable(); // sha256
$table->text('log_excerpt')->nullable();
$table->text('error')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_jobs');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backup_excludes', function (Blueprint $table) {
$table->id();
$table->foreignId('policy_id')->constrained('backup_policies')->cascadeOnDelete();
$table->enum('scope', ['maildirs','configs','db','general'])->default('general');
$table->string('pattern'); // z.B. "*.tmp" oder "*/logs/*"
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_excludes');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('fail2ban_settings', function (Blueprint $table) {
$table->id();
$table->integer('bantime')->default(3600);
$table->integer('max_bantime')->default(43200);
$table->boolean('bantime_increment')->default(true);
$table->float('bantime_factor')->default(1.5);
$table->integer('max_retry')->default(3);
$table->integer('findtime')->default(600);
$table->integer('cidr_v4')->default(32);
$table->integer('cidr_v6')->default(128);
$table->boolean('external_mode')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('fail2ban_settings');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('fail2ban_ip_lists', function (Blueprint $table) {
$table->id();
$table->string('ip');
$table->enum('type', ['whitelist', 'blacklist']);
$table->boolean('is_system')->default(false)->index();
$table->timestamps();
$table->unique(['ip', 'type'], 'fail2ban_ip_lists_ip_type_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('fail2ban_ip_lists');
}
};

View File

@ -0,0 +1,109 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Fail2banSetting;
use App\Models\Fail2banIpList;
use Illuminate\Support\Facades\Log;
class Fail2banSeeder extends Seeder
{
public function run(): void
{
$this->command->info('⚙️ Fail2ban Defaults werden initialisiert …');
// -----------------------------------------------------------
// 1) Standardwerte für Fail2ban Settings
// -----------------------------------------------------------
$settings = Fail2banSetting::firstOrCreate([], [
'bantime' => 3600, // 1h
'max_bantime' => 43200, // 12h
'bantime_increment' => true,
'bantime_factor' => 1.5,
'max_retry' => 5,
'findtime' => 600, // 10m
'cidr_v4' => 32,
'cidr_v6' => 128,
'external_mode' => false,
]);
// -----------------------------------------------------------
// 2) Standard-IPs für Whitelist
// -----------------------------------------------------------
$defaultWhitelist = [
'127.0.0.1/8',
'::1',
];
foreach ($defaultWhitelist as $ip) {
Fail2banIpList::firstOrCreate([
'ip' => $ip,
'type' => Fail2banIpList::TYPE_WHITELIST,
]);
}
// -----------------------------------------------------------
// 3) Fail2ban Config-Dateien erzeugen
// -----------------------------------------------------------
$this->writeDefaultsConfig($settings);
$this->writeWhitelistConfig();
// -----------------------------------------------------------
// 4) Fail2ban reload (optional, falls Dienst läuft)
// -----------------------------------------------------------
$out = shell_exec('sudo -n fail2ban-client reload 2>&1') ?? '';
if (stripos($out, 'OK') === false && stripos($out, 'Reloaded') === false) {
Log::warning('Fail2ban reload output', ['out' => $out]);
$this->command->warn('⚠️ Fail2ban reload möglicherweise nicht erfolgreich.');
} else {
$this->command->info('✅ Fail2ban reload erfolgreich.');
}
}
// -----------------------------------------------------------
// interne Hilfsfunktionen
// -----------------------------------------------------------
private function writeDefaultsConfig(Fail2banSetting $s): void
{
$content = <<<CONF
[DEFAULT]
bantime = {$s->bantime}
findtime = {$s->findtime}
maxretry = {$s->max_retry}
bantime.increment = {$this->boolToString($s->bantime_increment)}
bantime.factor = {$s->bantime_factor}
bantime.maxtime = {$s->max_bantime}
CONF;
$this->atomicWrite('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
}
private function writeWhitelistConfig(): void
{
$ips = Fail2banIpList::where('type', Fail2banIpList::TYPE_WHITELIST)
->pluck('ip')
->toArray();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->atomicWrite('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
}
private function atomicWrite(string $path, string $content): void
{
$tmp = $path . '.tmp';
file_put_contents($tmp, $content);
rename($tmp, $path);
@chown($path, 'root');
@chgrp($path, 'root');
@chmod($path, 0644);
}
private function boolToString(bool $v): string
{
return $v ? 'true' : 'false';
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use App\Models\BackupPolicy;
class SystemBackupSeeder extends Seeder
{
public function run(): void
{
// Defaults kommen jetzt aus config/backup.php
$enabled = false; // UI schaltet das später; Standard: aus
$cron = (string) config('backup.default_cron', '0 3 * * *');
$targetType = 'local';
$targetPath = (string) config('backup.local_path', '/var/backups/mailwolt');
$retentionCount = (int) config('backup.retention', 7);
// Falls du optional per ENV überschreiben willst (nicht nötig, aber möglich):
$cron = env('BACKUP_DEFAULT_CRON', $cron);
$targetPath = env('BACKUP_DIR', $targetPath);
$retentionCount = (int) env('BACKUP_RETENTION_COUNT', $retentionCount);
$targetType = Str::lower(env('BACKUP_TARGET_TYPE', $targetType)) === 's3' ? 's3' : 'local';
// optionale S3/MinIO-Parameter (nur wenn target_type = s3)
$s3Bucket = env('BACKUP_S3_BUCKET');
$s3Region = env('BACKUP_S3_REGION');
$s3Endpoint = env('BACKUP_S3_ENDPOINT');
$s3Key = env('BACKUP_S3_KEY');
$s3Secret = env('BACKUP_S3_SECRET');
$policy = BackupPolicy::query()->firstOrCreate(
['name' => 'Standard'],
['enabled' => false]
);
$payload = [
'enabled' => $enabled,
'schedule_cron' => $cron,
'target_type' => $targetType, // 'local' | 's3'
'target_path' => $targetPath, // bei local: Verzeichnis
'retention_count' => $retentionCount,
];
if ($targetType === 's3') {
$payload = array_merge($payload, [
's3_bucket' => $s3Bucket,
's3_region' => $s3Region,
's3_endpoint' => $s3Endpoint,
's3_key_enc' => $s3Key ? Crypt::encryptString($s3Key) : $policy->s3_key_enc,
's3_secret_enc' => $s3Secret ? Crypt::encryptString($s3Secret) : $policy->s3_secret_enc,
]);
} else {
$payload = array_merge($payload, [
's3_bucket' => null,
's3_region' => null,
's3_endpoint' => null,
's3_key_enc' => null,
's3_secret_enc' => null,
]);
}
$policy->fill($payload)->save();
}
}

View File

@ -140,12 +140,10 @@ class SystemDomainSeeder extends Seeder
$this->command->line("System-Domain angelegt: {$systemDomain->domain}");
}
// System-Absender (no-reply) ohne Passwort (kein Login)
$noReply = MailUser::firstOrCreate(
['email' => "no-reply@{$systemFqdn}"],
['domain_id' => $systemDomain->id, 'localpart' => 'no-reply'],
[
'domain_id' => $systemDomain->id,
'localpart' => 'no-reply',
'email' => 'no-reply@' . $systemDomain->domain,
'password_hash' => null,
'is_active' => true,
'is_system' => true,
@ -153,27 +151,32 @@ class SystemDomainSeeder extends Seeder
]
);
$seedGroup = function(string $local, array $emails) use ($systemDomain, $noReply) {
$addRecipient = function (MailAlias $alias, MailUser $user) {
// sichere, vollständige Adresse bauen
$user->loadMissing('domain');
$addr = $user->localpart.'@'.$user->domain->domain;
MailAliasRecipient::create([
'alias_id' => $alias->id,
'mail_user_id' => $user->id, // Referenz
'email' => $addr, // denormalisierte, lesbare Adresse
'position' => 0,
]);
};
$seedGroup = function (string $local, MailUser $user) use ($systemDomain, $addRecipient) {
$alias = MailAlias::updateOrCreate(
['domain_id' => $systemDomain->id, 'local' => $local],
['type' => 'group', 'is_active' => true, 'is_system' => true]
);
$alias->recipients()->delete();
$pos=0;
foreach ($emails as $addr) {
MailAliasRecipient::create([
'alias_id' => $alias->id,
'email' => $addr,
'position' => $pos++,
]);
}
$addRecipient($alias, $user);
};
// alle vier erst einmal nur ans no-reply Postfach
$seedGroup('system', [$noReply->email]);
$seedGroup('bounces', [$noReply->email]);
$seedGroup('postmaster', [$noReply->email]);
$seedGroup('abuse', [$noReply->email]);
$seedGroup('system', $noReply);
$seedGroup('bounces', $noReply);
$seedGroup('postmaster', $noReply);
$seedGroup('abuse', $noReply);
$this->command->info("System-Domain '{$systemFqdn}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen.");
$this->printDnsHints($systemDomain);

View File

@ -32,6 +32,14 @@
}
@keyframes pulse-slow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.safe-pads {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);

View File

@ -12,4 +12,4 @@ import './components/sidebar.js';
import './plugins/GlassToastra/toastra.glass.js'
import './plugins/GlassToastra/livewire-adapter';
// import './utils/events.js';
import './utils/events.js';

View File

@ -5,6 +5,7 @@ document.addEventListener('livewire:init', () => {
window.addEventListener('toast', (e) => showToast(e?.detail || {}));
window.addEventListener('toast.update', (e) => showToast(e?.detail || {})); // gleiche id => ersetzt Karte
window.addEventListener('toast.clear', (e) => window.toastraGlass?.clear(e?.detail?.position));
window.addEventListener('toast.reload', (e) => setTimeout(() => window.location.reload(), e.delay || 0));
});
// optional global

View File

@ -1,276 +1,299 @@
// import { showToast } from '../ui/toast.js'
Livewire.on('reload-page', e => setTimeout(() => window.location.reload(), e.delay || 0));
// — Livewire-Hooks (global)
// document.addEventListener('livewire:init', () => {
// if (window.Livewire?.on) {
// window.Livewire.on('toast', (payload = {}) => showToast(payload))
// document.addEventListener('livewire:error', e => {
// if (e.detail.status === 419) {
// console.warn('Session expired refreshing...');
// window.location.reload();
// }
// });
// // import { showToast } from '../ui/toast.js'
//
// // — Livewire-Hooks (global)
// // document.addEventListener('livewire:init', () => {
// // if (window.Livewire?.on) {
// // window.Livewire.on('toast', (payload = {}) => showToast(payload))
// // }
// // })
//
// document.addEventListener('livewire:init', () => {
// // Neu: Livewire v3 Browser-Events
// window.addEventListener('toast', (e) => {
// const d = e?.detail || {};
// showToastGlass(d);
// });
//
// // Optional: Update/Dismiss/Clear per Event
// window.addEventListener('toast.update', (e) => {
// const d = e?.detail || {};
// if (d.id) window.toastraGlass?.update(d.id, d);
// });
// window.addEventListener('toast.clear', (e) => {
// window.toastraGlass?.clear(e?.detail?.position);
// });
// });
//
//
// // document.addEventListener('livewire:init', (e) => {
// // console.log(e)
// //
// // window.addEventListener('toast-reload', (e) => {
// // console.log(e)
// // setTimeout(() => window.location.reload(), e.delay || 0)
// // });
// // });
//
// // Adapter: normalisiert Payload und ruft toastraGlass
// function showToastGlass({
// id,
// type, state, // "success" | "warning" | "error" ODER "done" | "failed" | "running"
// text, message, title, // Textquellen
// badge, domain,
// position = 'bottom-right',
// duration = 0, // 0 = stehen lassen; bei done/failed auto auf 6000ms
// } = {}) {
// // Map: type -> state
// const t = (type || state || 'done').toLowerCase();
// const map = {success: 'done', ok: 'done', error: 'failed', danger: 'failed', info: 'queued', warning: 'queued'};
// const st = ['done', 'failed', 'running', 'queued'].includes(t) ? t : (map[t] || 'queued');
//
// const msg = message || text || title || '';
// const _id = id || ('toast-' + Date.now());
//
// if (window.toastraGlass?.show) {
// window.toastraGlass.show({
// id: _id,
// state: st, // queued|running|done|failed → färbt Badge/Icon
// badge, // z.B. "DNS", "Signup"
// domain, // optional: kleine Überschrift rechts
// message: msg,
// position,
// duration, // 0 = stehen lassen; sonst ms
// });
// } else if (window.toastr) {
// // Fallback: alte toastr API
// const level = (type || (st === 'failed' ? 'error' : st === 'done' ? 'success' : 'info'));
// window.toastr[level](msg, badge || domain || '');
// } else {
// // Minimal-Fallback
// const box = document.createElement('div');
// box.className = 'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white px-4 py-3 backdrop-blur shadow-lg border border-white/10';
// box.textContent = msg || 'OK';
// document.body.appendChild(box);
// setTimeout(() => box.remove(), 3500);
// }
//
// return _id;
// }
//
// // — Session-Flash vom Backend (einmal pro Page-Load)
// function bootstrapFlashFromLayout() {
// const el = document.getElementById('__flash')
// if (!el) return
// try {
// const data = JSON.parse(el.textContent || '{}')
// if (data?.toast) showToast(data.toast)
// } catch {
// }
// }
//
// document.addEventListener('DOMContentLoaded', bootstrapFlashFromLayout)
//
// // — Optional: Echo/WebSocket-Kanal für „Push-Toasts“
// function setupEchoToasts() {
// if (!window.Echo) return
// // userId wird im Layout in das JSON injiziert (siehe unten)
// const el = document.getElementById('__flash')
// let uid = null
// try {
// uid = JSON.parse(el?.textContent || '{}')?.userId ?? null
// } catch {
// }
// if (!uid) return
//
// window.Echo.private(`users.${uid}`)
// .listen('.ToastPushed', (e) => {
// // e: { type, text, title }
// showToast(e)
// })
document.addEventListener('livewire:init', () => {
// Neu: Livewire v3 Browser-Events
window.addEventListener('toast', (e) => {
const d = e?.detail || {};
showToastGlass(d);
});
// Optional: Update/Dismiss/Clear per Event
window.addEventListener('toast.update', (e) => {
const d = e?.detail || {};
if (d.id) window.toastraGlass?.update(d.id, d);
});
window.addEventListener('toast.clear', (e) => {
window.toastraGlass?.clear(e?.detail?.position);
});
});
// Adapter: normalisiert Payload und ruft toastraGlass
function showToastGlass({
id,
type, state, // "success" | "warning" | "error" ODER "done" | "failed" | "running"
text, message, title, // Textquellen
badge, domain,
position = 'bottom-right',
duration = 0, // 0 = stehen lassen; bei done/failed auto auf 6000ms
} = {}) {
// Map: type -> state
const t = (type || state || 'done').toLowerCase();
const map = { success: 'done', ok: 'done', error: 'failed', danger: 'failed', info: 'queued', warning: 'queued' };
const st = ['done','failed','running','queued'].includes(t) ? t : (map[t] || 'queued');
const msg = message || text || title || '';
const _id = id || ('toast-' + Date.now());
if (window.toastraGlass?.show) {
window.toastraGlass.show({
id: _id,
state: st, // queued|running|done|failed → färbt Badge/Icon
badge, // z.B. "DNS", "Signup"
domain, // optional: kleine Überschrift rechts
message: msg,
position,
duration, // 0 = stehen lassen; sonst ms
});
} else if (window.toastr) {
// Fallback: alte toastr API
const level = (type || (st === 'failed' ? 'error' : st === 'done' ? 'success' : 'info'));
window.toastr[level](msg, badge || domain || '');
} else {
// Minimal-Fallback
const box = document.createElement('div');
box.className = 'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white px-4 py-3 backdrop-blur shadow-lg border border-white/10';
box.textContent = msg || 'OK';
document.body.appendChild(box);
setTimeout(() => box.remove(), 3500);
}
return _id;
}
// — Session-Flash vom Backend (einmal pro Page-Load)
function bootstrapFlashFromLayout() {
const el = document.getElementById('__flash')
if (!el) return
try {
const data = JSON.parse(el.textContent || '{}')
if (data?.toast) showToast(data.toast)
} catch {}
}
document.addEventListener('DOMContentLoaded', bootstrapFlashFromLayout)
// — Optional: Echo/WebSocket-Kanal für „Push-Toasts“
function setupEchoToasts() {
if (!window.Echo) return
// userId wird im Layout in das JSON injiziert (siehe unten)
const el = document.getElementById('__flash')
let uid = null
try { uid = JSON.parse(el?.textContent || '{}')?.userId ?? null } catch {}
if (!uid) return
window.Echo.private(`users.${uid}`)
.listen('.ToastPushed', (e) => {
// e: { type, text, title }
showToast(e)
})
}
document.addEventListener('DOMContentLoaded', setupEchoToasts)
// — Optional: global machen, falls du manuell aus JS/Blade rufen willst
// window.showToast = showToast
// document.addEventListener('livewire:init', () => {
// Livewire.on('toastra:show', (payload) => {
// // optionaler "mute" pro Nutzer lokal:
// if (localStorage.getItem('toast:hide:' + payload.id)) return;
// }
//
// const id = window.toastraGlass.show({
// id: payload.id,
// state: payload.state, // queued|running|done|failed
// badge: payload.badge,
// domain: payload.domain,
// message: payload.message,
// position: payload.position || 'bottom-center',
// duration: payload.duration ?? 0,
// close: payload.close !== false,
// });
// document.addEventListener('DOMContentLoaded', setupEchoToasts)
//
// // Wenn der User X klickt, markiere lokal als verborgen:
// window.addEventListener('toastra:closed:' + id, () => {
// localStorage.setItem('toast:hide:' + id, '1');
// }, { once: true });
// });
// });
// document.addEventListener('livewire:init', () => {
// Livewire.on('notify', (payload) => {
// const o = Array.isArray(payload) ? payload[0] : payload;
// window.toastraGlass?.show({
// id: o.id, state: o.state, badge: o.badge, domain: o.domain,
// message: o.message, position: o.position || 'bottom-right',
// duration: Number(o.duration ?? 0), close: o.close !== false,
// finalNote: (o.state === 'done' || o.state === 'failed')
// ? 'Diese Meldung verschwindet automatisch.' : ''
// });
// });
// });
// document.addEventListener('livewire:init', () => {
// Livewire.on('notify', (payload) => {
// // Livewire liefert das Event als Array mit einem Objekt
// const o = Array.isArray(payload) ? payload[0] : payload;
// // — Optional: global machen, falls du manuell aus JS/Blade rufen willst
// // window.showToast = showToast
//
// // Ein Aufruf reicht: gleiche id => ersetzt bestehenden Toast
// window.toastraGlass?.show({
// id: o.id,
// state: o.state, // queued|running|done|failed
// badge: o.badge, // z.B. CERTBOT
// domain: o.domain, // z.B. mail.example.com
// message: o.message,
// position: o.position || 'bottom-right',
// duration: Number(o.duration ?? 0),
// close: o.close !== false,
// // optional kannst du finalNote je nach state setzen:
// finalNote: (o.state === 'done' || o.state === 'failed')
// ? 'Diese Meldung verschwindet automatisch.'
// : ''
// });
// });
// });
// document.addEventListener('livewire:init', () => {
// Livewire.on('notify', (payload) => {
// const e = Array.isArray(payload) ? payload[0] : payload;
//
// // e.state: 'queued'|'running'|'done'|'failed'
// window.toastraGlass.show({
// id: e.id || ('toast-'+Date.now()),
// state: e.state || 'queued',
// badge: e.badge || (e.type ? String(e.type).toUpperCase() : null),
// domain: e.domain || '',
// message: e.message ?? e.text ?? '',
// position: e.position || 'bottom-right',
// duration: typeof e.duration === 'number' ? e.duration : (['done','failed'].includes(e.state) ? 6000 : 0),
// close: e.close ?? true,
// finalNote: (['done','failed'].includes(e.state) ? 'Diese Meldung verschwindet nach Aktualisierung automatisch.' : '')
// });
// });
// });
// document.addEventListener('livewire:init', () => {
// Livewire.on('notify', (event) => {
// const p = Array.isArray(event) ? event[0] : event;
//
// window.toastraGlass.show({
// id: p.id || ('toast-'+Date.now()),
// state: (p.type || 'info'), // info|update|success|warning|error
// title: p.title || '',
// domain: p.domain || '',
// message: p.message ?? p.text ?? '',
// badge: p.badge || null,
// duration: (typeof p.duration === 'number' ? p.duration : 0), // 0 = bleibt
// position: p.position || 'bottom-center', // top-left|top-center|top-right|bottom-*
// close: (p.close ?? true),
// });
// });
// });
// document.addEventListener('livewire:init', () => {
// // 1) Events aus PHP/Livewire-Komponenten
// Livewire.on('notify', (payload) => {
// const e = payload[0] || payload;
// toastra.notify({
// id: e.id,
// badge: e.badge || null,
// replace: true,
// title: e.title,
// text: e.text,
// subtitle: e.subtitle || null,
// type: e.type,
// // classname: e.classname,
// duration: e.duration ?? 0,
// close: e.close ?? true,
// icon: e.icon || null,
// });
// });
//
// document.addEventListener('notify', (e) => {
// console.log(e.detail);
// });
// // 2) Reine Browser-Events (für Konsole/JS)
// // window.addEventListener('notify', (ev) => {
// // const e = ev.detail || {};
// // toastra.notify({
// // id: e.id, replace: true,
// // title: e.title, text: e.message,
// // type: e.type, duration: e.duration ?? 0,
// // close: e.close ?? true, icon: e.icon || null,
// // document.addEventListener('livewire:init', () => {
// // Livewire.on('toastra:show', (payload) => {
// // // optionaler "mute" pro Nutzer lokal:
// // if (localStorage.getItem('toast:hide:' + payload.id)) return;
// //
// // const id = window.toastraGlass.show({
// // id: payload.id,
// // state: payload.state, // queued|running|done|failed
// // badge: payload.badge,
// // domain: payload.domain,
// // message: payload.message,
// // position: payload.position || 'bottom-center',
// // duration: payload.duration ?? 0,
// // close: payload.close !== false,
// // });
// //
// // // Wenn der User X klickt, markiere lokal als verborgen:
// // window.addEventListener('toastra:closed:' + id, () => {
// // localStorage.setItem('toast:hide:' + id, '1');
// // }, { once: true });
// // });
// // });
// });
//
// // document.addEventListener('notify', (e) => {
// // const d = e.detail;
// // toastra.notify({
// // id: d.id,
// // title: d.title,
// // text: d.text || d.message || '', // fallback
// // type: d.type,
// // duration: d.duration ?? 0,
// // close: d.close ?? true,
// // document.addEventListener('livewire:init', () => {
// // Livewire.on('notify', (payload) => {
// // const o = Array.isArray(payload) ? payload[0] : payload;
// // window.toastraGlass?.show({
// // id: o.id, state: o.state, badge: o.badge, domain: o.domain,
// // message: o.message, position: o.position || 'bottom-right',
// // duration: Number(o.duration ?? 0), close: o.close !== false,
// // finalNote: (o.state === 'done' || o.state === 'failed')
// // ? 'Diese Meldung verschwindet automatisch.' : ''
// // });
// // });
// // });
// // document.addEventListener('livewire:init', () => {
// // Livewire.on('notify', (payload) => {
// // // Livewire liefert das Event als Array mit einem Objekt
// // const o = Array.isArray(payload) ? payload[0] : payload;
// //
// // // Ein Aufruf reicht: gleiche id => ersetzt bestehenden Toast
// // window.toastraGlass?.show({
// // id: o.id,
// // state: o.state, // queued|running|done|failed
// // badge: o.badge, // z.B. CERTBOT
// // domain: o.domain, // z.B. mail.example.com
// // message: o.message,
// // position: o.position || 'bottom-right',
// // duration: Number(o.duration ?? 0),
// // close: o.close !== false,
// // // optional kannst du finalNote je nach state setzen:
// // finalNote: (o.state === 'done' || o.state === 'failed')
// // ? 'Diese Meldung verschwindet automatisch.'
// // : ''
// // });
// // });
// // });
//
// // document.addEventListener('livewire:init', () => {
// // // Livewire.on('notify', (event) => {
// // // toastra.notify({
// // // title: event[0].title,
// // // text: event[0].message,
// // // type: event[0].type,
// // // duration: event[0].duration,
// // // close: event[0].close
// // // });
// // // });
// // Livewire.on('notify', (payload) => {
// // const e = Array.isArray(payload) ? payload[0] : payload;
// //
// // // e.state: 'queued'|'running'|'done'|'failed'
// // window.toastraGlass.show({
// // id: e.id || ('toast-'+Date.now()),
// // state: e.state || 'queued',
// // badge: e.badge || (e.type ? String(e.type).toUpperCase() : null),
// // domain: e.domain || '',
// // message: e.message ?? e.text ?? '',
// // position: e.position || 'bottom-right',
// // duration: typeof e.duration === 'number' ? e.duration : (['done','failed'].includes(e.state) ? 6000 : 0),
// // close: e.close ?? true,
// // finalNote: (['done','failed'].includes(e.state) ? 'Diese Meldung verschwindet nach Aktualisierung automatisch.' : '')
// // });
// // });
// // });
//
//
// // document.addEventListener('livewire:init', () => {
// // Livewire.on('notify', (event) => {
// // const p = Array.isArray(event) ? event[0] : event;
// //
// // window.toastraGlass.show({
// // id: p.id || ('toast-'+Date.now()),
// // state: (p.type || 'info'), // info|update|success|warning|error
// // title: p.title || '',
// // domain: p.domain || '',
// // message: p.message ?? p.text ?? '',
// // badge: p.badge || null,
// // duration: (typeof p.duration === 'number' ? p.duration : 0), // 0 = bleibt
// // position: p.position || 'bottom-center', // top-left|top-center|top-right|bottom-*
// // close: (p.close ?? true),
// // });
// // });
// // });
//
// // document.addEventListener('livewire:init', () => {
// // // 1) Events aus PHP/Livewire-Komponenten
// // Livewire.on('notify', (payload) => {
// // const e = payload[0] || payload;
// // toastra.notify({
// // id: e.id,
// // badge: e.badge || null,
// // replace: true,
// // title: e.title,
// // text: e.message,
// // text: e.text,
// // subtitle: e.subtitle || null,
// // type: e.type,
// // // classname: e.classname,
// // duration: e.duration ?? 0,
// // close: e.close ?? true,
// // icon: e.icon || null
// // icon: e.icon || null,
// // });
// // });
// //
// // Livewire.on('notify-replace', (event) => {
// // const opts = event[0] || {};
// // const wrap = document.getElementById('notification');
// // if (wrap) wrap.innerHTML = '';
// // toastra.notify(opts);
// // document.addEventListener('notify', (e) => {
// // console.log(e.detail);
// // });
// // // 2) Reine Browser-Events (für Konsole/JS)
// // // window.addEventListener('notify', (ev) => {
// // // const e = ev.detail || {};
// // // toastra.notify({
// // // id: e.id, replace: true,
// // // title: e.title, text: e.message,
// // // type: e.type, duration: e.duration ?? 0,
// // // close: e.close ?? true, icon: e.icon || null,
// // // });
// // // });
// // });
// //
// // });
// // // document.addEventListener('notify', (e) => {
// // // const d = e.detail;
// // // toastra.notify({
// // // id: d.id,
// // // title: d.title,
// // // text: d.text || d.message || '', // fallback
// // // type: d.type,
// // // duration: d.duration ?? 0,
// // // close: d.close ?? true,
// // // });
// // // });
// //
// // // document.addEventListener('livewire:init', () => {
// // // // Livewire.on('notify', (event) => {
// // // // toastra.notify({
// // // // title: event[0].title,
// // // // text: event[0].message,
// // // // type: event[0].type,
// // // // duration: event[0].duration,
// // // // close: event[0].close
// // // // });
// // // // });
// // //
// // // Livewire.on('notify', (payload) => {
// // // const e = payload[0] || payload;
// // // toastra.notify({
// // // id: e.id,
// // // replace: true,
// // // title: e.title,
// // // text: e.message,
// // // type: e.type,
// // // duration: e.duration ?? 0,
// // // close: e.close ?? true,
// // // icon: e.icon || null
// // // });
// // // });
// // //
// // // Livewire.on('notify-replace', (event) => {
// // // const opts = event[0] || {};
// // // const wrap = document.getElementById('notification');
// // // if (wrap) wrap.innerHTML = '';
// // // toastra.notify(opts);
// // // });
// // //
// // // });

View File

@ -1,5 +1,5 @@
{{-- resources/views/auth/login.blade.php --}}
@extends('layouts.app')
@extends('layouts.blank')
@section('title', 'Login')

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.blank')
@section('title', 'Konto erstellen')

View File

@ -1,36 +1,13 @@
{{--resources/views/components/partials/header.blade.php--}}
<div
class="#sticky top-0 w-full border-b hr rounded-lg">
class="#sticky top-0 w-full border-b hr #rounded-lg max-w-9xl mx-auto">
<header id="header" class="header w-full rounded-r-2xl rounded-l-none">
<nav class="flex h-[71px] #h-[74px] items-center justify-between #p-3">
<div class="#flex-1 md:w-3/5 w-full">
<div class="relative flex items-center gap-5">
<button class="sidebar-toggle translate-0 right-5 block sm:hidden text-white/60 hover:text-white text-2xl">
<button class="sidebar-toggle translate-0 right-5 block s#m:hidden text-white/60 hover:text-white text-2xl">
<i class="ph ph-list"></i>
</button>
{{-- @if ($latest = cache('mailwolt.update_available'))--}}
{{-- <div class="bg-blue-900/40 text-blue-100 p-4 rounded-xl border border-blue-800">--}}
{{-- <div class="flex justify-between items-center">--}}
{{-- <div>--}}
{{-- <strong>Neue Version verfügbar:</strong> {{ $latest }}--}}
{{-- </div>--}}
{{-- <button wire:click="runUpdate"--}}
{{-- class="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded">--}}
{{-- Jetzt aktualisieren--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- <button id="sidebar-toggle-btn"--}}
{{-- class="action-button sidebar-toggle flex items-center justify-center p-1.5 shadow-2xl rounded">--}}
{{-- <svg class="size-5 group-[.expanded]/side:rotate-180"--}}
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
{{-- d="M9 5l7 7-7 7"></path>--}}
{{-- </svg>--}}
{{-- </button>--}}
<div class="flex items-center gap-3">
<h1 class="font-bold text-2xl">@yield('header_title')</h1>
</div>

View File

@ -62,8 +62,8 @@
$itemIcon = $item['icon'] ?? 'ph-dot-outline';
@endphp
<li>
{{-- <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"--}}
<a href="#"
<a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"
{{-- <a href="#"--}}
class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg
border border-transparent transition-colors
hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent
@ -83,20 +83,19 @@
</nav>
{{-- Footer Toggle (Desktop/Tablet) --}}
<div class="p-3 border-t hr flex justify-between items-center">
{{-- Version (z.B. aus config/app.php 'version' oder env) --}}
<div class="sidebar-label text-[10px] text-slate-300/70">
MailWolt <span class="font-semibold">{{ config('app.version', 'v1.0.0') }}</span>
<div class="p-5 border-t hr flex justify-center items-center">
<div class="sidebar-label text-[10px] text-slate-300/70 whitespace-nowrap">
<span class="text-xs text-white/40">{{ config('app.name') }} | {{ config('app.version') }}</span>
</div>
<button type="button"
class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded"
aria-label="Sidebar ein/ausklappen">
<svg class="size-6" fill="none" stroke="currentColor" stroke-width="1.5"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"></path>
</svg>
</button>
{{-- <button type="button"--}}
{{-- class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded"--}}
{{-- aria-label="Sidebar ein/ausklappen">--}}
{{-- <svg class="size-6" fill="none" stroke="currentColor" stroke-width="1.5"--}}
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"></path>--}}
{{-- </svg>--}}
{{-- </button>--}}
</div>
</div>
</div>

View File

@ -34,6 +34,20 @@
@vite(['resources/js/app.js'])
@livewireScripts
@livewire('wire-elements-modal')
<script>
// Registriere den Hook, sobald Livewire bereit ist.
document.addEventListener('livewire:init', () => {
Livewire.hook('request', ({ fail }) => {
fail(({ status, preventDefault, json }) => {
if (status === 419) {
preventDefault();
const to = (json && json.redirect) ? json.redirect : '{{ route('login') }}';
window.location.replace(to);
}
});
});
});
</script>
</body>
</html>
{{--<!DOCTYPE html>--}}

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>@yield('title', config('app.name'))</title>
<script>document.documentElement.setAttribute('data-ui', 'booting');</script>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body class="text-slate-100 font-bai">
<div class="app-backdrop"></div>
<div id="main" class="main content group/side main-shell rail">
<div class="px-2.5 pb-2.5 pt-0.5">
<div class="mt-5">
@yield('content')
</div>
</div>
</div>
@vite(['resources/js/app.js'])
@livewireScripts
@livewire('wire-elements-modal')
</body>
</html>

View File

@ -11,7 +11,8 @@
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
<div>
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<div
class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-cpu text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">CPU</span>
</div>
@ -31,7 +32,8 @@
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
<div>
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<div
class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-memory text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">RAM</span>
</div>
@ -54,7 +56,8 @@
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
<div>
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<div
class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-activity text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">Load</span>
</div>
@ -78,7 +81,8 @@
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
<div>
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<div
class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="{{ $uptimeIcon }} text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">Uptime</span>
</div>
@ -96,52 +100,16 @@
</div>
</div>
{{-- Dienste & Storage: kompakt & bündig --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Dienste kompakt --}}
<div class="glass-card p-4">
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-gear-six text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">Dienste</span>
</div>
<span class="inline-flex items-center gap-1 rounded-full bg-white/5 border border-white/10 px-2 py-0.5 text-[10px] text-white/60">
systemctl / TCP
</span>
</div>
<ul class="overflow-auto divide-y divide-white/5">
@forelse($servicesCompact as $s)
<li class="grid grid-cols-[auto,1fr,auto] items-center gap-3 py-2">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full {{ $s['dotClass'] }}"></span>
<div class="min-w-0">
<div class="text-white/90 truncate">{{ $s['label'] }}</div>
</div>
</div>
@if($s['hint'])
<div class="text-[11px] text-white/45 truncate">{{ $s['hint'] }}</div>
@endif
</div>
<span
class="justify-self-end inline-flex items-center px-2.5 py-0.5 rounded-full text-xs border {{ $s['pillClass'] }}">
{{ $s['pillText'] }}
</span>
</div>
</li>
@empty
<li class="py-2 text-white/50 text-sm">Keine Daten.</li>
@endforelse
</ul>
</div>
{{-- 2-Spalten Abschnitt: links Dienste, rechts Storage --}}
<div class="glass-card relative p-4">
{{-- Kopf: Titel + Link oben links --}}
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<div class="glass-card relative p-4 max-h-fit">
{{-- Inhalt: Donut links, Zahlen rechts stacked auf kleineren Screens --}}
<div class="grid grid-cols-1 items-center">
{{-- Donut --}}
<div class="flex items-center justify-between -mb-3">
<div
class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-hard-drives text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">Storage</span>
</div>
@ -150,14 +118,13 @@
Details <i class="ph ph-caret-right text-[12px]"></i>
</a>
</div>
{{-- Inhalt: Donut links, Zahlen rechts stacked auf kleineren Screens --}}
<div class="grid grid-cols-1 #md:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)] #gap-6 items-center">
{{-- Donut --}}
<div class="flex items-center justify-center">
<div class="relative"
style="width: {{ $diskInnerSize + 80 }}px; height: {{ $diskInnerSize + 80 }}px;">
{{-- Innerer grauer Kreis --}}
<div class="absolute inset-[36px] rounded-full bg-white/[0.04] backdrop-blur-sm ring-1 ring-white/10"></div>
<div
class="absolute inset-[36px] rounded-full bg-white/[0.04] backdrop-blur-sm ring-1 ring-white/10"></div>
{{-- Prozentanzeige im Zentrum leicht kleiner & feiner --}}
<div class="absolute inset-0 flex flex-col items-center justify-center">
@ -206,5 +173,47 @@
</div>
</div>
</div>
<div>
<div class="glass-card p-4">
<div class="flex items-center justify-between mb-3">
<div
class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-gear-six text-white/70 text-[13px]"></i>
<span class="text-[11px] tracking-wide uppercase text-white/70">Dienste</span>
</div>
<span
class="inline-flex items-center gap-1 rounded-full bg-white/5 border border-white/10 px-2 py-0.5 text-[10px] text-white/60">
systemctl / TCP
</span>
</div>
<ul class="overflow-auto divide-y divide-white/5">
@forelse($servicesCompact as $s)
<li class="grid grid-cols-[auto,1fr,auto] items-center gap-3 py-2">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full {{ $s['dotClass'] }}"></span>
<div class="min-w-0">
<div class="text-white/90 truncate">{{ $s['label'] }}</div>
</div>
</div>
@if($s['hint'])
<div class="text-[11px] text-white/45 truncate">{{ $s['hint'] }}</div>
@endif
</div>
<span
class="justify-self-end inline-flex items-center px-2.5 py-0.5 rounded-full text-xs border {{ $s['pillClass'] }}">
{{ $s['pillText'] }}
</span>
</div>
</li>
@empty
<li class="py-2 text-white/50 text-sm">Keine Daten.</li>
@endforelse
</ul>
</div>
</div>
</div>
</div>

View File

@ -1,12 +1,12 @@
{{-- resources/views/livewire/domains/dns-assistant-modal.blade.php --}}
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl relative">
<span class="absolute top-3 right-3 z-20 inline-flex items-center gap-2
rounded-full border border-slate-700/70 bg-slate-800/70
px-3 py-1 text-[11px] text-slate-200 shadow-sm">
<i class="ph ph-clock text-slate-300"></i> TTL: {{ $ttl }}
</span>
<h2 class="text-[18px] font-semibold text-slate-100">DNS-Einträge</h2>
<p class="text-[13px] text-slate-300/80">
Setze die folgenden Records für
@ -16,62 +16,40 @@
@endpush
<div class="relative p-5">
<div class="space-y-5">
{{-- GRID: links (Mail-Records), rechts (Infrastruktur) --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
{{-- Mail-Records --}}
{{-- Step 1: Mail-Records (domain-spezifisch) --}}
<section>
<div class="mb-2 flex items-center gap-2 text-[11px]">
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Step 1</span>
<span class="text-slate-300/80">Mail-Records</span>
<span class="ml-auto px-2 py-0.5 rounded bg-indigo-600/20 text-indigo-200 border border-indigo-500/20">
<i class="ph ph-seal-check mr-1"></i>Absenderdomain
<i class="ph ph-seal-check mr-1"></i> Absenderdomain
</span>
</div>
<div class="space-y-4">
@foreach ($dynamic as $r)
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="rounded-xl border {{ $r['boxClass'] ?? $stateColors['neutral'] }}">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">
{{ $r['type'] }}
</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if(!empty($r['helpUrl']))
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
<i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}
</a>
@endif
</div>
</div>
@endforeach
@foreach ($optional as $r)
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
<div class="px-4 pb-3 space-y-2">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 opacity-80 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual']))
<div class="text-[11px] text-white/60 break-words">
<span class="opacity-70">Ist:</span>
<span class="font-mono break-words">{{ $r['display_actual'] }}</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
<span class="text-[11px] px-2 py-0.5 rounded {{ $recordColors['OPTIONAL'] ?? 'bg-slate-700/50 text-slate-300' }}">Optional</span>
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if(!empty($r['helpUrl']))
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
<i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}
</a>
@endif
</div>
</div>
@ -79,7 +57,7 @@
</div>
</section>
{{-- Globale Infrastruktur --}}
{{-- Step 2: Globale Infrastruktur (MTA-Host) --}}
<section>
<div class="mb-2 flex items-center gap-2 text-[11px]">
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Step 2</span>
@ -89,34 +67,321 @@
<div class="space-y-4">
@foreach ($static as $r)
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
<div class="rounded-xl border {{ $r['boxClass'] ?? $stateColors['neutral'] }}">
{{-- <div class="flex items-center justify-between px-4 py-2 text-[12px]">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>--}}
{{-- <span class="text-slate-200">{{ $r['name'] }}</span>--}}
{{-- </div>--}}
{{-- <div class="flex items-center gap-2 text-slate-300/70">--}}
{{-- <x-button.copy-btn :text="$r['value']" />--}}
{{-- </div>--}}
{{-- </div>--}}
<div class="flex items-start justify-between gap-2 px-4 py-2 text-[12px]">
<div class="min-w-0 flex items-start gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">
{{ $r['type'] }}
</span>
<div class="min-w-0 flex-1">
<span
class="text-[12px] leading-5 text-slate-200 break-all"
title="{{ $r['name'] }}">{{ $r['name'] }}</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
</div>
<div class="shrink-0">
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
<div class="px-4 pb-3 space-y-2">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 opacity-80 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual']))
<div class="text-[11px] text-white/60 break-words">
<span class="opacity-70">Ist:</span>
<span class="font-mono break-words">{{ $r['display_actual'] }}</span>
</div>
@endif
</div>
</div>
@endforeach
</div>
</section>
</div>
{{-- Optional-Block unterhalb (einspaltig) --}}
@if(!empty($optional))
<div class="mt-5">
<div class="mb-2 flex items-center gap-2 text-[11px]">
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Optional</span>
<span class="text-slate-300/80">empfohlene Zusatz-Records</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach ($optional as $r)
<div class="rounded-xl border {{ $r['boxClass'] ?? $stateColors['neutral'] }}">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">
{{ $r['type'] }}
</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
<span class="ml-2 text-[10px] px-2 py-0.5 rounded bg-white/5 border border-white/10 text-white/70">
Optional
</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3 space-y-2">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 opacity-80 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual']))
<div class="text-[11px] text-white/60 break-words">
<span class="opacity-70">Ist:</span>
<span class="font-mono break-words">{{ $r['display_actual'] }}</span>
</div>
@endif
@if(!empty($r['helpUrl']))
<div>
<span class="text-xs text-white/60">{{ $r['info'] }}</span>
</div>
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
<i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}
</a>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end">
<div class="flex flex-wrap items-center gap-4 text-[12px] text-slate-300">
<span class="inline-flex items-center gap-1">
<i class="ph ph-check-circle text-emerald-300"></i> vorhanden
</span>
<span class="inline-flex items-center gap-1">
<i class="ph ph-warning text-amber-300"></i> Syntaxfehler
</span>
<span class="inline-flex items-center gap-1">
<i class="ph ph-x-circle text-rose-300"></i> fehlt (nur Pflicht)
</span>
<span class="ml-auto"></span>
<button wire:click="$dispatch('domain:check-dns')" wire:loading.attr="disabled"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-[12px]
bg-white/5 border border-white/10 hover:bg-white/10">
<i class="ph ph-magnifying-glass"></i>
<span wire:loading.remove>DNS prüfen</span>
<span wire:loading>prüfe…</span>
</button>
<button wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
class="px-3 py-1.5 rounded-lg text-sm bg-emerald-500/20 text-emerald-300
border border-emerald-400/30 hover:bg-emerald-500/30">
<i class="ph ph-check"></i> Fertig
</button>
</div>
</div>
@endpush
{{--@push('modal.header')--}}
{{-- <div class="px-5 pt-5 pb-3 border-b border-white/10--}}
{{-- backdrop-blur rounded-t-2xl relative">--}}
{{-- <span class="absolute top-3 right-3 z-20 inline-flex items-center gap-2--}}
{{-- rounded-full border border-slate-700/70 bg-slate-800/70--}}
{{-- px-3 py-1 text-[11px] text-slate-200 shadow-sm">--}}
{{-- <i class="ph ph-clock text-slate-300"></i> TTL: {{ $ttl }}--}}
{{-- </span>--}}
{{-- <h2 class="text-[18px] font-semibold text-slate-100">DNS-Einträge</h2>--}}
{{-- <p class="text-[13px] text-slate-300/80">--}}
{{-- Setze die folgenden Records für--}}
{{-- <span class="text-sky-300 underline decoration-sky-500/40 underline-offset-2">{{ $zone }}</span>.--}}
{{-- </p>--}}
{{-- </div>--}}
{{--@endpush--}}
{{--<div class="relative p-5">--}}
{{-- <div class="space-y-5">--}}
{{-- --}}{{-- GRID: links (Mail-Records), rechts (Infrastruktur) --}}
{{-- <div class="grid grid-cols-1 lg:grid-cols-2 gap-5">--}}
{{-- --}}{{-- Mail-Records --}}
{{-- <section>--}}
{{-- <div class="mb-2 flex items-center gap-2 text-[11px]">--}}
{{-- <span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Step 1</span>--}}
{{-- <span class="text-slate-300/80">Mail-Records</span>--}}
{{-- <span class="ml-auto px-2 py-0.5 rounded bg-indigo-600/20 text-indigo-200 border border-indigo-500/20">--}}
{{-- <i class="ph ph-seal-check mr-1"></i>Absenderdomain--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- <div class="space-y-4">--}}
{{-- @foreach ($dynamic as $r)--}}
{{-- <div class="rounded-xl border--}}
{{-- {{ $checked ? ($stateColors[$r['state'] ?? 'neutral'] ?? $stateColors['neutral']) : $stateColors['neutral'] }}">--}}
{{-- <div class="flex items-center justify-between px-4 py-2 text-[12px]">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">--}}
{{-- {{ $r['type'] }}--}}
{{-- </span>--}}
{{-- <span class="text-slate-200">{{ $r['name'] }}</span>--}}
{{-- </div>--}}
{{-- <div class="flex items-center gap-2 text-slate-300/70">--}}
{{-- <x-button.copy-btn :text="$r['value']" />--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="px-4 pb-3">--}}
{{-- <pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>--}}
{{-- @if(!empty($r['helpUrl']))--}}
{{-- <a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"--}}
{{-- class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">--}}
{{-- <i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}--}}
{{-- </a>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">--}}
{{-- <div class="flex items-center justify-between px-4 py-2 text-[12px]">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>--}}
{{-- <span class="text-slate-200">{{ $r['name'] }}</span>--}}
{{-- </div>--}}
{{-- <div class="flex items-center gap-2 text-slate-300/70">--}}
{{-- <x-button.copy-btn :text="$r['value']" />--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="px-4 pb-3">--}}
{{-- <pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>--}}
{{-- @if(!empty($r['helpUrl']))--}}
{{-- <a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"--}}
{{-- class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">--}}
{{-- <i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}--}}
{{-- </a>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- @foreach ($optional as $r)--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">--}}
{{-- <div class="flex items-center justify-between px-4 py-2 text-[12px]">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>--}}
{{-- <span class="text-slate-200">{{ $r['name'] }}</span>--}}
{{-- </div>--}}
{{-- <div class="flex items-center gap-2 text-slate-300/70">--}}
{{-- <span class="text-[11px] px-2 py-0.5 rounded {{ $recordColors['OPTIONAL'] ?? 'bg-slate-700/50 text-slate-300' }}">Optional</span>--}}
{{-- <x-button.copy-btn :text="$r['value']" />--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="px-4 pb-3">--}}
{{-- <pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>--}}
{{-- @if(!empty($r['helpUrl']))--}}
{{-- <a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"--}}
{{-- class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">--}}
{{-- <i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}--}}
{{-- </a>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- </div>--}}
{{-- </section>--}}
{{-- --}}{{-- Globale Infrastruktur --}}
{{-- <section>--}}
{{-- <div class="mb-2 flex items-center gap-2 text-[11px]">--}}
{{-- <span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Step 2</span>--}}
{{-- <span class="text-slate-300/80">Globale Infrastruktur (MTA-Host)</span>--}}
{{-- <span class="text-slate-400/70">gilt für alle Domains</span>--}}
{{-- </div>--}}
{{-- <div class="space-y-4">--}}
{{-- @foreach ($static as $r)--}}
{{-- <div class="rounded-xl border--}}
{{-- {{ $checked ? ($stateColors[$r['state'] ?? 'neutral'] ?? $stateColors['neutral']) : $stateColors['neutral'] }}">--}}
{{-- <div class="flex items-center justify-between px-4 py-2 text-[12px]">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">--}}
{{-- {{ $r['type'] }}--}}
{{-- </span>--}}
{{-- <span class="text-slate-200">{{ $r['name'] }}</span>--}}
{{-- </div>--}}
{{-- <div class="flex items-center gap-2 text-slate-300/70">--}}
{{-- <x-button.copy-btn :text="$r['value']" />--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="px-4 pb-3">--}}
{{-- <pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">--}}
{{-- <div class="flex items-center justify-between px-4 py-2 text-[12px]">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>--}}
{{-- <span class="text-slate-200">{{ $r['name'] }}</span>--}}
{{-- </div>--}}
{{-- <div class="flex items-center gap-2 text-slate-300/70">--}}
{{-- <x-button.copy-btn :text="$r['value']" />--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="px-4 pb-3">--}}
{{-- <pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- </div>--}}
{{-- </section>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}
{{--@push('modal.footer')--}}
{{-- <div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">--}}
{{-- <div class="flex flex-wrap items-center gap-4 text-[12px] text-slate-300">--}}
{{-- <span class="inline-flex items-center gap-1">--}}
{{-- <i class="ph ph-check-circle text-emerald-300"></i> vorhanden--}}
{{-- </span>--}}
{{-- <span class="inline-flex items-center gap-1">--}}
{{-- <i class="ph ph-warning text-amber-300"></i> abweichend--}}
{{-- </span>--}}
{{-- <span class="inline-flex items-center gap-1">--}}
{{-- <i class="ph ph-x-circle text-rose-300"></i> fehlt--}}
{{-- </span>--}}
{{-- <span class="ml-auto">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <button wire:click="$dispatch('domain:check-dns')" wire:loading.attr="disabled"--}}
{{-- class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-[12px]--}}
{{-- bg-white/5 border border-white/10 hover:bg-white/10">--}}
{{-- <i class="ph ph-magnifying-glass"></i>--}}
{{-- <span wire:loading.remove>DNS prüfen</span>--}}
{{-- <span wire:loading>prüfe…</span>--}}
{{-- </button>--}}
{{-- <button wire:click="$dispatch('closeModal')"--}}
{{-- class="px-3 py-1.5 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">--}}
{{-- <i class="ph ph-check"></i> Fertig--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--@endpush--}}
{{--@push('modal.footer')--}}
{{-- <div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">--}}
{{-- <div class="flex justify-end">--}}
{{-- <button wire:click="$dispatch('closeModal')"--}}
{{-- class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">--}}
{{-- <i class="ph ph-check"></i> Fertig--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--@endpush--}}

View File

@ -0,0 +1,20 @@
<div wire:poll.30s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
<i class="ph ph-envelope-x text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70">Bounces / Zustellung</span>
</div>
<div class="text-xl font-semibold">{{ $bounces24h }}</div>
</div>
<div class="text-sm text-white/70">Häufigste SMTP-Codes:</div>
<ul class="mt-2 space-y-1 text-sm">
@forelse($topCodes as $c)
<li class="flex justify-between">
<span class="text-white/80">{{ $c['code'] }}</span>
<span class="text-white/60">{{ $c['count'] }}</span>
</li>
@empty
<li class="text-white/50"></li>
@endforelse
</ul>
</div>

View File

@ -0,0 +1,230 @@
<div class="glass-card p-5 rounded-2xl border border-white/10 bg-white/5">
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
<i class="ph ph-globe-stand text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70 tracking-wide">Mail-DNS Health</span>
</div>
<div class="flex items-center gap-3">
<button wire:click="refresh"
class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">
<i class="ph ph-arrows-clockwise text-[13px]"></i>
Neu prüfen
</button>
</div>
</div>
<div class="divide-y divide-white/5">
@forelse($rows as $r)
<button type="button"
wire:click="openDnsModal({{ $r['id'] }})"
class="w-full text-left py-3 flex items-center justify-between rounded-lg px-2 hover:bg-white/5 text-sm">
<div class="text-white/85">{{ $r['name'] }}</div>
@if($r['ok'])
<span class="px-2 py-0.5 rounded-full border text-emerald-300 border-emerald-400/30 bg-emerald-500/10 text-xs">
OK
</span>
@else
<span class="px-2 py-0.5 rounded-full border text-amber-200 border-amber-400/30 bg-amber-500/10 text-xs">
Fertig konfigurieren
</span>
@endif
</button>
@empty
<div class="py-4 text-sm text-white/60">Keine Domains.</div>
@endforelse
</div>
</div>
{{--<div wire:poll.60s="refresh" class="glass-card p-5 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-4">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-globe-stand text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70 tracking-wide">Mail-DNS Health</span>--}}
{{-- </div>--}}
{{-- <div class="text-xs text-white/60">--}}
{{-- <span class="opacity-70">TLSA:</span>--}}
{{-- <span class="{{ $tlsa ? 'text-emerald-300' : 'text-rose-300' }}">--}}
{{-- {{ $tlsa ? 'ok' : 'fehlend' }}--}}
{{-- </span>--}}
{{-- <span class="opacity-50">({{ $mtaHost }})</span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="divide-y divide-white/5">--}}
{{-- @forelse($rows as $r)--}}
{{-- <button type="button"--}}
{{-- wire:click="openDnsModal({{ $r['id'] }})"--}}
{{-- class="w-full text-left py-3 flex items-center justify-between hover:bg-white/5/20 rounded-lg px-2 hover:bg-white/5">--}}
{{-- <div class="text-white/85">{{ $r['name'] }}</div>--}}
{{-- @if($r['ok'])--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-emerald-300 border-emerald-400/30 bg-emerald-500/10 text-xs">--}}
{{-- OK--}}
{{-- </span>--}}
{{-- @else--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-amber-200 border-amber-400/30 bg-amber-500/10 text-xs">--}}
{{-- Fertig konfigurieren--}}
{{-- </span>--}}
{{-- <span class="text-[11px] text-white/45">--}}
{{-- fehlt: {{ implode(', ', $r['missing']) }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- </button>--}}
{{-- @empty--}}
{{-- <div class="py-4 text-sm text-white/60">Keine Domains.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{--</div>--}}
{{--<div wire:poll.60s="refresh" class="glass-card p-5 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-4">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-globe-stand text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70 tracking-wide">DNS / Mail Health</span>--}}
{{-- </div>--}}
{{-- <div class="text-xs text-white/60">--}}
{{-- <span class="opacity-70">IP:</span>--}}
{{-- <span class="text-white/90 font-mono">{{ $ipv4 ?? '' }}</span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- --}}{{-- Hostweite TLSA-Anzeige --}}
{{-- <div class="mb-4 flex items-center gap-2 text-xs">--}}
{{-- <span class="px-2 py-0.5 rounded-full border--}}
{{-- {{ $tlsa ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- TLSA ({{ $host }})--}}
{{-- </span>--}}
{{-- <span class="text-white/45">für 25/465/587</span>--}}
{{-- </div>--}}
{{-- <div class="divide-y divide-white/5">--}}
{{-- @forelse($domains as $dom)--}}
{{-- <div class="py-2 flex items-center justify-between">--}}
{{-- <div class="text-white/85">{{ $dom['name'] }}</div>--}}
{{-- <div class="flex items-center gap-2 text-xs">--}}
{{-- <span class="px-2 py-0.5 rounded-full border--}}
{{-- {{ $dom['dkim'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- DKIM--}}
{{-- </span>--}}
{{-- <span class="px-2 py-0.5 rounded-full border--}}
{{-- {{ $dom['dmarc'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- DMARC--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="py-4 text-sm text-white/60">Keine Domains.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{--</div>--}}
{{--<div class="glass-card p-5 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-4">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-database text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70 tracking-wide">DNS Overview</span>--}}
{{-- </div>--}}
{{-- <div class="text-xs text-white/60">--}}
{{-- IP: <span class="text-white/90 font-mono">{{ $ipv4 ?? '' }}</span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @foreach($domains as $dom)--}}
{{-- <div class="border-t border-white/5 py-3">--}}
{{-- <div class="text-white/85 font-medium">{{ $dom['name'] }}</div>--}}
{{-- <div class="grid grid-cols-2 md:grid-cols-3 gap-2 mt-2 text-xs">--}}
{{-- @foreach($dom['checks'] as $label => $status)--}}
{{-- <div class="flex items-center gap-1">--}}
{{-- <span class="px-2 py-0.5 rounded-full border--}}
{{-- {{ $status ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- {{ strtoupper($label) }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{--</div>--}}
{{--<div wire:poll.60s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-4">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-globe-stand text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70 tracking-wide">DKIM / DMARC / TLSA</span>--}}
{{-- </div>--}}
{{-- <div class="text-xs text-white/60">--}}
{{-- <span class="opacity-70">IP:</span>--}}
{{-- <span class="text-white/90 font-mono">--}}
{{-- {{ $ipv4 ?? '' }}{{ $ipv6 ? ' / '.$ipv6 : '' }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- --}}{{-- Host-Status (TLSA) --}}
{{-- <div class="mb-4 flex items-center justify-between px-3 py-2 rounded-xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <i class="ph ph-server text-white/60 text-[15px]"></i>--}}
{{-- <span class="text-white/85 font-mono">{{ $host }}</span>--}}
{{-- </div>--}}
{{-- <div>--}}
{{-- <span class="px-2 py-0.5 text-xs rounded-full border--}}
{{-- {{ $tlsa ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- TLSA--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- --}}{{-- Domainliste: DKIM/DMARC --}}
{{-- <div class="divide-y divide-white/5">--}}
{{-- @forelse($rows as $r)--}}
{{-- <div class="py-2 flex items-center justify-between">--}}
{{-- <div class="text-white/85">{{ $r['dom'] }}</div>--}}
{{-- <div class="flex items-center gap-2 text-xs">--}}
{{-- <span class="px-2 py-0.5 rounded-full border--}}
{{-- {{ $r['dkim'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- DKIM--}}
{{-- </span>--}}
{{-- <span class="px-2 py-0.5 rounded-full border--}}
{{-- {{ $r['dmarc'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- DMARC--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="py-4 text-sm text-white/60 text-center">Keine aktiven Domains gefunden.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{--</div>--}}
{{--<div wire:poll.60s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-3">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-globe-stand text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">DKIM / DMARC / TLSA</span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="divide-y divide-white/5">--}}
{{-- @forelse($rows as $r)--}}
{{-- <div class="py-2 flex items-center justify-between">--}}
{{-- <div class="text-white/85">{{ $r['dom'] }}</div>--}}
{{-- <div class="flex items-center gap-2 text-xs">--}}
{{-- <span class="px-2 py-0.5 rounded-full border {{ $r['dkim'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">DKIM</span>--}}
{{-- <span class="px-2 py-0.5 rounded-full border {{ $r['dmarc'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">DMARC</span>--}}
{{-- <span class="px-2 py-0.5 rounded-full border {{ $r['tlsa'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">TLSA</span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="py-4 text-sm text-white/60">Keine Domains.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{--</div>--}}

View File

@ -305,10 +305,11 @@
<td class="px-3 py-2 rounded-r-xl">
<div class="flex items-center gap-2 justify-end">
{{-- <button wire:click="updateMailboxStatsOne('{{ $u['localpart'].'@'.$domain->domain }}')">Jetzt aktualisieren</button>--}}
<button wire:click="updateMailboxStats"
class="px-3 py-1 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-700 transition">
Jetzt aktualisieren
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs
bg-blue-500/20 text-blue-200 border border-blue-400/40
hover:bg-blue-500/30 hover:text-white transition">
<i class="ph ph-arrow-clockwise text-[12px]"></i> Jetzt aktualisieren
</button>
<button wire:click="openMailboxEdit({{ $u['id'] }})"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20">

View File

@ -32,7 +32,6 @@
{{-- Row 1: Domain + Typ --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{{-- DOMAIN (TailwindPlus Elements) --}}
<div class="relative" wire:ignore id="domain-select-{{ $this->getId() }}">
<label class="block text-xs text-white/60 mb-1">Domain</label>

View File

@ -0,0 +1,16 @@
<div wire:poll.15s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i class="ph ph-tray text-emerald-300"></i>
<div>
<div class="font-semibold text-white/90">Mail-Queue</div>
<div class="text-xs text-white/60">älteste: {{ $oldestAge }}</div>
</div>
</div>
<button wire:click="flush" class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">Flush</button>
</div>
<div class="mt-3 grid grid-cols-2 gap-3">
<div class="glass-chip">Active <span class="font-semibold">{{ $active }}</span></div>
<div class="glass-chip">Deferred <span class="font-semibold">{{ $deferred }}</span></div>
</div>
</div>

View File

@ -0,0 +1,377 @@
<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
<i class="ph ph-shield-checkered text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70">Fail2Ban</span>
</div>
@if($available)
<span class="px-2 py-0.5 rounded-full border text-xs
{{ $activeBans>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' }}">
{{ $activeBans }} aktuell
</span>
@else
<span class="px-2 py-0.5 rounded-full border text-xs text-rose-300 border-rose-400/30 bg-rose-500/10">
nicht installiert
</span>
@endif
</div>
@if(!$available)
<div class="text-sm text-white/60">fail2ban-client wurde nicht gefunden.</div>
@elseif($permDenied)
<div class="text-sm text-amber-200">
Keine Berechtigung (sudo) auf <code class="font-mono">fail2ban-client</code>/<code class="font-mono">journalctl</code>/<code class="font-mono">zgrep</code>.
<span class="opacity-80">Sudo-Regel prüfen.</span>
</div>
@elseif($error)
<div class="text-sm text-amber-200">
Unerwartete Ausgabe von <code class="font-mono">fail2ban-client status</code>.
<span class="opacity-80">Details in <code>storage/logs/laravel.log</code>.</span>
</div>
@else
<div class="space-y-2">
@forelse($jails as $j)
<div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2">
<div class="flex items-center justify-between">
<div class="text-white/85 font-medium">{{ $j['name'] }}</div>
<div class="flex items-center gap-2">
<span class="text-[11px] text-white/60">
Bannzeit: {{ $j['bantime'] === -1 ? 'permanent' : ($j['bantime'].'s') }}
</span>
<span class="px-2 py-0.5 rounded-full border text-[11px]
{{ $j['banned']>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-white/60 border-white/20 bg-white/5' }}">
{{ $j['banned'] }} gebannt
</span>
<button wire:click.stop="openDetails('{{ $j['name'] }}')"
class="text-[11px] px-2 py-0.5 rounded border border-white/15 bg-white/5 hover:bg-white/10">
Details
</button>
</div>
</div>
</div>
@empty
<div class="text-sm text-white/60">Keine Jails gefunden.</div>
@endforelse
</div>
<div class="mt-4 flex justify-end">
<button wire:click="refresh" wire:loading.attr="disabled"
class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">
<i class="ph ph-arrows-clockwise text-[13px]"></i>
<span wire:loading.remove>Neu prüfen</span>
<span wire:loading>prüfe…</span>
</button>
</div>
@endif
</div>
{{--<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-3">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-shield-checkered text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Fail2Ban</span>--}}
{{-- </div>--}}
{{-- @if($available)--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $activeBans>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' }}">--}}
{{-- {{ $activeBans }} aktuell--}}
{{-- </span>--}}
{{-- @else--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs text-rose-300 border-rose-400/30 bg-rose-500/10">--}}
{{-- nicht installiert--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @if(!$available)--}}
{{-- <div class="text-sm text-white/60">fail2ban-client wurde nicht gefunden.</div>--}}
{{-- @elseif($permDenied)--}}
{{-- <div class="text-sm text-amber-200">--}}
{{-- Keine Berechtigung auf <code class="font-mono">/var/run/fail2ban/fail2ban.sock</code>.--}}
{{-- <span class="opacity-80">Sudo-Regel prüfen.</span>--}}
{{-- </div>--}}
{{-- @else--}}
{{-- <div class="space-y-2">--}}
{{-- @forelse($jails as $j)--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2">--}}
{{-- <div class="flex items-center justify-between">--}}
{{-- <div class="text-white/85 font-medium">{{ $j['name'] }}</div>--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="text-[11px] text-white/60">--}}
{{-- Bannzeit:--}}
{{-- @if($j['bantime'] === -1)--}}
{{-- permanent--}}
{{-- @else--}}
{{-- {{ $j['bantime'] }}s--}}
{{-- @endif--}}
{{-- </span>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-[11px]--}}
{{-- {{ $j['banned']>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-white/60 border-white/20 bg-white/5' }}">--}}
{{-- {{ $j['banned'] }} gebannt--}}
{{-- </span>--}}
{{-- --}}{{-- fix: stop event bubbling --}}
{{-- <button wire:click.stop="openDetails('{{ $j['name'] }}')"--}}
{{-- class="text-[11px] px-2 py-0.5 rounded border border-white/15 bg-white/5 hover:bg-white/10">--}}
{{-- Details--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="text-sm text-white/60">Keine Jails gefunden.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- <div class="mt-4 flex justify-end">--}}
{{-- <button wire:click="refresh" wire:loading.attr="disabled"--}}
{{-- class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">--}}
{{-- <i class="ph ph-arrows-clockwise text-[13px]"></i>--}}
{{-- <span wire:loading.remove>Neu prüfen</span>--}}
{{-- <span wire:loading>prüfe…</span>--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- @endif--}}
{{--</div>--}}
{{--<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-3">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-shield-checkered text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Fail2Ban</span>--}}
{{-- </div>--}}
{{-- @if($available)--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $activeBans>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' }}">--}}
{{-- {{ $activeBans }} aktuell--}}
{{-- </span>--}}
{{-- @else--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs text-rose-300 border-rose-400/30 bg-rose-500/10">--}}
{{-- nicht installiert--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @if(!$available)--}}
{{-- <div class="text-sm text-white/60">fail2ban-client wurde nicht gefunden.</div>--}}
{{-- @elseif($permDenied)--}}
{{-- <div class="text-sm text-amber-200">--}}
{{-- Keine Berechtigung auf <code class="font-mono">/var/run/fail2ban/fail2ban.sock</code>.--}}
{{-- <span class="opacity-80">Sudo-Regel prüfen.</span>--}}
{{-- </div>--}}
{{-- @else--}}
{{-- <div class="space-y-2">--}}
{{-- @forelse($jails as $j)--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2">--}}
{{-- <div class="flex items-center justify-between">--}}
{{-- <div class="text-white/85 font-medium">{{ $j['name'] }}</div>--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-[11px]--}}
{{-- {{ $j['banned']>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-white/60 border-white/20 bg-white/5' }}">--}}
{{-- {{ $j['banned'] }} gebannt--}}
{{-- </span>--}}
{{-- --}}{{-- Optional: Details öffnen (Tab/Modal) --}}
{{-- <button wire:click="openDetails('{{ $j['name'] }}')"--}}
{{-- class="text-[11px] px-2 py-0.5 rounded border border-white/15 bg-white/5 hover:bg-white/10">--}}
{{-- Details--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="text-sm text-white/60">Keine Jails gefunden.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- <div class="mt-4 flex justify-end">--}}
{{-- <button wire:click="refresh" wire:loading.attr="disabled"--}}
{{-- class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">--}}
{{-- <i class="ph ph-arrows-clockwise text-[13px]"></i>--}}
{{-- <span wire:loading.remove>Neu prüfen</span>--}}
{{-- <span wire:loading>prüfe…</span>--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- @endif--}}
{{--</div>--}}
{{--<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-3">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-shield-checkered text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Fail2Ban</span>--}}
{{-- </div>--}}
{{-- @if($available)--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $activeBans>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' }}">--}}
{{-- {{ $activeBans }} aktuell--}}
{{-- </span>--}}
{{-- @else--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs text-rose-300 border-rose-400/30 bg-rose-500/10">--}}
{{-- nicht installiert--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @if(!$available)--}}
{{-- <div class="text-sm text-white/60">fail2ban-client wurde nicht gefunden.</div>--}}
{{-- @else--}}
{{-- --}}{{-- Jails --}}
{{-- <div class="space-y-2">--}}
{{-- @forelse($jails as $j)--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2">--}}
{{-- <div class="flex items-center justify-between">--}}
{{-- <div class="text-white/85 font-medium">{{ $j['name'] }}</div>--}}
{{-- <div class="flex items-center gap-2">--}}
{{-- <span class="text-[11px] text-white/50">--}}
{{-- Bannzeit:--}}
{{-- @if($j['bantime'] === -1) permanent--}}
{{-- @else {{ $j['bantime'] }}s--}}
{{-- @endif--}}
{{-- </span>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-[11px]--}}
{{-- {{ $j['banned']>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-white/60 border-white/20 bg-white/5' }}">--}}
{{-- {{ $j['banned'] }} gebannt--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @if(!empty($j['ips']))--}}
{{-- <div class="mt-2 grid gap-1">--}}
{{-- @foreach($j['ips'] as $ip)--}}
{{-- <div class="flex items-center gap-2 text-[12px] text-white/80 font-mono">--}}
{{-- <span>{{ $ip['ip'] }}</span>--}}
{{-- @if($ip['remaining'] === -1)--}}
{{-- <span class="px-1.5 py-0.5 rounded border border-rose-400/30 bg-rose-500/10 text-rose-200">--}}
{{-- permanent--}}
{{-- </span>--}}
{{-- @elseif(is_int($ip['remaining']) && $ip['remaining'] > 0)--}}
{{-- <span class="px-1.5 py-0.5 rounded border border-amber-400/30 bg-amber-500/10 text-amber-100">--}}
{{-- {{ gmdate('H\h i\m s\s', $ip['remaining']) }}--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="text-sm text-white/60">Keine Jails gefunden.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- --}}{{-- Top IPs aus Ban-Events --}}
{{-- <div class="mt-4">--}}
{{-- <div class="text-sm text-white/70">Top IPs (letzte Fail2Ban-Logs):</div>--}}
{{-- <ul class="mt-2 space-y-1 text-sm">--}}
{{-- @forelse($topIps as $i)--}}
{{-- <li class="flex justify-between">--}}
{{-- <span class="text-white/80 font-mono">{{ $i['ip'] }}</span>--}}
{{-- <span class="text-white/60">{{ $i['count'] }}</span>--}}
{{-- </li>--}}
{{-- @empty--}}
{{-- <li class="text-white/50"></li>--}}
{{-- @endforelse--}}
{{-- </ul>--}}
{{-- </div>--}}
{{-- <div class="mt-4 flex justify-end">--}}
{{-- <button wire:click="refresh" wire:loading.attr="disabled"--}}
{{-- class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">--}}
{{-- <i class="ph ph-arrows-clockwise text-[13px]"></i>--}}
{{-- <span wire:loading.remove>Neu prüfen</span>--}}
{{-- <span wire:loading>prüfe…</span>--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- @endif--}}
{{--</div>--}}
{{--<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-3">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-shield-checkered text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Fail2Ban</span>--}}
{{-- </div>--}}
{{-- @if($available)--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $activeBans>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' }}">--}}
{{-- {{ $activeBans }} aktuell--}}
{{-- </span>--}}
{{-- @else--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs text-rose-300 border-rose-400/30 bg-rose-500/10">--}}
{{-- nicht installiert--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @if(!$available)--}}
{{-- <div class="text-sm text-white/60">fail2ban-client wurde nicht gefunden.</div>--}}
{{-- @else--}}
{{-- --}}{{-- Jails --}}
{{-- <div class="space-y-2">--}}
{{-- @forelse($jails as $j)--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2">--}}
{{-- <div class="flex items-center justify-between">--}}
{{-- <div class="text-white/85 font-medium">{{ $j['name'] }}</div>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-[11px]--}}
{{-- {{ $j['banned']>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-white/60 border-white/20 bg-white/5' }}">--}}
{{-- {{ $j['banned'] }} gebannt--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- @if(!empty($j['ips']))--}}
{{-- <div class="mt-1 text-[12px] text-white/65 font-mono break-words">--}}
{{-- {{ implode(', ', $j['ips']) }}--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="text-sm text-white/60">Keine Jails gefunden.</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- --}}{{-- Top IPs aus Ban-Events --}}
{{-- <div class="mt-3">--}}
{{-- <div class="text-sm text-white/70">Top IPs (letzte Fail2Ban-Logs):</div>--}}
{{-- <ul class="mt-2 space-y-1 text-sm">--}}
{{-- @foreach($j['ips'] as $ip)--}}
{{-- <div class="flex items-center gap-2 text-[12px] text-white/80 font-mono">--}}
{{-- <span>{{ $ip['ip'] }}</span>--}}
{{-- @if($ip['remaining'] === -1)--}}
{{-- <span class="px-1.5 py-0.5 rounded border border-rose-400/30 bg-rose-500/10 text-rose-200">permanent</span>--}}
{{-- @elseif(is_int($ip['remaining']) && $ip['remaining'] > 0)--}}
{{-- <span class="px-1.5 py-0.5 rounded border border-amber-400/30 bg-amber-500/10 text-amber-100">--}}
{{-- {{ gmdate('H\h i\m s\s', $ip['remaining']) }}--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- @forelse($topIps as $i)--}}
{{-- <li class="flex justify-between">--}}
{{-- <span class="text-white/80 font-mono">{{ $i['ip'] }}</span>--}}
{{-- <span class="text-white/60">{{ $i['count'] }}</span>--}}
{{-- </li>--}}
{{-- @empty--}}
{{-- <li class="text-white/50"></li>--}}
{{-- @endforelse--}}
{{-- </ul>--}}
{{-- </div>--}}
{{-- <div class="mt-4 flex justify-end">--}}
{{-- <button wire:click="refresh" wire:loading.attr="disabled"--}}
{{-- class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">--}}
{{-- <i class="ph ph-arrows-clockwise text-[13px]"></i>--}}
{{-- <span wire:loading.remove>Neu prüfen</span>--}}
{{-- <span wire:loading>prüfe…</span>--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- @endif--}}
{{--</div>--}}

View File

@ -0,0 +1,36 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white/90">Aktuell gebannte IPs</h3>
<button wire:click="refreshList"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-arrows-counter-clockwise text-[14px]"></i>
Aktualisieren
</button>
</div>
@if (empty($rows))
<div class="text-white/50 text-sm">Keine aktiven Banns vorhanden.</div>
@else
<div class="space-y-3">
@foreach ($rows as $r)
<div class="flex items-center justify-between rounded-2xl border px-4 py-2.5 {{ $r['box'] }}">
<div class="flex items-center gap-3">
{{-- Statuspunkt: rot=permanent, gelb=temporär --}}
<span class="inline-block w-2.5 h-2.5 rounded-full {{ $r['dot'] }}"></span>
{{-- IP klein + monospace, ohne Jail-Text --}}
<span class="font-mono text-[13px] md:text-[14px] text-white/85 tracking-normal">
{{ $r['ip'] }}
</span>
</div>
<button
wire:click="unban('{{ $r['ip'] }}','{{ $r['jail'] }}')"
class="text-[12px] px-3 py-1.5 rounded-xl border {{ $r['btn'] }}">
Entbannen
</button>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,141 @@
<div class="grid grid-cols-1 xl:grid-cols-3 gap-5">
{{-- LEFT 2/3 --}}
<div class="xl:col-span-2 space-y-5">
<div class="glass-card p-5">
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-shield text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Fail2Ban Konfiguration</span>
</div>
<button wire:click="save"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-floppy-disk text-[14px]"></i> Speichern & Reload
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-white/60 text-sm mb-1">Bantime (Sekunden)</label>
<input type="number" wire:model.defer="bantime"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Standard-Sperrzeit.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Max. Bantime (Sekunden)</label>
<input type="number" wire:model.defer="max_bantime"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Obergrenze bei dynamischer Erhöhung.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Findtime (Sekunden)</label>
<input type="number" wire:model.defer="findtime"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Zeitraum für Wiederholungen.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Max. Retry</label>
<input type="number" wire:model.defer="max_retry"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Fehlversuche bis Bann.</p>
</div>
<div class="md:col-span-2">
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" wire:model.defer="bantime_increment" class="peer sr-only">
<span
class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5 peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40">
<i class="ph ph-check text-[12px] text-emerald-300 opacity-0 peer-checked:opacity-100"></i>
</span>
<span class="text-white/80 text-sm">Bantime dynamisch erhöhen (increment)</span>
</label>
</div>
<div class="md:col-span-2">
<label class="block text-white/60 text-sm mb-1">Erhöhungs-Faktor</label>
<input type="number" step="0.1" wire:model.defer="bantime_factor"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Multiplikator (z. B. 1.5).</p>
</div>
</div>
</div>
<div class="glass-card p-5">
<livewire:ui.security.fail2ban-banlist/>
</div>
<div class="glass-card p-5">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-4">
<i class="ph ph-info text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Hinweise</span>
</div>
<ul class="list-disc list-inside text-sm text-white/60 space-y-1">
<li><strong>bantime.increment</strong> = true bedeutet, dass sich die Sperrzeit bei wiederholten
Angriffen erhöht (z. B. 1 h 1.5 h 2.25 h ).
</li>
<li>Die SQLite-Datenbank befindet sich unter <code>/var/lib/fail2ban/fail2ban.sqlite3</code>.</li>
<li>Alle Änderungen hier werden nach Klick auf <em>„Speichern & Reload“</em> sofort aktiv.</li>
</ul>
</div>
</div>
{{-- RIGHT 1/3 --}}
<div class="space-y-5">
<div class="glass-card p-5">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-3">
<i class="ph ph-list text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Whitelist</span>
</div>
@forelse($whitelist as $ip)
<div
class="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 mb-2">
<span class="text-white/80 text-sm">{{ $ip }}</span>
<button class="text-[12px] px-2 py-0.5 rounded border border-white/10 hover:border-white/20"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{mode:'remove',type:'whitelist',ip:'{{ $ip }}'}})">
Entfernen
</button>
</div>
@empty
<div class="text-sm text-white/50">Keine Einträge.</div>
@endforelse
<button class="primary-btn w-full justify-center mt-2"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{type:'whitelist'}})">
IP hinzufügen
</button>
</div>
<div class="glass-card p-5">
<div
class="inline-flex items-center gap-2 rounded-full bg-rose-500/10 border border-rose-400/30 px-2.5 py-1 mb-3">
<i class="ph ph-hand text-rose-300 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-rose-300">Blacklist</span>
</div>
@forelse($blacklist as $ip)
<div
class="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 mb-2">
<span class="text-white/80 text-sm">{{ $ip }}</span>
<button class="text-[12px] px-2 py-0.5 rounded border border-white/10 hover:border-white/20"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{mode:'remove',type:'blacklist',ip:'{{ $ip }}'}})">
Entfernen
</button>
</div>
@empty
<div class="text-sm text-white/50">Keine Einträge.</div>
@endforelse
<button
class="text-[13px] w-full px-3 py-2 rounded-xl border border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{type:'blacklist'}})">
IP hinzufügen
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,88 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl">
<h2 class="text-[18px] font-semibold text-slate-100">Fail2Ban {{ $jail }}</h2>
<p class="text-[13px] text-slate-300/80">Aktuell gebannte IPs und Restlaufzeiten.</p>
</div>
@endpush
<div class="p-5 space-y-3">
@forelse($rows as $r)
<div class="rounded-xl border px-4 py-3 {{ $r['box_class'] }}">
<div class="flex items-center justify-between">
<div class="text-white/90 font-mono text-[14px]">{{ $r['ip'] }}</div>
<div class="text-[12px] text-white/80">{{ $r['time_text'] }}</div>
</div>
<div class="mt-1 text-[12px] text-white/55">{{ $r['meta_text'] }}</div>
</div>
@empty
<div class="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/70">
Keine gebannten IPs in diesem Jail.
</div>
@endforelse
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex items-center gap-2 justify-end">
{{-- WICHTIG: refresht NUR das Modal --}}
<button wire:click="$dispatch('f2b:refresh-banlist')" wire:loading.attr="disabled"
class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">
<i class="ph ph-arrows-clockwise text-[13px]"></i>
<span wire:loading.remove>Neu prüfen</span>
<span wire:loading>prüfe…</span>
</button>
<button wire:click="$dispatch('closeModal')"
class="px-3 py-1.5 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Fertig
</button>
</div>
</div>
@endpush
{{--@push('modal.header')--}}
{{-- <div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl">--}}
{{-- <h2 class="text-[18px] font-semibold text-slate-100">--}}
{{-- Fail2Ban {{ $jail }}--}}
{{-- </h2>--}}
{{-- <p class="text-[13px] text-slate-300/80">--}}
{{-- Aktuell gebannte IPs und Restlaufzeiten.--}}
{{-- </p>--}}
{{-- </div>--}}
{{--@endpush--}}
{{--<div class="p-5 space-y-3">--}}
{{-- @forelse($rows as $r)--}}
{{-- <div class="rounded-xl border px-4 py-3 {{ $r['box_class'] }}">--}}
{{-- <div class="flex items-center justify-between">--}}
{{-- <div class="text-white/90 font-mono text-[14px]">{{ $r['ip'] }}</div>--}}
{{-- <div class="text-[12px] text-white/80">--}}
{{-- {{ $r['time_text'] }}--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <div class="mt-1 text-[12px] text-white/55">--}}
{{-- {{ $r['meta_text'] }}--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/70">--}}
{{-- Keine gebannten IPs in diesem Jail.--}}
{{-- </div>--}}
{{-- @endforelse--}}
{{--</div>--}}
{{--@push('modal.footer')--}}
{{-- <div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">--}}
{{-- <div class="flex items-center gap-2 justify-end">--}}
{{-- <button wire:click="$dispatch('f2b:refresh-banlist')" wire:loading.attr="disabled"--}}
{{-- class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">--}}
{{-- <i class="ph ph-arrows-clockwise text-[13px]"></i>--}}
{{-- <span wire:loading.remove>Neu prüfen</span>--}}
{{-- <span wire:loading>prüfe…</span>--}}
{{-- </button>--}}
{{-- <button wire:click="$dispatch('closeModal')"--}}
{{-- class="px-3 py-1.5 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">--}}
{{-- Fertig--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--@endpush--}}

View File

@ -0,0 +1,55 @@
<div class="p-5">
{{-- Header --}}
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-2 rounded-full
{{ $type === 'blacklist' ? 'bg-rose-500/10 border border-rose-400/30' : 'bg-white/5 border border-white/10' }}
px-2.5 py-1">
<i class="ph {{ $type === 'blacklist' ? 'ph-hand text-rose-300' : 'ph-list text-white/70' }} text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide
{{ $type === 'blacklist' ? 'text-rose-300' : 'text-white/70' }}">
{{ strtoupper($type) }} {{ $mode === 'add' ? 'hinzufügen' : 'entfernen' }}
</span>
</div>
<button type="button" wire:click="$dispatch('closeModal')"
class="rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-white/70 hover:text-white">
Schließen
</button>
</div>
{{-- Body --}}
<div class="space-y-3">
@if($mode === 'add')
<div>
<label class="block text-white/60 text-sm mb-1">IP oder CIDR</label>
<input type="text" wire:model.defer="ip" placeholder="z. B. 203.0.113.4 oder 203.0.113.0/24 oder 2001:db8::/32"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@error('ip') <p class="text-sm text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
<button wire:click="save"
class="primary-btn w-full justify-center">
{{ $type === 'blacklist' ? 'Zur Blacklist hinzufügen & bannen' : 'Zur Whitelist hinzufügen' }}
</button>
@if($type === 'blacklist')
<p class="text-xs text-white/50 mt-2">
Wird sofort im Jail <code>mailwolt-blacklist</code> gebannt (bantime = permanent).
</p>
@endif
@else
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">
<div class="text-white/80 text-sm">IP: {{ $prefill ?? $ip }}</div>
<div class="text-white/50 text-xs">Wird aus der {{ $type }} entfernt
@if($type === 'blacklist') und im Blacklist-Jail entbannt @endif.
</div>
</div>
<button wire:click="remove"
class="text-[13px] w-full px-3 py-2 rounded-xl border
{{ $type === 'blacklist'
? 'border-rose-400/40 bg-rose-500/10 text-rose-200 hover:border-rose-400/70'
: 'border-white/20 bg-white/5 text-white/80 hover:border-white/40' }}">
Entfernen
</button>
@endif
</div>
</div>

View File

@ -0,0 +1,83 @@
<div class="glass-card p-5 rounded-2xl border border-white/10 bg-white/5">
<div class="flex items-center justify-between">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
<i class="ph ph-shield text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70 tracking-wide">Reputation / RBL</span>
</div>
<div class="text-xs text-white/60">
<span class="opacity-70">IP:</span>
<span class="text-white/90 font-mono">{{ $ip }}</span>
</div>
</div>
<div class="mt-5">
@if($hits === 0)
<div class="flex items-center gap-3 px-4 py-3 rounded-xl bg-emerald-500/10 border border-emerald-400/20">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500/20">
<i class="ph ph-shield-check text-emerald-300 text-lg"></i>
</div>
<div>
<div class="text-emerald-300 font-medium text-[15px]">
Deine IP ist auf keiner bekannten Blacklist.
</div>
<div class="text-[12px] text-white/50 mt-0.5">
Gute Reputation keine Auffälligkeiten gefunden.
</div>
</div>
</div>
<div class="mt-3 text-[11px] text-white/45 text-center">
Geprüfte öffentliche RBLs:
<span class="text-white/60">Spamhaus, PSBL, UCEPROTECT-1, s5h</span>
</div>
@else
<div class="flex items-center gap-3 px-4 py-3 rounded-xl bg-rose-500/10 border border-rose-400/20">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-rose-500/20">
<i class="ph ph-warning-circle text-rose-300 text-lg"></i>
</div>
<div>
<div class="text-rose-300 font-medium text-[15px]">
{{ $hits }} {{ Str::plural('Treffer', $hits) }} auf Blacklists
</div>
<ul class="mt-1 space-y-1 text-sm text-rose-300/90">
@foreach($lists as $l)
<li class="flex items-center gap-2">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400/80"></span>
<span>{{ $l }}</span>
</li>
@endforeach
</ul>
</div>
</div>
@endif
</div>
<div class="mt-5 flex justify-center">
<button wire:click="refresh"
class="px-3 py-1.5 text-[12px] rounded-lg bg-white/5 border border-white/10 hover:bg-white/10">
<i class="ph ph-arrows-clockwise text-[13px]"></i>
Neu prüfen
</button>
</div>
</div>
{{--<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-shield-warning text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Reputation / RBL</span>--}}
{{-- </div>--}}
{{-- <div class="text-xs text-white/70">IP: <span class="text-white/90">{{ $ip }}</span></div>--}}
{{-- </div>--}}
{{-- <div class="mt-3">--}}
{{-- @if($hits === 0)--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-emerald-300 border-emerald-400/30 bg-emerald-500/10">Keine Treffer</span>--}}
{{-- @else--}}
{{-- <div class="text-sm text-white/70">{{ $hits }} Treffer:</div>--}}
{{-- <ul class="mt-2 space-y-1 text-sm text-rose-300">--}}
{{-- @foreach($lists as $l)<li> {{ $l }}</li>@endforeach--}}
{{-- </ul>--}}
{{-- @endif--}}
{{-- </div>--}}
{{--</div>--}}

View File

@ -0,0 +1,30 @@
<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
<div class="flex items-center justify-between mb-3">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
<i class="ph ph-shield-star text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70">Spam & AV</span>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="glass-card bg-white/5 border-white/10 p-3 rounded-xl">
<div class="text-[11px] text-white/60">Ham</div>
<div class="text-xl font-semibold">{{ $ham }}</div>
</div>
<div class="glass-card bg-white/5 border-white/10 p-3 rounded-xl">
<div class="text-[11px] text-white/60">Spam</div>
<div class="text-xl font-semibold">{{ $spam }}</div>
</div>
<div class="glass-card bg-white/5 border-white/10 p-3 rounded-xl">
<div class="text-[11px] text-white/60">Rejects</div>
<div class="text-xl font-semibold">{{ $reject }}</div>
</div>
</div>
<div class="mt-4 grid md:grid-cols-2 gap-3 text-sm text-white/70">
<div class="glass-chip">Rspamd: <span class="text-white/90">{{ $rspamdVer }}</span></div>
<div class="glass-chip">ClamAV: <span class="text-white/90">{{ $clamVer }}</span></div>
<div class="md:col-span-2 text-xs text-white/60 truncate">Signaturen: {{ $sigUpdated ?? '' }}</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full mb-3">
<i class="ph ph-bell-ringing text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70">System-Warnungen</span>
</div>
@if(empty($alerts))
<div class="text-sm text-white/60">Keine Warnungen.</div>
@else
<ul class="space-y-2">
@foreach($alerts as $al)
<li class="flex items-center justify-between rounded-lg px-3 py-2 border
{{ $al['level']==='error' ? 'border-rose-400/30 bg-rose-500/10 text-rose-200'
: ($al['level']==='warn' ? 'border-amber-400/30 bg-amber-500/10 text-amber-200'
: 'border-white/10 bg-white/5 text-white/80') }}">
<span>{{ $al['msg'] }}</span>
<span class="text-xs opacity-70">{{ $al['when'] ?? '' }}</span>
</li>
@endforeach
</ul>
@endif
</div>

View File

@ -0,0 +1,295 @@
<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5"
@if($running) wire:poll.2s="refresh" @endif>
<div class="flex items-center justify-between mb-2">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
<i class="ph ph-archive-box text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase text-white/70">Backups</span>
</div>
<button wire:click="runNow"
class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">
Jetzt sichern
</button>
</div>
@if($running)
<div class="text-white/70 mb-2">
{{ $step === 'compress' ? 'Komprimiere …' : ($step === 'mysqldump' ? 'Datenbank exportieren …' : 'Backup läuft …') }}
<span class="float-right text-white/70">{{ $percent }}%</span>
</div>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-3">
<div class="h-2 bg-white/40" style="width: {{ $percent }}%"></div>
</div>
@endif
<div class="space-y-1 text-sm">
<div class="text-white/70">
Letztes Backup: <span class="text-white/90">{{ $lastAt ?? '' }}</span>
</div>
<div class="text-white/70">
Größe: <span class="text-white/90">{{ $lastSize ?? '' }}</span>
</div>
<div class="text-white/70">
Dauer: <span class="text-white/90">{{ $lastDuration ?? '' }}</span>
</div>
<div>
<span class="px-2 py-0.5 rounded-full border text-xs
{{ $ok === true ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
: ($ok === false ? 'text-rose-300 border-rose-400/30 bg-rose-500/10'
: 'text-white/60 border-white/20 bg-white/5') }}">
{{ $ok === null ? 'unbekannt' : ($ok ? 'erfolgreich' : 'fehlgeschlagen') }}
</span>
</div>
</div>
</div>
{{--<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5" wire:poll.2s="refresh">--}}
{{-- <div class="flex items-center justify-between mb-2">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-archive-box text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Backups</span>--}}
{{-- </div>--}}
{{-- <button wire:click="runNow" class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">--}}
{{-- Jetzt sichern--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- @if($running)--}}
{{-- <div class="text-white/70 mb-2">{{ $progressText }}</div>--}}
{{-- <div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-4">--}}
{{-- <div class="h-2 bg-white/40" style="width: {{ $percent }}%"></div>--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- <div class="space-y-1 text-sm">--}}
{{-- <div class="text-white/70">Letztes Backup: <span class="text-white/90">{{ $lastAt ?? '' }}</span></div>--}}
{{-- <div class="text-white/70">Größe: <span class="text-white/90">{{ $lastSize ?? '' }}</span></div>--}}
{{-- <div class="text-white/70">Dauer: <span class="text-white/90">{{ $lastDuration ?? '' }}</span></div>--}}
{{-- <div>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $ok === true ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : ($ok === false ? 'text-rose-300 border-rose-400/30 bg-rose-500/10'--}}
{{-- : 'text-white/60 border-white/20 bg-white/5') }}">--}}
{{-- {{ $ok === null ? 'unbekannt' : ($ok ? 'erfolgreich' : 'fehlgeschlagen') }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}
{{--<div wire:key="backup-card" wire:poll.2s="refresh"--}}
{{-- class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-2">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-archive-box text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Backups</span>--}}
{{-- </div>--}}
{{-- <button wire:click="runNow"--}}
{{-- class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">--}}
{{-- Jetzt sichern--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- --}}{{-- Fortschritt (sichtbar nur während run) --}}
{{-- <div class="mb-3 {{ $progressVisibleClass }}">--}}
{{-- <div class="flex items-center justify-between mb-1 text-xs text-white/70">--}}
{{-- <span>{{ $progressText }}</span>--}}
{{-- <span>{{ $progressPercent }}%</span>--}}
{{-- </div>--}}
{{-- <div class="w-full h-2 rounded bg-white/10 overflow-hidden">--}}
{{-- <div class="h-2 bg-white/40" style="width: {{ $progressPercent }}%"></div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- --}}{{-- Infos --}}
{{-- <div class="space-y-1 text-sm">--}}
{{-- <div class="text-white/70">Letztes Backup: <span class="text-white/90">{{ $lastAt }}</span></div>--}}
{{-- <div class="text-white/70">Größe: <span class="text-white/90">{{ $lastSize }}</span></div>--}}
{{-- <div class="text-white/70">Dauer: <span class="text-white/90">{{ $lastDuration }}</span></div>--}}
{{-- <div>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs {{ $statusColor }}">--}}
{{-- {{ $statusText }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}
{{--<div--}}
{{-- wire:key="backup-card"--}}
{{-- @if($running) wire:poll.1s="refresh" @endif--}}
{{-- class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5"--}}
{{-->--}}
{{-- <div class="flex items-center justify-between mb-2">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-archive-box text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Backups</span>--}}
{{-- </div>--}}
{{-- <button--}}
{{-- wire:click="runNow"--}}
{{-- class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20 disabled:opacity-50"--}}
{{-- @disabled($running)--}}
{{-- >--}}
{{-- Jetzt sichern--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- --}}{{-- Fortschritt --}}
{{-- <div class="mb-3">--}}
{{-- <div class="flex items-center justify-between mb-1 text-xs text-white/70">--}}
{{-- <span>{{ $progressText }}</span>--}}
{{-- <span>{{ $progressPercent }}%</span>--}}
{{-- </div>--}}
{{-- <div class="w-full h-2 rounded bg-white/10 overflow-hidden">--}}
{{-- <div class="h-2 bg-white/40" style="width: {{ $progressPercent }}%"></div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- --}}{{-- Backup Infos --}}
{{-- <div class="space-y-1 text-sm">--}}
{{-- <div class="text-white/70">Letztes Backup: <span class="text-white/90">{{ $lastAt }}</span></div>--}}
{{-- <div class="text-white/70">Größe: <span class="text-white/90">{{ $lastSize }}</span></div>--}}
{{-- <div class="text-white/70">Dauer: <span class="text-white/90">{{ $lastDuration }}</span></div>--}}
{{-- <div>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs {{ $statusColor }}">--}}
{{-- {{ $statusText }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}
{{--<div--}}
{{-- class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5"--}}
{{-- @if($running) wire:poll.1500ms="refresh" @endif--}}
{{-->--}}
{{-- <div class="flex items-center justify-between mb-2">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-archive-box text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Backups</span>--}}
{{-- </div>--}}
{{-- <button wire:click="runNow"--}}
{{-- @disabled($running)--}}
{{-- class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20 disabled:opacity-50">--}}
{{-- {{ $running ? 'Läuft…' : 'Jetzt sichern' }}--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- @if($running)--}}
{{-- <div class="space-y-2">--}}
{{-- <div class="flex items-center justify-between text-xs text-white/70">--}}
{{-- <span>Schritt: <span class="text-white/90">{{ $step ?? 'start' }}</span></span>--}}
{{-- <span>{{ $percent }}%</span>--}}
{{-- </div>--}}
{{-- <div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">--}}
{{-- <div class="h-2 bg-white/60" style="width: {{ max(2,$percent) }}%"></div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @else--}}
{{-- <div class="space-y-1 text-sm">--}}
{{-- <div class="text-white/70">Letztes Backup:--}}
{{-- <span class="text-white/90">{{ $lastAt ?? '' }}</span>--}}
{{-- </div>--}}
{{-- <div class="text-white/70">Größe:--}}
{{-- <span class="text-white/90">{{ $lastSize ?? '' }}</span>--}}
{{-- </div>--}}
{{-- <div class="text-white/70">Dauer:--}}
{{-- <span class="text-white/90">{{ $lastDuration ?? '' }}</span>--}}
{{-- </div>--}}
{{-- <div>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $ok ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- {{ $ok === null ? 'unbekannt' : ($ok ? 'erfolgreich' : 'fehlgeschlagen') }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endif--}}
{{--</div>--}}
{{--<div--}}
{{-- @class([--}}
{{-- 'glass-card p-4 rounded-2xl border bg-white/5',--}}
{{-- 'border-white/10'--}}
{{-- ])--}}
{{-- @if($state === 'running') wire:poll.1000ms="refresh" @endif--}}
{{-->--}}
{{-- <div class="flex items-center justify-between mb-3">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-archive-box text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Backups</span>--}}
{{-- </div>--}}
{{-- <button--}}
{{-- wire:click="runNow"--}}
{{-- class="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20 disabled:opacity-50"--}}
{{-- @disabled($state === 'running')--}}
{{-- >--}}
{{-- {{ $state === 'running' ? 'Läuft…' : 'Jetzt sichern' }}--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- --}}{{-- Laufender Fortschritt --}}
{{-- @if($state === 'running')--}}
{{-- <div class="space-y-2">--}}
{{-- <div class="text-white/80 text-sm">Sicherung läuft…</div>--}}
{{-- <ul class="space-y-1">--}}
{{-- @foreach($steps as $k => $label)--}}
{{-- @php--}}
{{-- $done = $k === 'finish' ? false : ($k === $step ? false : (array_key_first(array_flip($steps)) !== $k && $k !== $step && $step && array_search($k, array_keys($steps)) < array_search($step, array_keys($steps))));--}}
{{-- $isCurrent = $k === $step;--}}
{{-- @endphp--}}
{{-- <li class="flex items-center gap-2 text-sm">--}}
{{-- <span class="w-2 h-2 rounded-full--}}
{{-- {{ $done ? 'bg-emerald-400' : ($isCurrent ? 'bg-amber-400 animate-pulse' : 'bg-white/20') }}"></span>--}}
{{-- <span class="{{ $done ? 'text-white/80' : ($isCurrent ? 'text-white' : 'text-white/60') }}">--}}
{{-- {{ $label }}--}}
{{-- </span>--}}
{{-- </li>--}}
{{-- @endforeach--}}
{{-- </ul>--}}
{{-- </div>--}}
{{-- @else--}}
{{-- --}}{{-- Letztes Ergebnis --}}
{{-- <div class="space-y-1 text-sm">--}}
{{-- <div class="text-white/70">Letztes Backup:--}}
{{-- <span class="text-white/90 font-medium">{{ $lastAt ?? '' }}</span>--}}
{{-- </div>--}}
{{-- <div class="text-white/70">Größe:--}}
{{-- <span class="text-white/90 font-medium">{{ $lastSize ?? '' }}</span>--}}
{{-- </div>--}}
{{-- <div class="text-white/70">Dauer:--}}
{{-- <span class="text-white/90 font-medium">{{ $lastDuration ?? '' }}</span>--}}
{{-- </div>--}}
{{-- <div class="pt-1">--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $ok === null--}}
{{-- ? 'text-slate-300 border-slate-400/30 bg-slate-500/10'--}}
{{-- : ($ok ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'--}}
{{-- : 'text-rose-300 border-rose-400/30 bg-rose-500/10') }}">--}}
{{-- {{ $ok === null ? 'unbekannt' : ($ok ? 'erfolgreich' : 'fehlgeschlagen') }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endif--}}
{{--</div>--}}
{{--<div wire:key="storage-{{ md5($target ?? '/') }}" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">--}}
{{-- <div class="flex items-center justify-between mb-2">--}}
{{-- <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">--}}
{{-- <i class="ph ph-archive-box text-white/70 text-[13px]"></i>--}}
{{-- <span class="text-[11px] uppercase text-white/70">Backups</span>--}}
{{-- </div>--}}
{{-- <button wire:click="runNow" class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">Jetzt sichern</button>--}}
{{-- </div>--}}
{{-- <div class="space-y-1 text-sm">--}}
{{-- <div class="text-white/70">Letztes Backup: <span class="text-white/90">{{ $lastAt ?? '' }}</span></div>--}}
{{-- <div class="text-white/70">Größe: <span class="text-white/90">{{ $lastSize ?? '' }}</span></div>--}}
{{-- <div class="text-white/70">Dauer: <span class="text-white/90">{{ $lastDuration ?? '' }}</span></div>--}}
{{-- <div>--}}
{{-- <span class="px-2 py-0.5 rounded-full border text-xs--}}
{{-- {{ $ok ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">--}}
{{-- {{ $ok === null ? 'unbekannt' : ($ok ? 'erfolgreich' : 'fehlgeschlagen') }}--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}

Some files were not shown because too many files have changed in this diff Show More