Compare commits

..

No commits in common. "main" and "v1.0.76" have entirely different histories.

313 changed files with 5231 additions and 33429 deletions

0
, Normal file
View File

View File

@ -31,11 +31,9 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
# For cross-subdomain session sharing (e.g. webmail on mail.example.com):
# SESSION_DOMAIN=.example.com
SESSION_DOMAIN=
SESSION_DOMAIN=null
#BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
@ -65,11 +63,3 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Mailwolt domain config
BASE_DOMAIN=example.com
UI_SUB=admin
MTA_SUB=mail
# Custom webmail subdomain — users access webmail at WEBMAIL_SUB.BASE_DOMAIN
# Leave empty to use only the path-based fallback (/webmail on main domain)
WEBMAIL_SUB=webmail

1
.gitignore vendored
View File

@ -17,7 +17,6 @@
/public/hot
/public/storage
/storage/*.key
/storage/backups
/storage/pail
/vendor
Homestead.json

0
0 Normal file
View File

102
CLAUDE.md
View File

@ -1,102 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development (starts PHP server + queue + logs + Vite concurrently)
composer dev
# Build assets for production
npm run build
# Vite dev server only
npm run dev
# Run all tests
composer test
php artisan test
# Run a single test file
php artisan test tests/Feature/ExampleTest.php
# Run a single test by name
php artisan test --filter=TestName
# Migrations
php artisan migrate
```
## Architecture
### Design
- Tailwind CSS v4
- Modals sind immer vom Livewire Modals
- Kein Inline-Style nur wenn wirklich notwendig.
- Immer prüfen das das Style nicht kaputt ist und wenn Icons in Buttons mit Text sind, sind diese immer nebeneinadner Nie übereinander.
### What this is
Mailwolt is a self-hosted mail server administration panel (German UI). It manages domains, mailboxes, aliases, DNS records (DKIM/DMARC/SPF/TLSA), TLS, Fail2ban, IMAP, and email quarantine/queues. It also has a mail sandbox for testing Postfix transports.
### Layout & Routing
- **`resources/views/layouts/dvx.blade.php`** is the actual app layout (not `app.blade.php`). Livewire full-page components use `#[Layout('layouts.dvx')]`.
- Routes are in `routes/web.php` — each page maps directly to a Livewire full-page component via `->name()`.
- Navigation structure is driven by `config/ui-menu.php`.
- Für den Style wird Tailwind CSS v4
### Livewire component structure
```
app/Livewire/Ui/
├── Nx/ — Current production UI (Dashboard, DomainList, MailboxList, AliasList, etc.)
├── Domain/ — Domain modals and DKIM/DNS views
├── Mail/ — Mailbox/alias modals, queue, quarantine
├── Security/ — Fail2ban, SSL, RSpamd, TLS, audit logs
├── System/ — Settings, users, API keys, webhooks, sandbox, backups
├── Search/ — Global search palette modal
└── Webmail/ — Webmail-specific components
```
Full-page components live in `Ui/Nx/` and `Ui/System/`, `Ui/Security/`, etc. Modals are always in a `Modal/` subfolder and extend `LivewireUI\Modal\ModalComponent`.
### Modals (wire-elements/modal v2)
- Open from **outside** a Livewire component: `onclick="Livewire.dispatch('openModal', {component:'ui.system.modal.my-modal', arguments:{key:value}})"`
- Open from **inside** a Livewire component: `$this->dispatch('openModal', component: '...', arguments: [...])`
- **Never** use `wire:click="$dispatch('openModal',...)"` outside a Livewire component context — it won't work.
- Modal argument keys must match the `mount(int $keyName)` parameter names exactly.
- To prevent closing on backdrop/Escape, override in the modal class:
```php
public static function closeModalOnClickAway(): bool { return false; }
public static function closeModalOnEscape(): bool { return false; }
public static function closeModalOnEscapeIsForceful(): bool { return false; }
```
- To force-close the entire modal stack: `$this->forceClose()->closeModal()`
### Livewire dependency injection
- **Never** use constructor injection in Livewire components — Livewire calls `new Component()` with no args.
- Use `boot(MyService $service)` instead: this is called on every request and supports DI.
### CSS design system
The app uses a custom `mw-*` variable and class system defined in `resources/css/app.css` (Tailwind CSS v4, no `tailwind.config.js`):
**CSS variables:**
- `--mw-bg`, `--mw-bg3`, `--mw-bg4` — background layers
- `--mw-b1`, `--mw-b2`, `--mw-b3` — border shades
- `--mw-t1` through `--mw-t5` — text shades (t1 = primary, t4/t5 = muted)
- `--mw-v`, `--mw-v2`, `--mw-vbg` — purple accent (primary brand color)
- `--mw-gr` — green (success)
**Reusable component classes:** `.mw-btn-primary`, `.mw-btn-secondary`, `.mw-btn-cancel`, `.mw-btn-save`, `.mw-btn-del`, `.mbx-act-btn`, `.mbx-act-danger`, `.mw-modal-frame`, `.mw-modal-head`, `.mw-modal-body`, `.mw-modal-foot`, `.mw-modal-label`, `.mw-modal-input`, `.mw-modal-error`, `.mbx-badge-mute`, `.mbx-badge-ok`, `.mbx-badge-warn`.
### Services
`app/Services/` contains: `DkimService`, `DnsRecordService`, `ImapService`, `MailStorage`, `SandboxMailParser`, `SandboxService`, `TlsaService`, `TotpService`, `WebhookService`.
### API
REST API under `/api/v1/` uses Laravel Sanctum. Token abilities map to scopes like `mailboxes:read`, `domains:write`, etc. Defined in `routes/api.php`.
### Sandbox mail system
The mail sandbox intercepts Postfix mail via a pipe transport (`php artisan sandbox:receive`). `SandboxRoute` model controls which domains/addresses are intercepted. `SandboxService::syncTransportFile()` writes `/etc/postfix/transport.sandbox` and runs `postmap`.
### Queue & real-time
- Queue driver: database (configurable). Jobs in `app/Jobs/`.
- Real-time updates use Laravel Reverb + Pusher.js. Livewire polls (`wire:poll.5s`) are used as fallback on some pages.

View File

@ -1,72 +0,0 @@
# MailWolt Install Report
Datum: 2026-04-17
## Befunde & Fixes
### Migrationen
- **Status:** Alle 21 Migrationen erfolgreich durchgeführt (Batch 115)
- Tabellen vorhanden: `domains`, `mail_users`, `mail_aliases`, `mail_alias_recipients`, `dkim_keys`, `settings`, `system_tasks`, `spf_records`, `dmarc_records`, `tlsa_records`, `backup_*`, `fail2ban_*`, `two_factor_*`
- Feldbezeichnungen weichen von der Spec ab (z. B. `local` statt `source` bei Aliases) ist konsistent mit der restlichen Anwendung
### Setup-Wizard
- **Fix:** Route `/setup` fehlte in `routes/web.php` → hinzugefügt
- Controller: `App\Http\Controllers\Setup\SetupWizard` (existiert)
- Middleware `EnsureSetupCompleted` leitet nicht-abgeschlossene Setups korrekt auf `/setup` weiter
- `SETUP_PHASE=bootstrap` in `.env` → Setup noch nicht abgeschlossen
### Nginx vHost (`/etc/nginx/sites-available/mailwolt.conf`)
- `$uri`-Escapes: korrekt (kein Escaping-Problem)
- PHP-FPM Socket: `unix:/run/php/php8.2-fpm.sock`
- `nginx -t`: **syntax ok / test successful**
## Dienste-Status
```
postfix active
dovecot active
nginx active
mariadb active
redis-server active
rspamd active
opendkim activating ← startet noch / Sockets werden ggf. verzögert gebunden
fail2ban active
php8.2-fpm active
laravel-queue active
reverb active
```
## Port-Test (Smoke Test)
```
Port 25: OPEN (SMTP)
Port 465: OPEN (SMTPS)
Port 587: OPEN (Submission)
Port 143: OPEN (IMAP)
Port 993: OPEN (IMAPS)
Port 80: OPEN (HTTP → HTTPS Redirect)
Port 443: OPEN (HTTPS)
```
## Noch ausstehend (manuell)
- **Setup-Wizard aufrufen:** https://10.10.70.58/setup
(Domain, Admin-E-Mail, Admin-Passwort setzen)
- **DKIM-Keys für Domain generieren** (nach Setup, wenn Domain angelegt):
```
rspamadm dkim_keygen -s mail -d example.com
```
- **DNS-Einträge setzen** (beim Domain-Hoster):
- `MX``mail.example.com`
- `SPF``v=spf1 mx ~all`
- `DKIM` → TXT-Eintrag aus `rspamadm dkim_keygen`
- `DMARC``v=DMARC1; p=none; rua=mailto:dmarc@example.com`
- **Let's Encrypt Zertifikat** (nach DNS-Propagation):
```
certbot --nginx -d mail.example.com
```
- **opendkim** prüfen ob Dienst vollständig gestartet ist:
```
systemctl status opendkim
```

View File

@ -0,0 +1,9 @@
= App\Models\Setting {#6409
id: 13,
group: "woltguard",
key: "services",
value: "{"ts":1761504019,"rows":[{"name":"postfix","ok":true},{"name":"dovecot","ok":true},{"name":"rspamd","ok":true},{"name":"clamav","ok":true},{"name":"db","ok":true},{"name":"redis","ok":true},{"name":"php-fpm","ok":true},{"name":"nginx","ok":true},{"name":"mw-queue","ok":true},{"name":"mw-schedule","ok":true},{"name":"mw-ws","ok":true},{"name":"fail2ban","ok":true},{"name":"journal","ok":true}]}",
created_at: "2025-10-26 19:40:19",
updated_at: "2025-10-26 19:40:19",
}

0
[hint], Normal file
View File

0
[label], Normal file
View File

View File

@ -1,201 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\Setting;
use Illuminate\Console\Command;
class BackupRun extends Command
{
protected $signature = 'backup:run {jobId : ID des BackupJob-Eintrags}';
protected $description = 'Führt einen Backup-Job aus und aktualisiert den Status';
public function handle(): int
{
$job = BackupJob::with('policy')->findOrFail($this->argument('jobId'));
$job->update(['status' => 'running']);
$policy = $job->policy;
$tmpDir = sys_get_temp_dir() . '/clubird_backup_' . $job->id;
mkdir($tmpDir, 0700, true);
$sources = [];
$log = [];
$exitCode = 0;
// ── 1. External backup script (takes full control if present) ──────
$script = '/usr/local/sbin/mailwolt-backup';
if (file_exists($script)) {
$output = [];
exec('sudo -n ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
$artifact = null;
foreach ($output as $line) {
if (str_starts_with($line, 'ARTIFACT:')) {
$artifact = trim(substr($line, 9));
}
}
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->cleanTmp($tmpDir);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
// ── 2. Built-in backup ────────────────────────────────────────────
// Database
if ($policy?->include_db ?? true) {
[$ok, $msg, $dumpFile] = $this->dumpDatabase($tmpDir);
if ($ok) {
$sources[] = $dumpFile;
$log[] = '✓ Datenbank gesichert';
} else {
$log[] = '✗ Datenbank: ' . $msg;
$exitCode = 1;
}
}
// Mail directories
if ($policy?->include_maildirs ?? true) {
$mailDir = (string) Setting::get('backup_mail_dir', '');
if (empty($mailDir)) {
foreach (['/var/vmail', '/var/mail/vhosts', '/home/vmail'] as $c) {
if (is_dir($c)) { $mailDir = $c; break; }
}
}
if ($mailDir && is_dir($mailDir)) {
$sources[] = $mailDir;
$log[] = '✓ Maildirs: ' . $mailDir;
} else {
$log[] = '✗ Maildir nicht gefunden (konfiguriere den Pfad in den Einstellungen)';
}
}
// Config files + SSL
if ($policy?->include_configs ?? true) {
foreach (['/etc/postfix', '/etc/dovecot', '/etc/opendkim', '/etc/rspamd', '/etc/letsencrypt'] as $dir) {
if (is_dir($dir)) {
$sources[] = $dir;
$log[] = '✓ ' . $dir;
}
}
if (file_exists(base_path('.env'))) {
$sources[] = base_path('.env');
$log[] = '✓ .env';
}
}
if (empty($sources)) {
$msg = 'Keine Quellen zum Sichern gefunden.';
$job->update(['status' => 'failed', 'finished_at' => now(), 'error' => $msg]);
$this->cleanTmp($tmpDir);
return self::FAILURE;
}
// Build archive — use storage/app/backups so www-data always has write access
$outDir = storage_path('app/backups');
if (!is_dir($outDir) && !mkdir($outDir, 0750, true) && !is_dir($outDir)) {
// Final fallback: /tmp (always writable)
$outDir = sys_get_temp_dir() . '/clubird_backups';
mkdir($outDir, 0750, true);
}
$stamp = now()->format('Y-m-d_H-i-s');
$outFile = "{$outDir}/clubird_{$stamp}.tar.gz";
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
$tarOutput = [];
$tarExit = 0;
exec("tar --ignore-failed-read -czf " . escapeshellarg($outFile) . " {$srcArgs} 2>&1", $tarOutput, $tarExit);
$this->cleanTmp($tmpDir);
// tar exit 1 = some files unreadable but archive was written — treat as ok
if ($tarExit <= 1 && file_exists($outFile)) {
$tarExit = 0;
}
if ($tarExit !== 0) {
$exitCode = $tarExit;
}
$fullLog = implode("\n", $log) . "\n\n" . implode("\n", array_slice($tarOutput, -20));
$this->finalize($job, $exitCode, $fullLog, $tarExit === 0 ? $outFile : null);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
private function dumpDatabase(string $tmpDir): array
{
$conn = config('database.connections.' . config('database.default'));
if (($conn['driver'] ?? '') !== 'mysql') {
return [false, 'Nur MySQL/MariaDB wird unterstützt.', null];
}
$host = $conn['host'] ?? '127.0.0.1';
$port = (string)($conn['port'] ?? '3306');
$username = $conn['username'] ?? '';
$password = $conn['password'] ?? '';
$database = $conn['database'] ?? '';
$dumpFile = "{$tmpDir}/database.sql";
$cmd = 'MYSQL_PWD=' . escapeshellarg($password)
. ' mysqldump'
. ' -h ' . escapeshellarg($host)
. ' -P ' . escapeshellarg($port)
. ' -u ' . escapeshellarg($username)
. ' --single-transaction --routines --triggers'
. ' ' . escapeshellarg($database)
. ' > ' . escapeshellarg($dumpFile)
. ' 2>&1';
$output = [];
$exit = 0;
exec($cmd, $output, $exit);
if ($exit !== 0 || !file_exists($dumpFile)) {
return [false, implode('; ', $output), null];
}
return [true, '', $dumpFile];
}
private function finalize(BackupJob $job, int $exitCode, string $log, ?string $artifact): void
{
$sizeBytes = ($artifact && file_exists($artifact)) ? filesize($artifact) : 0;
if ($exitCode === 0) {
$job->update([
'status' => 'ok',
'finished_at' => now(),
'log_excerpt' => $log,
'artifact_path' => $artifact,
'size_bytes' => $sizeBytes,
]);
$job->policy?->update([
'last_run_at' => now(),
'last_status' => 'ok',
'last_size_bytes' => $sizeBytes,
]);
} else {
$job->update([
'status' => 'failed',
'finished_at' => now(),
'error' => $log,
]);
$job->policy?->update(['last_status' => 'failed']);
}
}
private function cleanTmp(string $dir): void
{
if (!is_dir($dir)) return;
foreach (glob("{$dir}/*") ?: [] as $f) {
is_file($f) ? unlink($f) : null;
}
@rmdir($dir);
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use Illuminate\Console\Command;
class BackupScheduled extends Command
{
protected $signature = 'backup:scheduled {policyId}';
protected $description = 'Legt einen BackupJob an und startet backup:run (wird vom Scheduler aufgerufen)';
public function handle(): int
{
$policy = BackupPolicy::find($this->argument('policyId'));
if (! $policy || ! $policy->enabled) {
return self::SUCCESS;
}
if (BackupJob::whereIn('status', ['queued', 'running'])->exists()) {
$this->warn('Backup läuft bereits — übersprungen.');
return self::SUCCESS;
}
$job = BackupJob::create([
'policy_id' => $policy->id,
'status' => 'queued',
'started_at' => now(),
]);
$artisan = base_path('artisan');
exec("nohup php {$artisan} backup:run {$job->id} > /dev/null 2>&1 &");
$this->info("Backup-Job #{$job->id} gestartet.");
return self::SUCCESS;
}
}

View File

@ -6,125 +6,49 @@ use Illuminate\Console\Command;
class CheckUpdates extends Command
{
protected $signature = 'clubird:check-updates';
protected $aliases = ['mailwolt:check-updates'];
protected $description = 'Check for newer CluBird releases via git tags';
protected $signature = 'mailwolt:check-updates';
protected $description = 'Check for newer MailWolt releases via git tags';
public function handle(): int
{
$currentNorm = $this->readInstalledVersionNorm();
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
$appPath = base_path();
$remoteFile = '/var/lib/mailwolt/version_remote';
$appPath = base_path();
$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;
// Fallback-Kette für Remote-Tags (erste funktionierende Methode gewinnt):
// 1. mailwolt-update --check-only (sudoers: mailwolt-update)
// 2. mailwolt-fetch-tags (sudoers: mailwolt-fetch-tags)
// 3. Gitea/GitHub API (kein Auth nötig wenn Repo öffentlich)
// 4. git ls-remote (klappt wenn Credentials im git-Config)
// 5. git fetch --tags direkt
$updateBin = '/usr/local/sbin/mailwolt-update';
$fetchHelper = '/usr/local/sbin/mailwolt-fetch-tags';
$fetched = false;
if (file_exists($updateBin) && str_contains((string) @file_get_contents($updateBin), '--check-only')) {
@exec('sudo -n ' . escapeshellarg($updateBin) . ' --check-only 2>/dev/null', $_, $rc);
$fetched = ($rc === 0);
$latestTagRaw = trim((string) shell_exec($cmd));
if ($latestTagRaw === '') {
$latestTagRaw = trim((string) shell_exec("cd {$appPath} && git tag -l --sort=-v:refname | head -n1"));
}
if (!$fetched && file_exists($fetchHelper)) {
@exec('sudo -n ' . escapeshellarg($fetchHelper) . ' 2>/dev/null', $_, $rc);
$fetched = ($rc === 0);
}
// Gitea/GitHub API funktioniert ohne Credentials wenn Repo öffentlich
if (!$fetched) {
$remoteUrl = trim((string) @shell_exec('git -C ' . escapeshellarg($appPath) . ' remote get-url origin 2>/dev/null'));
if (preg_match('~https?://([^/]+)/([^/]+/[^/]+?)(?:\.git)?$~', $remoteUrl, $rm)) {
$host = $rm[1];
$project = $rm[2];
// Gitea API
$apiUrl = "https://{$host}/api/v1/repos/{$project}/tags?limit=50";
$ctx = stream_context_create(['http' => ['timeout' => 5, 'ignore_errors' => true]]);
$json = @file_get_contents($apiUrl, false, $ctx);
if ($json) {
$tags = json_decode($json, true);
if (is_array($tags)) {
$versions = [];
foreach ($tags as $t) {
$name = $t['name'] ?? '';
if (preg_match('/^v[\d.]+$/', $name)) {
$versions[] = $name;
}
}
usort($versions, 'version_compare');
$latest = end($versions);
if ($latest) {
@mkdir(dirname($remoteFile), 0755, true);
file_put_contents($remoteFile, $latest);
$fetched = true;
}
}
}
}
}
// git ls-remote (klappt wenn Credentials im git-Config hinterlegt sind)
if (!$fetched) {
$lsOut = [];
@exec('git -C ' . escapeshellarg($appPath) . ' ls-remote --tags origin \'v*\' 2>/dev/null', $lsOut);
$lsVersions = [];
foreach ($lsOut as $line) {
if (preg_match('~refs/tags/(v[\d.]+)$~', $line, $m)) {
$lsVersions[] = $m[1];
}
}
if (!empty($lsVersions)) {
usort($lsVersions, 'version_compare');
$latest = end($lsVersions);
@mkdir(dirname($remoteFile), 0755, true);
file_put_contents($remoteFile, $latest);
$fetched = true;
}
}
// Direkter git fetch als letzter Fallback
if (!$fetched) {
@exec('git -C ' . escapeshellarg($appPath) . ' fetch --tags origin 2>/dev/null', $_, $rc);
}
// version_remote von Helfer geschrieben; als frisch wenn < 2h alt
$remoteRaw = '';
if (file_exists($remoteFile) && (time() - filemtime($remoteFile)) < 7200) {
$remoteRaw = trim((string) file_get_contents($remoteFile));
}
// Lokale Tags (nach fetch hoffentlich aktuell)
$out = [];
@exec("git -C " . escapeshellarg($appPath) . " tag -l 'v*' --sort=-v:refname 2>/dev/null", $out);
$latestTagRaw = $remoteRaw !== '' ? $remoteRaw : trim($out[0] ?? '');
$latestNorm = $this->normalizeVersion($latestTagRaw);
// Nichts gefunden -> alles leeren
if (!$latestNorm) {
cache()->forget('updates:latest');
cache()->forget('updates:latest_raw');
cache()->forget('mailwolt.update_available');
cache()->forget('mailwolt.update_available'); // legacy
$this->warn('Keine Release-Tags gefunden.');
return self::SUCCESS;
}
// 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);
cache()->forever('mailwolt.update_available', $latestNorm); // legacy-kompat
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
} else {
// Kein Update -> Keys löschen
cache()->forget('updates:latest');
cache()->forget('updates:latest_raw');
cache()->forget('mailwolt.update_available');
cache()->forget('mailwolt.update_available'); // legacy
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
}
@ -136,35 +60,22 @@ class CheckUpdates extends Command
private function readInstalledVersionNorm(): ?string
{
// version_raw ist die zuverlässigste Quelle wird vom Update-Script geschrieben
$rawVer = $this->normalizeVersion($this->readInstalledVersionRaw() ?? '');
$fileVer = null;
foreach (['/var/lib/mailwolt/version', base_path('VERSION')] as $p) {
$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 !== '') { $fileVer = $this->normalizeVersion($raw); break; }
if ($raw !== '') return $this->normalizeVersion($raw);
}
// version_raw bevorzugen (echter installierter Stand)
// Nur wenn version_raw fehlt: version-Datei oder git-Tag als Fallback
if ($rawVer) {
return $rawVer;
}
// git-Tag als letzter Fallback (funktioniert auf Dev-Servern)
$out = [];
@exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
$gitVer = $this->normalizeVersion(trim($out[0] ?? ''));
if ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) {
return $gitVer;
}
return $fileVer ?: null;
// 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';
$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;
}
@ -174,8 +85,8 @@ class CheckUpdates extends Command
if (!$v) return null;
$v = trim($v);
if ($v === '') return null;
$v = ltrim($v, "vV \t\n\r\0\x0B");
$v = preg_replace('/-.*$/', '', $v);
$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

@ -6,13 +6,12 @@ use Illuminate\Console\Command;
class MailwoltRestart extends Command
{
protected $signature = 'clubird:restart-services';
protected $aliases = ['mailwolt:restart-services'];
protected $signature = 'mailwolt:restart-services';
protected $description = 'Restart or reload MailWolt-related system services';
public function handle(): int
{
$units = config('clubird.units', []);
$units = config('mailwolt.units', []);
foreach ($units as $u) {
$base = (string)($u['name'] ?? '');
@ -46,12 +45,12 @@ class MailwoltRestart extends Command
//class MailwoltRestart extends Command
//{
// protected $signature = 'clubird:restart-services';
// protected $signature = 'mailwolt:restart-services';
// protected $description = 'Restart or reload MailWolt-related system services';
//
// public function handle(): int
// {
// $units = config('clubird.units', []);
// $units = config('mailwolt.units', []);
// $allowed = ['reload','restart','try-reload-or-restart'];
//
// foreach ($units as $u) {
@ -91,12 +90,12 @@ class MailwoltRestart extends Command
//
//class MailwoltRestart extends Command
//{
// protected $signature = 'clubird:restart-services';
// protected $signature = 'mailwolt:restart-services';
// protected $description = 'Restart or reload MailWolt-related system services';
//
// public function handle(): int
// {
// $units = config('clubird.units', []);
// $units = config('mailwolt.units', []);
//
// foreach ($units as $u) {
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';

View File

@ -1,64 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MigrateConfigNames extends Command
{
protected $signature = 'clubird:migrate-config-names {--dry-run : Nur anzeigen was gemacht würde}';
protected $description = 'Benennt server-seitige Config-Dateien von mailwolt-* auf generische Namen um';
private array $renames = [
'/etc/rspamd/local.d/mailwolt-actions.conf' => '/etc/rspamd/local.d/actions.conf',
'/etc/dovecot/conf.d/99-mailwolt-tls.conf' => '/etc/dovecot/conf.d/99-tls.conf',
'/etc/postfix/mailwolt-tls.cf' => '/etc/postfix/tls.cf',
'/etc/fail2ban/jail.d/mailwolt-whitelist.local' => '/etc/fail2ban/jail.d/whitelist.local',
'/etc/fail2ban/jail.d/00-mailwolt-defaults.local'=> '/etc/fail2ban/jail.d/00-defaults.local',
];
public function handle(): int
{
$dry = $this->option('dry-run');
foreach ($this->renames as $old => $new) {
if (!file_exists($old)) {
$this->line(" <fg=gray>übersprungen</> {$old} (nicht vorhanden)");
continue;
}
if (file_exists($new) && !is_link($new)) {
$this->line(" <fg=yellow>bereits vorhanden</> {$new}");
continue;
}
if ($dry) {
$this->line(" <fg=cyan>[dry-run]</> mv {$old}{$new}");
continue;
}
// Als root direkt umbenennen; als www-data via sudo (sudo mv muss in sudoers sein)
if (posix_getuid() === 0) {
$ok = @rename($old, $new);
$errMsg = error_get_last()['message'] ?? '';
} else {
@exec('sudo -n mv ' . escapeshellarg($old) . ' ' . escapeshellarg($new) . ' 2>&1', $out, $rc);
$ok = ($rc === 0);
$errMsg = implode(' ', $out);
}
if ($ok) {
$this->info(" umbenannt: {$old}{$new}");
} else {
$this->error(" FEHLER bei {$old}: {$errMsg}");
$this->line(" → manuell: <fg=yellow>mv {$old} {$new}</>");
}
}
if (!$dry) {
$this->info('Fertig. Dienste ggf. neu starten: rspamd, dovecot, postfix, fail2ban');
}
return self::SUCCESS;
}
}

View File

@ -1,234 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MigrateDovecot24 extends Command
{
protected $signature = 'clubird:migrate-dovecot24 {--dry-run : Nur anzeigen was geändert würde}';
protected $description = 'Migriert Dovecot-Config von 2.3 auf 2.4 Syntax';
public function handle(): int
{
$dry = $this->option('dry-run');
// 1) dovecot.conf: dovecot_config_version als erste Zeile
$this->fixDovecotConf($dry);
// 2) 10-auth.conf: disable_plaintext_auth → auth_allow_cleartext
$this->fixAuthConf($dry);
// 3) 10-mail.conf: mail_location → mail_driver + mail_path
$this->fixMailConf($dry);
// 4) 10-master.conf: einzeilige Blöcke aufsplitten
$this->fixMasterConf($dry);
// 5) 10-ssl.conf: ssl_cert/ssl_key → ssl_server_cert_file/ssl_server_key_file
$this->fixSslConf($dry);
// 6) auth-sql.conf.ext: neue passdb/userdb-Syntax
$this->fixAuthSqlConf($dry);
if (!$dry) {
$this->info('Fertig. Starte dovecot neu…');
@exec('systemctl restart dovecot 2>&1', $out, $rc);
if ($rc === 0) {
$this->info('Dovecot erfolgreich gestartet.');
} else {
$this->error('Dovecot-Neustart fehlgeschlagen: ' . implode("\n", $out));
$this->line('Prüfen mit: doveconf -n 2>&1');
}
}
return self::SUCCESS;
}
private function fixDovecotConf(bool $dry): void
{
$file = '/etc/dovecot/dovecot.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
if (str_starts_with(trim($content), 'dovecot_config_version')) {
// Falsche Version ersetzen
$new = preg_replace('/^dovecot_config_version\s*=\s*\S+/m', 'dovecot_config_version = 2.4.1', $content);
} else {
$new = "dovecot_config_version = 2.4.1\n" . $content;
}
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde dovecot_config_version ergänzen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixMailConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-mail.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
$new = preg_replace_callback(
'/^mail_location\s*=\s*(\w+):(.+)$/m',
function ($m) {
return "mail_driver = {$m[1]}\nmail_path = {$m[2]}";
},
$content
);
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde mail_location aufteilen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixMasterConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-master.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
// Einzeilige Blöcke "name { key = val }" → mehrzeilig
$new = preg_replace_callback(
'/^(\s*)(\S+)\s*\{\s*([^}]+)\s*\}(\s*)$/m',
function ($m) {
$indent = $m[1];
$name = $m[2];
$inner = trim($m[3]);
$pairs = preg_split('/\s+(?=\w+=)/', $inner);
$body = implode("\n{$indent} ", array_map('trim', $pairs));
return "{$indent}{$name} {\n{$indent} {$body}\n{$indent}}";
},
$content
);
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde einzeilige Blöcke aufsplitten in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixSslConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-ssl.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
// ssl_cert = </path> → ssl_server_cert_file = /path
$new = preg_replace('/^ssl_cert\s*=\s*<(.+)$/m', 'ssl_server_cert_file = $1', $content);
$new = preg_replace('/^ssl_key\s*=\s*<(.+)$/m', 'ssl_server_key_file = $1', $new);
// Direkte Pfade ohne < (falls schon teilweise migriert)
$new = preg_replace('/^ssl_cert\s*=\s*(?!<)(.+)$/m', 'ssl_server_cert_file = $1', $new);
$new = preg_replace('/^ssl_key\s*=\s*(?!<)(.+)$/m', 'ssl_server_key_file = $1', $new);
// dovecot_storage_version in dovecot.conf (wenn noch nicht vorhanden)
$mainConf = '/etc/dovecot/dovecot.conf';
$mainContent = (string) @file_get_contents($mainConf);
if (!str_contains($mainContent, 'dovecot_storage_version')) {
if (!$dry) {
file_put_contents($mainConf, $mainContent . "\ndovecot_storage_version = 2.4.1\n");
$this->info(" dovecot_storage_version ergänzt in {$mainConf}");
} else {
$this->line(" [dry-run] würde dovecot_storage_version ergänzen in {$mainConf}");
}
}
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde ssl_cert/ssl_key umbenennen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixAuthConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-auth.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
$new = str_replace('disable_plaintext_auth = yes', 'auth_allow_cleartext = no', $content);
$new = str_replace('disable_plaintext_auth = no', 'auth_allow_cleartext = yes', $new);
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde disable_plaintext_auth ersetzen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixAuthSqlConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/auth-sql.conf.ext';
$sqlConf = '/etc/dovecot/dovecot-sql.conf.ext';
// Alte Werte aus dovecot-sql.conf.ext lesen
$sqlRaw = (string) @file_get_contents($sqlConf);
$host = $this->parseSqlValue($sqlRaw, 'host') ?: '127.0.0.1';
$dbname = $this->parseSqlValue($sqlRaw, 'dbname') ?: 'mailwolt';
$user = $this->parseSqlValue($sqlRaw, 'user') ?: 'mailwolt';
$pass = $this->parseSqlValue($sqlRaw, 'password') ?: '';
$scheme = $this->parseSqlValue($sqlRaw, 'default_pass_scheme') ?: 'BLF-CRYPT';
// password_query extrahieren (auch aus Kommentaren)
$query = $this->parsePasswordQuery($sqlRaw);
$new = "mysql {$host} {\n"
. " dbname = {$dbname}\n"
. " user = {$user}\n"
. " password = {$pass}\n"
. "}\n\n"
. "passdb sql {\n"
. " default_password_scheme = {$scheme}\n"
. " query = {$query}\n"
. "}\n\n"
. "userdb static {\n"
. " fields {\n"
. " uid = vmail\n"
. " gid = vmail\n"
. " home = /var/mail/vhosts/%{user|domain}/%{user|username}\n"
. " }\n"
. "}\n";
if ($dry) {
$this->line(" [dry-run] würde {$file} neu schreiben:");
$this->line($new);
return;
}
file_put_contents($file, $new);
$this->info(" neu geschrieben: {$file}");
}
private function parseSqlValue(string $content, string $key): ?string
{
// Aus connect-Zeile: connect = host=x dbname=y user=z password=w
if (preg_match('/^connect\s*=\s*(.+)/m', $content, $m)) {
$connect = $m[1];
if (preg_match('/' . preg_quote($key, '/') . '\s*=\s*(\S+)/i', $connect, $m2)) {
return trim($m2[1]);
}
}
// Direkte Zeile: key = value
if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(.+)/m', $content, $m)) {
return trim($m[1]);
}
return null;
}
private function parsePasswordQuery(string $content): string
{
// Auskommentierte oder aktive password_query / query Zeile
if (preg_match('/^#?\s*password_query\s*=\s*(.+)/m', $content, $m)) {
$q = trim($m[1]);
// %u → %{user}
$q = str_replace("'%u'", "'%{user}'", $q);
return rtrim($q, ';');
}
return "SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%{user}' AND is_active = 1 LIMIT 1";
}
}

View File

@ -1,127 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MigrateEnvReverb extends Command
{
protected $signature = 'clubird:migrate-env-reverb';
protected $aliases = ['mailwolt:migrate-env-reverb'];
protected $description = 'Migriert veraltete REVERB_* .env-Werte auf Domain-Basis';
public function handle(): int
{
$env = base_path('.env');
if (!file_exists($env)) {
$this->warn('.env nicht gefunden.');
return self::SUCCESS;
}
$content = file_get_contents($env);
// APP_HOST ermitteln: .env → APP_URL → DB-Setting ui_domain
$appHost = $this->extractVar($content, 'APP_HOST');
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
$appUrl = $this->extractVar($content, 'APP_URL');
$appHost = parse_url($appUrl ?: '', PHP_URL_HOST) ?: '';
}
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
try {
$appHost = (string) \App\Models\Setting::get('ui_domain', '');
} catch (\Throwable) {
$appHost = '';
}
}
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
$this->warn('Kein gültiger Hostname gefunden Migration übersprungen.');
return self::SUCCESS;
}
$scheme = $this->detectScheme($appHost, $content);
$correctPort = $scheme === 'https' ? '443' : '80';
$viteHost = $this->extractVar($content, 'VITE_REVERB_HOST');
$currentPort = $this->extractVar($content, 'REVERB_PORT');
$hostOk = ($viteHost === '${REVERB_HOST}' || $viteHost === $appHost);
$portOk = ($currentPort === $correctPort);
if ($hostOk && $portOk) {
$this->info('REVERB-Werte bereits korrekt.');
return self::SUCCESS;
}
$port = $correctPort;
$fixes = [
'REVERB_HOST' => '${APP_HOST}',
'REVERB_PORT' => $port,
'REVERB_SCHEME' => $scheme,
'REVERB_PATH' => '/ws',
'REVERB_SERVER_HOST' => '127.0.0.1',
'REVERB_SERVER_PORT' => '8080',
'REVERB_SERVER_SCHEME' => 'http',
'REVERB_SERVER_PATH' => '',
'VITE_REVERB_HOST' => '${REVERB_HOST}',
'VITE_REVERB_PORT' => '${REVERB_PORT}',
'VITE_REVERB_SCHEME' => '${REVERB_SCHEME}',
'VITE_REVERB_PATH' => '${REVERB_PATH}',
];
foreach ($fixes as $key => $val) {
if (preg_match("/^{$key}=/m", $content)) {
$content = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $content);
} else {
$content .= "\n{$key}={$val}";
}
}
// APP_HOST setzen falls fehlend
if (!$this->extractVar($content, 'APP_HOST')) {
$content .= "\nAPP_HOST={$appHost}";
}
file_put_contents($env, $content);
$this->info("REVERB .env migriert für Host: {$appHost}");
// Assets neu bauen damit wsHost korrekt eingebacken wird
$buildLog = base_path('../mailwolt-frontend-build.log');
exec('cd ' . escapeshellarg(base_path()) . ' && npm run build --silent 2>/dev/null', $out, $rc);
if ($rc !== 0) {
$this->warn('npm run build fehlgeschlagen bitte manuell ausführen.');
} else {
$this->info('Assets neu gebaut.');
}
return self::SUCCESS;
}
private function detectScheme(string $host, string $envContent): string
{
// 1. APP_URL in .env bereits https?
$appUrl = $this->extractVar($envContent, 'APP_URL');
if (str_starts_with($appUrl, 'https://')) return 'https';
// 2. nginx-Konfiguration prüfen (world-readable)
foreach (glob('/etc/nginx/sites-enabled/*') ?: [] as $f) {
$c = @file_get_contents($f) ?: '';
if (str_contains($c, $host) && str_contains($c, 'ssl_certificate')) return 'https';
}
// 3. letsencrypt-Pfade (falls doch lesbar)
if (file_exists("/etc/letsencrypt/renewal/{$host}.conf")
|| is_dir("/etc/letsencrypt/live/{$host}")
|| file_exists("/etc/letsencrypt/live/{$host}/fullchain.pem")) {
return 'https';
}
return 'http';
}
private function extractVar(string $content, string $key): string
{
preg_match("/^{$key}=(.*)$/m", $content, $m);
return trim($m[1] ?? '', " \t\"'");
}
}

View File

@ -1,168 +0,0 @@
<?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

@ -7,7 +7,7 @@
//
//class ProvisionCert extends Command
//{
//// protected $signature = 'clubird:provision-cert
//// protected $signature = 'mailwolt:provision-cert
//// {domain : z.B. mail.example.com}
//// {--email= : E-Mail für Let\'s Encrypt}
//// {--self-signed : Statt LE ein self-signed Zertifikat erzeugen}';

View File

@ -1,152 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use Illuminate\Console\Command;
class RestoreRun extends Command
{
protected $signature = 'restore:run {backupJobId : ID des Quell-Backups} {token : Statusdatei-Token}';
protected $description = 'Stellt ein Backup wieder her und schreibt den Status in eine Temp-Datei';
public function handle(): int
{
$sourceJob = BackupJob::find($this->argument('backupJobId'));
$token = $this->argument('token');
$statusFile = sys_get_temp_dir() . '/' . $token . '.json';
$artifact = $sourceJob?->artifact_path;
if (!$artifact || !file_exists($artifact)) {
$this->writeStatus($statusFile, 'failed', ['✗ Archivdatei nicht gefunden: ' . $artifact]);
return self::FAILURE;
}
$this->writeStatus($statusFile, 'running', ['Archiv wird extrahiert…']);
$extractDir = sys_get_temp_dir() . '/mailwolt_restore_' . $token;
mkdir($extractDir, 0700, true);
$log = [];
$exitCode = 0;
// ── 1. Archiv extrahieren ─────────────────────────────────────────
$tarOut = [];
$tarExit = 0;
exec(
"tar --ignore-failed-read -xzf " . escapeshellarg($artifact)
. " -C " . escapeshellarg($extractDir) . " 2>&1",
$tarOut, $tarExit
);
if ($tarExit > 1) {
$log[] = '✗ Extraktion fehlgeschlagen: ' . implode('; ', array_slice($tarOut, -3));
$exitCode = 1;
} else {
$log[] = '✓ Archiv extrahiert';
}
$this->writeStatus($statusFile, 'running', $log);
// ── 2. Datenbank ─────────────────────────────────────────────────
$dbFiles = [];
exec("find " . escapeshellarg($extractDir) . " -name 'database.sql' -type f 2>/dev/null", $dbFiles);
if (!empty($dbFiles)) {
$log[] = 'Datenbank wird importiert…';
$this->writeStatus($statusFile, 'running', $log);
[$ok, $msg] = $this->importDatabase($dbFiles[0]);
$log[] = $ok ? '✓ Datenbank wiederhergestellt' : '✗ Datenbank: ' . $msg;
if (!$ok) $exitCode = 1;
} else {
$log[] = '— Kein Datenbank-Dump im Archiv';
}
$this->writeStatus($statusFile, 'running', $log);
// ── 3. E-Mails (Maildirs) ────────────────────────────────────────
foreach (["{$extractDir}/var/mail", "{$extractDir}/var/vmail"] as $mailSrc) {
if (is_dir($mailSrc)) {
$log[] = 'E-Mails werden wiederhergestellt…';
$this->writeStatus($statusFile, 'running', $log);
$destParent = '/' . implode('/', array_slice(explode('/', $mailSrc, -1 + substr_count($mailSrc, '/')), 1, -1));
$cpOut = [];
$cpExit = 0;
exec("cp -rp " . escapeshellarg($mailSrc) . " " . escapeshellarg($destParent) . "/ 2>&1", $cpOut, $cpExit);
$log[] = $cpExit === 0
? '✓ E-Mails wiederhergestellt'
: '✗ Mails: ' . implode('; ', array_slice($cpOut, -3));
if ($cpExit !== 0) $exitCode = 1;
}
}
// ── 4. Konfiguration ─────────────────────────────────────────────
$etcSrc = "{$extractDir}/etc";
if (is_dir($etcSrc)) {
$log[] = 'Konfiguration wird wiederhergestellt…';
$this->writeStatus($statusFile, 'running', $log);
foreach (scandir($etcSrc) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
$cpOut = [];
$cpExit = 0;
exec("cp -rp " . escapeshellarg("{$etcSrc}/{$entry}") . " /etc/ 2>&1", $cpOut, $cpExit);
$log[] = $cpExit === 0
? '✓ /etc/' . $entry
: '— /etc/' . $entry . ': ' . implode('; ', array_slice($cpOut, -1));
}
}
// ── 5. Dienste neu laden ─────────────────────────────────────────
foreach (['postfix', 'dovecot'] as $svc) {
if (is_dir("{$etcSrc}/{$svc}")) {
$restOut = [];
exec("systemctl reload-or-restart {$svc} 2>&1", $restOut, $restExit);
$log[] = $restExit === 0 ? '✓ ' . $svc . ' neu geladen' : '— ' . $svc . ': ' . implode('; ', $restOut);
}
}
// ── Aufräumen ────────────────────────────────────────────────────
exec("rm -rf " . escapeshellarg($extractDir));
$finalStatus = $exitCode === 0 ? 'ok' : 'failed';
$this->writeStatus($statusFile, $finalStatus, $log);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
private function importDatabase(string $sqlFile): array
{
$conn = config('database.connections.' . config('database.default'));
if (($conn['driver'] ?? '') !== 'mysql') {
return [false, 'Nur MySQL/MariaDB wird unterstützt.'];
}
$cmd = 'MYSQL_PWD=' . escapeshellarg($conn['password'] ?? '')
. ' mysql'
. ' -h ' . escapeshellarg($conn['host'] ?? '127.0.0.1')
. ' -P ' . escapeshellarg((string)($conn['port'] ?? '3306'))
. ' -u ' . escapeshellarg($conn['username'] ?? '')
. ' ' . escapeshellarg($conn['database'] ?? '')
. ' < ' . escapeshellarg($sqlFile)
. ' 2>&1';
$output = [];
$exit = 0;
exec($cmd, $output, $exit);
return $exit === 0
? [true, '']
: [false, implode('; ', array_slice($output, -3))];
}
private function writeStatus(string $file, string $status, array $log): void
{
file_put_contents($file, json_encode([
'status' => $status,
'log' => implode("\n", $log),
'timestamp' => time(),
]));
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\SandboxMailParser;
use Illuminate\Console\Command;
class SandboxReceive extends Command
{
protected $signature = 'sandbox:receive {--to=* : Envelope recipients}';
protected $description = 'Receive a raw email from Postfix pipe and store in sandbox';
public function handle(SandboxMailParser $parser): int
{
$raw = '';
$stdin = fopen('php://stdin', 'r');
while (!feof($stdin)) {
$raw .= fread($stdin, 8192);
}
fclose($stdin);
if (empty(trim($raw))) {
return 1;
}
$recipients = $this->option('to') ?? [];
$parser->parseAndStore($raw, $recipients);
return 0;
}
}

View File

@ -1,6 +1,5 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
@ -11,11 +10,6 @@ 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') ?: '/';
@ -24,26 +18,17 @@ class StorageProbe extends Command
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',
'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%.1fGB mails=%.1fGB backups=%.1fGB',
$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'])
$data['breakdown']['system_gb'],
$data['breakdown']['mails_gb'],
$data['breakdown']['backup_gb'],
));
return self::SUCCESS;
@ -51,11 +36,12 @@ class StorageProbe extends Command
protected function probe(string $target): array
{
// ── 1) df lesen (Gesamt/Frei inkl. Reserve) ──────────────────────────
// --- 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) {
@ -67,267 +53,55 @@ class StorageProbe extends Command
}
}
$toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // Ganzzahl für Kopfzahlen
$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);
$usedGb = max(0, $totalGb - $freeGb);
$res5Gb = (int)round($totalGb * 0.05);
$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;
// Bytes für Breakdown rechnen
$totalBytes = (int)$totalKb * 1024;
$freeBytes = (int)$availKb * 1024;
$usedBytes = max(0, $totalBytes - $freeBytes);
// --- du: reale Verbräuche bestimmter Bäume (KB) -----------------------
$duKb = function (string $path): int {
if (!is_dir($path)) return 0;
$kb = (int)trim((string)@shell_exec('LC_ALL=C du -sk --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1'));
return max(0, $kb);
};
// ── 2) Mails: bevorzugt aus Cache (mail:update-stats) ────────────────
[$mailUsersBytes, $mailSystemBytes] = $this->readMailTotals();
$kbMails = $duKb('/var/mail/vhosts');
$kbBackup = $duKb('/var/backups/mailwolt');
// ── 3) Backups schnell messen ────────────────────────────────────────
$bytesBackup = $this->duBytesDir('/var/backups/mailwolt');
// „System“ = alles übrige, was nicht Mails/Backups ist (OS, App, Logs, DB-Daten, …)
$gbMails = $toGiB_f($kbMails);
$gbBackup = $toGiB_f($kbBackup);
// ── 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;
}
// system_gb aus „usedGb (mails+backup)“, nie negativ
$gbSystem = max(0, round($usedGb - ($gbMails + $gbBackup), 1));
return [
'device' => $device ?: 'unknown',
'mount' => $mount ?: $target,
'total_gb' => $totalGb,
'used_gb' => $usedGb,
'free_gb' => $freeGb,
'reserve5_gb' => $res5Gb,
'free_plus_reserve_gb' => $freePlusReserveGb,
'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,
'breakdown_bytes' => [
'system' => $bytesSystem,
'mails' => $bytesMails,
'backup' => $bytesBackup,
// Reale Breakdown-Werte
'breakdown' => [
'system_gb' => $gbSystem,
'mails_gb' => $gbMails,
'backup_gb' => $gbBackup,
],
];
}
/**
* 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;
//

View File

@ -15,238 +15,103 @@ class UpdateMailboxStats extends Command
protected $signature = 'mail:update-stats {--user=}';
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 = Log::channel('mailstats');
$onlyUser = trim((string)$this->option('user')) ?: null;
$t0 = microtime(true);
// Summen
$sumUserBytes = 0;
$sumSystemBytes = 0;
// aktiver Benutzerbestand (inkl. Domains, um system/non-system zu unterscheiden)
// Basis-Query: nur aktive, keine System-Mailboxen und keine System-Domains
$base = MailUser::query()
->select(['id','domain_id','localpart','email','is_active','is_system'])
->select(['id', 'domain_id', 'localpart', 'email', 'is_active', 'is_system'])
->with(['domain:id,domain,is_system'])
->where('is_active', true);
->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;
$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) {
$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}";
$isSystemDomain = (bool)($u->domain->is_system ?? false);
if ($isSystemDomain || $u->is_system) {
$sumSystemBytes += $this->sizeViaDoveadm($email) ?? $this->sizeViaFilesystem($maildir) ?? 0;
continue;
}
// Alle Ordner zählen (nicht nur INBOX) — doveadm ist primäre Quelle
$messageCount = $this->countAllFoldersViaDoveadm($email);
$usedBytes = $this->sizeViaDoveadm($email);
if ($messageCount === null) {
// Filesystem-Fallback nur wenn das Verzeichnis lesbar ist
$fsCount = $this->countViaFilesystem($maildir);
$fsSize = $this->sizeViaFilesystem($maildir);
if ($fsCount === 0 && $fsSize === null) {
$log->debug('skip (no access)', ['email' => $email]);
continue;
// 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();
}
$messageCount = $fsCount;
$usedBytes = $fsSize ?? 0;
} else {
$usedBytes = $usedBytes ?? $this->sizeViaFilesystem($maildir) ?? 0;
}
$sumUserBytes += $usedBytes;
// Message-Count
$messageCount = $this->countViaDoveadm($email);
if ($messageCount === null) {
$messageCount = $this->countViaFilesystem($maildir);
}
// Immer schreiben wenn doveadm erfolgreich war — keine Change-Detection
Setting::set("mailbox.{$email}", [
'used_bytes' => (int)$usedBytes,
$key = "mailbox.{$email}";
$prev = (array)(Setting::get($key, []) ?: []);
$new = [
'used_bytes' => (int)$usedBytes,
'message_count' => (int)$messageCount,
'updated_at' => now()->toDateTimeString(),
]);
\Illuminate\Support\Facades\DB::table('mail_users')
->where('id', $u->id)
->update([
'used_bytes' => (int)$usedBytes,
'message_count' => (int)$messageCount,
'stats_refreshed_at' => now(),
]);
$changed++;
$this->line(sprintf("%-35s %7.2f MiB %5d msgs",
$email, $usedBytes / 1048576, $messageCount));
$log->info('updated', ['email' => $email, 'used_bytes' => $usedBytes, 'message_count' => $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']]);
}
}
});
// 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'));
$ms = (int)((microtime(true) - $t0) * 1000);
$log->info('mail:update-stats DONE', compact('checked', 'changed', 'ms'));
$this->info('Mailbox-Statistiken aktualisiert.');
return self::SUCCESS;
}
private function doveadmStatus(string $email, string $fields): array
private function countViaDoveadm(string $email): ?int
{
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
. escapeshellarg($email) . " {$fields} INBOX 2>/dev/null";
. escapeshellarg($email) . " messages INBOX 2>&1";
$out = [];
$rc = 0;
$rc = 0;
exec($cmd, $out, $rc);
if ($rc !== 0) return [];
if ($rc !== 0) return null;
// header: "mailbox\tmessages" data: "INBOX\t4"
$header = null;
foreach ($out as $line) {
$parts = explode("\t", trim($line));
if ($header === null) {
$header = $parts;
continue;
if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) {
return (int)$m[1];
}
if (count($parts) !== count($header)) continue;
return array_combine($header, $parts);
}
return [];
}
private function countAllFoldersViaDoveadm(string $email): ?int
{
// Entwürfe, Papierkorb und Spam/Junk nicht mitzählen
static $exclude = ['Drafts', 'Trash', 'Junk', 'Spam'];
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
. escapeshellarg($email) . " messages '*' 2>/dev/null";
$out = [];
$rc = 0;
exec($cmd, $out, $rc);
if ($rc !== 0 || count($out) < 2) return null;
$total = 0;
$header = null;
foreach ($out as $line) {
$parts = explode("\t", trim($line));
if ($header === null) { $header = $parts; continue; }
if (count($parts) !== count($header)) continue;
$row = array_combine($header, $parts);
if (in_array($row['mailbox'] ?? '', $exclude, true)) continue;
$total += (int)($row['messages'] ?? 0);
}
return $total;
}
private function sizeViaDoveadm(string $email): ?int
{
$row = $this->doveadmStatus($email, 'vsize');
if (isset($row['vsize'])) return (int)$row['vsize'];
return null;
}
@ -256,7 +121,7 @@ class UpdateMailboxStats extends Command
foreach (['cur', 'new'] as $sub) {
$dir = "{$maildir}/{$sub}";
if (!is_dir($dir)) continue;
$h = @opendir($dir);
$h = opendir($dir);
if (!$h) continue;
while (($fn = readdir($h)) !== false) {
if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue;
@ -266,23 +131,6 @@ class UpdateMailboxStats extends Command
}
return $n;
}
private function sizeViaFilesystem(string $maildir): ?int
{
if (!is_dir($maildir) || !is_readable($maildir)) return null;
try {
$bytes = 0;
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if ($f->isFile()) $bytes += $f->getSize();
}
return $bytes;
} catch (\Throwable) {
return null;
}
}
}
//namespace App\Console\Commands;

View File

@ -1,124 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
class WizardDomains extends Command
{
protected $signature = 'clubird:wizard-domains
{--ui= : UI-Domain}
{--mail= : Mail-Domain}
{--webmail= : Webmail-Domain}
{--ssl=1 : SSL automatisch (1/0)}';
protected $aliases = ['mailwolt:wizard-domains'];
protected $description = 'Wizard: Domains einrichten mit Status-Dateien';
private const STATE_DIR = '/var/lib/mailwolt/wizard';
public function handle(): int
{
$ui = $this->option('ui');
$mail = $this->option('mail');
$webmail = $this->option('webmail');
$ssl = (bool)(int)$this->option('ssl');
@mkdir(self::STATE_DIR, 0755, true);
foreach (['ui', 'mail', 'webmail'] as $key) {
file_put_contents(self::STATE_DIR . "/{$key}", 'pending');
}
$domains = ['ui' => $ui, 'mail' => $mail, 'webmail' => $webmail];
$allOk = true;
// DNS prüfen
foreach ($domains as $key => $domain) {
if (!$domain) {
file_put_contents(self::STATE_DIR . "/{$key}", 'skip');
continue;
}
file_put_contents(self::STATE_DIR . "/{$key}", 'running');
$hasDns = checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
if (!$hasDns) {
file_put_contents(self::STATE_DIR . "/{$key}", 'nodns');
$allOk = false;
}
}
if (!$allOk) {
// Domains die noch auf "running" stehen wurden nie verarbeitet → error
foreach (['ui', 'mail', 'webmail'] as $key) {
$status = trim((string) @file_get_contents(self::STATE_DIR . "/{$key}"));
if ($status === 'running') {
file_put_contents(self::STATE_DIR . "/{$key}", 'error');
}
}
file_put_contents(self::STATE_DIR . '/done', '0');
Setting::set('ssl_configured', '0');
return self::SUCCESS;
}
// Nginx-Vhosts + optionales SSL via mailwolt-apply-domains
// Das Script erstellt erst die Vhosts (mit ACME-Location), dann certbot --webroot
$helper = '/usr/local/sbin/mailwolt-apply-domains';
$out = shell_exec(sprintf(
'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d',
escapeshellarg($helper),
escapeshellarg($ui),
escapeshellarg($webmail),
escapeshellarg($mail),
$ssl ? 1 : 0,
));
// Shell-Script schreibt per-Domain-Status selbst in die State-Dateien.
// Fallback: Domains die noch auf running/pending stehen auf error setzen.
foreach (['ui', 'mail', 'webmail'] as $key) {
$status = trim((string) @file_get_contents(self::STATE_DIR . "/{$key}"));
if ($status === 'running' || $status === 'pending') {
file_put_contents(self::STATE_DIR . "/{$key}", 'error');
}
}
// done-Datei: Shell-Script schreibt "1"/"0"; Fallback wenn Script abstürzte.
$doneVal = trim((string) @file_get_contents(self::STATE_DIR . '/done'));
if ($doneVal === '') {
file_put_contents(self::STATE_DIR . '/done', '0');
$doneVal = '0';
}
// ssl_configured anhand tatsächlich ausgestellter LE-Zertifikate bestimmen
$hasAnyCert = false;
foreach ($domains as $domain) {
if ($domain && is_dir("/etc/letsencrypt/live/{$domain}")) {
$hasAnyCert = true;
break;
}
}
Setting::set('ssl_configured', $hasAnyCert ? '1' : '0');
// SESSION_SECURE_COOKIE wird nicht automatisch gesetzt —
// nginx leitet HTTP→HTTPS weiter, Secure-Flag wird im Admin gesetzt
return self::SUCCESS;
}
private function updateEnv(string $path, string $key, string $value): void
{
$content = @file_get_contents($path) ?: '';
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
$line = $key . '=' . $value;
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
}
file_put_contents($path, $content);
}
}

View File

@ -4,35 +4,12 @@ namespace App\Enums;
enum Role: string
{
case Admin = 'admin';
case Operator = 'operator';
case Viewer = 'viewer';
public function label(): string
{
return match($this) {
self::Admin => 'Admin',
self::Operator => 'Operator',
self::Viewer => 'Viewer',
};
}
public function badgeClass(): string
{
return match($this) {
self::Admin => 'role-badge-admin',
self::Operator => 'role-badge-op',
self::Viewer => 'mbx-badge-mute',
};
}
case Member = 'member';
case Admin = 'admin';
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public static function options(): array
{
return array_map(fn($r) => ['value' => $r->value, 'label' => $r->label()], self::cases());
}
}

View File

@ -1,26 +0,0 @@
<?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,6 +31,7 @@ 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);
@ -38,25 +39,17 @@ 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

@ -1,103 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailAlias;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AliasController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = MailAlias::with(['domain', 'recipients'])
->where('is_system', false);
if ($request->filled('domain')) {
$query->whereHas('domain', fn($q) => $q->where('domain', $request->domain));
}
$aliases = $query->orderBy('local')->paginate(100);
return response()->json([
'data' => $aliases->map(fn($a) => $this->format($a)),
'meta' => ['total' => $aliases->total(), 'per_page' => $aliases->perPage(), 'current_page' => $aliases->currentPage()],
]);
}
public function show(int $id): JsonResponse
{
$alias = MailAlias::with(['domain', 'recipients'])->where('is_system', false)->findOrFail($id);
return response()->json(['data' => $this->format($alias)]);
}
public function store(Request $request): JsonResponse
{
$request->tokenCan('aliases:write') || abort(403, 'Scope aliases:write required.');
if ($request->isSandbox ?? false) {
return response()->json(['data' => array_merge(['id' => 9999], $request->only('local', 'domain')), 'sandbox' => true], 201);
}
$data = $request->validate([
'local' => 'required|string|max:64',
'domain' => 'required|string',
'recipients' => 'required|array|min:1',
'recipients.*'=> 'email',
'is_active' => 'nullable|boolean',
]);
$domain = Domain::where('domain', $data['domain'])->firstOrFail();
$alias = MailAlias::create([
'domain_id' => $domain->id,
'local' => $data['local'],
'type' => 'alias',
'is_active' => $data['is_active'] ?? true,
]);
foreach ($data['recipients'] as $i => $addr) {
$alias->recipients()->create(['address' => $addr, 'position' => $i]);
}
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('alias.created', $this->format($alias->load(['domain', 'recipients'])));
}
return response()->json(['data' => $this->format($alias->load(['domain', 'recipients']))], 201);
}
public function destroy(Request $request, int $id): JsonResponse
{
$request->tokenCan('aliases:write') || abort(403, 'Scope aliases:write required.');
$alias = MailAlias::where('is_system', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['sandbox' => true], 204);
}
$formatted = $this->format($alias->load(['domain', 'recipients']));
$alias->recipients()->delete();
$alias->delete();
app(WebhookService::class)->dispatch('alias.deleted', $formatted);
return response()->json(null, 204);
}
private function format(MailAlias $a): array
{
return [
'id' => $a->id,
'address' => $a->address,
'local' => $a->local,
'domain' => $a->domain?->domain,
'type' => $a->type,
'is_active' => $a->is_active,
'recipients' => $a->recipients->pluck('address'),
'created_at' => $a->created_at->toIso8601String(),
];
}
}

View File

@ -1,83 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DomainController extends Controller
{
public function index(): JsonResponse
{
$domains = Domain::where('is_system', false)
->where('is_server', false)
->orderBy('domain')
->get()
->map(fn($d) => $this->format($d));
return response()->json(['data' => $domains]);
}
public function show(int $id): JsonResponse
{
$domain = Domain::where('is_system', false)->where('is_server', false)->findOrFail($id);
return response()->json(['data' => $this->format($domain)]);
}
public function store(Request $request): JsonResponse
{
$request->tokenCan('domains:write') || abort(403, 'Scope domains:write required.');
if ($request->isSandbox ?? false) {
return response()->json(['data' => array_merge(['id' => 9999], $request->only('domain')), 'sandbox' => true], 201);
}
$data = $request->validate([
'domain' => 'required|string|max:253|unique:domains,domain',
'description' => 'nullable|string|max:255',
'max_aliases' => 'nullable|integer|min:0',
'max_mailboxes' => 'nullable|integer|min:0',
'default_quota_mb' => 'nullable|integer|min:0',
]);
$domain = Domain::create($data);
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('domain.created', $this->format($domain));
}
return response()->json(['data' => $this->format($domain)], 201);
}
public function destroy(Request $request, int $id): JsonResponse
{
$request->tokenCan('domains:write') || abort(403, 'Scope domains:write required.');
$domain = Domain::where('is_system', false)->where('is_server', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['sandbox' => true], 204);
}
$formatted = $this->format($domain);
$domain->delete();
app(WebhookService::class)->dispatch('domain.deleted', $formatted);
return response()->json(null, 204);
}
private function format(Domain $d): array
{
return [
'id' => $d->id,
'domain' => $d->domain,
'description' => $d->description,
'is_active' => $d->is_active,
'max_aliases' => $d->max_aliases,
'max_mailboxes' => $d->max_mailboxes,
'default_quota_mb' => $d->default_quota_mb,
'created_at' => $d->created_at->toIso8601String(),
];
}
}

View File

@ -1,132 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailUser;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MailboxController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = MailUser::with('domain')
->where('is_system', false);
if ($request->filled('domain')) {
$query->whereHas('domain', fn($q) => $q->where('domain', $request->domain));
}
if ($request->filled('active')) {
$query->where('is_active', filter_var($request->active, FILTER_VALIDATE_BOOLEAN));
}
$mailboxes = $query->orderBy('email')->paginate(100);
return response()->json([
'data' => $mailboxes->map(fn($m) => $this->format($m)),
'meta' => ['total' => $mailboxes->total(), 'per_page' => $mailboxes->perPage(), 'current_page' => $mailboxes->currentPage()],
]);
}
public function show(int $id): JsonResponse
{
$mailbox = MailUser::with('domain')->where('is_system', false)->findOrFail($id);
return response()->json(['data' => $this->format($mailbox)]);
}
public function store(Request $request): JsonResponse
{
$request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.');
if ($request->isSandbox ?? false) {
return response()->json(['data' => array_merge(['id' => 9999], $request->only('email')), 'sandbox' => true], 201);
}
$data = $request->validate([
'email' => 'required|email|unique:mail_users,email',
'password' => 'required|string|min:8',
'display_name'=> 'nullable|string|max:120',
'quota_mb' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
]);
[$local, $domainName] = explode('@', $data['email']);
$domain = Domain::where('domain', $domainName)->firstOrFail();
$mailbox = MailUser::create([
'domain_id' => $domain->id,
'localpart' => $local,
'email' => $data['email'],
'display_name' => $data['display_name'] ?? null,
'password_hash'=> '{ARGON2I}' . base64_encode(password_hash($data['password'], PASSWORD_ARGON2I)),
'quota_mb' => $data['quota_mb'] ?? $domain->default_quota_mb ?? 1024,
'is_active' => $data['is_active'] ?? true,
]);
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('mailbox.created', $this->format($mailbox->load('domain')));
}
return response()->json(['data' => $this->format($mailbox->load('domain'))], 201);
}
public function update(Request $request, int $id): JsonResponse
{
$request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.');
$mailbox = MailUser::where('is_system', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['data' => $this->format($mailbox), 'sandbox' => true]);
}
$data = $request->validate([
'display_name' => 'nullable|string|max:120',
'quota_mb' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'can_login' => 'nullable|boolean',
]);
$mailbox->update(array_filter($data, fn($v) => !is_null($v)));
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('mailbox.updated', $this->format($mailbox->load('domain')));
}
return response()->json(['data' => $this->format($mailbox->load('domain'))]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.');
$mailbox = MailUser::where('is_system', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['sandbox' => true], 204);
}
$formatted = $this->format($mailbox->load('domain'));
$mailbox->delete();
app(WebhookService::class)->dispatch('mailbox.deleted', $formatted);
return response()->json(null, 204);
}
private function format(MailUser $m): array
{
return [
'id' => $m->id,
'email' => $m->email,
'display_name' => $m->display_name,
'domain' => $m->domain?->domain,
'quota_mb' => $m->quota_mb,
'is_active' => $m->is_active,
'can_login' => $m->can_login,
'last_login_at'=> $m->last_login_at?->toIso8601String(),
'created_at' => $m->created_at->toIso8601String(),
];
}
}

View File

@ -4,20 +4,11 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function show()
{
return view('auth.login');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
return view('auth.login'); // enthält @livewire('login-form')
}
}

View File

@ -3,147 +3,14 @@
namespace App\Http\Controllers\UI;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailUser;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index()
{
// ggf. Prefetch für Blade, sonst alles via Livewire
return view('ui.dashboard.index');
}
public function redesign()
{
$services = [
['name' => 'Postfix', 'type' => 'MTA', 'online' => $this->isRunning('postfix')],
['name' => 'Dovecot', 'type' => 'IMAP', 'online' => $this->isRunning('dovecot')],
['name' => 'Rspamd', 'type' => 'Spam', 'online' => $this->isRunning('rspamd')],
['name' => 'OpenDKIM', 'type' => 'DKIM', 'online' => $this->isRunning('opendkim')],
['name' => 'MariaDB', 'type' => 'DB', 'online' => $this->isRunning('mariadb')],
['name' => 'Redis', 'type' => 'Cache', 'online' => $this->isRunning('redis')],
['name' => 'Nginx', 'type' => 'Web', 'online' => $this->isRunning('nginx')],
['name' => 'ClamAV', 'type' => 'AV', 'online' => $this->isRunning('clamav')],
];
$servicesActive = count(array_filter($services, fn($s) => $s['online']));
$servicesTotal = count($services);
[$cpu, $cpuCores, $cpuMhz] = $this->getCpu();
[$ramPercent, $ramUsed, $ramTotal] = $this->getRam();
[$load1, $load5, $load15] = $this->getLoad();
[$uptimeDays, $uptimeHours] = $this->getUptime();
[$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->getDisk();
return view('ui.dashboard.redesign', [
'domainCount' => Domain::count(),
'mailboxCount' => MailUser::count(),
'servicesActive' => $servicesActive,
'servicesTotal' => $servicesTotal,
'alertCount' => 0,
'mailHostname' => gethostname() ?: 'mailserver',
'services' => $services,
'cpu' => $cpu,
'cpuCores' => $cpuCores,
'cpuMhz' => $cpuMhz,
'ramPercent' => $ramPercent,
'ramUsed' => $ramUsed,
'ramTotal' => $ramTotal,
'load1' => $load1,
'load5' => $load5,
'load15' => $load15,
'uptimeDays' => $uptimeDays,
'uptimeHours' => $uptimeHours,
'diskUsedPercent' => $diskUsedPercent,
'diskUsedGb' => $diskUsedGb,
'diskFreeGb' => $diskFreeGb,
'diskTotalGb' => $diskTotalGb,
'bounceCount' => 0,
'spamCount' => 0,
'lastBackup' => '—',
'backupSize' => '—',
'backupDuration' => '—',
]);
}
private function isRunning(string $service): bool
{
exec("systemctl is-active --quiet " . escapeshellarg($service) . " 2>/dev/null", $out, $code);
return $code === 0;
}
private function getCpu(): array
{
$cores = (int) shell_exec("nproc 2>/dev/null") ?: 1;
$mhz = round((float) shell_exec("awk '/^cpu MHz/{sum+=$4; n++} END{if(n)print sum/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1);
$s1 = $this->readStat();
usleep(400000);
$s2 = $this->readStat();
// /proc/stat fields: user(0) nice(1) system(2) idle(3) iowait(4) irq(5) softirq(6) steal(7)
$idle1 = $s1[3] + ($s1[4] ?? 0);
$idle2 = $s2[3] + ($s2[4] ?? 0);
$total1 = array_sum($s1);
$total2 = array_sum($s2);
$dt = $total2 - $total1;
$di = $idle2 - $idle1;
$cpu = $dt > 0 ? max(0, min(100, round(($dt - $di) / $dt * 100))) : 0;
return [$cpu, $cores, $mhz ?: '—'];
}
private function readStat(): array
{
$raw = trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: '');
$parts = preg_split('/\s+/', $raw);
array_shift($parts); // remove 'cpu' label
return array_map('intval', $parts);
}
private function getRam(): array
{
$raw = shell_exec("cat /proc/meminfo 2>/dev/null") ?: '';
preg_match('/MemTotal:\s+(\d+)/', $raw, $mt);
preg_match('/MemAvailable:\s+(\d+)/', $raw, $ma);
$total = isset($mt[1]) ? (int)$mt[1] : 0;
$avail = isset($ma[1]) ? (int)$ma[1] : 0;
$used = $total - $avail;
$percent = $total > 0 ? round($used / $total * 100) : 0;
return [
$percent,
round($used / 1048576, 1),
round($total / 1048576, 1),
];
}
private function getLoad(): array
{
$raw = trim(shell_exec("cat /proc/loadavg 2>/dev/null") ?: '');
$p = explode(' ', $raw);
return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00'];
}
private function getUptime(): array
{
$secs = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime 2>/dev/null") ?: 0);
return [intdiv($secs, 86400), intdiv($secs % 86400, 3600)];
}
private function getDisk(): array
{
$raw = trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: '');
$p = preg_split('/\s+/', $raw);
$total = isset($p[1]) ? (int)$p[1] : 0;
$used = isset($p[2]) ? (int)$p[2] : 0;
$free = isset($p[3]) ? (int)$p[3] : 0;
$percent = $total > 0 ? round($used / $total * 100) : 0;
return [$percent, $used, $free, $total];
}
}

View File

@ -1,13 +0,0 @@
<?php
namespace App\Http\Controllers\UI\V2\Mail;
use App\Http\Controllers\Controller;
class MailboxController extends Controller
{
public function index()
{
return view('ui.v2.mail.mailbox-index');
}
}

View File

@ -18,7 +18,7 @@ class GuestOnlyMiddleware
{
if (Auth::check()) {
// Eingeloggt → z. B. Dashboard weiterleiten
return redirect()->route('ui.dashboard');
return redirect()->route('dashboard');
}
return $next($request);

View File

@ -1,20 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class InjectSandboxMode
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->user()?->currentAccessToken();
if ($token && $token->sandbox) {
$request->merge(['isSandbox' => true]);
}
return $next($request);
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Services\TotpService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Require2FA
{
public function __construct(private TotpService $totp) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user) return $next($request);
if (!$this->totp->isEnabled($user)) return $next($request);
if ($request->session()->get('2fa_verified')) return $next($request);
if ($request->routeIs('auth.2fa*')) return $next($request);
return redirect()->route('auth.2fa');
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Role;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user || !in_array($user->role?->value, $roles, true)) {
abort(403, 'Keine Berechtigung.');
}
return $next($request);
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateHost
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
if ($this->isAllowed($host)) {
return $next($request);
}
abort(404);
}
private function isAllowed(string $host): bool
{
// Always allow localhost and loopback (health checks, artisan, etc.)
if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
return true;
}
$base = config('clubird.domain.base');
$uiSub = config('clubird.domain.ui');
$mtaSub = config('clubird.domain.mail');
$wmHost = config('clubird.domain.webmail_host');
$allowed = array_filter([
$wmHost,
$uiSub && $base ? "{$uiSub}.{$base}" : null,
$mtaSub && $base ? "{$mtaSub}.{$base}" : null,
// APP_HOST as fallback (e.g. during setup before domains are saved)
parse_url(config('app.url'), PHP_URL_HOST) ?: null,
]);
return in_array($host, $allowed, true);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ClamavEnable implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 120;
public function handle(): void
{
exec('sudo -n /usr/local/sbin/mailwolt-clamav enable 2>&1', $out, $rc);
\Log::info('ClamavEnable job', ['rc' => $rc, 'out' => $out]);
if ($rc !== 0) {
throw new \RuntimeException('mailwolt-clamav enable failed (rc=' . $rc . '): ' . implode(' ', $out));
}
}
}

View File

@ -21,7 +21,7 @@ class InstallDkimKey implements ShouldQueue
public int $dkimKeyId,
public string $privPath,
public string $dnsTxtContent,
public string $selector = 'clb1',
public string $selector = 'mwl1',
) {}
public function handle(): void

View File

@ -67,7 +67,7 @@ class ProvisionCertJob implements ShouldQueue
if ($this->useLetsEncrypt) {
$this->emit($task, 'running', 'Lets Encrypt wird ausgeführt…', $mode);
$exit = Artisan::call('clubird:provision-cert', [
$exit = Artisan::call('mailwolt:provision-cert', [
'domain' => $this->domain,
'--email' => $this->email ?? '',
]);
@ -83,14 +83,14 @@ class ProvisionCertJob implements ShouldQueue
// Fallback → self-signed
$mode = 'self-signed';
$exit = Artisan::call('clubird:provision-cert', [
$exit = Artisan::call('mailwolt:provision-cert', [
'domain' => $this->domain,
'--self-signed' => true,
]);
}
} else {
$this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode);
$exit = Artisan::call('clubird:provision-cert', [
$exit = Artisan::call('mailwolt:provision-cert', [
'domain' => $this->domain,
'--self-signed' => true,
]);
@ -120,7 +120,7 @@ class ProvisionCertJob implements ShouldQueue
// $task->update(['message' => 'Lets Encrypt wird ausgeführt…']);
// $this->syncCache($task);
//
// $exit = Artisan::call('clubird:provision-cert', [
// $exit = Artisan::call('mailwolt:provision-cert', [
// 'domain' => $this->domain,
// '--email' => $this->email ?? '',
// ]);
@ -131,7 +131,7 @@ class ProvisionCertJob implements ShouldQueue
// $this->syncCache($task);
//
// // Fallback: Self-Signed
// $exit = Artisan::call('clubird:provision-cert', [
// $exit = Artisan::call('mailwolt:provision-cert', [
// 'domain' => $this->domain,
// '--self-signed' => true,
// ]);
@ -140,7 +140,7 @@ class ProvisionCertJob implements ShouldQueue
// $task->update(['message' => 'Self-Signed wird erstellt…']);
// $this->syncCache($task);
//
// $exit = Artisan::call('clubird:provision-cert', [
// $exit = Artisan::call('mailwolt:provision-cert', [
// 'domain' => $this->domain,
// '--self-signed' => true,
// ]);

View File

@ -4,33 +4,43 @@ namespace App\Jobs;
use App\Models\Setting as SettingsModel;
use App\Support\CacheVer;
use App\Support\WoltGuard\MonitClient;
use App\Support\WoltGuard\Probes;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Finder\Finder;
class RunHealthChecks implements ShouldQueue
{
use Queueable;
use Queueable, Probes;
public int $timeout = 10;
public int $tries = 1;
public function handle(): void
{
$monit = new MonitClient();
$svcRows = $monit->services();
if (empty($svcRows)) {
Log::warning('WG: Monit nicht erreichbar kein Update');
return;
$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);
Log::info('WG: services from Monit', ['count' => count($svcRows), 'key' => CacheVer::k('health:services'), 'names' => array_column($svcRows, 'name')]);
Cache::forget('health:services');
}
/** Wraps a probe; logs and returns fallback on error */

View File

@ -12,7 +12,6 @@ class LoginForm extends Component
public string $name = '';
public string $password = '';
public bool $remember = false;
public ?string $error = null;
public bool $show = false;
@ -46,7 +45,6 @@ class LoginForm extends Component
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
request()->session()->regenerate();
Auth::user()->update(['last_login_at' => now()]);
return redirect()->intended(route('ui.dashboard'));
}

View File

@ -1,47 +0,0 @@
<?php
namespace App\Livewire\Auth;
use App\Models\TwoFactorRecoveryCode;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class TwoFaChallenge extends Component
{
public string $code = '';
public bool $useRecovery = false;
public ?string $error = null;
public function verify(): mixed
{
$this->error = null;
$user = Auth::user();
if ($this->useRecovery) {
$this->validate(['code' => 'required|string']);
if (!TwoFactorRecoveryCode::verifyAndConsume($user->id, strtoupper(trim($this->code)))) {
$this->error = 'Ungültiger Recovery-Code.';
return null;
}
} else {
$this->validate(['code' => 'required|digits:6']);
$secret = app(TotpService::class)->getSecret($user);
if (!$secret || !app(TotpService::class)->verify($secret, $this->code)) {
$this->error = 'Ungültiger Code. Bitte erneut versuchen.';
return null;
}
}
session()->put('2fa_verified', true);
return redirect()->intended(route('ui.dashboard'));
}
public function render()
{
return view('livewire.auth.two-fa-challenge')
->layout('layouts.blank');
}
}

View File

@ -2,243 +2,212 @@
namespace App\Livewire\Setup;
use App\Models\Setting;
use App\Jobs\ProvisionCertJob;
use App\Support\Setting;
use App\Models\SystemTask;
use App\Models\User;
use App\Support\EnvWriter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Illuminate\Support\Facades\Redis;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.setup')]
#[Title('Einrichtung · Mailwolt')]
class Wizard extends Component
{
public int $step = 1;
public int $totalSteps = 5;
public int $step = 1;
// Schritt 1 — System
public string $instance_name = 'Mailwolt';
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
// Step 1
#[Validate('required|string|min:3')]
public string $form_domain = '';
// Schritt 2 — Domains
public string $ui_domain = '';
public string $mail_domain = '';
public string $webmail_domain = '';
#[Validate('required|timezone')]
public string $form_timezone = 'UTC';
// Schritt 4 — Option
public bool $skipSsl = false;
public bool $form_cert_force_https = true;
// Schritt 3 — Admin-Account
public string $admin_name = '';
public string $admin_email = '';
public string $admin_password = '';
public string $admin_password_confirmation = '';
// Step 2
#[Validate('required|string|min:3')]
public string $form_admin_name = '';
// Schritt 5 — Domain-Setup Status
public array $domainStatus = [
'ui' => 'pending',
'mail' => 'pending',
'webmail' => 'pending',
];
public bool $setupDone = false;
#[Validate('required|email')]
public string $form_admin_email = '';
private const STATE_DIR = '/var/lib/mailwolt/wizard';
/** optional: wenn du Login auch über username erlauben willst */
public ?string $form_admin_username = null;
public function mount()
#[Validate('required|string|min:8|same:form_admin_password_confirmation')]
public string $form_admin_password = '';
public string $form_admin_password_confirmation = '';
// Step 3 (Zertifikat)
public bool $form_cert_create_now = false;
#[Validate('required_if:form_cert_create_now,true|email')]
public string $form_cert_email = '';
public function nextStep()
{
$this->instance_name = config('app.name', 'Mailwolt');
try {
$this->timezone = Setting::get('timezone', 'Europe/Berlin');
$this->locale = Setting::get('locale', 'de');
$this->ui_domain = Setting::get('ui_domain', '');
$this->mail_domain = Setting::get('mail_domain', '');
$this->webmail_domain = Setting::get('webmail_domain', '');
} catch (\Throwable) {
// DB noch nicht migriert — Standardwerte bleiben
if ($this->step === 1) {
$this->validateOnly('form_domain');
$this->validateOnly('form_timezone');
} elseif ($this->step === 2) {
$this->validate([
'form_admin_name' => 'required|string|min:3',
'form_admin_email' => 'required|email',
'form_admin_password' => 'required|string|min:8|same:form_admin_password_confirmation',
]);
}
$this->step = min($this->step + 1, 3);
}
public function updatedUiDomain(): void { $this->fillEmptyDomains($this->ui_domain); }
public function updatedMailDomain(): void { $this->fillEmptyDomains($this->mail_domain); }
public function updatedWebmailDomain(): void { $this->fillEmptyDomains($this->webmail_domain); }
private function fillEmptyDomains(string $value): void
{
if ($value === '') return;
if ($this->ui_domain === '') $this->ui_domain = $value;
if ($this->mail_domain === '') $this->mail_domain = $value;
if ($this->webmail_domain === '') $this->webmail_domain = $value;
}
public function next(): void
{
match ($this->step) {
1 => $this->validate([
'instance_name' => 'required|string|min:2|max:64',
'locale' => 'required|in:de,en,fr',
'timezone' => 'required|timezone',
]),
2 => $this->validate([
'ui_domain' => ['required', 'regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
'mail_domain' => ['required', 'regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
'webmail_domain' => ['required', 'regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
], [
'ui_domain.required' => 'Pflichtfeld.',
'mail_domain.required' => 'Pflichtfeld.',
'webmail_domain.required' => 'Pflichtfeld.',
'ui_domain.regex' => 'Ungültige Domain — kein Schema (http://) erlaubt.',
'mail_domain.regex' => 'Ungültige Domain — kein Schema (http://) erlaubt.',
'webmail_domain.regex' => 'Ungültige Domain — kein Schema (http://) erlaubt.',
]),
3 => $this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
'admin_password_confirmation' => 'required',
], [
'admin_password.min' => 'Mindestens 6 Zeichen.',
'admin_password.same' => 'Passwörter stimmen nicht überein.',
]),
default => null,
};
$this->step = min($this->step + 1, $this->totalSteps);
}
public function back(): void
public function prevStep()
{
$this->step = max($this->step - 1, 1);
}
public function finish(): void
public function finish()
{
$this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
// Step 3 Validierung (nur wenn sofort erstellen)
if ($this->form_cert_create_now) {
$this->validateOnly('form_cert_email');
}
// 1) Settings persistieren
Setting::set('app.domain', $this->form_domain);
Setting::set('app.timezone', $this->form_timezone);
Setting::set('app.force_https', (bool)$this->form_cert_force_https);
// Optional: .env spiegeln, damit URLs/HMR etc. sofort passen
$scheme = $this->form_cert_force_https ? 'https' : 'http';
EnvWriter::set([
'APP_HOST' => $this->form_domain,
'APP_URL' => "{$scheme}://{$this->form_domain}",
'APP_TIMEZONE' => $this->form_timezone,
]);
// Settings + .env speichern
Setting::setMany([
'locale' => $this->locale,
'timezone' => $this->timezone,
'ui_domain' => $this->ui_domain,
'mail_domain' => $this->mail_domain,
'webmail_domain' => $this->webmail_domain,
'setup_completed' => '1',
]);
// 2) Admin anlegen/aktualisieren
$user = User::query()
->where('email', $this->form_admin_email)
->when($this->form_admin_username, fn($q) => $q->orWhere('username', $this->form_admin_username)
)
->first();
$this->writeEnv([
'APP_NAME' => $this->instance_name,
'APP_HOST' => $this->ui_domain,
'APP_URL' => 'https://' . $this->ui_domain,
'MTA_SUB' => explode('.', $this->mail_domain)[0] ?? '',
'WEBMAIL_DOMAIN' => $this->webmail_domain,
]);
// Admin anlegen
$user = User::where('email', $this->admin_email)->first() ?? new User();
$user->name = $this->admin_name;
$user->email = $this->admin_email;
$user->password = Hash::make($this->admin_password);
$user->role = 'admin';
$user->save();
// Status-Verzeichnis leeren und Domain-Setup im Hintergrund starten
@mkdir(self::STATE_DIR, 0755, true);
@unlink(self::STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
$ssl = $this->skipSsl ? 0 : 1;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->ui_domain),
escapeshellarg($this->mail_domain),
escapeshellarg($this->webmail_domain),
$ssl,
);
@shell_exec($cmd);
$this->step = 5;
}
public function pollSetup(): void
{
if ($this->setupDone) return;
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::STATE_DIR . "/{$key}";
$this->domainStatus[$key] = is_readable($file)
? trim(@file_get_contents($file))
: 'pending';
}
$done = @file_get_contents(self::STATE_DIR . '/done');
if ($done !== false) {
$this->setupDone = true;
}
}
public function retryDomains(): void
{
@unlink(self::STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
$this->domainStatus = ['ui' => 'pending', 'mail' => 'pending', 'webmail' => 'pending'];
$this->setupDone = false;
$ssl = $this->skipSsl ? 0 : 1;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->ui_domain),
escapeshellarg($this->mail_domain),
escapeshellarg($this->webmail_domain),
$ssl,
);
@shell_exec($cmd);
}
public function goToLogin(): mixed
{
$sslOk = Setting::get('ssl_configured', '0') === '1' && $this->ui_domain;
$url = $sslOk
? 'https://' . $this->ui_domain . '/login'
: '/login';
return redirect()->to($url)->with('setup_done', true);
}
private function writeEnv(array $values): void
{
$path = base_path('.env');
$content = @file_get_contents($path) ?: '';
foreach ($values as $key => $value) {
$escaped = str_contains($value, ' ') ? '"' . $value . '"' : $value;
$line = $key . '=' . $escaped;
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
if (!$user) {
$user = new User();
$user->email = $this->form_admin_email;
if ($this->form_admin_username) {
$user->username = $this->form_admin_username;
}
} else {
// vorhandene Email/Username harmonisieren
$user->email = $this->form_admin_email;
if ($this->form_admin_username) {
$user->username = $this->form_admin_username;
}
}
file_put_contents($path, $content);
$user->name = $this->form_admin_name;
$user->is_admin = true;
$user->password = Hash::make($this->form_admin_password);
$user->required_change_password = true;
$user->save();
// 3) Zertifikat jetzt ausstellen (optional)
$taskKey = 'issue-cert:' . $this->form_domain;
if ($this->form_cert_create_now) {
SystemTask::updateOrCreate(
['key' => $taskKey],
[
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_email,
'mode' => 'letsencrypt'
],
]
);
Cache::store('redis')->put($taskKey, [
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
],
], now()->addMinutes(30));
Redis::sadd('ui:toasts', $taskKey);
ProvisionCertJob::dispatch(
domain: $this->form_domain,
email: $this->form_cert_email,
taskKey: $taskKey,
useLetsEncrypt: true
);
session()->flash('task_key', $taskKey);
session()->flash('banner_ok', 'Lets Encrypt wird gestartet…');
} else {
// automatisch self-signed
SystemTask::updateOrCreate(
['key' => $taskKey],
[
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'mode' => 'self-signed'
],
]
);
Cache::store('redis')->put($taskKey, [
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
],
], now()->addMinutes(30));
Cache::store('redis')->put($taskKey, [
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
],
], now()->addMinutes(30));
Redis::sadd('ui:toasts', $taskKey);
ProvisionCertJob::dispatch(
domain: $this->form_domain,
email: null,
taskKey: $taskKey,
useLetsEncrypt: false
);
session()->flash('task_key', $taskKey);
session()->flash('banner_ok', 'Self-Signed Zertifikat wird erstellt…');
}
return redirect()->route('dashboard');
}
public function render()
{
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
return view('livewire.setup.wizard', compact('timezones'));
return view('livewire.setup.wizard');
}
}

View File

@ -24,7 +24,7 @@ class DkimStatus extends Component
?: optional(
$domain->dkimKeys()->where('is_active', true)->latest()->first()
)->selector
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
}
/**
@ -46,7 +46,7 @@ class DkimStatus extends Component
// public function regenerate(?string $selector = null): void
// {
// $selector = $selector
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
//
// Log::info('DKIM regenerate() CLICKED', [
// 'domain' => $this->domain->domain,
@ -79,7 +79,7 @@ class DkimStatus extends Component
public function regenerate(?string $selector = null): void
{
$selector = $selector
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
Log::info('DKIM regenerate() CLICKED', [
'domain' => $this->domain->domain,
@ -126,7 +126,7 @@ class DkimStatus extends Component
public function render(): View
{
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
return view('livewire.ui.domain.dkim-status', compact('dkimOk'));

View File

@ -52,7 +52,7 @@ class DomainCreateModal extends ModalComponent
$this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb', 3072);
$this->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240);
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$this->dkim_bits = (int) config('mailpool.defaults.dkim_bits', 2048);
// Speicherpool-Grenze

File diff suppressed because it is too large Load Diff

View File

@ -30,14 +30,14 @@ class AliasList extends Component
{
// nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente)
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
'aliasId' => $aliasId,
$aliasId,
]);
}
public function openAliasDelete(int $aliasId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
'aliasId' => $aliasId,
$aliasId,
]);
}

View File

@ -5,540 +5,39 @@ 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 array $rows = []; // [ ['domain'=>..., 'dkim'=>bool, 'dmarc'=>bool, 'tlsa'=>bool], ... ]
public function mount(): void
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->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') ?? '';
$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');
}
$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) ?: '';
return $rows;
});
}
protected function hasTxt(string $name): bool
{
return trim($this->digShort('TXT', $name)) !== '';
$out = @shell_exec("dig +short TXT ".escapeshellarg($name)." 2>/dev/null");
return is_string($out) && trim($out) !== '';
}
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;
$out = @shell_exec("dig +short TLSA ".escapeshellarg($name)." 2>/dev/null");
return is_string($out) && trim($out) !== '';
}
}
//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) !== '';
// }
//}

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_server', false)
->where('is_system', false)
->orderBy('domain')->get(['id', 'domain'])->toArray();
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
@ -291,3 +291,251 @@ 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

@ -1,289 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use App\Models\Domain;
use App\Models\MailUser;
use App\Models\SandboxRoute;
use App\Models\Setting as SettingModel;
use App\Support\CacheVer;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Dashboard · Mailwolt')]
class Dashboard extends Component
{
public function render()
{
$services = $this->loadServices();
[$cpu, $cpuCores, $cpuMhz] = $this->cpu();
[$ramPercent, $ramUsed, $ramTotal] = $this->ram();
[$load1, $load5, $load15] = $this->load();
[$uptimeDays, $uptimeHours] = $this->uptime();
[$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->disk();
$servicesActive = count(array_filter($services, fn($s) => $s['status'] === 'online'));
// Echte Zertifikatsdateien prüfen — nicht nur DB-Wert (kann veraltet sein)
$uiDomain = (string) SettingModel::get('ui_domain', '');
$sslConfigured = !$uiDomain
|| file_exists("/etc/letsencrypt/renewal/{$uiDomain}.conf")
|| is_dir("/etc/letsencrypt/live/{$uiDomain}");
// DB-Wert nachziehen damit Banner nach einmaligem Check dauerhaft weg ist
if ($sslConfigured && SettingModel::get('ssl_configured', '0') !== '1') {
SettingModel::set('ssl_configured', '1');
}
return view('livewire.ui.nx.dashboard', [
'sslConfigured' => $sslConfigured,
'domainCount' => Domain::where('is_system', false)->where('is_server', false)->count(),
'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(),
'servicesActive' => $servicesActive,
'servicesTotal' => count($services),
'alertCount' => SandboxRoute::where('is_active', true)->count(),
'sandboxAlerts' => SandboxRoute::activeRoutes(),
'backup' => $this->backupData(),
'mailHostname' => gethostname() ?: 'mailserver',
'services' => $services,
'cpu' => $cpu,
'cpuCores' => $cpuCores,
'cpuMhz' => $cpuMhz,
'ramPercent' => $ramPercent,
'ramUsed' => $ramUsed,
'ramTotal' => $ramTotal,
'load1' => $load1,
'load5' => $load5,
'load15' => $load15,
'uptimeDays' => $uptimeDays,
'uptimeHours' => $uptimeHours,
'diskUsedPercent' => $diskUsedPercent,
'diskUsedGb' => $diskUsedGb,
'diskFreeGb' => $diskFreeGb,
'diskTotalGb' => $diskTotalGb,
'updateLatest' => Cache::get('updates:latest_raw') ?: (Cache::get('updates:latest') ? 'v' . Cache::get('updates:latest') : null),
...$this->mailSecurity(),
'ports' => $this->ports(),
]);
}
private function loadServices(): array
{
$allCards = config('woltguard.cards', []);
$dashKeys = config('woltguard.dashboard', array_keys($allCards));
// 1) Monit-Cache für nicht-optionale Dienste
$cached = Cache::get(CacheVer::k('health:services'), []);
$monitRows = $cached['rows'] ?? [];
$monitIndex = [];
foreach ($monitRows as $r) {
$monitIndex[strtolower($r['name'] ?? '')] = $r;
}
$rows = [];
foreach ($dashKeys as $key) {
$card = $allCards[$key] ?? null;
if (!$card) continue;
$optional = $card['optional'] ?? false;
// Optionale Dienste (z.B. ClamAV) immer live prüfen Monit-Cache kann veraltet sein
if (!$optional && isset($monitIndex[strtolower($card['label'] ?? '')])) {
$rows[] = $monitIndex[strtolower($card['label'])];
continue;
}
$isOk = false;
foreach ($card['sources'] as $src) {
if ($this->probeSource($src)) { $isOk = true; break; }
}
if (!$isOk && $optional) continue;
$rows[] = ['label' => $card['label'], 'hint' => $card['hint'], 'ok' => $isOk];
}
// Fallback: wenn gar keine Daten, alle live proben
if (empty($rows) && !empty($monitRows)) {
$rows = $monitRows;
}
return array_map(fn($r) => [
'name' => $r['label'] ?? ucfirst($r['name'] ?? ''),
'type' => $r['hint'] ?? '',
'status' => ($r['ok'] ?? false) ? 'online' : 'offline',
], $rows);
}
private function probeSource(string $src): bool
{
if (str_starts_with($src, 'systemd:')) {
$unit = substr($src, 8);
$exit = null;
@exec("systemctl is-active --quiet " . escapeshellarg($unit) . " 2>/dev/null", $_, $exit);
return $exit === 0;
}
if (str_starts_with($src, 'tcp:')) {
[, $host, $port] = explode(':', $src, 3);
$fp = @fsockopen($host, (int)$port, $e1, $e2, 1);
if (is_resource($fp)) { fclose($fp); return true; }
return false;
}
if (str_starts_with($src, 'socket:')) {
return @file_exists(substr($src, 7));
}
if ($src === 'db') {
try { \Illuminate\Support\Facades\DB::connection()->getPdo(); return true; }
catch (\Throwable) { return false; }
}
return false;
}
private function ports(): array
{
$check = [25, 465, 587, 110, 143, 993, 995, 80, 443];
$out = trim(@shell_exec('ss -tlnH 2>/dev/null') ?? '');
$listening = [];
foreach (explode("\n", $out) as $line) {
if (preg_match('/:(\d+)\s/', $line, $m)) {
$listening[(int)$m[1]] = true;
}
}
$result = [];
foreach ($check as $port) {
$result[$port] = isset($listening[$port]);
}
return $result;
}
private function mailSecurity(): array
{
// rspamd metrics from cache (populated by spamav:collect every 5 min)
$av = Cache::get('dash.spamav') ?? SettingModel::get('spamav.metrics', []);
$spam = (int)($av['spam'] ?? 0);
$reject = (int)($av['reject'] ?? 0);
$ham = (int)($av['ham'] ?? 0);
$clamVer = $av['clamVer'] ?? '—';
// Postfix queue counts (active + deferred)
$queueOut = trim(@shell_exec('postqueue -p 2>/dev/null') ?? '');
$qActive = preg_match_all('/^[A-F0-9]{9,}\*?\s+/mi', $queueOut);
$qDeferred = substr_count($queueOut, '(deferred)');
$qTotal = $qActive + $qDeferred;
return [
'spamBlocked' => $spam + $reject,
'spamTagged' => $spam,
'spamRejected'=> $reject,
'hamCount' => $ham,
'clamVer' => $clamVer,
'queueTotal' => $qTotal,
'queueDeferred' => $qDeferred,
];
}
private function cpu(): array
{
$cores = (int)(shell_exec("nproc 2>/dev/null") ?: 1);
$mhz = round((float)shell_exec("awk '/^cpu MHz/{s+=$4;n++}END{if(n)print s/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1);
$s1 = $this->stat(); usleep(400000); $s2 = $this->stat();
$idle1 = $s1[3] + ($s1[4] ?? 0); $idle2 = $s2[3] + ($s2[4] ?? 0);
$dt = array_sum($s2) - array_sum($s1);
$cpu = $dt > 0 ? max(0, min(100, round(($dt - ($idle2 - $idle1)) / $dt * 100))) : 0;
return [$cpu, $cores, $mhz ?: '—'];
}
private function stat(): array
{
$p = preg_split('/\s+/', trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: ''));
array_shift($p);
return array_map('intval', $p);
}
private function ram(): array
{
preg_match('/MemTotal:\s+(\d+)/', shell_exec("cat /proc/meminfo") ?: '', $mt);
preg_match('/MemAvailable:\s+(\d+)/', shell_exec("cat /proc/meminfo") ?: '', $ma);
$total = (int)($mt[1] ?? 0); $avail = (int)($ma[1] ?? 0); $used = $total - $avail;
return [$total > 0 ? round($used / $total * 100) : 0, round($used / 1048576, 1), round($total / 1048576, 1)];
}
private function backupData(): array
{
$policy = BackupPolicy::first();
if (!$policy) {
return ['status' => 'unconfigured', 'last_at' => null, 'last_at_full' => null, 'size' => null, 'duration' => null, 'next_at' => null, 'enabled' => false];
}
$running = BackupJob::whereIn('status', ['queued', 'running'])->exists();
if ($running) {
$status = 'running';
} elseif ($policy->last_run_at === null) {
$status = 'pending';
} else {
$status = $policy->last_status ?? 'unknown';
}
$size = ($policy->last_size_bytes ?? 0) > 0 ? $this->fmtBytes($policy->last_size_bytes) : null;
$duration = null;
$lastJob = BackupJob::where('status', 'ok')->latest('finished_at')->first();
if ($lastJob && $lastJob->started_at && $lastJob->finished_at) {
$secs = $lastJob->started_at->diffInSeconds($lastJob->finished_at);
$duration = $secs >= 60
? round($secs / 60) . ' min'
: $secs . 's';
}
$next = null;
if ($policy->enabled && $policy->schedule_cron) {
try {
$cron = new \Cron\CronExpression($policy->schedule_cron);
$next = $cron->getNextRunDate()->format('d.m.Y H:i');
} catch (\Throwable) {}
}
return [
'status' => $status,
'last_at' => $policy->last_run_at?->diffForHumans(),
'last_at_full' => $policy->last_run_at?->format('d.m.Y H:i'),
'size' => $size,
'duration' => $duration,
'next_at' => $next,
'enabled' => (bool) $policy->enabled,
];
}
private function fmtBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$v = (float) $bytes;
while ($v >= 1024 && $i < 4) { $v /= 1024; $i++; }
return number_format($v, $i <= 1 ? 0 : 1) . ' ' . $units[$i];
}
private function load(): array
{
$p = explode(' ', trim(shell_exec("cat /proc/loadavg") ?: ''));
return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00'];
}
private function uptime(): array
{
$s = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime") ?: 0);
return [intdiv($s, 86400), intdiv($s % 86400, 3600)];
}
private function disk(): array
{
$p = preg_split('/\s+/', trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: ''));
$total = (int)($p[1] ?? 0); $used = (int)($p[2] ?? 0); $free = (int)($p[3] ?? 0);
return [$total > 0 ? round($used / $total * 100) : 0, $used, $free, $total];
}
}

View File

@ -1,84 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use App\Services\DkimService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Domain · Mailwolt')]
class DnsDkim extends Component
{
#[On('domain-updated')]
#[On('domain-created')]
#[On('domain:delete')]
public function refresh(): void {}
public function openDns(int $id): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $id]);
}
public function openDelete(int $id): void
{
$domain = Domain::findOrFail($id);
if ($domain->is_system) {
$this->dispatch('toast', type: 'forbidden', badge: 'System-Domain',
title: 'Domain', text: 'System-Domains können nicht gelöscht werden.', duration: 4000);
return;
}
$this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: ['domainId' => $id]);
}
public function regenerateDkim(int $id): void
{
$domain = Domain::findOrFail($id);
$selector = optional($domain->dkimKeys()->where('is_active', true)->latest()->first())->selector
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
try {
/** @var DkimService $svc */
$svc = app(DkimService::class);
$svc->generateForDomain($domain, 2048, $selector);
$this->dispatch('toast', type: 'done', badge: 'DKIM',
title: 'DKIM erneuert', text: "Schlüssel für <b>{$domain->domain}</b> wurde neu generiert.", duration: 5000);
} catch (\Throwable $e) {
$this->dispatch('toast', type: 'error', badge: 'DKIM',
title: 'Fehler', text: $e->getMessage(), duration: 0);
}
}
private function dkimReady(string $domain, string $selector): bool
{
$path = storage_path("app/private/dkim/{$domain}/{$selector}.private");
return is_file($path) && filesize($path) > 0;
}
public function render()
{
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$mapped = Domain::where('is_server', false)
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
->orderBy('domain')
->get()
->map(function (Domain $d) use ($defaultSelector) {
$key = $d->dkimKeys->first();
$selector = $key?->selector ?? $defaultSelector;
$d->setAttribute('dkim_selector', $selector);
$d->setAttribute('dkim_ready', $this->dkimReady($d->domain, $selector));
$d->setAttribute('dkim_txt', $key?->asTxtValue() ?? '');
return $d;
});
$systemDomains = $mapped->where('is_system', true)->values();
$userDomains = $mapped->where('is_system', false)->values();
return view('livewire.ui.nx.domain.dns-dkim', compact('systemDomains', 'userDomains'));
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Domains · Mailwolt')]
class DomainList extends Component
{
#[Url(as: 'q', keep: true)]
public string $search = '';
#[On('domain-updated')]
#[On('domain-created')]
#[On('domain:delete')]
public function refresh(): void {}
public function openCreate(): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-create-modal');
}
public function openEdit(int $id): void
{
if ($this->isSystem($id)) return;
$this->dispatch('openModal', component: 'ui.domain.modal.domain-edit-modal', arguments: ['domainId' => $id]);
}
public function openLimits(int $id): void
{
if ($this->isSystem($id)) return;
$this->dispatch('openModal', component: 'ui.domain.modal.domain-limits-modal', arguments: ['domainId' => $id]);
}
public function openDns(int $id): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $id]);
}
public function openDelete(int $id): void
{
if ($this->isSystem($id)) return;
$this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: ['domainId' => $id]);
}
private function isSystem(int $id): bool
{
$domain = Domain::findOrFail($id);
if ($domain->is_system) {
$this->dispatch('toast', type: 'forbidden', badge: 'System-Domain',
title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann nicht bearbeitet werden.', duration: 0);
return true;
}
return false;
}
public function render()
{
$query = Domain::where('is_system', false)
->where('is_server', false)
->withCount(['mailUsers as mailboxes_count', 'mailAliases as aliases_count'])
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
->orderBy('domain');
if ($this->search !== '') {
$query->where('domain', 'like', '%' . $this->search . '%');
}
$domains = $query->get()->map(function (Domain $d) {
$tags = is_array($d->tags) ? $d->tags : [];
$d->setAttribute('visible_tags', array_slice($tags, 0, 2));
$d->setAttribute('extra_tags', max(count($tags) - 2, 0));
return $d;
});
$systemDomain = Domain::where('is_system', true)
->withCount(['mailUsers as mailboxes_count', 'mailAliases as aliases_count'])
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
->first();
$total = Domain::where('is_system', false)->where('is_server', false)->count();
return view('livewire.ui.nx.domain.domain-list', compact('domains', 'systemDomain', 'total'));
}
}

View File

@ -1,102 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use App\Models\Domain;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Aliasse · Mailwolt')]
class AliasList extends Component
{
public string $search = '';
#[On('alias:created')]
#[On('alias:updated')]
#[On('alias:deleted')]
public function refreshAliasList(): void
{
$this->dispatch('$refresh');
}
public function openAliasCreate(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
'domainId' => $domainId,
]);
}
public function openAliasEdit(int $aliasId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
'aliasId' => $aliasId,
]);
}
public function openAliasDelete(int $aliasId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
'aliasId' => $aliasId,
]);
}
public function render()
{
$term = trim($this->search);
$hasTerm = $term !== '';
$needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$domains = Domain::query()
->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('mailAliases', fn($a) => $a
->where('is_system', false)
->where(fn($x) => $x
->where('local', 'like', $needle)
->orWhere('destination', 'like', $needle)
)
);
});
})
->withCount(['mailAliases as aliases_count' => fn($a) => $a->where('is_system', false)])
->with(['mailAliases' => function ($q) use ($hasTerm, $needle) {
$q->where('is_system', false);
if ($hasTerm) {
$q->where(fn($x) => $x
->where('local', 'like', $needle)
->orWhere('destination', 'like', $needle)
);
}
$q->orderBy('local');
}])
->orderBy('domain')
->get();
if ($hasTerm) {
$lower = Str::lower($term);
foreach ($domains as $d) {
if (Str::contains(Str::lower($d->domain), $lower)) {
$d->setRelation('mailAliases', $d->mailAliases()
->where('is_system', false)
->orderBy('local')
->get()
);
}
}
}
$totalAliases = $domains->sum('aliases_count');
return view('livewire.ui.nx.mail.alias-list', [
'domains' => $domains,
'totalAliases' => $totalAliases,
]);
}
}

View File

@ -1,169 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use App\Models\Domain;
use App\Models\MailUser;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Postfächer · Mailwolt')]
class MailboxList extends Component
{
public string $search = '';
public function mount(): void
{
Artisan::call('mail:update-stats');
}
#[On('mailbox:updated')]
#[On('mailbox:deleted')]
#[On('mailbox:created')]
public function refreshMailboxList(): void
{
$this->dispatch('$refresh');
}
public function openMailboxCreate(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
'domainId' => $domainId,
]);
}
public function openMailboxEdit(int $mailUserId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
$mailUserId,
]);
}
public function openMailboxDelete(int $mailUserId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
$mailUserId,
]);
}
public function updateMailboxStats(): void
{
Artisan::call('mail:update-stats');
$this->dispatch('$refresh');
$this->dispatch('toast',
type: 'done',
badge: 'Mailbox',
title: 'Statistiken aktualisiert',
text: 'Alle Mailbox-Statistiken wurden neu berechnet.',
duration: 4000,
);
}
public function updateSingleStat(int $mailUserId): void
{
$u = MailUser::with('domain:id,domain')->find($mailUserId);
if (! $u) return;
$email = (string) ($u->getRawOriginal('email') ?? '');
if (! $email) return;
Artisan::call('mail:update-stats', ['--user' => $email]);
$this->dispatch('$refresh');
}
public function render()
{
$term = trim($this->search);
$hasTerm = $term !== '';
$needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$domains = Domain::query()
->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('is_system', false)
->where('localpart', 'like', $needle)
);
});
})
->withCount(['mailUsers as mail_users_count' => fn($u) => $u
->where('is_system', false)
])
->with(['mailUsers' => function ($q) use ($hasTerm, $needle) {
$q->where('is_system', false);
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()
->where('is_system', false)
->orderBy('localpart')
->get()
);
}
}
}
foreach ($domains as $d) {
$prepared = [];
$domainActive = (bool)($d->is_active ?? true);
foreach ($d->mailUsers as $u) {
$usedBytes = (int)($u->used_bytes ?? 0);
$messageCount = (int)($u->message_count ?? 0);
$quotaMiB = (int)($u->quota_mb ?? 0);
$usedMiB = round($usedBytes / 1048576, 2);
$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' : 'Postfach inaktiv';
}
$barClass = $usage > 85 ? 'mbx-bar-high' : ($usage > 60 ? 'mbx-bar-mid' : 'mbx-bar-low');
$prepared[] = [
'id' => $u->id,
'localpart' => (string)$u->localpart,
'quota_mb' => $quotaMiB,
'used_mb' => $usedMiB,
'usage_percent' => $usage,
'bar_class' => $barClass,
'message_count' => $messageCount,
'is_active' => $mailboxActive,
'is_effective_active' => $effective,
'inactive_reason' => $reason,
'last_login' => $u->last_login_at?->diffForHumans() ?? '—',
];
}
$d->prepared_mailboxes = $prepared;
}
return view('livewire.ui.nx.mail.mailbox-list', [
'domains' => $domains,
'totalMailboxes' => $domains->sum('mail_users_count'),
]);
}
}

View File

@ -1,81 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Mail\Modal;
use LivewireUI\Modal\ModalComponent;
class QuarantineMessageModal extends ModalComponent
{
public string $msgId;
public array $message = [];
public function mount(string $msgId): void
{
$this->msgId = $msgId;
$this->message = $this->fetchMessage($msgId);
}
public function render()
{
return view('livewire.ui.nx.mail.modal.quarantine-message-modal');
}
private function fetchMessage(string $id): array
{
$password = env('RSPAMD_PASSWORD', '');
$opts = [
'http' => [
'timeout' => 3,
'ignore_errors' => true,
'header' => $password !== '' ? "Password: {$password}\r\n" : '',
],
];
$ctx = stream_context_create($opts);
$raw = @file_get_contents("http://127.0.0.1:11334/history?rows=500", false, $ctx);
if (!$raw) return $this->emptyMessage($id);
$data = json_decode($raw, true);
$items = $data['rows'] ?? (isset($data[0]) ? $data : []);
foreach ($items as $r) {
$scanId = $r['scan_id'] ?? ($r['id'] ?? '');
if ($scanId !== $id) continue;
$rcpt = $r['rcpt'] ?? [];
if (is_array($rcpt)) $rcpt = implode(', ', $rcpt);
$symbols = [];
foreach ($r['symbols'] ?? [] as $name => $sym) {
$symbols[] = [
'name' => $name,
'score' => round((float)($sym['score'] ?? 0), 3),
'description' => $sym['description'] ?? '',
];
}
usort($symbols, fn($a, $b) => abs($b['score']) <=> abs($a['score']));
return [
'id' => $id,
'msg_id' => $r['message-id'] ?? '—',
'from' => $r['from'] ?? $r['sender'] ?? '—',
'rcpt' => $rcpt ?: '—',
'subject' => $r['subject'] ?? '(kein Betreff)',
'score' => round((float)($r['score'] ?? 0), 2),
'required' => round((float)($r['required_score'] ?? 15), 2),
'action' => $r['action'] ?? 'unknown',
'time' => (int)($r['unix_time'] ?? 0),
'size' => (int)($r['size'] ?? 0),
'ip' => $r['ip'] ?? '—',
'symbols' => $symbols,
];
}
return $this->emptyMessage($id);
}
private function emptyMessage(string $id): array
{
return ['id' => $id, 'from' => '—', 'rcpt' => '—', 'subject' => '—', 'score' => 0, 'required' => 0, 'action' => '—', 'time' => 0, 'size' => 0, 'ip' => '—', 'symbols' => [], 'msg_id' => '—'];
}
}

View File

@ -1,80 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Mail\Modal;
use LivewireUI\Modal\ModalComponent;
class QueueMessageModal extends ModalComponent
{
public string $queueId;
public array $message = [];
public function mount(string $queueId): void
{
$this->queueId = $queueId;
$this->message = $this->fetchMessage($queueId);
}
public function delete(): void
{
@shell_exec('sudo postsuper -d ' . escapeshellarg($this->queueId) . ' 2>&1');
$this->dispatch('toast', type: 'success', title: 'Nachricht gelöscht');
$this->dispatch('queue:updated');
$this->dispatch('closeModal');
}
public function hold(): void
{
@shell_exec('sudo postsuper -h ' . escapeshellarg($this->queueId) . ' 2>&1');
$this->message['queue'] = 'hold';
$this->dispatch('toast', type: 'info', title: 'Nachricht zurückgestellt');
$this->dispatch('queue:updated');
$this->dispatch('closeModal');
}
public function release(): void
{
@shell_exec('sudo postsuper -H ' . escapeshellarg($this->queueId) . ' 2>&1');
$this->dispatch('toast', type: 'success', title: 'Nachricht freigegeben');
$this->dispatch('queue:updated');
$this->dispatch('closeModal');
}
public function render()
{
return view('livewire.ui.nx.mail.modal.queue-message-modal');
}
private function fetchMessage(string $id): array
{
// Get queue details from postqueue -j
$out = trim(@shell_exec('postqueue -j 2>/dev/null') ?? '');
foreach (explode("\n", $out) as $line) {
$line = trim($line);
if ($line === '') continue;
$m = json_decode($line, true);
if (!is_array($m) || ($m['queue_id'] ?? '') !== $id) continue;
$recipients = $m['recipients'] ?? [];
$rcptList = array_map(fn($r) => [
'address' => $r['address'] ?? '',
'reason' => $r['delay_reason'] ?? '',
], $recipients);
// Try to get message headers
$header = trim(@shell_exec('sudo postcat -hq ' . escapeshellarg($id) . ' 2>/dev/null') ?? '');
return [
'id' => $id,
'queue' => $m['queue_name'] ?? 'deferred',
'sender' => $m['sender'] ?? '—',
'recipients' => $rcptList,
'size' => (int)($m['message_size'] ?? 0),
'arrival' => (int)($m['arrival_time'] ?? 0),
'header' => mb_substr($header, 0, 3000),
];
}
return ['id' => $id, 'queue' => '—', 'sender' => '—', 'recipients' => [], 'size' => 0, 'arrival' => 0, 'header' => ''];
}
}

View File

@ -1,177 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.dvx')]
#[Title('Quarantäne · Mailwolt')]
class QuarantineList extends Component
{
use WithPagination;
#[Url(as: 'filter', keep: true)]
public string $filter = 'suspicious';
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'limit', keep: true)]
public int $perPage = 25;
public int $rows = 500;
#[On('quarantine:updated')]
public function refresh(): void {}
// ── Löschen (lokal via Cache, RSpamd hat keine per-entry API) ────────────
private function hiddenKey(): string
{
return 'quarantine.hidden.' . session()->getId();
}
private function getHidden(): array
{
return \Cache::get($this->hiddenKey(), []);
}
private function saveHidden(array $ids): void
{
\Cache::put($this->hiddenKey(), array_values(array_unique($ids)), now()->addDays(7));
}
public function deleteEntry(string $id): void
{
$hidden = $this->getHidden();
$hidden[] = $id;
$this->saveHidden($hidden);
}
public function deleteCurrentTab(): void
{
$all = $this->fetchHistory();
$hidden = $this->getHidden();
$toHide = match($this->filter) {
'suspicious' => array_filter($all, fn($m) => $m['action'] !== 'no action'),
'all' => $all,
default => array_filter($all, fn($m) => $m['action'] === $this->filter),
};
foreach ($toHide as $m) {
$hidden[] = $m['id'];
}
$this->saveHidden($hidden);
$this->resetPage();
}
public function updatedFilter(): void { $this->resetPage(); }
public function updatedSearch(): void { $this->resetPage(); }
public function updatedPerPage(): void { $this->resetPage(); }
public function openMessage(string $msgId): void
{
$this->dispatch('openModal',
component: 'ui.nx.mail.modal.quarantine-message-modal',
arguments: ['msgId' => $msgId]
);
}
public function render()
{
$hidden = $this->getHidden();
$all = array_values(array_filter(
$this->fetchHistory(),
fn($m) => !in_array($m['id'], $hidden, true)
));
$suspicious = array_values(array_filter($all, fn($m) => $m['action'] !== 'no action'));
$counts = [
'all' => count($all),
'suspicious' => count($suspicious),
'reject' => count(array_filter($all, fn($m) => $m['action'] === 'reject')),
'add header' => count(array_filter($all, fn($m) => $m['action'] === 'add header')),
'greylist' => count(array_filter($all, fn($m) => $m['action'] === 'greylist')),
];
$messages = match($this->filter) {
'suspicious' => $suspicious,
'all' => $all,
default => array_values(array_filter($all, fn($m) => $m['action'] === $this->filter)),
};
if ($this->search !== '') {
$s = strtolower($this->search);
$messages = array_values(array_filter($messages, fn($m) =>
str_contains(strtolower($m['from'] ?? ''), $s) ||
str_contains(strtolower($m['rcpt'] ?? ''), $s) ||
str_contains(strtolower($m['subject'] ?? ''), $s)
));
}
$total = count($messages);
$currentPage = LengthAwarePaginator::resolveCurrentPage();
$paged = new LengthAwarePaginator(
array_slice($messages, ($currentPage - 1) * $this->perPage, $this->perPage),
$total,
$this->perPage,
$currentPage,
['path' => request()->url()]
);
return view('livewire.ui.nx.mail.quarantine-list', [
'messages' => $paged,
'counts' => $counts,
]);
}
// ── helpers ──────────────────────────────────────────────────────────────
public function fetchHistory(): array
{
$password = env('RSPAMD_PASSWORD', '');
$opts = [
'http' => [
'timeout' => 3,
'ignore_errors' => true,
'header' => $password !== '' ? "Password: {$password}\r\n" : '',
],
];
$ctx = stream_context_create($opts);
$raw = @file_get_contents("http://127.0.0.1:11334/history?rows={$this->rows}", false, $ctx);
if (!$raw) return [];
$data = json_decode($raw, true);
if (!is_array($data)) return [];
$items = $data['rows'] ?? (isset($data[0]) ? $data : []);
return array_map(function ($r) {
$rcpt = $r['rcpt'] ?? [];
if (is_array($rcpt)) $rcpt = implode(', ', $rcpt);
return [
'id' => $r['scan_id'] ?? ($r['id'] ?? uniqid('q_')),
'msg_id' => $r['message-id'] ?? '—',
'from' => $r['from'] ?? $r['sender'] ?? '—',
'rcpt' => $rcpt ?: '—',
'subject' => $r['subject'] ?? '(kein Betreff)',
'score' => round((float)($r['score'] ?? 0), 2),
'required' => round((float)($r['required_score'] ?? 15), 2),
'action' => $r['action'] ?? 'unknown',
'time' => (int)($r['unix_time'] ?? $r['time'] ?? 0),
'size' => (int)($r['size'] ?? 0),
'symbols' => array_keys($r['symbols'] ?? []),
'ip' => $r['ip'] ?? '—',
];
}, $items);
}
}

View File

@ -1,188 +0,0 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.dvx')]
#[Title('Mail-Queue · Mailwolt')]
class QueueList extends Component
{
use WithPagination;
#[Url(as: 'filter', keep: true)]
public string $filter = 'all';
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'limit', keep: true)]
public int $perPage = 25;
public array $selected = [];
public bool $selectAll = false;
#[On('queue:updated')]
public function refresh(): void {}
public function updatedFilter(): void { $this->resetPage(); $this->selected = []; $this->selectAll = false; }
public function updatedSearch(): void { $this->resetPage(); }
public function updatedPerPage(): void { $this->resetPage(); $this->selected = []; $this->selectAll = false; }
public function updatedSelectAll(bool $val): void
{
$messages = $this->fetchQueue();
$this->selected = $val ? array_column($messages, 'id') : [];
}
public function flush(): void
{
@shell_exec('sudo postqueue -f >/dev/null 2>&1 &');
$this->dispatch('toast', type: 'info', title: 'Queue-Flush gestartet');
}
public function deleteSelected(): void
{
$count = count($this->selected);
foreach ($this->selected as $id) {
@shell_exec('sudo postsuper -d ' . escapeshellarg($id) . ' 2>&1');
}
$this->selected = [];
$this->selectAll = false;
$this->dispatch('toast', type: 'success', title: "{$count} Nachricht(en) gelöscht");
}
public function deleteAll(): void
{
@shell_exec('sudo postsuper -d ALL 2>&1');
$this->selected = [];
$this->selectAll = false;
$this->dispatch('toast', type: 'warning', title: 'Alle Queue-Nachrichten gelöscht');
}
public function openMessage(string $queueId): void
{
$this->dispatch('openModal',
component: 'ui.nx.mail.modal.queue-message-modal',
arguments: ['queueId' => $queueId]
);
}
public function render()
{
$all = $this->fetchQueue();
$counts = [
'all' => count($all),
'active' => count(array_filter($all, fn($m) => $m['queue'] === 'active')),
'deferred' => count(array_filter($all, fn($m) => $m['queue'] === 'deferred')),
'hold' => count(array_filter($all, fn($m) => $m['queue'] === 'hold')),
];
$messages = $all;
if ($this->filter !== 'all') {
$messages = array_values(array_filter($messages, fn($m) => $m['queue'] === $this->filter));
}
if ($this->search !== '') {
$s = strtolower($this->search);
$messages = array_values(array_filter($messages, fn($m) =>
str_contains(strtolower($m['sender'] ?? ''), $s) ||
str_contains(strtolower($m['recipient'] ?? ''), $s) ||
str_contains(strtolower($m['id'] ?? ''), $s)
));
}
$total = count($messages);
$currentPage = LengthAwarePaginator::resolveCurrentPage();
$paged = new LengthAwarePaginator(
array_slice($messages, ($currentPage - 1) * $this->perPage, $this->perPage),
$total,
$this->perPage,
$currentPage,
['path' => request()->url()]
);
return view('livewire.ui.nx.mail.queue-list', [
'messages' => $paged,
'counts' => $counts,
]);
}
// ── helpers ──────────────────────────────────────────────────────────────
public function fetchQueue(): array
{
$out = trim(@shell_exec('postqueue -j 2>/dev/null') ?? '');
if ($out !== '') {
return $this->parseJson($out);
}
return $this->parseText(trim(@shell_exec('postqueue -p 2>/dev/null') ?? ''));
}
private function parseJson(string $out): array
{
$rows = [];
foreach (explode("\n", $out) as $line) {
$line = trim($line);
if ($line === '') continue;
$m = json_decode($line, true);
if (!is_array($m)) continue;
$recipients = $m['recipients'] ?? [];
$rcptList = array_column($recipients, 'address');
$reason = $recipients[0]['delay_reason'] ?? '';
$rows[] = [
'id' => $m['queue_id'] ?? '',
'queue' => $m['queue_name'] ?? 'deferred',
'sender' => $m['sender'] ?? '',
'recipient' => implode(', ', $rcptList),
'size' => (int)($m['message_size'] ?? 0),
'arrival' => (int)($m['arrival_time'] ?? 0),
'reason' => $this->trimReason($reason),
];
}
return $rows;
}
private function parseText(string $out): array
{
$rows = [];
$current = null;
foreach (explode("\n", $out) as $line) {
if (preg_match('/^([A-F0-9]{9,})\*?\s+(\d+)\s+\S+\s+\S+\s+\d+\s+\d{1,2}:\d{2}:\d{2}\s+(.*)/i', $line, $m)) {
if ($current) $rows[] = $current;
$current = [
'id' => $m[1],
'queue' => 'active',
'sender' => trim($m[3]),
'recipient' => '',
'size' => (int)$m[2],
'arrival' => 0,
'reason' => '',
];
} elseif ($current && preg_match('/^\s+([\w.+%-]+@[\w.-]+)/', $line, $m)) {
$current['recipient'] = $m[1];
} elseif ($current && preg_match('/^\s+\((.+)\)/', $line, $m)) {
$current['reason'] = $this->trimReason($m[1]);
$current['queue'] = 'deferred';
}
}
if ($current) $rows[] = $current;
return $rows;
}
private function trimReason(string $r): string
{
$r = preg_replace('/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+/', '', trim($r));
return mb_strlen($r) > 100 ? mb_substr($r, 0, 100) . '…' : $r;
}
}

View File

@ -126,20 +126,21 @@ class SearchPaletteModal extends ModalComponent
public function go(string $type, int $id): void
{
if ($type === 'domain') {
$component = 'ui.domain.modal.domain-edit-modal';
$arguments = ['domainId' => $id];
} elseif ($type === 'mailbox') {
$component = 'ui.mail.modal.mailbox-edit-modal';
$arguments = ['mailboxId' => $id];
} else {
return;
}
// Schließe die Palette …
$this->dispatch('closeModal');
// Search palette schließen, dann nach dem State-Reset (300 ms) das Ziel-Modal öffnen
$this->forceClose()->closeModal();
$payload = json_encode(['component' => $component, 'arguments' => $arguments]);
$this->js("setTimeout(()=>Livewire.dispatch('openModal',{$payload}),350)");
// … und navigiere / öffne Kontext:
// - Domain → scrolle/markiere Domainkarte
// - Mailbox → öffne Bearbeiten-Modal
// Passe an, was du bevorzugst:
if ($type === 'domain') {
$this->dispatch('focus:domain', id: $id);
} elseif ($type === 'mailbox') {
// direkt Edit-Modal auf
$this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]);
} elseif ($type === 'user') {
$this->dispatch('focus:user', id: $id);
}
}
public static function modalMaxWidth(): string

View File

@ -2,82 +2,12 @@
namespace App\Livewire\Ui\Security;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Audit-Logs · Mailwolt')]
class AuditLogsTable extends Component
{
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'lvl', keep: true)]
public string $level = '';
public int $limit = 200;
public function loadMore(): void
{
$this->limit += 200;
}
private function parseLogs(): array
{
$logFile = storage_path('logs/laravel.log');
if (!is_readable($logFile)) return [];
$fp = @fopen($logFile, 'r');
if (!$fp) return [];
$size = filesize($logFile);
$chunk = 250_000;
fseek($fp, max(0, $size - $chunk));
$raw = fread($fp, $chunk);
fclose($fp);
if (!$raw) return [];
$lines = explode("\n", $raw);
$lines = array_slice($lines, -2000);
$entries = [];
$current = null;
foreach ($lines as $line) {
if (preg_match('/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.+)/', $line, $m)) {
if ($current !== null) $entries[] = $current;
$current = [
'time' => str_replace('T', ' ', $m[1]),
'level' => strtolower($m[2]),
'message' => trim($m[3]),
];
} elseif ($current !== null) {
$current['message'] .= "\n" . trim($line);
}
}
if ($current !== null) $entries[] = $current;
$entries = array_reverse($entries);
$filtered = [];
foreach ($entries as $e) {
if ($this->level && $e['level'] !== $this->level) continue;
if ($this->search !== '' && !str_contains(strtolower($e['message']), strtolower($this->search))) continue;
$e['message'] = Str::limit(preg_replace('/\s+/', ' ', $e['message']), 300);
$filtered[] = $e;
if (count($filtered) >= $this->limit) break;
}
return $filtered;
}
public function render()
{
$logs = $this->parseLogs();
$levels = ['', 'info', 'debug', 'warning', 'error', 'critical'];
return view('livewire.ui.security.audit-logs-table', compact('logs', 'levels'));
return view('livewire.ui.security.audit-logs-table');
}
}

View File

@ -1,181 +0,0 @@
<?php
namespace App\Livewire\Ui\Security;
use App\Support\CacheVer;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Virenschutz · Mailwolt')]
class ClamavManager extends Component
{
public bool $running = false;
public bool $enabled = false;
public string $dbDate = '—';
public string $dbVersion = '—';
public ?int $ramMb = null;
public string $lastError = '';
public string $lastSuccess = '';
public bool $starting = false;
public int $startSecs = 0;
private const STARTING_FLAG = '/tmp/mw-clamav-starting';
public function mount(): void
{
$this->refresh();
$this->restoreStartingState();
}
private function restoreStartingState(): void
{
if (!file_exists(self::STARTING_FLAG)) return;
if ($this->running) {
@unlink(self::STARTING_FLAG);
return;
}
$elapsed = time() - (int) @file_get_contents(self::STARTING_FLAG);
if ($elapsed > 180) {
@unlink(self::STARTING_FLAG);
return;
}
$this->starting = true;
$this->startSecs = max(0, $elapsed);
}
public function refresh(): void
{
$this->lastError = '';
$this->lastSuccess = '';
$this->running = $this->serviceActive();
$this->enabled = $this->serviceEnabled();
$this->ramMb = $this->running ? $this->readRamMb() : null;
[$this->dbDate, $this->dbVersion] = $this->readDbInfo();
}
public function enable(): void
{
$this->lastError = '';
$this->lastSuccess = '';
[$ok, $msg] = $this->runCmd('enable');
if (!$ok) {
$this->lastError = $msg;
return;
}
file_put_contents(self::STARTING_FLAG, time());
$this->enabled = true;
$this->starting = true;
$this->startSecs = 0;
}
public function pollStatus(): void
{
if (!$this->starting) return;
$this->startSecs += 3;
$this->running = $this->serviceActive();
if ($this->running) {
@unlink(self::STARTING_FLAG);
$this->starting = false;
$this->startSecs = 0;
$this->enabled = $this->serviceEnabled();
$this->ramMb = $this->readRamMb();
$this->lastSuccess = 'ClamAV ist aktiv und läuft.';
Cache::forget(CacheVer::k('health:services'));
}
}
public function disable(): void
{
[$ok, $msg] = $this->runCmd('disable');
if ($ok) $this->lastSuccess = 'ClamAV wurde gestoppt und deaktiviert.';
else $this->lastError = $msg;
$this->running = $this->serviceActive();
$this->enabled = $this->serviceEnabled();
Cache::forget(CacheVer::k('health:services'));
}
public function updateDb(): void
{
[$ok, $msg] = $this->runCmd('freshclam');
if ($ok) $this->lastSuccess = 'Virensignaturen wurden aktualisiert.';
else $this->lastError = $msg;
[$this->dbDate, $this->dbVersion] = $this->readDbInfo();
}
private function runCmd(string $action): array
{
$out = []; $rc = null;
exec('sudo -n /usr/local/sbin/mailwolt-clamav ' . escapeshellarg($action) . ' 2>&1', $out, $rc);
\Log::info('ClamAV ' . $action, ['rc' => $rc, 'out' => $out]);
$msg = implode(' ', $out) ?: 'Unbekannter Fehler (rc=' . $rc . ')';
return [$rc === 0, $msg];
}
private function isInstalled(): bool
{
return file_exists('/usr/sbin/clamd')
|| file_exists('/lib/systemd/system/clamav-daemon.service')
|| file_exists('/usr/lib/systemd/system/clamav-daemon.service');
}
private function serviceActive(): bool
{
$exit = null;
@exec('systemctl is-active --quiet clamav-daemon 2>/dev/null', $_, $exit);
return $exit === 0;
}
private function serviceEnabled(): bool
{
$exit = null;
@exec('systemctl is-enabled --quiet clamav-daemon 2>/dev/null', $_, $exit);
return $exit === 0;
}
private function readRamMb(): ?int
{
$out = [];
@exec("ps -C clamd -o rss= 2>/dev/null", $out);
$kb = array_sum(array_map('intval', array_filter($out)));
return $kb > 0 ? (int) round($kb / 1024) : null;
}
private function readDbInfo(): array
{
$paths = [
'/var/lib/clamav/main.cvd',
'/var/lib/clamav/main.cld',
'/var/lib/clamav/daily.cvd',
'/var/lib/clamav/daily.cld',
];
$latest = 0;
foreach ($paths as $p) {
if (@file_exists($p)) {
$mtime = @filemtime($p);
if ($mtime > $latest) $latest = $mtime;
}
}
if ($latest === 0) return ['—', '—'];
$date = date('d.m.Y H:i', $latest);
$ver = '—';
$out = [];
@exec('sigtool --info /var/lib/clamav/daily.cld 2>/dev/null | grep "^Version:" | head -1', $out);
if (empty($out)) {
@exec('sigtool --info /var/lib/clamav/daily.cvd 2>/dev/null | grep "^Version:" | head -1', $out);
}
if (!empty($out[0])) {
$ver = trim(str_replace('Version:', '', $out[0]));
}
return [$date, $ver];
}
public function render()
{
return view('livewire.ui.security.clamav-manager');
}
}

View File

@ -1,964 +1,34 @@
<?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 array $topIps = []; // [['ip'=>'1.2.3.4','count'=>12],...]
public function mount(): void
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.security.fail2-ban-card'); }
public function refresh(): void { $this->load(true); }
protected function load(bool $force=false): void
{
$this->load();
}
$status = @shell_exec('fail2ban-client status 2>/dev/null') ?? '';
$bans = preg_match('/Currently banned:\s+(\d+)/i', $status, $m) ? (int)$m[1] : 0;
$this->activeBans = $bans;
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;
// quick & rough: last 1000 lines auth/mail logs → top IPs
$log = @shell_exec('tail -n 1000 /var/log/auth.log /var/log/mail.log 2>/dev/null | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | sort | uniq -c | sort -nr | head -5');
$rows = [];
foreach ($jails as $j) {
$jEsc = escapeshellarg($j);
[, $s] = $this->f2b("status {$jEsc}");
if ($this->looksDenied($s)) {
$this->permDenied = true;
return;
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]];
}
}
$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;
$this->topIps = $rows;
}
}
//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

@ -1,210 +0,0 @@
<?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;
/**
* @var array<int,array{
* ip:string,jail:string,service:string,permanent:bool,label:string,
* remaining:string,box:string,badge:string,dot: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;
}
$banInfo = $this->getBanInfo($j, $ip);
$permanent = $banInfo['permanent'];
$remaining = $banInfo['remaining'];
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,
'service' => $this->serviceLabel($j),
'permanent' => $permanent,
'style' => $style,
'label' => $label,
'remaining' => $remaining,
'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 ================= */
/** Gibt permanent-Flag und verbleibende Zeit für (jail, ip) zurück. */
private function getBanInfo(string $jail, string $ip): array
{
$fallbackPermanent = ($jail === 'mailwolt-blacklist');
$cmd = sprintf('sudo -n /usr/local/sbin/clubird-f2b-baninfo %s %s 2>&1',
escapeshellarg($jail), escapeshellarg($ip));
$out = trim((string)@shell_exec($cmd));
if ($out !== '') {
[$timeofban, $bantime] = array_pad(explode('|', $out), 2, '0');
$timeofban = (int)$timeofban;
$bantime = (int)$bantime;
if ($bantime < 0) {
return ['permanent' => true, 'remaining' => ''];
}
$remaining = ($timeofban + $bantime) - time();
if ($remaining > 0) {
return ['permanent' => false, 'remaining' => $this->formatRemaining($remaining)];
}
}
// Fallback: DB-Eintrag ist abgelaufen (fail2ban-Neustart setzt Timer intern neu,
// ohne die DB zu aktualisieren) → konfigurierte Jail-Banzeit als Näherung
$cfgBantime = (int)trim($this->f2b('get ' . escapeshellarg($jail) . ' bantime'));
if ($cfgBantime < 0) {
return ['permanent' => true, 'remaining' => ''];
}
if ($cfgBantime > 0) {
return ['permanent' => false, 'remaining' => '≤ ' . $this->formatRemaining($cfgBantime)];
}
return ['permanent' => $fallbackPermanent, 'remaining' => ''];
}
private function formatRemaining(int $seconds): string
{
if ($seconds <= 0) return 'läuft ab';
if ($seconds < 60) return "noch {$seconds} Sek.";
if ($seconds < 3600) return 'noch ' . (int)ceil($seconds / 60) . ' Min.';
if ($seconds < 86400) return 'noch ' . round($seconds / 3600, 1) . ' Std.';
return 'noch ' . (int)ceil($seconds / 86400) . ' Tage';
}
private function serviceLabel(string $jail): string
{
return match(true) {
$jail === 'sshd' => 'SSH',
$jail === 'dovecot' => 'IMAP/POP3',
str_starts_with($jail, 'postfix') => 'SMTP',
str_starts_with($jail, 'nginx') => 'Web',
$jail === 'recidive' => 'Recidive',
str_contains($jail, 'blacklist') => 'Blacklist',
default => $jail,
};
}
/** 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');
}
}

View File

@ -1,547 +0,0 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use App\Models\Fail2banSetting;
use App\Models\Fail2banIpList;
use Illuminate\Validation\ValidationException;
#[Layout('layouts.dvx')]
#[Title('Fail2Ban · Mailwolt')]
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-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/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-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/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-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/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

@ -1,13 +0,0 @@
<?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

@ -1,567 +0,0 @@
<?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/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/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/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/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

@ -2,54 +2,90 @@
namespace App\Livewire\Ui\Security\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
use Vectorface\GoogleAuthenticator;
class TotpSetupModal extends ModalComponent
{
public string $step = 'scan';
public string $code = '';
public string $secret = '';
public array $recoveryCodes = [];
public string $qrSvg = '';
public string $secret;
public string $otp = '';
public string $qrPng; // PNG Data-URI
public bool $alreadyActive = false;
public static function modalMaxWidth(): string { return 'md'; }
// << Wichtig: je Modal eigene Breite >>
public static function modalMaxWidth(): string
{
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
return 'xl'; // kompakt für TOTP
}
public function mount(): void
{
$totp = app(TotpService::class);
$this->secret = $totp->generateSecret();
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
$user = Auth::user();
$ga = new GoogleAuthenticator();
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
$this->secret = $user->totp_secret ?: $ga->createSecret();
$issuer = config('app.name', 'MailWolt');
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
}
public function verify(): void
#[On('security:totp:enable')]
public function verifyAndEnable(string $code): void
{
$this->validate(['code' => 'required|digits:6']);
$totp = app(TotpService::class);
if (!$totp->verify($this->secret, $this->code)) {
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
$code = preg_replace('/\D/', '', $code ?? '');
if (strlen($code) !== 6) {
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
return;
}
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
$this->step = 'codes';
$ga = new GoogleAuthenticator();
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
if (!$ok) {
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
return;
}
$user = Auth::user();
$user->totp_secret = $this->secret;
$user->two_factor_enabled = true;
$user->save();
$this->dispatch('totp-enabled');
$this->dispatch('toast', body: 'TOTP aktiviert.');
$this->dispatch('closeModal');
}
public function done(): void
public function disable(): void
{
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP aktiviert',
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
$this->dispatch('2fa-status-changed');
$this->closeModal();
$user = Auth::user();
$user->totp_secret = null;
$user->two_factor_enabled = false;
$user->save();
$this->dispatch('totp-disabled');
$this->dispatch('toast', body: 'TOTP deaktiviert.');
$this->dispatch('closeModal');
}
public function saveAccount() { /* $this->validate(..); user->update([...]) */ }
public function changePassword() { /* validate & set */ }
public function changeEmail() { /* validate, send verify link, etc. */ }
public function openRecovery() { /* optional modal or page */ }
public function logoutOthers() { /* … */ }
public function logoutSession(string $id) { /* … */ }
public function render()
{
return view('livewire.ui.system.modal.totp-setup-modal');
return view('livewire.ui.security.modal.totp-setup-modal');
}
}

View File

@ -1,343 +1,156 @@
<?php
// App\Livewire\Ui\Security\RblCard.php
declare(strict_types=1);
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 $ip = '';
public int $hits = 0;
public array $lists = [];
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 mount(): void { $this->load(); }
public function render() { return view('livewire.ui.security.rbl-card'); }
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) …
Cache::forget('dash.rbl');
$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', []));
// 1) IPv4/IPv6 bevorzugt aus /etc/mailwolt/installer.env
[$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
$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;
// 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 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;
//

View File

@ -2,85 +2,12 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Rspamd · Mailwolt')]
class RspamdForm extends Component
{
public float $spam_score = 5.0;
public float $greylist_score = 4.0;
public float $reject_score = 15.0;
public bool $enabled = true;
public function mount(): void
{
$this->spam_score = (float) Setting::get('rspamd.spam_score', 5.0);
$this->greylist_score = (float) Setting::get('rspamd.greylist_score', 4.0);
$this->reject_score = (float) Setting::get('rspamd.reject_score', 15.0);
$this->enabled = (bool) Setting::get('rspamd.enabled', true);
}
public function save(): void
{
$this->validate([
'spam_score' => 'required|numeric|min:1|max:50',
'greylist_score' => 'required|numeric|min:0|max:50',
'reject_score' => 'required|numeric|min:1|max:100',
]);
Setting::setMany([
'rspamd.spam_score' => $this->spam_score,
'rspamd.greylist_score' => $this->greylist_score,
'rspamd.reject_score' => $this->reject_score,
'rspamd.enabled' => $this->enabled,
]);
try {
$this->writeRspamdConfig();
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload-or-restart', 'rspamd']);
$this->dispatch('toast', type: 'done', badge: 'Rspamd',
title: 'Einstellungen gespeichert',
text: 'Rspamd-Konfiguration wurde übernommen und neu geladen.', duration: 5000);
} catch (\Throwable $e) {
$this->dispatch('toast', type: 'error', badge: 'Rspamd',
title: 'Fehler', text: $e->getMessage(), duration: 0);
}
}
private function writeRspamdConfig(): void
{
$target = '/etc/rspamd/local.d/actions.conf';
$content = <<<CONF
actions {
reject = {$this->reject_score};
add_header = {$this->spam_score};
greylist = {$this->greylist_score};
}
CONF;
$proc = proc_open(
sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)),
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$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]);
if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed writing to {$target}");
}
public function render()
{
$r = Process::run(['systemctl', 'is-active', 'rspamd']);
$running = trim($r->output()) === 'active';
return view('livewire.ui.security.rspamd-form', compact('running'));
return view('livewire.ui.security.rspamd-form');
}
}

View File

@ -2,182 +2,10 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('SSL/TLS · Mailwolt')]
class SslCertificatesTable extends Component
{
public array $certs = [];
// Domains (aus Einstellungen)
public string $uiDomain = '';
public string $webmailDomain = '';
public string $mailDomain = '';
// Provisioning
public bool $sslProvisioning = false;
public bool $sslDone = false;
public array $sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
public function mount(): void
{
$this->uiDomain = (string) Setting::get('ui_domain', '');
$this->webmailDomain = (string) Setting::get('webmail_domain', '');
$this->mailDomain = (string) Setting::get('mail_domain', '');
$this->certs = $this->loadCertificates();
$this->restoreSslProvisioningState();
}
public function refresh(): void
{
$this->certs = $this->loadCertificates();
$this->dispatch('toast', type: 'done', badge: 'SSL', title: 'Aktualisiert',
text: 'Zertifikatsliste wurde neu geladen.', duration: 3000);
}
public function startSslProvisioning(): void
{
if (! ($this->uiDomain && $this->webmailDomain && $this->mailDomain)) {
$this->dispatch('toast', type: 'warn', badge: 'SSL',
title: 'Domains fehlen',
text: 'Bitte erst alle Domains unter Einstellungen speichern.', duration: 6000);
return;
}
@mkdir(self::SSL_STATE_DIR, 0755, true);
@unlink(self::SSL_STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::SSL_STATE_DIR . "/{$k}", 'pending');
}
$this->sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
$this->sslDone = false;
$this->sslProvisioning = true;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=1 > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->uiDomain),
escapeshellarg($this->mailDomain),
escapeshellarg($this->webmailDomain),
);
@shell_exec($cmd);
}
public function pollSsl(): void
{
if (! $this->sslProvisioning || $this->sslDone) return;
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::SSL_STATE_DIR . "/{$key}";
$this->sslProgress[$key] = is_readable($file)
? trim((string) @file_get_contents($file))
: 'pending';
}
$done = @file_get_contents(self::SSL_STATE_DIR . '/done');
if ($done !== false) {
$this->sslDone = true;
$this->sslProvisioning = false;
$this->certs = $this->loadCertificates();
}
}
public function renew(string $name): void
{
$safe = preg_replace('/[^a-z0-9._-]/i', '', $name);
if ($safe === '') return;
$out = (string) @shell_exec(
"sudo -n /usr/bin/certbot renew --cert-name {$safe} --force-renewal 2>&1"
);
$this->certs = $this->loadCertificates();
if (str_contains($out, 'Successfully renewed') || str_contains($out, 'success')) {
$this->dispatch('toast', type: 'done', badge: 'SSL',
title: 'Zertifikat erneuert', text: "Zertifikat <b>{$safe}</b> wurde erfolgreich erneuert.", duration: 5000);
} else {
$this->dispatch('toast', type: 'error', badge: 'SSL',
title: 'Fehler', text: nl2br(htmlspecialchars(substr($out, 0, 300))), duration: 0);
}
}
private function restoreSslProvisioningState(): void
{
$doneFile = self::SSL_STATE_DIR . '/done';
if (! file_exists($doneFile)) return;
$this->sslDone = true;
$this->sslProvisioning = false;
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::SSL_STATE_DIR . "/{$key}";
if (is_readable($file)) {
$this->sslProgress[$key] = trim((string) @file_get_contents($file));
}
}
}
private function loadCertificates(): array
{
$out = (string) @shell_exec('sudo -n /usr/bin/certbot certificates 2>&1');
if (empty(trim($out))) return [['_error' => 'unavailable']];
if (str_contains($out, 'No certificates found')) return [];
$certs = [];
$blocks = preg_split('/\n(?=\s*Certificate Name:)/m', $out);
foreach ($blocks as $block) {
if (!preg_match('/Certificate Name:\s*(.+)/i', $block, $nameM)) continue;
preg_match('/Domains:\s*(.+)/i', $block, $domainsM);
preg_match('/Expiry Date:\s*(.+)/i', $block, $expiryM);
preg_match('/Certificate Path:\s*(.+)/i', $block, $certM);
$expiryRaw = trim($expiryM[1] ?? '');
$daysLeft = null;
$expired = false;
$expiryDate = null;
// Datum extrahieren (Format: "2026-07-24 10:30:00+00:00 (VALID: 88 days)")
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $expiryRaw, $dateM)) {
$ts = strtotime($dateM[1]);
$expiryDate = $ts ? date('d.m.Y', $ts) : null;
}
if (preg_match('/VALID: (\d+) days/i', $expiryRaw, $dM)) {
$daysLeft = (int) $dM[1];
} elseif (preg_match('/INVALID/i', $expiryRaw)) {
$expired = true;
$daysLeft = 0;
}
$domainsRaw = trim($domainsM[1] ?? '');
$domains = $domainsRaw !== '' ? array_values(array_filter(explode(' ', $domainsRaw))) : [];
$certs[] = [
'name' => trim($nameM[1]),
'domains' => $domains,
'expiry_date' => $expiryDate,
'days_left' => $daysLeft,
'expired' => $expired,
'cert_path' => trim($certM[1] ?? ''),
];
}
usort($certs, fn($a, $b) => ($a['days_left'] ?? 999) <=> ($b['days_left'] ?? 999));
return $certs;
}
public function render()
{
return view('livewire.ui.security.ssl-certificates-table');

View File

@ -2,133 +2,12 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('TLS-Ciphers · Mailwolt')]
class TlsCiphersForm extends Component
{
public string $preset = 'intermediate';
public string $postfix_protocols = '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1';
public string $postfix_ciphers = 'medium';
public string $dovecot_min_proto = 'TLSv1.2';
public string $dovecot_ciphers = 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS';
private const PRESETS = [
'modern' => [
'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1, !TLSv1.2',
'postfix_ciphers' => 'high',
'dovecot_min_proto' => 'TLSv1.3',
'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:!aNULL:!MD5',
],
'intermediate' => [
'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
'postfix_ciphers' => 'medium',
'dovecot_min_proto' => 'TLSv1.2',
'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS',
],
'old' => [
'postfix_protocols' => '!SSLv2, !SSLv3',
'postfix_ciphers' => 'low',
'dovecot_min_proto' => 'TLSv1',
'dovecot_ciphers' => 'HIGH:MEDIUM:!aNULL:!MD5',
],
];
public function mount(): void
{
$this->preset = Setting::get('tls.preset', 'intermediate');
$this->postfix_protocols = Setting::get('tls.postfix_protocols', self::PRESETS['intermediate']['postfix_protocols']);
$this->postfix_ciphers = Setting::get('tls.postfix_ciphers', self::PRESETS['intermediate']['postfix_ciphers']);
$this->dovecot_min_proto = Setting::get('tls.dovecot_min_proto', self::PRESETS['intermediate']['dovecot_min_proto']);
$this->dovecot_ciphers = Setting::get('tls.dovecot_ciphers', self::PRESETS['intermediate']['dovecot_ciphers']);
}
public function applyPreset(string $preset): void
{
if (!isset(self::PRESETS[$preset])) return;
$p = self::PRESETS[$preset];
$this->preset = $preset;
$this->postfix_protocols = $p['postfix_protocols'];
$this->postfix_ciphers = $p['postfix_ciphers'];
$this->dovecot_min_proto = $p['dovecot_min_proto'];
$this->dovecot_ciphers = $p['dovecot_ciphers'];
}
public function save(): void
{
$this->validate([
'postfix_protocols' => 'required|string|max:200',
'postfix_ciphers' => 'required|string|max:500',
'dovecot_min_proto' => 'required|string|max:50',
'dovecot_ciphers' => 'required|string|max:500',
]);
Setting::setMany([
'tls.preset' => $this->preset,
'tls.postfix_protocols' => $this->postfix_protocols,
'tls.postfix_ciphers' => $this->postfix_ciphers,
'tls.dovecot_min_proto' => $this->dovecot_min_proto,
'tls.dovecot_ciphers' => $this->dovecot_ciphers,
]);
try {
$this->writePostfixConfig();
$this->writeDovecotConfig();
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'postfix']);
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'dovecot']);
$this->dispatch('toast', type: 'done', badge: 'TLS',
title: 'TLS-Konfiguration übernommen',
text: 'Postfix und Dovecot wurden neu geladen.', duration: 5000);
} catch (\Throwable $e) {
$this->dispatch('toast', type: 'error', badge: 'TLS',
title: 'Fehler', text: $e->getMessage(), duration: 0);
}
}
private function writePostfixConfig(): void
{
$target = '/etc/postfix/tls.cf';
$content = "smtpd_tls_protocols = {$this->postfix_protocols}\n"
. "smtp_tls_protocols = {$this->postfix_protocols}\n"
. "smtpd_tls_ciphers = {$this->postfix_ciphers}\n"
. "smtp_tls_ciphers = {$this->postfix_ciphers}\n";
$this->tee($target, $content);
}
private function writeDovecotConfig(): void
{
$target = '/etc/dovecot/conf.d/99-tls.conf';
$content = "ssl_min_protocol = {$this->dovecot_min_proto}\n"
. "ssl_cipher_list = {$this->dovecot_ciphers}\n";
$this->tee($target, $content);
}
private function tee(string $target, string $content): void
{
$proc = proc_open(
sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)),
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$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]);
if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed: {$target}");
}
public function render()
{
return view('livewire.ui.security.tls-ciphers-form', ['presets' => array_keys(self::PRESETS)]);
return view('livewire.ui.security.tls-ciphers-form');
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('API Keys · Mailwolt')]
class ApiKeyTable extends Component
{
public function deleteToken(int $id): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($id);
$token->delete();
$this->dispatch('notify', type: 'success', message: 'API Key gelöscht.');
}
public function render()
{
$tokens = Auth::user()
->tokens()
->latest()
->get();
return view('livewire.ui.system.api-key-table', compact('tokens'));
}
}

View File

@ -1,130 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Backups · Mailwolt')]
class BackupJobList extends Component
{
public function runNow(): void
{
$policy = BackupPolicy::first();
if (!$policy) {
$this->dispatch('toast', type: 'warn', badge: 'Backup',
title: 'Kein Zeitplan', text: 'Bitte zuerst einen Backup-Zeitplan konfigurieren.', duration: 4000);
return;
}
// Reset stale jobs (stuck > 30 min with no running process)
BackupJob::whereIn('status', ['queued', 'running'])
->where('started_at', '<', now()->subMinutes(30))
->update(['status' => 'failed', 'finished_at' => now(), 'error' => 'Timeout — Prozess nicht mehr aktiv.']);
$running = BackupJob::whereIn('status', ['queued', 'running'])->exists();
if ($running) {
$this->dispatch('toast', type: 'warn', badge: 'Backup',
title: 'Läuft bereits', text: 'Ein Backup-Job ist bereits aktiv.', duration: 3000);
return;
}
$job = BackupJob::create([
'policy_id' => $policy->id,
'status' => 'queued',
'started_at' => now(),
]);
$artisan = base_path('artisan');
exec("nohup php {$artisan} backup:run {$job->id} > /dev/null 2>&1 &");
$this->dispatch('openModal',
component: 'ui.system.modal.backup-progress-modal',
arguments: ['jobId' => $job->id]
);
}
public function openProgress(int $id): void
{
$this->dispatch('openModal',
component: 'ui.system.modal.backup-progress-modal',
arguments: ['jobId' => $id]
);
}
public function openDeleteConfirm(int $id): void
{
$this->dispatch('openModal',
component: 'ui.system.modal.backup-delete-modal',
arguments: ['jobId' => $id]
);
}
public function openRestoreConfirm(int $id): void
{
$this->dispatch('openModal',
component: 'ui.system.modal.backup-restore-confirm-modal',
arguments: ['jobId' => $id]
);
}
#[On('backup-list-refresh')]
public function refresh(): void {}
#[On('backup:do-restore')]
public function onRestoreConfirmed(int $jobId): void
{
$this->restore($jobId);
}
public function restore(int $id): void
{
$sourceJob = BackupJob::findOrFail($id);
if (!$sourceJob->artifact_path || !file_exists($sourceJob->artifact_path)) {
$this->dispatch('toast', type: 'warn', badge: 'Backup',
title: 'Datei nicht gefunden', text: 'Das Backup-Archiv wurde nicht gefunden.', duration: 4000);
return;
}
$token = 'mailwolt_restore_' . uniqid();
$artisan = base_path('artisan');
exec("nohup php {$artisan} restore:run {$sourceJob->id} {$token} > /dev/null 2>&1 &");
$this->dispatch('openModal',
component: 'ui.system.modal.backup-progress-modal',
arguments: ['jobId' => $sourceJob->id, 'restoreToken' => $token]
);
}
public function delete(int $id): void
{
$job = BackupJob::findOrFail($id);
if ($job->artifact_path && file_exists($job->artifact_path)) {
@unlink($job->artifact_path);
}
$job->delete();
$this->dispatch('toast', type: 'done', badge: 'Backup',
title: 'Gelöscht', text: 'Backup-Eintrag wurde entfernt.', duration: 3000);
}
public function render()
{
$jobs = BackupJob::where('checksum', '!=', 'restore')->orWhereNull('checksum')->latest('started_at')->paginate(20);
$policy = BackupPolicy::first();
$hasRunning = BackupJob::whereIn('status', ['queued', 'running'])
->where('started_at', '>=', now()->subMinutes(30))
->exists();
return view('livewire.ui.system.backup-job-list', compact('jobs', 'policy', 'hasRunning'));
}
}

View File

@ -3,7 +3,6 @@
namespace App\Livewire\Ui\System;
use DateTimeImmutable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Validation\Rule;
use Livewire\Component;
@ -11,14 +10,12 @@ class DomainsSslForm extends Component
{
/* ========= Basis & Hosts ========= */
public string $base_domain = '';
public string $base_domain = 'example.com';
// nur Subdomain-Teile (ohne Punkte/Protokoll)
public string $ui_sub = '';
public string $webmail_sub = '';
public string $mta_sub = '';
public bool $domainsSaving = false;
public string $ui_sub = 'mail';
public string $webmail_sub = 'webmail';
public string $mta_sub = 'mx';
/* ========= TLS / Redirect ========= */
public bool $force_https = true;
@ -90,9 +87,9 @@ class DomainsSslForm extends Component
{
return [
'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'],
'ui_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
'webmail_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
'mta_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'force_https' => ['boolean'],
'hsts' => ['boolean'],
@ -127,16 +124,6 @@ class DomainsSslForm extends Component
}
public function mount(): void
{
$this->base_domain = (string) config('clubird.domain.base', '');
$this->ui_sub = (string) config('clubird.domain.ui', '');
$this->webmail_sub = (string) config('clubird.domain.webmail', '');
$this->mta_sub = (string) config('clubird.domain.mail', '');
$this->loadMtaStsFromFileIfPossible();
}
protected function loadMtaStsFromFileIfPossible(): void
{
$file = public_path('.well-known/mta-sts.txt');
@ -167,52 +154,9 @@ class DomainsSslForm extends Component
public function saveDomains(): void
{
$this->validate(['base_domain', 'ui_sub', 'webmail_sub', 'mta_sub']);
$this->domainsSaving = true;
$wmHost = $this->webmail_sub ? $this->webmail_sub.'.'.$this->base_domain : '';
$this->writeEnv([
'BASE_DOMAIN' => $this->base_domain,
'UI_SUB' => $this->ui_sub,
'WEBMAIL_SUB' => $this->webmail_sub,
'MTA_SUB' => $this->mta_sub,
'WEBMAIL_DOMAIN' => $wmHost,
]);
Artisan::call('config:clear');
Artisan::call('route:clear');
$this->domainsSaving = false;
$this->dispatch('toast',
type: 'done',
badge: 'Domains',
title: 'Einstellungen gespeichert',
text: 'Konfiguration wurde übernommen.',
duration: 4000,
);
}
private function writeEnv(array $values): void
{
$path = base_path('.env');
$content = file_get_contents($path);
foreach ($values as $key => $value) {
$escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value);
$line = $key . '=' . $escaped;
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
}
}
file_put_contents($path, $content);
$this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']);
// TODO: persist
$this->dispatch('toast', body: 'Domains gespeichert.');
}
public function saveTls(): void

View File

@ -1,49 +0,0 @@
<?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('clubird.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

@ -1,79 +0,0 @@
<?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

@ -1,48 +0,0 @@
<?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

@ -1,223 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Installer · Mailwolt')]
class InstallerPage extends Component
{
/* ===== Run state ===== */
public string $state = 'idle'; // idle | running
public bool $running = false;
public ?int $rc = null;
public ?string $lowState = null;
public array $logLines = [];
public int $progressPct = 0;
public string $component = 'all';
public bool $postActionsDone = false;
/* ===== Component status ===== */
public array $componentStatus = [];
private const STATE_DIR = '/var/lib/mailwolt/install';
private const INSTALL_LOG = '/var/log/mailwolt-install.log';
private const COMPONENTS = [
'nginx' => ['label' => 'Nginx', 'service' => 'nginx'],
'postfix' => ['label' => 'Postfix', 'service' => 'postfix'],
'dovecot' => ['label' => 'Dovecot', 'service' => 'dovecot'],
'rspamd' => ['label' => 'Rspamd', 'service' => 'rspamd'],
'fail2ban' => ['label' => 'Fail2ban', 'service' => 'fail2ban'],
'certbot' => ['label' => 'Certbot', 'service' => null, 'binary' => 'certbot'],
];
/* ========================================================= */
public function mount(): void
{
$this->refreshLowLevelState();
$this->readLogLines();
if ($this->running) {
$this->state = 'running';
}
$this->checkComponentStatus();
$this->recalcProgress();
}
public function render()
{
return view('livewire.ui.system.installer-page');
}
/* ================== Aktionen ================== */
public function openConfirmModal(string $component = 'all'): void
{
$this->component = $component;
$this->dispatch('openModal',
component: 'ui.system.modal.installer-confirm-modal',
arguments: ['component' => $component]
);
}
public function runInstaller(string $component = 'all'): void
{
if ($this->running || $this->state === 'running') {
$this->dispatch('toast', type: 'warn', badge: 'Installer',
title: 'Läuft bereits',
text: 'Ein Installer-Prozess ist bereits aktiv.',
duration: 3000);
return;
}
$this->component = $component;
$this->state = 'running';
$this->running = true;
$this->rc = null;
$this->postActionsDone = false;
$this->logLines = ['Installer gestartet …'];
$this->progressPct = 5;
$safeComponent = preg_replace('/[^a-z0-9_-]/i', '', $component);
@shell_exec("nohup sudo -n /usr/local/sbin/mailwolt-install {$safeComponent} >/dev/null 2>&1 &");
}
public function pollStatus(): void
{
$this->refreshLowLevelState();
$this->readLogLines();
$this->recalcProgress();
if ($this->rc !== null) {
$this->running = false;
}
if ($this->lowState === 'done') {
usleep(300_000);
$this->readLogLines();
$this->progressPct = 100;
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan db:seed --class="Database\\\\Seeders\\\\SystemDomainSeeder" --force >/dev/null 2>&1 &');
$this->postActionsDone = true;
$this->dispatch('toast', type: 'done', badge: 'Installer',
title: 'Installation abgeschlossen',
text: 'Die Komponente wurde erfolgreich installiert/konfiguriert.',
duration: 6000);
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
$this->postActionsDone = true;
$this->dispatch('toast', type: 'error', badge: 'Installer',
title: 'Installation fehlgeschlagen',
text: "Rückgabecode: {$this->rc}. Bitte Log prüfen.",
duration: 0);
}
$this->state = 'idle';
$this->checkComponentStatus();
}
}
public function checkComponentStatus(): void
{
$statuses = [];
foreach (self::COMPONENTS as $key => $info) {
$installed = false;
$active = false;
// Check if binary exists
$binary = $info['binary'] ?? $key;
$which = @trim(@shell_exec("which {$binary} 2>/dev/null") ?: '');
$installed = $which !== '';
// Check service active state
if ($installed && isset($info['service']) && $info['service']) {
$svcState = @trim(@shell_exec("systemctl is-active {$info['service']} 2>/dev/null") ?: '');
$active = ($svcState === 'active');
} elseif ($installed && $key === 'certbot') {
$active = true; // certbot is a one-shot tool, if installed it's "OK"
}
$statuses[$key] = [
'label' => $info['label'],
'installed' => $installed,
'active' => $active,
];
}
$this->componentStatus = $statuses;
}
public function clearLog(): void
{
@file_put_contents(self::INSTALL_LOG, '');
$this->logLines = [];
$this->dispatch('toast', type: 'done', badge: 'Installer',
title: 'Log geleert', text: '', duration: 2500);
}
/* ================== Helpers ================== */
protected function refreshLowLevelState(): void
{
$state = @trim(@file_get_contents(self::STATE_DIR . '/state') ?: '');
$rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: '');
$this->lowState = $state !== '' ? $state : null;
$this->running = ($this->lowState !== 'done');
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null;
}
protected function readLogLines(): void
{
$p = self::INSTALL_LOG;
if (!is_readable($p)) {
$this->logLines = [];
return;
}
$lines = @file($p, FILE_IGNORE_NEW_LINES) ?: [];
$this->logLines = array_slice($lines, -100);
}
protected function recalcProgress(): void
{
if ($this->state !== 'running' && $this->lowState !== 'running') {
if ($this->lowState === 'done') {
$this->progressPct = 100;
}
return;
}
$text = implode("\n", $this->logLines);
$pct = 5;
foreach ([
'Installation gestartet' => 10,
'Nginx' => 20,
'Postfix' => 35,
'Dovecot' => 50,
'Rspamd' => 65,
'Fail2ban' => 78,
'SSL' => 88,
'Installation beendet' => 100,
] as $needle => $val) {
if (stripos($text, $needle) !== false) {
$pct = max($pct, $val);
}
}
if ($this->lowState === 'done') {
$pct = 100;
}
$this->progressPct = $pct;
}
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyCreateModal extends ModalComponent
{
public string $name = '';
public bool $sandbox = false;
public array $selected = [];
public static array $availableScopes = [
'mailboxes:read' => 'Mailboxen lesen',
'mailboxes:write' => 'Mailboxen schreiben',
'aliases:read' => 'Aliases lesen',
'aliases:write' => 'Aliases schreiben',
'domains:read' => 'Domains lesen',
'domains:write' => 'Domains schreiben',
];
public static function closeModalOnClickAway(): bool { return false; }
public static function closeModalOnEscape(): bool { return false; }
public static function closeModalOnEscapeIsForceful(): bool { return false; }
public function create(): void
{
$this->validate([
'name' => 'required|string|max:80',
'selected' => 'required|array|min:1',
'selected.*' => 'in:' . implode(',', array_keys(self::$availableScopes)),
], [
'selected.required' => 'Bitte mindestens einen Scope auswählen.',
'selected.min' => 'Bitte mindestens einen Scope auswählen.',
]);
$token = Auth::user()->createToken($this->name, $this->selected);
$pat = $token->accessToken;
if ($this->sandbox) {
$pat->sandbox = true;
$pat->save();
}
$this->dispatch('token-created', plainText: $token->plainTextToken);
$this->dispatch('openModal',
component: 'ui.system.modal.api-key-show-modal',
arguments: ['plainText' => $token->plainTextToken],
);
}
public function toggleAll(): void
{
if (count($this->selected) === count(self::$availableScopes)) {
$this->selected = [];
} else {
$this->selected = array_keys(self::$availableScopes);
}
}
public function render()
{
return view('livewire.ui.system.modal.api-key-create-modal', [
'scopes' => self::$availableScopes,
]);
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyDeleteModal extends ModalComponent
{
public int $tokenId;
public string $tokenName = '';
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($tokenId);
$this->tokenId = $tokenId;
$this->tokenName = $token->name;
}
public function delete(): void
{
PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($this->tokenId)
->delete();
$this->dispatch('toast', type: 'done', badge: 'API Key',
title: 'Gelöscht', text: "Key <b>{$this->tokenName}</b> wurde entfernt.", duration: 4000);
$this->dispatch('token-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.api-key-delete-modal');
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyScopesModal extends ModalComponent
{
public string $tokenName = '';
public array $scopes = [];
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($tokenId);
$this->tokenName = $token->name;
$this->scopes = $token->abilities;
}
public function render()
{
return view('livewire.ui.system.modal.api-key-scopes-modal');
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class ApiKeyShowModal extends ModalComponent
{
public string $plainText = '';
public function mount(string $plainText): void
{
$this->plainText = $plainText;
}
public static function closeModalOnClickAway(): bool { return false; }
public static function closeModalOnEscape(): bool { return false; }
public static function closeModalOnEscapeIsForceful(): bool { return false; }
public function dismiss(): void
{
$this->forceClose()->closeModal();
}
public static function modalMaxWidth(): string
{
return '2xl';
}
public function render()
{
return view('livewire.ui.system.modal.api-key-show-modal');
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class BackupDeleteModal extends ModalComponent
{
public int $jobId;
public string $filename = '';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(int $jobId): void
{
$job = BackupJob::findOrFail($jobId);
$this->jobId = $job->id;
$this->filename = $job->artifact_path ? basename($job->artifact_path) : '—';
}
#[On('backup:confirm-delete')]
public function delete(): void
{
$job = BackupJob::find($this->jobId);
if ($job) {
if ($job->artifact_path && file_exists($job->artifact_path)) {
@unlink($job->artifact_path);
}
$job->delete();
}
$this->dispatch('backup-list-refresh');
$this->dispatch('toast', type: 'done', badge: 'Backup',
title: 'Gelöscht', text: 'Backup-Eintrag wurde entfernt.', duration: 3000);
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.backup-delete-modal');
}
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use LivewireUI\Modal\ModalComponent;
class BackupProgressModal extends ModalComponent
{
public int $jobId;
public string $restoreToken = '';
public bool $notifiedDone = false;
public static function modalMaxWidth(): string { return 'md'; }
public function mount(int $jobId, string $restoreToken = ''): void
{
$this->jobId = $jobId;
$this->restoreToken = $restoreToken;
}
public function getJobProperty(): ?BackupJob
{
return BackupJob::find($this->jobId);
}
public function getRestoreStatusProperty(): array
{
if (empty($this->restoreToken)) {
return ['status' => 'unknown', 'log' => ''];
}
$file = sys_get_temp_dir() . '/' . $this->restoreToken . '.json';
if (!file_exists($file)) {
return ['status' => 'queued', 'log' => 'Wartend auf Start…'];
}
$data = json_decode(file_get_contents($file), true);
return $data ?: ['status' => 'unknown', 'log' => ''];
}
public function close(): void
{
// Clean up status file for restore jobs
if ($this->restoreToken) {
$file = sys_get_temp_dir() . '/' . $this->restoreToken . '.json';
@unlink($file);
}
$this->closeModal();
}
public function render()
{
if (!$this->notifiedDone) {
$status = empty($this->restoreToken)
? ($this->job?->status ?? 'queued')
: ($this->restoreStatus['status'] ?? 'queued');
if (in_array($status, ['ok', 'failed', 'canceled'])) {
$this->notifiedDone = true;
$this->dispatch('backup-list-refresh');
}
}
return view('livewire.ui.system.modal.backup-progress-modal');
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use LivewireUI\Modal\ModalComponent;
class BackupRestoreConfirmModal extends ModalComponent
{
public int $jobId;
public string $filename = '';
public string $startedAt = '';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(int $jobId): void
{
$job = BackupJob::findOrFail($jobId);
$this->jobId = $job->id;
$this->filename = $job->artifact_path ? basename($job->artifact_path) : '—';
$this->startedAt = $job->started_at?->format('d.m.Y H:i') ?? '—';
}
public function confirm(): void
{
$this->closeModal();
// Trigger restore in the parent list component via event
$this->dispatch('backup:do-restore', jobId: $this->jobId);
}
public function render()
{
return view('livewire.ui.system.modal.backup-restore-confirm-modal');
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class InstallerConfirmModal extends ModalComponent
{
public string $component = 'all';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(string $component = 'all'): void
{
$this->component = $component;
}
public function confirm(): void
{
$this->closeModal();
$this->dispatch('installer:run', component: $this->component);
}
public function render()
{
return view('livewire.ui.system.modal.installer-confirm-modal');
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class SslProvisionModal extends ModalComponent
{
public static function modalMaxWidth(): string { return 'sm'; }
public function confirm(): void
{
$this->closeModal();
$this->dispatch('ssl:provision');
}
public function render()
{
return view('livewire.ui.system.modal.ssl-provision-modal');
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class TotpSetupModal extends ModalComponent
{
public string $step = 'scan'; // scan → verify → codes
public string $code = '';
public string $secret = '';
public array $recoveryCodes = [];
public string $qrSvg = '';
public static function modalMaxWidth(): string { return 'md'; }
public function mount(): void
{
$totp = app(TotpService::class);
$this->secret = $totp->generateSecret();
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
}
public function verify(): void
{
$this->validate(['code' => 'required|digits:6']);
$totp = app(TotpService::class);
if (!$totp->verify($this->secret, $this->code)) {
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
return;
}
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
$this->step = 'codes';
}
public function done(): void
{
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP aktiviert',
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
$this->dispatch('2fa-status-changed');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.totp-setup-modal');
}
}

View File

@ -1,89 +0,0 @@
<?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') ?: '');
// Log einlesen
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
// Nur als "done" gelten wenn [DONE]-Marker im Log steht —
// verhindert dass das Modal fertig zeigt während das Script noch läuft
$logDone = in_array('[DONE]', array_map('trim', $lines), true);
if ($st === 'done' && !$logDone) {
$st = 'running'; // Noch warten bis Log vollständig
}
$this->state = $st ?: 'unknown';
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
$this->tail = array_slice(
array_filter($lines, fn($l) => trim($l) !== '[DONE]'),
-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

@ -1,60 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use LivewireUI\Modal\ModalComponent;
class UserCreateModal extends ModalComponent
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $role = Role::Operator->value;
public bool $is_active = true;
protected function rules(): array
{
return [
'name' => 'required|string|max:100|unique:users,name',
'email' => 'required|email|max:190|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'required|in:' . implode(',', Role::values()),
];
}
protected function messages(): array
{
return [
'name.unique' => 'Dieser Benutzername ist bereits vergeben.',
'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.',
];
}
public function save(): void
{
$this->validate();
User::create([
'name' => $this->name,
'email' => $this->email,
'password' => Hash::make($this->password),
'role' => $this->role,
'is_active' => $this->is_active,
]);
$this->dispatch('toast', type: 'done', badge: 'Benutzer',
title: 'Erstellt', text: "Benutzer <b>{$this->name}</b> wurde angelegt.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
$roles = Role::cases();
return view('livewire.ui.system.modal.user-create-modal', compact('roles'));
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\User;
use LivewireUI\Modal\ModalComponent;
class UserDeleteModal extends ModalComponent
{
public int $userId;
public string $userName = '';
public function mount(int $userId): void
{
$user = User::findOrFail($userId);
$this->userId = $userId;
$this->userName = $user->name;
}
public function delete(): void
{
if ($this->userId === auth()->id()) {
$this->dispatch('toast', type: 'error', badge: 'Benutzer',
title: 'Fehler', text: 'Du kannst deinen eigenen Account nicht löschen.', duration: 5000);
$this->closeModal();
return;
}
User::findOrFail($this->userId)->delete();
$this->dispatch('toast', type: 'done', badge: 'Benutzer',
title: 'Gelöscht', text: "Benutzer <b>{$this->userName}</b> wurde entfernt.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.user-delete-modal');
}
}

View File

@ -1,69 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use LivewireUI\Modal\ModalComponent;
class UserEditModal extends ModalComponent
{
public int $userId;
public string $name = '';
public string $email = '';
public string $password = '';
public string $role = '';
public bool $is_active = true;
public function mount(int $userId): void
{
$user = User::findOrFail($userId);
$this->userId = $userId;
$this->name = $user->name;
$this->email = $user->email;
$this->role = $user->role?->value ?? Role::Operator->value;
$this->is_active = $user->is_active;
}
protected function rules(): array
{
return [
'name' => "required|string|max:100|unique:users,name,{$this->userId}",
'email' => "required|email|max:190|unique:users,email,{$this->userId}",
'password' => 'nullable|string|min:8',
'role' => 'required|in:' . implode(',', Role::values()),
];
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'is_active' => $this->is_active,
];
if ($this->password !== '') {
$data['password'] = Hash::make($this->password);
}
User::findOrFail($this->userId)->update($data);
$this->dispatch('toast', type: 'done', badge: 'Benutzer',
title: 'Gespeichert', text: "Benutzer <b>{$this->name}</b> wurde aktualisiert.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
$roles = Role::cases();
$isSelf = $this->userId === auth()->id();
return view('livewire.ui.system.modal.user-edit-modal', compact('roles', 'isSelf'));
}
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use App\Services\WebhookService;
use LivewireUI\Modal\ModalComponent;
class WebhookCreateModal extends ModalComponent
{
public string $name = '';
public string $url = '';
public array $selected = [];
public bool $is_active = true;
public function save(): void
{
$this->validate([
'name' => 'required|string|max:80',
'url' => 'required|url|max:500',
'selected' => 'required|array|min:1',
'selected.*' => 'in:' . implode(',', array_keys(Webhook::allEvents())),
], [
'selected.required' => 'Bitte mindestens ein Event auswählen.',
'selected.min' => 'Bitte mindestens ein Event auswählen.',
]);
Webhook::create([
'name' => $this->name,
'url' => $this->url,
'events' => $this->selected,
'secret' => WebhookService::generateSecret(),
'is_active' => $this->is_active,
]);
$this->dispatch('webhook-saved');
$this->closeModal();
}
public function toggleAll(): void
{
$all = array_keys(Webhook::allEvents());
$this->selected = count($this->selected) === count($all) ? [] : $all;
}
public function render()
{
return view('livewire.ui.system.modal.webhook-create-modal', [
'allEvents' => Webhook::allEvents(),
]);
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class WebhookDeleteModal extends ModalComponent
{
public int $webhookId;
public string $webhookName = '';
public function mount(int $webhookId): void
{
$webhook = Webhook::findOrFail($webhookId);
$this->webhookId = $webhookId;
$this->webhookName = $webhook->name;
}
public function delete(): void
{
Webhook::findOrFail($this->webhookId)->delete();
$this->dispatch('webhook-saved');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.webhook-delete-modal');
}
}

View File

@ -1,70 +0,0 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class WebhookEditModal extends ModalComponent
{
public int $webhookId;
public string $name = '';
public string $url = '';
public array $selected = [];
public bool $is_active = true;
public string $secret = '';
public function mount(int $webhookId): void
{
$webhook = Webhook::findOrFail($webhookId);
$this->webhookId = $webhookId;
$this->name = $webhook->name;
$this->url = $webhook->url;
$this->selected = $webhook->events;
$this->is_active = $webhook->is_active;
$this->secret = $webhook->secret;
}
public function save(): void
{
$this->validate([
'name' => 'required|string|max:80',
'url' => 'required|url|max:500',
'selected' => 'required|array|min:1',
'selected.*' => 'in:' . implode(',', array_keys(Webhook::allEvents())),
], [
'selected.required' => 'Bitte mindestens ein Event auswählen.',
]);
Webhook::findOrFail($this->webhookId)->update([
'name' => $this->name,
'url' => $this->url,
'events' => $this->selected,
'is_active' => $this->is_active,
]);
$this->dispatch('webhook-saved');
$this->closeModal();
}
public function regenerateSecret(): void
{
$webhook = Webhook::findOrFail($this->webhookId);
$webhook->update(['secret' => \App\Services\WebhookService::generateSecret()]);
$this->secret = $webhook->fresh()->secret;
$this->dispatch('notify', type: 'success', message: 'Secret neu generiert.');
}
public function toggleAll(): void
{
$all = array_keys(Webhook::allEvents());
$this->selected = count($this->selected) === count($all) ? [] : $all;
}
public function render()
{
return view('livewire.ui.system.modal.webhook-edit-modal', [
'allEvents' => Webhook::allEvents(),
]);
}
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Profil · Mailwolt')]
class ProfilePage extends Component
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
public bool $totpEnabled = false;
public function mount(): void
{
$u = Auth::user();
$this->name = $u->name ?? '';
$this->email = $u->email ?? '';
$this->totpEnabled = app(TotpService::class)->isEnabled($u);
}
#[On('2fa-status-changed')]
public function refreshTotpStatus(): void
{
$this->totpEnabled = app(TotpService::class)->isEnabled(Auth::user());
}
public function saveProfile(): void
{
$this->validate([
'name' => 'required|string|max:100',
]);
$u = Auth::user();
$u->name = $this->name;
$u->save();
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Gespeichert',
text: 'Profilname wurde aktualisiert.', duration: 4000);
}
public function changePassword(): void
{
if ($this->new_password === '') {
$this->addError('new_password', 'Kein neues Passwort eingegeben.');
return;
}
$this->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:8',
'new_password_confirmation' => 'required|same:new_password',
]);
$u = Auth::user();
if (!Hash::check($this->current_password, $u->password)) {
$this->addError('current_password', 'Aktuelles Passwort ist falsch.');
return;
}
$u->password = Hash::make($this->new_password);
$u->save();
$this->reset(['current_password', 'new_password', 'new_password_confirmation']);
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Passwort geändert',
text: 'Dein Passwort wurde aktualisiert.', duration: 4000);
}
public function disableTotp(): void
{
app(TotpService::class)->disable(Auth::user());
session()->forget('2fa_verified');
$this->totpEnabled = false;
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP deaktiviert',
text: 'Zwei-Faktor-Authentifizierung wurde deaktiviert.', duration: 5000);
}
public function render()
{
return view('livewire.ui.system.profile-page');
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\SandboxMail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Mail-Sandbox · Mailwolt')]
class SandboxMailbox extends Component
{
#[Url]
public string $search = '';
public ?int $selectedId = null;
public function select(int $id): void
{
$this->selectedId = $id;
SandboxMail::find($id)?->update(['is_read' => true]);
}
public function deleteOne(int $id): void
{
SandboxMail::findOrFail($id)->delete();
if ($this->selectedId === $id) {
$this->selectedId = null;
}
}
public function clearAll(): void
{
SandboxMail::truncate();
$this->selectedId = null;
}
public function render()
{
$query = SandboxMail::orderByDesc('received_at');
if ($this->search !== '') {
$s = '%' . $this->search . '%';
$query->where(fn($q) => $q
->where('from_address', 'like', $s)
->orWhere('subject', 'like', $s)
->orWhereJsonContains('to_addresses', $this->search)
);
}
$mails = $query->get();
$selected = $this->selectedId ? SandboxMail::find($this->selectedId) : null;
$unread = SandboxMail::where('is_read', false)->count();
return view('livewire.ui.system.sandbox-mailbox', compact('mails', 'selected', 'unread'));
}
}

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