Compare commits
278 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
97cc0091c0 | |
|
|
eeae972d8b | |
|
|
16fa6d3f4e | |
|
|
04e28903ea | |
|
|
4efc014a15 | |
|
|
2cdcb5ec38 | |
|
|
c313582b0b | |
|
|
eff76f6568 | |
|
|
c48a5c7e02 | |
|
|
d190beadcf | |
|
|
f1c1cf55da | |
|
|
c94f90bcde | |
|
|
0fedff759c | |
|
|
dec95d5803 | |
|
|
1ae3c7c54a | |
|
|
fce4f43833 | |
|
|
6710827b81 | |
|
|
f20a30839f | |
|
|
3bb9afefc3 | |
|
|
5c8a03cc45 | |
|
|
05cc37271c | |
|
|
7a89493efd | |
|
|
afce3d9687 | |
|
|
0beb12569b | |
|
|
be3502f197 | |
|
|
315608a108 | |
|
|
30d2c28504 | |
|
|
b7e411be82 | |
|
|
93699108eb | |
|
|
ff28b98555 | |
|
|
3e157f4e56 | |
|
|
d84537b343 | |
|
|
21015e69a3 | |
|
|
fc0ede4840 | |
|
|
aaeea2bb86 | |
|
|
f266228fcf | |
|
|
41de455826 | |
|
|
49463bf34d | |
|
|
eb77ec3342 | |
|
|
ff6002ca0b | |
|
|
1f013b6dcd | |
|
|
dcb4456ba6 | |
|
|
2fb66df19e | |
|
|
f756789cc4 | |
|
|
32cf27bcd2 | |
|
|
74600185ba | |
|
|
919c2013f9 | |
|
|
e23d9058ee | |
|
|
b48c74b676 | |
|
|
e49b1f6c4c | |
|
|
f473ec66d4 | |
|
|
22b6a9e0f2 | |
|
|
93bcb56605 | |
|
|
b6475fea75 | |
|
|
14901a40e3 | |
|
|
20c9100226 | |
|
|
5dc55a9d1c | |
|
|
fc7e1ed0f9 | |
|
|
5f71a8d5e9 | |
|
|
ea7dc3b94d | |
|
|
4671909e14 | |
|
|
653bf55415 | |
|
|
978fad64f4 | |
|
|
23d6d9cb46 | |
|
|
d3012792bc | |
|
|
bbc7cc6c34 | |
|
|
8ea925e4ae | |
|
|
c276a17a98 | |
|
|
597773123f | |
|
|
afad3c3471 | |
|
|
8a546f4f3d | |
|
|
486166c05a | |
|
|
f375ea0215 | |
|
|
fa8e9e7e88 | |
|
|
742c39f91a | |
|
|
2ec10a8a81 | |
|
|
fb6573598d | |
|
|
6bb2d09621 | |
|
|
cdfdcc0756 | |
|
|
c6d6798898 | |
|
|
496912f5ca | |
|
|
2c82555235 | |
|
|
eb17d056b7 | |
|
|
96477ede1c | |
|
|
8ff63dd966 | |
|
|
50a68047fc | |
|
|
a2ba9a7c03 | |
|
|
ea37abacf8 | |
|
|
32a3b51b2a | |
|
|
0375aed2ce | |
|
|
fd8d81fecb | |
|
|
fcbe944e32 | |
|
|
598f0edacf | |
|
|
00f715480f | |
|
|
9673e717d1 | |
|
|
bf76e93103 | |
|
|
dbd7848f68 | |
|
|
cae269fae1 | |
|
|
77fad53655 | |
|
|
de314adebe | |
|
|
7e7d5c1069 | |
|
|
aaccb4bb32 | |
|
|
7ccc7921c1 | |
|
|
a96d8f690a | |
|
|
b0daf839d9 | |
|
|
ce66a84c8d | |
|
|
fa65bc1c2e | |
|
|
5f627dcb0e | |
|
|
3f6f787fd3 | |
|
|
2331ef74f6 | |
|
|
029d959d51 | |
|
|
d0e1d5613c | |
|
|
68e7635f6d | |
|
|
673e9ec1df | |
|
|
0fc6ee81d6 | |
|
|
79b1eeb93c | |
|
|
dc5d8582e1 | |
|
|
a9ad26757b | |
|
|
1bcd93908e | |
|
|
7d738392b2 | |
|
|
6fa77f9f9b | |
|
|
b721b7b0df | |
|
|
ad85bc0326 | |
|
|
7b1274f349 | |
|
|
cbdab12c19 | |
|
|
b83af57e56 | |
|
|
0635d1d9fe | |
|
|
a771f97516 | |
|
|
9d40597842 | |
|
|
140d737231 | |
|
|
09f9cf3553 | |
|
|
1d18e80749 | |
|
|
190b627fd4 | |
|
|
a119686bfa | |
|
|
b107e9a580 | |
|
|
a7f6d8e242 | |
|
|
3e12d7fd70 | |
|
|
c16315711d | |
|
|
3c6325db32 | |
|
|
a51f271069 | |
|
|
e0128312cf | |
|
|
c834b5f85d | |
|
|
7454638506 | |
|
|
f49b92074e | |
|
|
29566499f9 | |
|
|
81f1c9512a | |
|
|
1e053c2bb6 | |
|
|
2369a29161 | |
|
|
26eb3abdcd | |
|
|
4fc31726be | |
|
|
8fa28a4d84 | |
|
|
60ebd0ed16 | |
|
|
093b5a9eea | |
|
|
becc1bd737 | |
|
|
b59e4c53b4 | |
|
|
29ef2b6a2c | |
|
|
1b79454df5 | |
|
|
38d6fdba6f | |
|
|
996c9c19fe | |
|
|
871d69cc2d | |
|
|
f12c412bbe | |
|
|
7f56926b70 | |
|
|
904d60ed2b | |
|
|
8fd9fccc70 | |
|
|
227c623578 | |
|
|
d60d228012 | |
|
|
96e2b4d5ab | |
|
|
5ec7084cbd | |
|
|
240c672198 | |
|
|
bd7bb50a52 | |
|
|
17ed5b18d9 | |
|
|
096e983313 | |
|
|
880b99f1e0 | |
|
|
2688b2528b | |
|
|
ae3b1e6b14 | |
|
|
f740ffc753 | |
|
|
c4a2f33293 | |
|
|
831f656b54 | |
|
|
5511498200 | |
|
|
3b0ebce1df | |
|
|
22eee053a3 | |
|
|
05cc53ef49 | |
|
|
8a654bef89 | |
|
|
72973e3ca5 | |
|
|
8ff1aeac2a | |
|
|
d50aedeafb | |
|
|
bc2810eb8a | |
|
|
894f753b81 | |
|
|
9d3cbd88b6 | |
|
|
1547302297 | |
|
|
68a31e894d | |
|
|
7833257126 | |
|
|
216e46311b | |
|
|
f9f7433b98 | |
|
|
73bda08244 | |
|
|
660402a32d | |
|
|
32f43020d3 | |
|
|
ed176ec243 | |
|
|
4fd37985b3 | |
|
|
7d30faa7d7 | |
|
|
4f3066e225 | |
|
|
a30c21a1a9 | |
|
|
085f27d67c | |
|
|
3869f6e67f | |
|
|
a322aa17ac | |
|
|
0b415c6862 | |
|
|
31f486c753 | |
|
|
af045b21d5 | |
|
|
5b9e486b98 | |
|
|
fbce5dc8ba | |
|
|
f8f30d57f7 | |
|
|
eb16f7d6ad | |
|
|
814776d1ff | |
|
|
01e7db589a | |
|
|
3c8eaa16df | |
|
|
e833ab72c6 | |
|
|
d5d5fd819c | |
|
|
19618746ba | |
|
|
8551a00414 | |
|
|
94cddb7987 | |
|
|
7c3376bfbc | |
|
|
75d1f136a3 | |
|
|
b9c2eb5eef | |
|
|
a07a0d1a98 | |
|
|
8e8dff39c9 | |
|
|
d271c96828 | |
|
|
b8d121f251 | |
|
|
45e762be7f | |
|
|
38d22c85ed | |
|
|
fc8dbf894a | |
|
|
acab5d4c84 | |
|
|
c1d5ca1988 | |
|
|
79548c5aa0 | |
|
|
52887e2dd5 | |
|
|
3064c0b186 | |
|
|
949cdd1e5b | |
|
|
5158d7b3b3 | |
|
|
6796ff3859 | |
|
|
12a16dbd64 | |
|
|
46fb3f12ff | |
|
|
3bc3862b69 | |
|
|
627ef668e5 | |
|
|
077a029ff4 | |
|
|
35fe7c2c6f | |
|
|
ff53344a67 | |
|
|
8699b9d991 | |
|
|
bc66870681 | |
|
|
b52ea46f22 | |
|
|
ad60bd61fe | |
|
|
291f13c034 | |
|
|
2049057f7f | |
|
|
2ae126cf20 | |
|
|
3240bfcd0c | |
|
|
7bb922191f | |
|
|
af369acbf6 | |
|
|
d81c3bc07c | |
|
|
821a2bde33 | |
|
|
8e68051fde | |
|
|
afb8d09db3 | |
|
|
fc04ef44d0 | |
|
|
a7d84899fb | |
|
|
e3dc81ef73 | |
|
|
9acea7b89b | |
|
|
6c3cde5f65 | |
|
|
77f22518c8 | |
|
|
9aa9475387 | |
|
|
d4255b08fa | |
|
|
94aec78d4c | |
|
|
d3783e1717 | |
|
|
dcf9a8d3e9 | |
|
|
595828c5f6 | |
|
|
834f173bb9 | |
|
|
a3a4ec4d06 | |
|
|
2f390af9ed | |
|
|
530faf6b45 | |
|
|
8058f9b814 | |
|
|
c4b906223c | |
|
|
81860d1851 |
14
.env.example
14
.env.example
|
|
@ -31,9 +31,11 @@ SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
# For cross-subdomain session sharing (e.g. webmail on mail.example.com):
|
||||||
|
# SESSION_DOMAIN=.example.com
|
||||||
|
SESSION_DOMAIN=
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
#BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
|
@ -63,3 +65,11 @@ AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
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
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
|
/storage/backups
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
# MailWolt Install Report
|
||||||
|
Datum: 2026-04-17
|
||||||
|
|
||||||
|
## Befunde & Fixes
|
||||||
|
|
||||||
|
### Migrationen
|
||||||
|
- **Status:** Alle 21 Migrationen erfolgreich durchgeführt (Batch 1–15)
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[90m= [39m[34;4mApp\Models\Setting[39;24m {#6409
|
|
||||||
[34mid[39m: [35m13[39m,
|
|
||||||
[34mgroup[39m: "[32mwoltguard[39m",
|
|
||||||
[34mkey[39m: "[32mservices[39m",
|
|
||||||
[34mvalue[39m: "[32m{"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}]}[39m",
|
|
||||||
[34mcreated_at[39m: "[32m2025-10-26 19:40:19[39m",
|
|
||||||
[34mupdated_at[39m: "[32m2025-10-26 19:40:19[39m",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,49 +6,125 @@ use Illuminate\Console\Command;
|
||||||
|
|
||||||
class CheckUpdates extends Command
|
class CheckUpdates extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'mailwolt:check-updates';
|
protected $signature = 'clubird:check-updates';
|
||||||
protected $description = 'Check for newer MailWolt releases via git tags';
|
protected $aliases = ['mailwolt:check-updates'];
|
||||||
|
protected $description = 'Check for newer CluBird releases via git tags';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$currentNorm = $this->readInstalledVersionNorm();
|
$currentNorm = $this->readInstalledVersionNorm();
|
||||||
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
|
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
|
||||||
|
|
||||||
$appPath = base_path();
|
$appPath = base_path();
|
||||||
$cmd = <<<BASH
|
$remoteFile = '/var/lib/mailwolt/version_remote';
|
||||||
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;
|
|
||||||
|
|
||||||
$latestTagRaw = trim((string) shell_exec($cmd));
|
// Fallback-Kette für Remote-Tags (erste funktionierende Methode gewinnt):
|
||||||
if ($latestTagRaw === '') {
|
// 1. mailwolt-update --check-only (sudoers: mailwolt-update)
|
||||||
$latestTagRaw = trim((string) shell_exec("cd {$appPath} && git tag -l --sort=-v:refname | head -n1"));
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
$latestNorm = $this->normalizeVersion($latestTagRaw);
|
||||||
|
|
||||||
// Nichts gefunden -> alles leeren
|
|
||||||
if (!$latestNorm) {
|
if (!$latestNorm) {
|
||||||
cache()->forget('updates:latest');
|
cache()->forget('updates:latest');
|
||||||
cache()->forget('updates:latest_raw');
|
cache()->forget('updates:latest_raw');
|
||||||
cache()->forget('mailwolt.update_available'); // legacy
|
cache()->forget('mailwolt.update_available');
|
||||||
$this->warn('Keine Release-Tags gefunden.');
|
$this->warn('Keine Release-Tags gefunden.');
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur wenn wirklich neuer als installiert -> Keys setzen
|
|
||||||
if ($currentNorm && version_compare($latestNorm, $currentNorm, '>')) {
|
if ($currentNorm && version_compare($latestNorm, $currentNorm, '>')) {
|
||||||
cache()->forever('updates:latest', $latestNorm);
|
cache()->forever('updates:latest', $latestNorm);
|
||||||
cache()->forever('updates:latest_raw', $latestTagRaw ?: ('v'.$latestNorm));
|
cache()->forever('updates:latest_raw', $latestTagRaw ?: ('v'.$latestNorm));
|
||||||
cache()->forever('mailwolt.update_available', $latestNorm); // legacy-kompat
|
cache()->forever('mailwolt.update_available', $latestNorm);
|
||||||
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
|
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
|
||||||
} else {
|
} else {
|
||||||
// Kein Update -> Keys löschen
|
|
||||||
cache()->forget('updates:latest');
|
cache()->forget('updates:latest');
|
||||||
cache()->forget('updates:latest_raw');
|
cache()->forget('updates:latest_raw');
|
||||||
cache()->forget('mailwolt.update_available'); // legacy
|
cache()->forget('mailwolt.update_available');
|
||||||
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
|
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,22 +136,35 @@ class CheckUpdates extends Command
|
||||||
|
|
||||||
private function readInstalledVersionNorm(): ?string
|
private function readInstalledVersionNorm(): ?string
|
||||||
{
|
{
|
||||||
$paths = [
|
// version_raw ist die zuverlässigste Quelle – wird vom Update-Script geschrieben
|
||||||
'/var/lib/mailwolt/version', // vom Wrapper (normiert)
|
$rawVer = $this->normalizeVersion($this->readInstalledVersionRaw() ?? '');
|
||||||
base_path('VERSION'), // App-Fallback
|
|
||||||
];
|
$fileVer = null;
|
||||||
foreach ($paths as $p) {
|
foreach (['/var/lib/mailwolt/version', base_path('VERSION')] as $p) {
|
||||||
$raw = @trim(@file_get_contents($p) ?: '');
|
$raw = @trim(@file_get_contents($p) ?: '');
|
||||||
if ($raw !== '') return $this->normalizeVersion($raw);
|
if ($raw !== '') { $fileVer = $this->normalizeVersion($raw); break; }
|
||||||
}
|
}
|
||||||
// Noch ein Fallback aus RAW-Datei
|
|
||||||
$raw = $this->readInstalledVersionRaw();
|
// version_raw bevorzugen (echter installierter Stand)
|
||||||
return $raw ? $this->normalizeVersion($raw) : null;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function readInstalledVersionRaw(): ?string
|
private function readInstalledVersionRaw(): ?string
|
||||||
{
|
{
|
||||||
$p = '/var/lib/mailwolt/version_raw'; // vom Wrapper (z.B. "v1.0.25" oder "v1.0.25-3-gabcd")
|
$p = '/var/lib/mailwolt/version_raw';
|
||||||
$raw = @trim(@file_get_contents($p) ?: '');
|
$raw = @trim(@file_get_contents($p) ?: '');
|
||||||
return $raw !== '' ? $raw : null;
|
return $raw !== '' ? $raw : null;
|
||||||
}
|
}
|
||||||
|
|
@ -85,8 +174,8 @@ class CheckUpdates extends Command
|
||||||
if (!$v) return null;
|
if (!$v) return null;
|
||||||
$v = trim($v);
|
$v = trim($v);
|
||||||
if ($v === '') return null;
|
if ($v === '') return null;
|
||||||
$v = ltrim($v, "vV \t\n\r\0\x0B"); // führendes v entfernen
|
$v = ltrim($v, "vV \t\n\r\0\x0B");
|
||||||
$v = preg_replace('/-.*$/', '', $v); // Build-/dirty-Suffix abschneiden
|
$v = preg_replace('/-.*$/', '', $v);
|
||||||
return $v !== '' ? $v : null;
|
return $v !== '' ? $v : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ use Illuminate\Console\Command;
|
||||||
|
|
||||||
class MailwoltRestart extends Command
|
class MailwoltRestart extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'mailwolt:restart-services';
|
protected $signature = 'clubird:restart-services';
|
||||||
|
protected $aliases = ['mailwolt:restart-services'];
|
||||||
protected $description = 'Restart or reload MailWolt-related system services';
|
protected $description = 'Restart or reload MailWolt-related system services';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$units = config('mailwolt.units', []);
|
$units = config('clubird.units', []);
|
||||||
|
|
||||||
foreach ($units as $u) {
|
foreach ($units as $u) {
|
||||||
$base = (string)($u['name'] ?? '');
|
$base = (string)($u['name'] ?? '');
|
||||||
|
|
@ -45,12 +46,12 @@ class MailwoltRestart extends Command
|
||||||
|
|
||||||
//class MailwoltRestart extends Command
|
//class MailwoltRestart extends Command
|
||||||
//{
|
//{
|
||||||
// protected $signature = 'mailwolt:restart-services';
|
// protected $signature = 'clubird:restart-services';
|
||||||
// protected $description = 'Restart or reload MailWolt-related system services';
|
// protected $description = 'Restart or reload MailWolt-related system services';
|
||||||
//
|
//
|
||||||
// public function handle(): int
|
// public function handle(): int
|
||||||
// {
|
// {
|
||||||
// $units = config('mailwolt.units', []);
|
// $units = config('clubird.units', []);
|
||||||
// $allowed = ['reload','restart','try-reload-or-restart'];
|
// $allowed = ['reload','restart','try-reload-or-restart'];
|
||||||
//
|
//
|
||||||
// foreach ($units as $u) {
|
// foreach ($units as $u) {
|
||||||
|
|
@ -90,12 +91,12 @@ class MailwoltRestart extends Command
|
||||||
//
|
//
|
||||||
//class MailwoltRestart extends Command
|
//class MailwoltRestart extends Command
|
||||||
//{
|
//{
|
||||||
// protected $signature = 'mailwolt:restart-services';
|
// protected $signature = 'clubird:restart-services';
|
||||||
// protected $description = 'Restart or reload MailWolt-related system services';
|
// protected $description = 'Restart or reload MailWolt-related system services';
|
||||||
//
|
//
|
||||||
// public function handle(): int
|
// public function handle(): int
|
||||||
// {
|
// {
|
||||||
// $units = config('mailwolt.units', []);
|
// $units = config('clubird.units', []);
|
||||||
//
|
//
|
||||||
// foreach ($units as $u) {
|
// foreach ($units as $u) {
|
||||||
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';
|
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
<?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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?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\"'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
//
|
//
|
||||||
//class ProvisionCert extends Command
|
//class ProvisionCert extends Command
|
||||||
//{
|
//{
|
||||||
//// protected $signature = 'mailwolt:provision-cert
|
//// protected $signature = 'clubird:provision-cert
|
||||||
//// {domain : z.B. mail.example.com}
|
//// {domain : z.B. mail.example.com}
|
||||||
//// {--email= : E-Mail für Let\'s Encrypt}
|
//// {--email= : E-Mail für Let\'s Encrypt}
|
||||||
//// {--self-signed : Statt LE ein self-signed Zertifikat erzeugen}';
|
//// {--self-signed : Statt LE ein self-signed Zertifikat erzeugen}';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?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(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -134,45 +134,50 @@ class UpdateMailboxStats extends Command
|
||||||
[$local, $domain] = explode('@', $email, 2);
|
[$local, $domain] = explode('@', $email, 2);
|
||||||
$maildir = "/var/mail/vhosts/{$domain}/{$local}";
|
$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();
|
|
||||||
}
|
|
||||||
|
|
||||||
$isSystemDomain = (bool)($u->domain->is_system ?? false);
|
$isSystemDomain = (bool)($u->domain->is_system ?? false);
|
||||||
|
|
||||||
if ($isSystemDomain || $u->is_system) {
|
if ($isSystemDomain || $u->is_system) {
|
||||||
// systemische Mailboxen fließen nur in die System-Summe ein
|
$sumSystemBytes += $this->sizeViaDoveadm($email) ?? $this->sizeViaFilesystem($maildir) ?? 0;
|
||||||
$sumSystemBytes += $usedBytes;
|
continue;
|
||||||
continue; // KEIN per-user Setting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message-Count
|
// Alle Ordner zählen (nicht nur INBOX) — doveadm ist primäre Quelle
|
||||||
$messageCount = $this->countViaDoveadm($email);
|
$messageCount = $this->countAllFoldersViaDoveadm($email);
|
||||||
if ($messageCount === null) $messageCount = $this->countViaFilesystem($maildir);
|
$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;
|
||||||
|
}
|
||||||
|
$messageCount = $fsCount;
|
||||||
|
$usedBytes = $fsSize ?? 0;
|
||||||
|
} else {
|
||||||
|
$usedBytes = $usedBytes ?? $this->sizeViaFilesystem($maildir) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
$sumUserBytes += $usedBytes;
|
$sumUserBytes += $usedBytes;
|
||||||
|
|
||||||
$key = "mailbox.{$email}";
|
// Immer schreiben wenn doveadm erfolgreich war — keine Change-Detection
|
||||||
$prev = (array)(Setting::get($key, []) ?: []);
|
Setting::set("mailbox.{$email}", [
|
||||||
$new = [
|
|
||||||
'used_bytes' => (int)$usedBytes,
|
'used_bytes' => (int)$usedBytes,
|
||||||
'message_count' => (int)$messageCount,
|
'message_count' => (int)$messageCount,
|
||||||
'updated_at' => now()->toDateTimeString(),
|
'updated_at' => now()->toDateTimeString(),
|
||||||
];
|
]);
|
||||||
|
\Illuminate\Support\Facades\DB::table('mail_users')
|
||||||
if (($prev['used_bytes'] ?? null) !== $new['used_bytes']
|
->where('id', $u->id)
|
||||||
|| ($prev['message_count'] ?? null) !== $new['message_count']) {
|
->update([
|
||||||
Setting::set($key, $new);
|
'used_bytes' => (int)$usedBytes,
|
||||||
$changed++;
|
'message_count' => (int)$messageCount,
|
||||||
$this->line(sprintf("%-35s %7.2f MiB %5d msgs",
|
'stats_refreshed_at' => now(),
|
||||||
$email, $usedBytes / 1048576, $messageCount));
|
]);
|
||||||
$log->info('updated', ['email'=>$email]+$new);
|
$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]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -190,20 +195,58 @@ class UpdateMailboxStats extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private function countViaDoveadm(string $email): ?int
|
private function doveadmStatus(string $email, string $fields): array
|
||||||
{
|
{
|
||||||
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
|
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
|
||||||
. escapeshellarg($email) . " messages INBOX 2>&1";
|
. escapeshellarg($email) . " {$fields} INBOX 2>/dev/null";
|
||||||
$out = [];
|
$out = [];
|
||||||
$rc = 0;
|
$rc = 0;
|
||||||
exec($cmd, $out, $rc);
|
exec($cmd, $out, $rc);
|
||||||
if ($rc !== 0) return null;
|
if ($rc !== 0) return [];
|
||||||
|
|
||||||
|
// header: "mailbox\tmessages" data: "INBOX\t4"
|
||||||
|
$header = null;
|
||||||
foreach ($out as $line) {
|
foreach ($out as $line) {
|
||||||
if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) {
|
$parts = explode("\t", trim($line));
|
||||||
return (int)$m[1];
|
if ($header === null) {
|
||||||
|
$header = $parts;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +256,7 @@ class UpdateMailboxStats extends Command
|
||||||
foreach (['cur', 'new'] as $sub) {
|
foreach (['cur', 'new'] as $sub) {
|
||||||
$dir = "{$maildir}/{$sub}";
|
$dir = "{$maildir}/{$sub}";
|
||||||
if (!is_dir($dir)) continue;
|
if (!is_dir($dir)) continue;
|
||||||
$h = opendir($dir);
|
$h = @opendir($dir);
|
||||||
if (!$h) continue;
|
if (!$h) continue;
|
||||||
while (($fn = readdir($h)) !== false) {
|
while (($fn = readdir($h)) !== false) {
|
||||||
if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue;
|
if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue;
|
||||||
|
|
@ -223,6 +266,23 @@ class UpdateMailboxStats extends Command
|
||||||
}
|
}
|
||||||
return $n;
|
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;
|
//namespace App\Console\Commands;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,35 @@ namespace App\Enums;
|
||||||
|
|
||||||
enum Role: string
|
enum Role: string
|
||||||
{
|
{
|
||||||
case Member = 'member';
|
case Admin = 'admin';
|
||||||
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function values(): array
|
public static function values(): array
|
||||||
{
|
{
|
||||||
return array_column(self::cases(), 'value');
|
return array_column(self::cases(), 'value');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return array_map(fn($r) => ['value' => $r->value, 'label' => $r->label()], self::cases());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ if (!function_exists('webmail_host')) {
|
||||||
if (!function_exists('mta_host')) {
|
if (!function_exists('mta_host')) {
|
||||||
function mta_host(?int $domainId = null): string
|
function mta_host(?int $domainId = null): string
|
||||||
{
|
{
|
||||||
// 1️⃣ Vorrang: Datenbankwert (z. B. aus der domains-Tabelle)
|
|
||||||
if ($domainId) {
|
if ($domainId) {
|
||||||
try {
|
try {
|
||||||
$domain = \App\Models\Domain::find($domainId);
|
$domain = \App\Models\Domain::find($domainId);
|
||||||
|
|
@ -39,17 +38,25 @@ if (!function_exists('mta_host')) {
|
||||||
return $domain->mta_host;
|
return $domain->mta_host;
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} 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');
|
$sub = env('MTA_SUB');
|
||||||
if ($sub) {
|
if ($sub) {
|
||||||
return domain_host($sub);
|
return domain_host($sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3️⃣ Notfall: statischer Fallback
|
|
||||||
return domain_host('mx');
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,20 @@ namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class LoginController extends Controller
|
class LoginController extends Controller
|
||||||
{
|
{
|
||||||
public function show()
|
public function show()
|
||||||
{
|
{
|
||||||
return view('auth.login'); // enthält @livewire('login-form')
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
return redirect()->route('login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,147 @@
|
||||||
namespace App\Http\Controllers\UI;
|
namespace App\Http\Controllers\UI;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use App\Models\Domain;
|
||||||
|
use App\Models\MailUser;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
// ggf. Prefetch für Blade, sonst alles via Livewire
|
|
||||||
return view('ui.dashboard.index');
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ class InstallDkimKey implements ShouldQueue
|
||||||
public int $dkimKeyId,
|
public int $dkimKeyId,
|
||||||
public string $privPath,
|
public string $privPath,
|
||||||
public string $dnsTxtContent,
|
public string $dnsTxtContent,
|
||||||
public string $selector = 'mwl1',
|
public string $selector = 'clb1',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class ProvisionCertJob implements ShouldQueue
|
||||||
if ($this->useLetsEncrypt) {
|
if ($this->useLetsEncrypt) {
|
||||||
$this->emit($task, 'running', 'Let’s Encrypt wird ausgeführt…', $mode);
|
$this->emit($task, 'running', 'Let’s Encrypt wird ausgeführt…', $mode);
|
||||||
|
|
||||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
$exit = Artisan::call('clubird:provision-cert', [
|
||||||
'domain' => $this->domain,
|
'domain' => $this->domain,
|
||||||
'--email' => $this->email ?? '',
|
'--email' => $this->email ?? '',
|
||||||
]);
|
]);
|
||||||
|
|
@ -83,14 +83,14 @@ class ProvisionCertJob implements ShouldQueue
|
||||||
|
|
||||||
// Fallback → self-signed
|
// Fallback → self-signed
|
||||||
$mode = 'self-signed';
|
$mode = 'self-signed';
|
||||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
$exit = Artisan::call('clubird:provision-cert', [
|
||||||
'domain' => $this->domain,
|
'domain' => $this->domain,
|
||||||
'--self-signed' => true,
|
'--self-signed' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode);
|
$this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode);
|
||||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
$exit = Artisan::call('clubird:provision-cert', [
|
||||||
'domain' => $this->domain,
|
'domain' => $this->domain,
|
||||||
'--self-signed' => true,
|
'--self-signed' => true,
|
||||||
]);
|
]);
|
||||||
|
|
@ -120,7 +120,7 @@ class ProvisionCertJob implements ShouldQueue
|
||||||
// $task->update(['message' => 'Let’s Encrypt wird ausgeführt…']);
|
// $task->update(['message' => 'Let’s Encrypt wird ausgeführt…']);
|
||||||
// $this->syncCache($task);
|
// $this->syncCache($task);
|
||||||
//
|
//
|
||||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
// $exit = Artisan::call('clubird:provision-cert', [
|
||||||
// 'domain' => $this->domain,
|
// 'domain' => $this->domain,
|
||||||
// '--email' => $this->email ?? '',
|
// '--email' => $this->email ?? '',
|
||||||
// ]);
|
// ]);
|
||||||
|
|
@ -131,7 +131,7 @@ class ProvisionCertJob implements ShouldQueue
|
||||||
// $this->syncCache($task);
|
// $this->syncCache($task);
|
||||||
//
|
//
|
||||||
// // Fallback: Self-Signed
|
// // Fallback: Self-Signed
|
||||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
// $exit = Artisan::call('clubird:provision-cert', [
|
||||||
// 'domain' => $this->domain,
|
// 'domain' => $this->domain,
|
||||||
// '--self-signed' => true,
|
// '--self-signed' => true,
|
||||||
// ]);
|
// ]);
|
||||||
|
|
@ -140,7 +140,7 @@ class ProvisionCertJob implements ShouldQueue
|
||||||
// $task->update(['message' => 'Self-Signed wird erstellt…']);
|
// $task->update(['message' => 'Self-Signed wird erstellt…']);
|
||||||
// $this->syncCache($task);
|
// $this->syncCache($task);
|
||||||
//
|
//
|
||||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
// $exit = Artisan::call('clubird:provision-cert', [
|
||||||
// 'domain' => $this->domain,
|
// 'domain' => $this->domain,
|
||||||
// '--self-signed' => true,
|
// '--self-signed' => true,
|
||||||
// ]);
|
// ]);
|
||||||
|
|
|
||||||
|
|
@ -4,43 +4,33 @@ namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Setting as SettingsModel;
|
use App\Models\Setting as SettingsModel;
|
||||||
use App\Support\CacheVer;
|
use App\Support\CacheVer;
|
||||||
use App\Support\WoltGuard\Probes;
|
use App\Support\WoltGuard\MonitClient;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Process;
|
|
||||||
use Symfony\Component\Finder\Finder;
|
|
||||||
|
|
||||||
class RunHealthChecks implements ShouldQueue
|
class RunHealthChecks implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable, Probes;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 10;
|
public int $timeout = 10;
|
||||||
public int $tries = 1;
|
public int $tries = 1;
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
$cards = config('woltguard.cards', []);
|
$monit = new MonitClient();
|
||||||
$svcRows = [];
|
$svcRows = $monit->services();
|
||||||
foreach ($cards as $key => $card) {
|
|
||||||
$ok = false;
|
if (empty($svcRows)) {
|
||||||
foreach ($card['sources'] as $src) {
|
Log::warning('WG: Monit nicht erreichbar – kein Update');
|
||||||
if ($this->check($src)) {
|
return;
|
||||||
$ok = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$svcRows[] = ['name' => $key, 'ok' => $ok]; // labels brauchst du im UI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = ['ts' => time(), 'rows' => $svcRows];
|
$payload = ['ts' => time(), 'rows' => $svcRows];
|
||||||
Cache::put(CacheVer::k('health:services'), $payload, 300);
|
Cache::put(CacheVer::k('health:services'), $payload, 300);
|
||||||
Log::info('WG: writing services', ['count'=>count($svcRows)]);
|
|
||||||
SettingsModel::set('woltguard.services', $payload);
|
SettingsModel::set('woltguard.services', $payload);
|
||||||
Cache::forget('health:services');
|
Log::info('WG: services from Monit', ['count' => count($svcRows), 'key' => CacheVer::k('health:services'), 'names' => array_column($svcRows, 'name')]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wraps a probe; logs and returns fallback on error */
|
/** Wraps a probe; logs and returns fallback on error */
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class LoginForm extends Component
|
||||||
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
public string $password = '';
|
public string $password = '';
|
||||||
|
public bool $remember = false;
|
||||||
public ?string $error = null;
|
public ?string $error = null;
|
||||||
public bool $show = false;
|
public bool $show = false;
|
||||||
|
|
||||||
|
|
@ -45,6 +46,7 @@ class LoginForm extends Component
|
||||||
|
|
||||||
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
|
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
|
||||||
request()->session()->regenerate();
|
request()->session()->regenerate();
|
||||||
|
Auth::user()->update(['last_login_at' => now()]);
|
||||||
return redirect()->intended(route('ui.dashboard'));
|
return redirect()->intended(route('ui.dashboard'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,212 +2,243 @@
|
||||||
|
|
||||||
namespace App\Livewire\Setup;
|
namespace App\Livewire\Setup;
|
||||||
|
|
||||||
use App\Jobs\ProvisionCertJob;
|
use App\Models\Setting;
|
||||||
use App\Support\Setting;
|
|
||||||
use App\Models\SystemTask;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\EnvWriter;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.setup')]
|
||||||
|
#[Title('Einrichtung · Mailwolt')]
|
||||||
class Wizard extends Component
|
class Wizard extends Component
|
||||||
{
|
{
|
||||||
public int $step = 1;
|
public int $step = 1;
|
||||||
|
public int $totalSteps = 5;
|
||||||
|
|
||||||
// Step 1
|
// Schritt 1 — System
|
||||||
#[Validate('required|string|min:3')]
|
public string $instance_name = 'Mailwolt';
|
||||||
public string $form_domain = '';
|
public string $locale = 'de';
|
||||||
|
public string $timezone = 'Europe/Berlin';
|
||||||
|
|
||||||
#[Validate('required|timezone')]
|
// Schritt 2 — Domains
|
||||||
public string $form_timezone = 'UTC';
|
public string $ui_domain = '';
|
||||||
|
public string $mail_domain = '';
|
||||||
|
public string $webmail_domain = '';
|
||||||
|
|
||||||
public bool $form_cert_force_https = true;
|
// Schritt 4 — Option
|
||||||
|
public bool $skipSsl = false;
|
||||||
|
|
||||||
// Step 2
|
// Schritt 3 — Admin-Account
|
||||||
#[Validate('required|string|min:3')]
|
public string $admin_name = '';
|
||||||
public string $form_admin_name = '';
|
public string $admin_email = '';
|
||||||
|
public string $admin_password = '';
|
||||||
|
public string $admin_password_confirmation = '';
|
||||||
|
|
||||||
#[Validate('required|email')]
|
// Schritt 5 — Domain-Setup Status
|
||||||
public string $form_admin_email = '';
|
public array $domainStatus = [
|
||||||
|
'ui' => 'pending',
|
||||||
|
'mail' => 'pending',
|
||||||
|
'webmail' => 'pending',
|
||||||
|
];
|
||||||
|
public bool $setupDone = false;
|
||||||
|
|
||||||
/** optional: wenn du Login auch über username erlauben willst */
|
private const STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||||
public ?string $form_admin_username = null;
|
|
||||||
|
|
||||||
#[Validate('required|string|min:8|same:form_admin_password_confirmation')]
|
public function mount()
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
if ($this->step === 1) {
|
$this->instance_name = config('app.name', 'Mailwolt');
|
||||||
$this->validateOnly('form_domain');
|
try {
|
||||||
$this->validateOnly('form_timezone');
|
$this->timezone = Setting::get('timezone', 'Europe/Berlin');
|
||||||
} elseif ($this->step === 2) {
|
$this->locale = Setting::get('locale', 'de');
|
||||||
$this->validate([
|
$this->ui_domain = Setting::get('ui_domain', '');
|
||||||
'form_admin_name' => 'required|string|min:3',
|
$this->mail_domain = Setting::get('mail_domain', '');
|
||||||
'form_admin_email' => 'required|email',
|
$this->webmail_domain = Setting::get('webmail_domain', '');
|
||||||
'form_admin_password' => 'required|string|min:8|same:form_admin_password_confirmation',
|
} catch (\Throwable) {
|
||||||
]);
|
// DB noch nicht migriert — Standardwerte bleiben
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->step = min($this->step + 1, 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function prevStep()
|
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
|
||||||
{
|
{
|
||||||
$this->step = max($this->step - 1, 1);
|
$this->step = max($this->step - 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function finish()
|
public function finish(): void
|
||||||
{
|
{
|
||||||
// Step 3 Validierung (nur wenn sofort erstellen)
|
$this->validate([
|
||||||
if ($this->form_cert_create_now) {
|
'admin_name' => 'required|string|min:2|max:64',
|
||||||
$this->validateOnly('form_cert_email');
|
'admin_email' => 'required|email|max:190',
|
||||||
}
|
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
|
||||||
|
|
||||||
// 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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2) Admin anlegen/aktualisieren
|
// Settings + .env speichern
|
||||||
$user = User::query()
|
Setting::setMany([
|
||||||
->where('email', $this->form_admin_email)
|
'locale' => $this->locale,
|
||||||
->when($this->form_admin_username, fn($q) => $q->orWhere('username', $this->form_admin_username)
|
'timezone' => $this->timezone,
|
||||||
)
|
'ui_domain' => $this->ui_domain,
|
||||||
->first();
|
'mail_domain' => $this->mail_domain,
|
||||||
|
'webmail_domain' => $this->webmail_domain,
|
||||||
|
'setup_completed' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
if (!$user) {
|
$this->writeEnv([
|
||||||
$user = new User();
|
'APP_NAME' => $this->instance_name,
|
||||||
$user->email = $this->form_admin_email;
|
'APP_HOST' => $this->ui_domain,
|
||||||
if ($this->form_admin_username) {
|
'APP_URL' => 'https://' . $this->ui_domain,
|
||||||
$user->username = $this->form_admin_username;
|
'MTA_SUB' => explode('.', $this->mail_domain)[0] ?? '',
|
||||||
}
|
'WEBMAIL_DOMAIN' => $this->webmail_domain,
|
||||||
} else {
|
]);
|
||||||
// vorhandene Email/Username harmonisieren
|
|
||||||
$user->email = $this->form_admin_email;
|
|
||||||
if ($this->form_admin_username) {
|
|
||||||
$user->username = $this->form_admin_username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->name = $this->form_admin_name;
|
// Admin anlegen
|
||||||
$user->is_admin = true;
|
$user = User::where('email', $this->admin_email)->first() ?? new User();
|
||||||
$user->password = Hash::make($this->form_admin_password);
|
$user->name = $this->admin_name;
|
||||||
$user->required_change_password = true;
|
$user->email = $this->admin_email;
|
||||||
|
$user->password = Hash::make($this->admin_password);
|
||||||
|
$user->role = 'admin';
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
// 3) Zertifikat jetzt ausstellen (optional)
|
// Status-Verzeichnis leeren und Domain-Setup im Hintergrund starten
|
||||||
$taskKey = 'issue-cert:' . $this->form_domain;
|
@mkdir(self::STATE_DIR, 0755, true);
|
||||||
|
@unlink(self::STATE_DIR . '/done');
|
||||||
if ($this->form_cert_create_now) {
|
foreach (['ui', 'mail', 'webmail'] as $k) {
|
||||||
SystemTask::updateOrCreate(
|
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
|
||||||
['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', 'Let’s 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');
|
$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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($path, $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.setup.wizard');
|
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
|
||||||
|
return view('livewire.setup.wizard', compact('timezones'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class DkimStatus extends Component
|
||||||
?: optional(
|
?: optional(
|
||||||
$domain->dkimKeys()->where('is_active', true)->latest()->first()
|
$domain->dkimKeys()->where('is_active', true)->latest()->first()
|
||||||
)->selector
|
)->selector
|
||||||
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,7 +46,7 @@ class DkimStatus extends Component
|
||||||
// public function regenerate(?string $selector = null): void
|
// public function regenerate(?string $selector = null): void
|
||||||
// {
|
// {
|
||||||
// $selector = $selector
|
// $selector = $selector
|
||||||
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
|
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
|
||||||
//
|
//
|
||||||
// Log::info('DKIM regenerate() CLICKED', [
|
// Log::info('DKIM regenerate() CLICKED', [
|
||||||
// 'domain' => $this->domain->domain,
|
// 'domain' => $this->domain->domain,
|
||||||
|
|
@ -79,7 +79,7 @@ class DkimStatus extends Component
|
||||||
public function regenerate(?string $selector = null): void
|
public function regenerate(?string $selector = null): void
|
||||||
{
|
{
|
||||||
$selector = $selector
|
$selector = $selector
|
||||||
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
|
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
|
||||||
|
|
||||||
Log::info('DKIM regenerate() CLICKED', [
|
Log::info('DKIM regenerate() CLICKED', [
|
||||||
'domain' => $this->domain->domain,
|
'domain' => $this->domain->domain,
|
||||||
|
|
@ -126,7 +126,7 @@ class DkimStatus extends Component
|
||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||||
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
|
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
|
||||||
|
|
||||||
return view('livewire.ui.domain.dkim-status', compact('dkimOk'));
|
return view('livewire.ui.domain.dkim-status', compact('dkimOk'));
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class DomainCreateModal extends ModalComponent
|
||||||
$this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb', 3072);
|
$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->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240);
|
||||||
|
|
||||||
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||||
$this->dkim_bits = (int) config('mailpool.defaults.dkim_bits', 2048);
|
$this->dkim_bits = (int) config('mailpool.defaults.dkim_bits', 2048);
|
||||||
|
|
||||||
// Speicherpool-Grenze
|
// Speicherpool-Grenze
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,13 @@ class DomainDnsModal extends ModalComponent
|
||||||
$ipv6 = $ips['ipv6'];
|
$ipv6 = $ips['ipv6'];
|
||||||
|
|
||||||
$this->zone = $this->extractZone($d->domain);
|
$this->zone = $this->extractZone($d->domain);
|
||||||
$mta_sub = env('MTA_SUB');
|
|
||||||
$base = env('BASE_DOMAIN');
|
// Setting::get('mail_domain') liefert den vollen MTA-FQDN (z.B. mail.example.com)
|
||||||
$mailServerFqdn = "{$mta_sub}.{$base}";
|
// Fallback auf config-Werte, die env() sicher kapseln
|
||||||
|
$mailDomainSetting = strtolower(trim((string) \App\Models\Setting::get('mail_domain', '')));
|
||||||
|
$mta_sub = config('mailpool.mta_sub', 'mail');
|
||||||
|
$base = config('mailpool.platform_zone', 'example.com');
|
||||||
|
$mailServerFqdn = $mailDomainSetting ?: "{$mta_sub}.{$base}";
|
||||||
|
|
||||||
// --- Infrastruktur (global) ---
|
// --- Infrastruktur (global) ---
|
||||||
$this->static = [
|
$this->static = [
|
||||||
|
|
@ -88,7 +92,7 @@ class DomainDnsModal extends ModalComponent
|
||||||
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
|
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
|
||||||
|
|
||||||
$dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
$dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
||||||
$selector = $dkim ? $dkim->selector : 'mwl1';
|
$selector = $dkim ? $dkim->selector : 'clb1';
|
||||||
$dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
$dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
||||||
$dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
$dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
||||||
? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
|
? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
|
||||||
|
|
@ -167,7 +171,7 @@ class DomainDnsModal extends ModalComponent
|
||||||
// --- Komfort / Web ---
|
// --- Komfort / Web ---
|
||||||
[
|
[
|
||||||
'type' => 'CNAME',
|
'type' => 'CNAME',
|
||||||
'name' => env('WEBMAIL_SUB') . ".{$this->domainName}",
|
'name' => (config('clubird.domain.webmail') ?: env('WEBMAIL_SUB', 'webmail')) . ".{$this->domainName}",
|
||||||
'value' => "{$mailServerFqdn}.",
|
'value' => "{$mailServerFqdn}.",
|
||||||
'helpLabel' => 'Webmail Alias',
|
'helpLabel' => 'Webmail Alias',
|
||||||
'helpUrl' => 'https://en.wikipedia.org/wiki/CNAME_record',
|
'helpUrl' => 'https://en.wikipedia.org/wiki/CNAME_record',
|
||||||
|
|
@ -670,7 +674,7 @@ class DomainDnsModal extends ModalComponent
|
||||||
// $dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
|
// $dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
|
||||||
//
|
//
|
||||||
// $dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
// $dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
||||||
// $selector = $dkim ? $dkim->selector : 'mwl1';
|
// $selector = $dkim ? $dkim->selector : 'clb1';
|
||||||
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
||||||
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
||||||
// ? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
|
// ? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
|
||||||
|
|
@ -1055,7 +1059,7 @@ class DomainDnsModal extends ModalComponent
|
||||||
//
|
//
|
||||||
// $dkim = DB::table('dkim_keys')
|
// $dkim = DB::table('dkim_keys')
|
||||||
// ->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
// ->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
||||||
// $selector = $dkim ? $dkim->selector : 'mwl1';
|
// $selector = $dkim ? $dkim->selector : 'clb1';
|
||||||
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
||||||
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
||||||
// ? 'v=DKIM1; k=rsa; p=' . $dkim->public_key_txt
|
// ? 'v=DKIM1; k=rsa; p=' . $dkim->public_key_txt
|
||||||
|
|
@ -1172,10 +1176,10 @@ class DomainDnsModal extends ModalComponent
|
||||||
////
|
////
|
||||||
//// // Placeholder-Werte, sofern du sie anderswo speicherst gern ersetzen:
|
//// // Placeholder-Werte, sofern du sie anderswo speicherst gern ersetzen:
|
||||||
//// $serverIp = config('app.server_ip', 'DEINE.SERVER.IP');
|
//// $serverIp = config('app.server_ip', 'DEINE.SERVER.IP');
|
||||||
//// $mxHost = config('mailwolt.mx_fqdn', 'mx.' . $this->domain->domain);
|
//// $mxHost = config('clubird.mx_fqdn', 'mx.' . $this->domain->domain);
|
||||||
//// $selector = optional(
|
//// $selector = optional(
|
||||||
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
|
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
|
||||||
//// )->selector ?? 'mwl1';
|
//// )->selector ?? 'clb1';
|
||||||
////
|
////
|
||||||
//// $dkimTxt = optional(
|
//// $dkimTxt = optional(
|
||||||
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
|
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,14 @@ class AliasList extends Component
|
||||||
{
|
{
|
||||||
// nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente)
|
// nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente)
|
||||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
|
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
|
||||||
$aliasId,
|
'aliasId' => $aliasId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function openAliasDelete(int $aliasId): void
|
public function openAliasDelete(int $aliasId): void
|
||||||
{
|
{
|
||||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
|
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
|
||||||
$aliasId,
|
'aliasId' => $aliasId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -92,7 +92,7 @@ class MailboxCreateModal extends ModalComponent
|
||||||
{
|
{
|
||||||
// alle Nicht-System-Domains in Select
|
// alle Nicht-System-Domains in Select
|
||||||
$this->domains = Domain::query()
|
$this->domains = Domain::query()
|
||||||
->where('is_system', false)
|
->where('is_system', false)->where('is_server', false)
|
||||||
->orderBy('domain')->get(['id', 'domain'])->toArray();
|
->orderBy('domain')->get(['id', 'domain'])->toArray();
|
||||||
|
|
||||||
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
|
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
|
||||||
|
|
@ -291,251 +291,3 @@ class MailboxCreateModal extends ModalComponent
|
||||||
return view('livewire.ui.mail.modal.mailbox-create-modal');
|
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');
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
<?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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?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' => '—'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?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' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -126,21 +126,20 @@ class SearchPaletteModal extends ModalComponent
|
||||||
|
|
||||||
public function go(string $type, int $id): void
|
public function go(string $type, int $id): void
|
||||||
{
|
{
|
||||||
// Schließe die Palette …
|
|
||||||
$this->dispatch('closeModal');
|
|
||||||
|
|
||||||
// … und navigiere / öffne Kontext:
|
|
||||||
// - Domain → scrolle/markiere Domainkarte
|
|
||||||
// - Mailbox → öffne Bearbeiten-Modal
|
|
||||||
// Passe an, was du bevorzugst:
|
|
||||||
if ($type === 'domain') {
|
if ($type === 'domain') {
|
||||||
$this->dispatch('focus:domain', id: $id);
|
$component = 'ui.domain.modal.domain-edit-modal';
|
||||||
|
$arguments = ['domainId' => $id];
|
||||||
} elseif ($type === 'mailbox') {
|
} elseif ($type === 'mailbox') {
|
||||||
// direkt Edit-Modal auf
|
$component = 'ui.mail.modal.mailbox-edit-modal';
|
||||||
$this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]);
|
$arguments = ['mailboxId' => $id];
|
||||||
} elseif ($type === 'user') {
|
} else {
|
||||||
$this->dispatch('focus:user', id: $id);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function modalMaxWidth(): string
|
public static function modalMaxWidth(): string
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,82 @@
|
||||||
|
|
||||||
namespace App\Livewire\Ui\Security;
|
namespace App\Livewire\Ui\Security;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('Audit-Logs · Mailwolt')]
|
||||||
class AuditLogsTable extends Component
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.ui.security.audit-logs-table');
|
$logs = $this->parseLogs();
|
||||||
|
$levels = ['', 'info', 'debug', 'warning', 'error', 'critical'];
|
||||||
|
return view('livewire.ui.security.audit-logs-table', compact('logs', 'levels'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,547 @@
|
||||||
|
<?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');
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
@ -17,35 +17,146 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
|
|
||||||
/* ---------------- intern ---------------- */
|
/* ---------------- intern ---------------- */
|
||||||
|
|
||||||
|
// protected function load(bool $force = false): void
|
||||||
|
// {
|
||||||
|
// $jail = $this->jail;
|
||||||
|
//
|
||||||
|
// [, $s] = $this->f2b('status '.escapeshellarg($jail));
|
||||||
|
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||||
|
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
||||||
|
//
|
||||||
|
// $defaultBantime = $this->getBantime($jail);
|
||||||
|
//
|
||||||
|
// $rows = [];
|
||||||
|
// foreach ($ips as $ip) {
|
||||||
|
// $banAt = null; $until = null; $remaining = null;
|
||||||
|
//
|
||||||
|
// // 1) Primärquelle: DB
|
||||||
|
// if ($info = $this->banInfoFromDb($jail, $ip)) {
|
||||||
|
// $banAt = $info['banned_at'];
|
||||||
|
// if ((int)$info['expire'] === -1) {
|
||||||
|
// $remaining = -1; // permanent
|
||||||
|
// } elseif ((int)$info['expire'] > 0) {
|
||||||
|
// $until = (int)$info['expire'];
|
||||||
|
// $remaining = max(0, $until - time());
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // 2) Fallback: Log + Jail-Bantime
|
||||||
|
// if ($remaining === null) {
|
||||||
|
// $banAt = $banAt ?? $this->lastBanTimestamp($jail, $ip);
|
||||||
|
// if ($banAt !== null) {
|
||||||
|
// $remaining = max(0, $defaultBantime - (time() - $banAt));
|
||||||
|
// $until = $remaining > 0 ? $banAt + $defaultBantime : null;
|
||||||
|
// } else {
|
||||||
|
// $remaining = -2; // ~approx
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // 3) Wenn 0 Sekunden, aber Fail2Ban hält die IP noch → „verlängert/unbekannt“
|
||||||
|
// if ($remaining === 0 && $this->isStillBanned($jail, $ip)) {
|
||||||
|
// $remaining = -2; // markiere als „~ unbekannt/verlängert“
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// [$timeText, $metaText, $boxClass] = $this->present($remaining, $banAt, $until);
|
||||||
|
//
|
||||||
|
// $rows[] = [
|
||||||
|
// 'ip' => $ip,
|
||||||
|
// 'bantime' => $defaultBantime,
|
||||||
|
// 'banned_at' => $banAt,
|
||||||
|
// 'remaining' => $remaining,
|
||||||
|
// 'until' => $until,
|
||||||
|
// 'time_text' => $timeText,
|
||||||
|
// 'meta_text' => $metaText,
|
||||||
|
// 'box_class' => $boxClass,
|
||||||
|
// ];
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// $this->rows = $rows;
|
||||||
|
// logger()->debug('rows='.json_encode($rows, JSON_UNESCAPED_SLASHES));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// protected function load(bool $force = false): void
|
||||||
|
// {
|
||||||
|
// $jail = $this->jail;
|
||||||
|
//
|
||||||
|
// // 1) Primär: DB
|
||||||
|
// $rows = $this->activeBansFromDb($jail);
|
||||||
|
//
|
||||||
|
// // 2) Fallback: status parsen, wenn DB leer (z. B. sehr alte F2B-Setups)
|
||||||
|
// if (empty($rows)) {
|
||||||
|
// [, $s] = $this->f2b('status '.escapeshellarg($jail));
|
||||||
|
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||||
|
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
||||||
|
// $defaultBantime = $this->getBantime($jail);
|
||||||
|
//
|
||||||
|
// foreach ($ips as $ip) {
|
||||||
|
// $banAt = $this->lastBanTimestamp($jail, $ip);
|
||||||
|
// $remaining = $banAt !== null ? max(0, $defaultBantime - (time() - $banAt)) : -2;
|
||||||
|
// $until = ($remaining > 0 && $banAt) ? $banAt + $defaultBantime : null;
|
||||||
|
// $rows[] = [
|
||||||
|
// 'ip' => $ip,
|
||||||
|
// 'bantime' => $defaultBantime,
|
||||||
|
// 'banned_at' => $banAt,
|
||||||
|
// 'remaining' => $remaining,
|
||||||
|
// 'until' => $until,
|
||||||
|
// ];
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Präsentationswerte bauen (einheitlich)
|
||||||
|
// $presented = [];
|
||||||
|
// foreach ($rows as $r) {
|
||||||
|
// [$timeText, $metaText, $boxClass] = $this->present($r['remaining'], $r['banned_at'], $r['until']);
|
||||||
|
// $presented[] = $r + [
|
||||||
|
// 'time_text' => $timeText,
|
||||||
|
// 'meta_text' => $metaText,
|
||||||
|
// 'box_class' => $boxClass,
|
||||||
|
// ];
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// $this->rows = $presented;
|
||||||
|
// logger()->debug('rows='.json_encode($presented, JSON_UNESCAPED_SLASHES));
|
||||||
|
// }
|
||||||
|
|
||||||
protected function load(bool $force = false): void
|
protected function load(bool $force = false): void
|
||||||
{
|
{
|
||||||
$jail = $this->jail;
|
$jail = $this->jail;
|
||||||
|
|
||||||
[, $s] = $this->f2b('status '.escapeshellarg($jail));
|
// 1) IP-Liste IMMER aus "status <jail>" holen (Quelle der Wahrheit)
|
||||||
|
[, $s] = $this->f2b('status '.escapeshellarg($jail));
|
||||||
$ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
$ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||||
$ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
$ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
||||||
|
|
||||||
$bantime = $this->getBantime($jail);
|
$defaultBantime = $this->getBantime($jail);
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($ips as $ip) {
|
foreach ($ips as $ip) {
|
||||||
$banAt = $this->lastBanTimestamp($jail, $ip); // Unix-Timestamp oder null
|
$banAt = null; $until = null; $remaining = null;
|
||||||
|
|
||||||
$remaining = null; $until = null;
|
// 1) Primär: DB
|
||||||
if ($bantime === -1) {
|
if ($info = $this->banInfoFromDb($jail, $ip)) {
|
||||||
$remaining = -1; // permanent
|
$banAt = $info['banned_at'];
|
||||||
} elseif ($banAt !== null) {
|
if ((int)$info['expire'] === -1) {
|
||||||
$remaining = max(0, $bantime - (time() - $banAt));
|
$remaining = -1; // permanent
|
||||||
$until = $remaining > 0 ? $banAt + $bantime : null;
|
} elseif ((int)$info['expire'] > 0) {
|
||||||
} else {
|
$until = (int)$info['expire'];
|
||||||
$remaining = -2; // ≈ Approximation (Bantime bekannt, Start unbekannt)
|
$remaining = max(0, $until - time());
|
||||||
|
} else {
|
||||||
|
// kein expire in DB → Fallback unten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: KEIN fixes Datum, nur "~ Bantime"
|
||||||
|
if ($remaining === null) {
|
||||||
|
$remaining = -2; // "~ ca. {$defaultBantime}s"
|
||||||
}
|
}
|
||||||
|
|
||||||
[$timeText, $metaText, $boxClass] = $this->present($remaining, $banAt, $until);
|
[$timeText, $metaText, $boxClass] = $this->present($remaining, $banAt, $until);
|
||||||
|
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'ip' => $ip,
|
'ip' => $ip,
|
||||||
'bantime' => $bantime,
|
'bantime' => $defaultBantime,
|
||||||
'banned_at' => $banAt,
|
'banned_at' => $banAt,
|
||||||
'remaining' => $remaining,
|
'remaining' => $remaining,
|
||||||
'until' => $until,
|
'until' => $until,
|
||||||
|
|
@ -56,6 +167,7 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->rows = $rows;
|
$this->rows = $rows;
|
||||||
|
logger()->debug('rows='.json_encode($rows, JSON_UNESCAPED_SLASHES));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ROBUST: findet Binaries automatisch */
|
/** ROBUST: findet Binaries automatisch */
|
||||||
|
|
@ -65,6 +177,62 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
return $p !== '' ? $p : $name;
|
return $p !== '' ? $p : $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function activeBansFromDb(string $jail): array
|
||||||
|
{
|
||||||
|
$sudo = $this->bin('sudo');
|
||||||
|
$sqlite = $this->bin('sqlite3');
|
||||||
|
$db = $this->getDbFile();
|
||||||
|
|
||||||
|
$sql = <<<SQL
|
||||||
|
WITH last AS (
|
||||||
|
SELECT ip, MAX(timeofban) AS t
|
||||||
|
FROM bans
|
||||||
|
WHERE jail=:JAIL:
|
||||||
|
GROUP BY ip
|
||||||
|
),
|
||||||
|
curr AS (
|
||||||
|
SELECT b.ip, b.timeofban, b.bantime
|
||||||
|
FROM bans b
|
||||||
|
JOIN last l ON l.ip=b.ip AND l.t=b.timeofban
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ip,
|
||||||
|
timeofban,
|
||||||
|
bantime,
|
||||||
|
CASE WHEN bantime < 0 THEN -1
|
||||||
|
ELSE (timeofban + bantime) - CAST(strftime('%s','now') AS INTEGER)
|
||||||
|
END AS remaining,
|
||||||
|
CASE WHEN bantime < 0 THEN NULL
|
||||||
|
ELSE (timeofban + bantime)
|
||||||
|
END AS expire
|
||||||
|
FROM curr
|
||||||
|
WHERE bantime < 0
|
||||||
|
OR (timeofban + bantime) > CAST(strftime('%s','now') AS INTEGER)
|
||||||
|
ORDER BY timeofban DESC;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$qSql = str_replace(':JAIL:', $this->sql($jail), $sql);
|
||||||
|
$cmd = "$sudo -n $sqlite -readonly ".escapeshellarg($db).' '.escapeshellarg($qSql).' 2>&1';
|
||||||
|
$out = trim((string)@shell_exec($cmd));
|
||||||
|
if ($out === '') return [];
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach (explode("\n", $out) as $line) {
|
||||||
|
$parts = explode('|', $line);
|
||||||
|
if (count($parts) < 5) continue;
|
||||||
|
[$ip, $banAt, $bantime, $remaining, $expire] = [ $parts[0], (int)$parts[1], (int)$parts[2], (int)$parts[3], ($parts[4] !== '' ? (int)$parts[4] : null) ];
|
||||||
|
$rows[] = [
|
||||||
|
'ip' => trim($ip),
|
||||||
|
'bantime' => $bantime,
|
||||||
|
'banned_at' => $banAt ?: null,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'until' => $expire,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
/** letzte "Ban <IP>"-Zeile → Unix-Timestamp (mit Fallbacks) */
|
/** letzte "Ban <IP>"-Zeile → Unix-Timestamp (mit Fallbacks) */
|
||||||
private function lastBanTimestamp(string $jail, string $ip): ?int
|
private function lastBanTimestamp(string $jail, string $ip): ?int
|
||||||
{
|
{
|
||||||
|
|
@ -117,6 +285,22 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getDbFile(): string
|
||||||
|
{
|
||||||
|
[, $out] = $this->f2b('get dbfile');
|
||||||
|
// nimm die letzte nicht-leere Zeile
|
||||||
|
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
|
||||||
|
$path = end($lines) ?: '';
|
||||||
|
// fail2ban zeigt manchmal "`- /pfad" → führende Zeichen abschneiden
|
||||||
|
$path = preg_replace('/^`?-?\s*/', '', $path);
|
||||||
|
return $path !== '' ? $path : '/var/lib/fail2ban/fail2ban.sqlite3';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sql(string $s): string
|
||||||
|
{
|
||||||
|
return "'".str_replace("'", "''", $s)."'";
|
||||||
|
}
|
||||||
|
|
||||||
/** Darstellung: permanent / Restzeit / abgelaufen / ~approx / unbekannt */
|
/** Darstellung: permanent / Restzeit / abgelaufen / ~approx / unbekannt */
|
||||||
private function present(?int $remaining, ?int $banAt, ?int $until): array
|
private function present(?int $remaining, ?int $banAt, ?int $until): array
|
||||||
{
|
{
|
||||||
|
|
@ -128,7 +312,7 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
return ['permanent', $banAt ? ('seit '.$this->fmtTs($banAt)) : '—', $rose];
|
return ['permanent', $banAt ? ('seit '.$this->fmtTs($banAt)) : '—', $rose];
|
||||||
}
|
}
|
||||||
if ($remaining === -2) {
|
if ($remaining === -2) {
|
||||||
return ['~ '.$this->fmtSecs($this->getApproxBantime()), '—', $amber];
|
return ['~ '.$this->fmtSecs($this->getBantime($this->jail)), '—', $amber];
|
||||||
}
|
}
|
||||||
if (is_int($remaining)) {
|
if (is_int($remaining)) {
|
||||||
if ($remaining > 0) {
|
if ($remaining > 0) {
|
||||||
|
|
@ -142,8 +326,7 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
return ['—', '—', $muted];
|
return ['—', '—', $muted];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getApproxBantime(): int { return 600; }
|
private function getApproxBantime(): int { return $this->getBantime($this->jail); }
|
||||||
|
|
||||||
private function f2b(string $args): array
|
private function f2b(string $args): array
|
||||||
{
|
{
|
||||||
$sudo = $this->bin('sudo');
|
$sudo = $this->bin('sudo');
|
||||||
|
|
@ -155,6 +338,78 @@ class Fail2BanJailModal extends ModalComponent
|
||||||
return [$ok, $out];
|
return [$ok, $out];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function banInfoFromDb(string $jail, string $ip): ?array
|
||||||
|
{
|
||||||
|
$sudo = $this->bin('sudo');
|
||||||
|
$sqlite = $this->bin('sqlite3');
|
||||||
|
$db = $this->getDbFile();
|
||||||
|
|
||||||
|
// 1) Spalten ermitteln
|
||||||
|
$cmdCols = "$sudo -n $sqlite -readonly ".escapeshellarg($db)." ".escapeshellarg("PRAGMA table_info(bans);");
|
||||||
|
$colsRaw = trim((string)@shell_exec($cmdCols));
|
||||||
|
if ($colsRaw === '') return null;
|
||||||
|
|
||||||
|
$hasExpire = strpos($colsRaw, "|expiretime|") !== false;
|
||||||
|
$hasBantime= strpos($colsRaw, "|bantime|") !== false;
|
||||||
|
|
||||||
|
// 2) Query bauen nach Schema
|
||||||
|
if ($hasExpire) {
|
||||||
|
$q = sprintf(
|
||||||
|
"SELECT timeofban, expiretime FROM bans
|
||||||
|
WHERE jail=%s AND ip=%s
|
||||||
|
ORDER BY timeofban DESC LIMIT 1",
|
||||||
|
$this->sql($jail), $this->sql($ip)
|
||||||
|
);
|
||||||
|
$cmd = "$sudo -n $sqlite -readonly ".escapeshellarg($db).' '.escapeshellarg($q).' 2>&1';
|
||||||
|
$out = trim((string)@shell_exec($cmd));
|
||||||
|
if ($out === '') return null;
|
||||||
|
[$timeofban, $expire] = array_map('intval', explode('|', $out)) + [null, null];
|
||||||
|
return ['banned_at' => $timeofban ?: null, 'expire' => $expire ?? null];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasBantime) {
|
||||||
|
$q = sprintf(
|
||||||
|
"SELECT timeofban, bantime FROM bans
|
||||||
|
WHERE jail=%s AND ip=%s
|
||||||
|
ORDER BY timeofban DESC LIMIT 1",
|
||||||
|
$this->sql($jail), $this->sql($ip)
|
||||||
|
);
|
||||||
|
$cmd = "$sudo -n $sqlite -readonly ".escapeshellarg($db).' '.escapeshellarg($q).' 2>&1';
|
||||||
|
$out = trim((string)@shell_exec($cmd));
|
||||||
|
if ($out === '') return null;
|
||||||
|
[$timeofban, $bantime] = array_map('intval', explode('|', $out)) + [null, null];
|
||||||
|
$expire = ($timeofban && $bantime) ? ($timeofban + $bantime) : null;
|
||||||
|
return ['banned_at' => $timeofban ?: null, 'expire' => $expire];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: nur timeofban vorhanden → aktuelles Jail-Bantime verwenden
|
||||||
|
$q = sprintf(
|
||||||
|
"SELECT timeofban FROM bans
|
||||||
|
WHERE jail=%s AND ip=%s
|
||||||
|
ORDER BY timeofban DESC LIMIT 1",
|
||||||
|
$this->sql($jail), $this->sql($ip)
|
||||||
|
);
|
||||||
|
$cmd = "$sudo -n $sqlite -readonly ".escapeshellarg($db).' '.escapeshellarg($q).' 2>&1';
|
||||||
|
$out = trim((string)@shell_exec($cmd));
|
||||||
|
if ($out === '') return null;
|
||||||
|
|
||||||
|
$timeofban = (int)$out;
|
||||||
|
$bantime = $this->getBantime($jail); // aktuell konfiguriert (z. B. 3600)
|
||||||
|
$expire = $timeofban ? ($timeofban + max(0, $bantime)) : null;
|
||||||
|
|
||||||
|
return ['banned_at' => $timeofban ?: null, 'expire' => $expire];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isStillBanned(string $jail, string $ip): bool
|
||||||
|
{
|
||||||
|
[, $out1] = $this->f2b('get '.escapeshellarg($jail).' banip '.escapeshellarg($ip));
|
||||||
|
if (preg_match('/\b1\b/', $out1)) return true;
|
||||||
|
|
||||||
|
[, $out3] = $this->f2b('status '.escapeshellarg($jail));
|
||||||
|
$ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $out3) ?: '';
|
||||||
|
return $ipList !== '' && preg_match('/(^|\s)'.preg_quote($ip,'/').'(\s|$)/', $ipList) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
private function getBantime(string $jail): int
|
private function getBantime(string $jail): int
|
||||||
{
|
{
|
||||||
[, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
|
[, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,567 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\Security\Modal;
|
||||||
|
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
use App\Models\Fail2banIpList;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class Fail2banIpModal extends ModalComponent
|
||||||
|
{
|
||||||
|
/** 'whitelist' | 'blacklist' */
|
||||||
|
public string $type = 'whitelist';
|
||||||
|
|
||||||
|
/** 'add' | 'remove' */
|
||||||
|
public string $mode = 'add';
|
||||||
|
|
||||||
|
/** IP/CIDR im Formular */
|
||||||
|
public string $ip = '';
|
||||||
|
|
||||||
|
/** Für "remove" vorbefüllt */
|
||||||
|
public ?string $prefill = null;
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
|
||||||
|
{
|
||||||
|
$type = strtolower($type);
|
||||||
|
$mode = strtolower($mode);
|
||||||
|
|
||||||
|
if (!in_array($type, ['whitelist', 'blacklist'], true)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid type');
|
||||||
|
}
|
||||||
|
if (!in_array($mode, ['add', 'remove'], true)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->type = $type;
|
||||||
|
$this->mode = $mode;
|
||||||
|
$this->ip = $ip ?? '';
|
||||||
|
$this->prefill = $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.ui.security.modal.fail2ban-ip-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- actions ---------------- */
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->assertAddMode();
|
||||||
|
$ip = trim($this->ip);
|
||||||
|
|
||||||
|
if (!Fail2banIpList::isValidIpOrCidr($ip)) {
|
||||||
|
throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schutz: System-/Loopback-IPs darf der User nicht manuell pflegen
|
||||||
|
if (Fail2banIpList::isLoopback($ip)) {
|
||||||
|
throw ValidationException::withMessages(['ip' => 'Loopback/localhost ist bereits systemseitig erlaubt und kann nicht geändert werden.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikate abfangen
|
||||||
|
$exists = Fail2banIpList::where('ip', $ip)->where('type', $this->type)->exists();
|
||||||
|
if ($exists) {
|
||||||
|
throw ValidationException::withMessages(['ip' => ucfirst($this->type) . ' enthält diese IP bereits.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB schreiben
|
||||||
|
Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
|
||||||
|
|
||||||
|
if ($this->type === 'whitelist') {
|
||||||
|
// Whitelist-Datei aktualisieren + Fail2Ban reload
|
||||||
|
$this->writeWhitelistConfig();
|
||||||
|
$this->reloadFail2ban();
|
||||||
|
|
||||||
|
// UI aktualisieren & Toast
|
||||||
|
$this->dispatch('f2b:refresh');
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'success',
|
||||||
|
badge: 'Fail2Ban',
|
||||||
|
title: 'Whitelist aktualisiert',
|
||||||
|
text: 'Die IP wurde erfolgreich zur Whitelist hinzugefügt und ist nun freigegeben.',
|
||||||
|
duration: 6000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Blacklist = sofort bannen
|
||||||
|
$this->banIp($ip);
|
||||||
|
|
||||||
|
// UI aktualisieren & Toast
|
||||||
|
$this->dispatch('f2b:refresh');
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'warning',
|
||||||
|
badge: 'Fail2Ban',
|
||||||
|
title: 'Blacklist aktualisiert',
|
||||||
|
text: 'Die IP wurde zur Blacklist hinzugefügt und umgehend blockiert.',
|
||||||
|
duration: 6000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal bewusst am Ende schließen (Toast bleibt sichtbar)
|
||||||
|
$this->closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(): void
|
||||||
|
{
|
||||||
|
$this->assertRemoveMode();
|
||||||
|
$ip = trim($this->prefill ?? $this->ip);
|
||||||
|
if ($ip === '') return;
|
||||||
|
|
||||||
|
// System-Whitelist darf nicht entfernt werden
|
||||||
|
$row = Fail2banIpList::where('type', $this->type)->where('ip', $ip)->first();
|
||||||
|
if ($row && $row->is_system) {
|
||||||
|
throw ValidationException::withMessages(['ip' => 'Systemeintrag kann nicht entfernt werden.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
|
||||||
|
|
||||||
|
if ($this->type === 'whitelist') {
|
||||||
|
$this->writeWhitelistConfig();
|
||||||
|
$this->reloadFail2ban();
|
||||||
|
|
||||||
|
$this->dispatch('f2b:refresh');
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'info',
|
||||||
|
badge: 'Fail2Ban',
|
||||||
|
title: 'Whitelist geändert',
|
||||||
|
text: 'Die IP wurde aus der Whitelist entfernt.',
|
||||||
|
duration: 6000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->unbanIp($ip);
|
||||||
|
|
||||||
|
$this->dispatch('f2b:refresh');
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'info',
|
||||||
|
badge: 'Fail2Ban',
|
||||||
|
title: 'Blacklist geändert',
|
||||||
|
text: 'Die IP wurde aus der Blacklist entfernt und ist wieder freigegeben.',
|
||||||
|
duration: 6000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- helper ---------------- */
|
||||||
|
|
||||||
|
private function assertAddMode(): void
|
||||||
|
{
|
||||||
|
if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertRemoveMode(): void
|
||||||
|
{
|
||||||
|
if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeWhitelistConfig(): void
|
||||||
|
{
|
||||||
|
// WICHTIG: inkl. System-IPs (unsichtbar in der UI)
|
||||||
|
$ips = Fail2banIpList::allWhitelistForConfig();
|
||||||
|
$ignore = implode(' ', array_unique(array_filter($ips)));
|
||||||
|
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||||
|
|
||||||
|
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/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");
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
@ -2,90 +2,54 @@
|
||||||
|
|
||||||
namespace App\Livewire\Ui\Security\Modal;
|
namespace App\Livewire\Ui\Security\Modal;
|
||||||
|
|
||||||
|
use App\Services\TotpService;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use LivewireUI\Modal\ModalComponent;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
use Vectorface\GoogleAuthenticator;
|
|
||||||
|
|
||||||
class TotpSetupModal extends ModalComponent
|
class TotpSetupModal extends ModalComponent
|
||||||
{
|
{
|
||||||
public string $secret;
|
public string $step = 'scan';
|
||||||
public string $otp = '';
|
public string $code = '';
|
||||||
public string $qrPng; // PNG Data-URI
|
public string $secret = '';
|
||||||
public bool $alreadyActive = false;
|
public array $recoveryCodes = [];
|
||||||
|
public string $qrSvg = '';
|
||||||
|
|
||||||
// << Wichtig: je Modal eigene Breite >>
|
public static function modalMaxWidth(): string { return 'md'; }
|
||||||
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$totp = app(TotpService::class);
|
||||||
|
$this->secret = $totp->generateSecret();
|
||||||
$ga = new GoogleAuthenticator();
|
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[On('security:totp:enable')]
|
public function verify(): void
|
||||||
public function verifyAndEnable(string $code): void
|
|
||||||
{
|
{
|
||||||
$code = preg_replace('/\D/', '', $code ?? '');
|
$this->validate(['code' => 'required|digits:6']);
|
||||||
if (strlen($code) !== 6) {
|
|
||||||
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
|
$totp = app(TotpService::class);
|
||||||
|
|
||||||
|
if (!$totp->verify($this->secret, $this->code)) {
|
||||||
|
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ga = new GoogleAuthenticator();
|
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
|
||||||
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
|
$this->step = 'codes';
|
||||||
|
|
||||||
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 disable(): void
|
public function done(): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$this->dispatch('toast', type: 'done', badge: '2FA',
|
||||||
$user->totp_secret = null;
|
title: 'TOTP aktiviert',
|
||||||
$user->two_factor_enabled = false;
|
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
|
||||||
$user->save();
|
$this->dispatch('2fa-status-changed');
|
||||||
|
$this->closeModal();
|
||||||
$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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.ui.security.modal.totp-setup-modal');
|
return view('livewire.ui.system.modal.totp-setup-modal');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,85 @@
|
||||||
|
|
||||||
namespace App\Livewire\Ui\Security;
|
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;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('Rspamd · Mailwolt')]
|
||||||
class RspamdForm extends Component
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.ui.security.rspamd-form');
|
$r = Process::run(['systemctl', 'is-active', 'rspamd']);
|
||||||
|
$running = trim($r->output()) === 'active';
|
||||||
|
return view('livewire.ui.security.rspamd-form', compact('running'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,182 @@
|
||||||
|
|
||||||
namespace App\Livewire\Ui\Security;
|
namespace App\Livewire\Ui\Security;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('SSL/TLS · Mailwolt')]
|
||||||
class SslCertificatesTable extends Component
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.ui.security.ssl-certificates-table');
|
return view('livewire.ui.security.ssl-certificates-table');
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,133 @@
|
||||||
|
|
||||||
namespace App\Livewire\Ui\Security;
|
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;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('TLS-Ciphers · Mailwolt')]
|
||||||
class TlsCiphersForm extends Component
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.ui.security.tls-ciphers-form');
|
return view('livewire.ui.security.tls-ciphers-form', ['presets' => array_keys(self::PRESETS)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Livewire\Ui\System;
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -10,12 +11,14 @@ class DomainsSslForm extends Component
|
||||||
{
|
{
|
||||||
/* ========= Basis & Hosts ========= */
|
/* ========= Basis & Hosts ========= */
|
||||||
|
|
||||||
public string $base_domain = 'example.com';
|
public string $base_domain = '';
|
||||||
|
|
||||||
// nur Subdomain-Teile (ohne Punkte/Protokoll)
|
// nur Subdomain-Teile (ohne Punkte/Protokoll)
|
||||||
public string $ui_sub = 'mail';
|
public string $ui_sub = '';
|
||||||
public string $webmail_sub = 'webmail';
|
public string $webmail_sub = '';
|
||||||
public string $mta_sub = 'mx';
|
public string $mta_sub = '';
|
||||||
|
|
||||||
|
public bool $domainsSaving = false;
|
||||||
|
|
||||||
/* ========= TLS / Redirect ========= */
|
/* ========= TLS / Redirect ========= */
|
||||||
public bool $force_https = true;
|
public bool $force_https = true;
|
||||||
|
|
@ -87,9 +90,9 @@ class DomainsSslForm extends Component
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'],
|
'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'],
|
||||||
'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
|
'ui_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
|
||||||
'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
|
'webmail_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
|
||||||
'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
|
'mta_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
|
||||||
|
|
||||||
'force_https' => ['boolean'],
|
'force_https' => ['boolean'],
|
||||||
'hsts' => ['boolean'],
|
'hsts' => ['boolean'],
|
||||||
|
|
@ -124,6 +127,16 @@ 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
|
protected function loadMtaStsFromFileIfPossible(): void
|
||||||
{
|
{
|
||||||
$file = public_path('.well-known/mta-sts.txt');
|
$file = public_path('.well-known/mta-sts.txt');
|
||||||
|
|
@ -154,9 +167,52 @@ class DomainsSslForm extends Component
|
||||||
|
|
||||||
public function saveDomains(): void
|
public function saveDomains(): void
|
||||||
{
|
{
|
||||||
$this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']);
|
$this->validate(['base_domain', 'ui_sub', 'webmail_sub', 'mta_sub']);
|
||||||
// TODO: persist
|
|
||||||
$this->dispatch('toast', body: 'Domains gespeichert.');
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveTls(): void
|
public function saveTls(): void
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System\Form;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class DomainsSslForm extends Component
|
||||||
|
{
|
||||||
|
// fix / readonly aus ENV oder config
|
||||||
|
public string $mail_domain_readonly = '';
|
||||||
|
|
||||||
|
// editierbar
|
||||||
|
public string $ui_domain = '';
|
||||||
|
public string $webmail_domain = '';
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ui_domain' => 'nullable|string|max:190',
|
||||||
|
'webmail_domain' => 'nullable|string|max:190',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mail_domain_readonly = (string) config('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'); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System\Form;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class GeneralForm extends Component
|
||||||
|
{
|
||||||
|
public string $locale = 'de';
|
||||||
|
public string $timezone = 'Europe/Berlin';
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'locale' => 'required|string|max:10',
|
||||||
|
'timezone' => 'required|string|max:64',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Defaults aus ENV nur für den allerersten Seed in Settings (Redis/DB)
|
||||||
|
$envLocale = env('APP_LOCALE') ?? env('APP_FALLBACK_LOCALE') ?? $this->locale;
|
||||||
|
$envTimezone = env('APP_TIMEZONE') ?? $this->timezone;
|
||||||
|
|
||||||
|
// Wenn (noch) nichts in Settings liegt, einmalig mit ENV-Werten befüllen
|
||||||
|
if (Setting::get('locale', null) === null) {
|
||||||
|
Setting::set('locale', $envLocale);
|
||||||
|
}
|
||||||
|
if (Setting::get('timezone', null) === null) {
|
||||||
|
Setting::set('timezone', $envTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier ausschließlich aus Settings lesen (Redis → DB Fallback)
|
||||||
|
$this->locale = (string) Setting::get('locale', $envLocale);
|
||||||
|
$this->timezone = (string) Setting::get('timezone', $envTimezone);
|
||||||
|
|
||||||
|
// Sofort für die aktuelle Request anwenden
|
||||||
|
app()->setLocale($this->locale);
|
||||||
|
@date_default_timezone_set($this->timezone);
|
||||||
|
config([
|
||||||
|
'app.locale' => $this->locale,
|
||||||
|
'app.fallback_locale' => $this->locale,
|
||||||
|
'app.timezone' => $this->timezone,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
// Persistieren: DB → Redis (siehe Setting::set)
|
||||||
|
Setting::set('locale', $this->locale);
|
||||||
|
Setting::set('timezone', $this->timezone);
|
||||||
|
|
||||||
|
// Direkt in der laufenden Request aktivieren
|
||||||
|
app()->setLocale($this->locale);
|
||||||
|
@date_default_timezone_set($this->timezone);
|
||||||
|
config([
|
||||||
|
'app.locale' => $this->locale,
|
||||||
|
'app.fallback_locale' => $this->locale, // optional
|
||||||
|
'app.timezone' => $this->timezone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'done',
|
||||||
|
badge: 'System',
|
||||||
|
title: 'Allgemein gespeichert',
|
||||||
|
text: 'Sprache und Zeitzone wurden übernommen.',
|
||||||
|
duration: 5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.ui.system.form.general-form');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System\Form;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class SecurityForm extends Component
|
||||||
|
{
|
||||||
|
public bool $twofa_enabled = false;
|
||||||
|
public ?int $rate_limit = 5;
|
||||||
|
public ?int $password_min = 10;
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'twofa_enabled' => 'boolean',
|
||||||
|
'rate_limit' => 'nullable|integer|min:1|max:100',
|
||||||
|
'password_min' => 'nullable|integer|min:6|max:128',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled);
|
||||||
|
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
|
||||||
|
$this->password_min = (int) Setting::get('password_min', $this->password_min);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
Setting::put('twofa_enabled', $this->twofa_enabled);
|
||||||
|
Setting::put('rate_limit', $this->rate_limit);
|
||||||
|
Setting::put('password_min', $this->password_min);
|
||||||
|
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'done',
|
||||||
|
badge: 'Sicherheit',
|
||||||
|
title: 'Sicherheit gespeichert',
|
||||||
|
text: '2FA/Rate-Limits/Passwortregeln wurden übernommen.',
|
||||||
|
duration: 5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render() { return view('livewire.ui.system.form.security-form'); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,12 +34,24 @@ class UpdateModal extends ModalComponent
|
||||||
$st = @trim(@file_get_contents(self::STATE_DIR.'/state') ?: '');
|
$st = @trim(@file_get_contents(self::STATE_DIR.'/state') ?: '');
|
||||||
$rcRaw = @trim(@file_get_contents(self::STATE_DIR.'/rc') ?: '');
|
$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->state = $st ?: 'unknown';
|
||||||
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
|
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
|
||||||
|
|
||||||
// Log einlesen
|
$this->tail = array_slice(
|
||||||
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
|
array_filter($lines, fn($l) => trim($l) !== '[DONE]'),
|
||||||
$this->tail = array_slice($lines, -30);
|
-30
|
||||||
|
);
|
||||||
|
|
||||||
$last = trim($this->tail ? end($this->tail) : '');
|
$last = trim($this->tail ? end($this->tail) : '');
|
||||||
$last = preg_replace('/^\[\w\]\s*/', '', $last);
|
$last = preg_replace('/^\[\w\]\s*/', '', $last);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
|
use App\Models\SandboxRoute;
|
||||||
|
use App\Services\SandboxService;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class SandboxRoutes extends Component
|
||||||
|
{
|
||||||
|
public string $newType = 'domain';
|
||||||
|
public string $newTarget = '';
|
||||||
|
public ?string $syncMessage = null;
|
||||||
|
public bool $syncOk = false;
|
||||||
|
|
||||||
|
public function boot(SandboxService $sandboxService): void
|
||||||
|
{
|
||||||
|
$this->sandboxService = $sandboxService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRoute(): void
|
||||||
|
{
|
||||||
|
if ($this->newType !== 'global') {
|
||||||
|
$this->validate(['newTarget' => 'required|string|max:255']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $this->newType === 'global' ? null : trim($this->newTarget);
|
||||||
|
$this->sandboxService->enable($this->newType, $target);
|
||||||
|
$this->sandboxService->syncTransportFile();
|
||||||
|
$this->dispatch('sandbox-route-changed');
|
||||||
|
$this->newTarget = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeRoute(int $id): void
|
||||||
|
{
|
||||||
|
$this->sandboxService->delete($id);
|
||||||
|
$this->sandboxService->syncTransportFile();
|
||||||
|
$this->dispatch('sandbox-route-changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleRoute(int $id): void
|
||||||
|
{
|
||||||
|
$route = SandboxRoute::findOrFail($id);
|
||||||
|
$route->is_active = !$route->is_active;
|
||||||
|
$route->save();
|
||||||
|
$this->sandboxService->syncTransportFile();
|
||||||
|
$this->dispatch('sandbox-route-changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncPostfix(): void
|
||||||
|
{
|
||||||
|
$result = $this->sandboxService->syncTransportFile();
|
||||||
|
$this->syncOk = $result['ok'];
|
||||||
|
$this->syncMessage = $result['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function routes()
|
||||||
|
{
|
||||||
|
return SandboxRoute::orderBy('type')->orderBy('target')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.ui.system.sandbox-routes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,91 +2,523 @@
|
||||||
|
|
||||||
namespace App\Livewire\Ui\System;
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
|
use App\Models\BackupPolicy;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('Einstellungen · Mailwolt')]
|
||||||
class SettingsForm extends Component
|
class SettingsForm extends Component
|
||||||
{
|
{
|
||||||
// Tab-Steuerung (optional per Alpine, hier aber auch als Prop)
|
#[Url]
|
||||||
public string $tab = 'general';
|
public string $tab = 'general';
|
||||||
|
|
||||||
// Allgemein
|
private const VALID_TABS = ['general', 'domains', 'backup', 'notifications'];
|
||||||
public string $instance_name = ''; // readonly
|
|
||||||
public string $locale = 'de';
|
|
||||||
public string $timezone = 'Europe/Berlin';
|
|
||||||
public ?int $session_timeout = 120; // Minuten
|
|
||||||
|
|
||||||
// Domains & SSL
|
// Allgemein
|
||||||
|
public string $instance_name = '';
|
||||||
|
public string $locale = 'de';
|
||||||
|
public string $timezone = 'Europe/Berlin';
|
||||||
|
public int $session_timeout = 120;
|
||||||
|
|
||||||
|
// Domains
|
||||||
public string $ui_domain = '';
|
public string $ui_domain = '';
|
||||||
public string $mail_domain = '';
|
public string $mail_domain = '';
|
||||||
public string $webmail_domain = '';
|
public string $webmail_domain = '';
|
||||||
public bool $ssl_auto = true;
|
|
||||||
|
// SSL-Status (read-only, für Anzeige in Einstellungen)
|
||||||
|
public array $sslCerts = [];
|
||||||
|
|
||||||
|
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||||
|
|
||||||
|
// Benachrichtigungen
|
||||||
|
public string $notify_sender_name = '';
|
||||||
|
public string $notify_admin_email = '';
|
||||||
|
|
||||||
// Sicherheit
|
// Sicherheit
|
||||||
public bool $twofa_enabled = false;
|
public int $rate_limit = 5;
|
||||||
public ?int $rate_limit = 5; // Versuche / Minute
|
public int $password_min = 10;
|
||||||
public ?int $password_min = 10;
|
|
||||||
|
// Backup
|
||||||
|
public bool $backup_enabled = false;
|
||||||
|
public string $backup_preset = 'daily';
|
||||||
|
public string $backup_time = '03:00';
|
||||||
|
public string $backup_weekday = '1';
|
||||||
|
public string $backup_monthday = '1';
|
||||||
|
public string $backup_cron = '0 3 * * *';
|
||||||
|
public int $backup_retention = 7;
|
||||||
|
public bool $backup_include_db = true;
|
||||||
|
public bool $backup_include_maildirs = true;
|
||||||
|
public bool $backup_include_configs = true;
|
||||||
|
public string $backup_mail_dir = '';
|
||||||
|
|
||||||
protected function rules(): array
|
protected function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'locale' => 'required|string|max:10',
|
'locale' => 'required|string|max:10',
|
||||||
'timezone' => 'required|string|max:64',
|
'timezone' => 'required|string|max:64',
|
||||||
'session_timeout' => 'nullable|integer|min:5|max:1440',
|
'session_timeout' => 'required|integer|min:5|max:1440',
|
||||||
|
'notify_sender_name' => 'nullable|string|max:80',
|
||||||
|
'notify_admin_email' => 'nullable|email|max:190',
|
||||||
|
'rate_limit' => 'required|integer|min:1|max:100',
|
||||||
|
'password_min' => 'required|integer|min:6|max:128',
|
||||||
|
'backup_enabled' => 'boolean',
|
||||||
|
'backup_preset' => 'required|in:hourly,daily,weekly,monthly,custom',
|
||||||
|
'backup_time' => 'required_unless:backup_preset,hourly,custom|regex:/^\d{1,2}:\d{2}$/',
|
||||||
|
'backup_weekday' => 'required_if:backup_preset,weekly|integer|min:0|max:6',
|
||||||
|
'backup_monthday' => 'required_if:backup_preset,monthly|integer|min:1|max:28',
|
||||||
|
'backup_cron' => 'required_if:backup_preset,custom|string|max:64',
|
||||||
|
'backup_retention' => 'required|integer|min:1|max:365',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
'ui_domain' => 'nullable|string|max:190',
|
protected function domainRules(): array
|
||||||
'mail_domain' => 'nullable|string|max:190',
|
{
|
||||||
'webmail_domain' => 'nullable|string|max:190',
|
$host = 'required|string|max:190|regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
|
||||||
'ssl_auto' => 'boolean',
|
return [
|
||||||
|
'ui_domain' => $host,
|
||||||
|
'mail_domain' => $host,
|
||||||
|
'webmail_domain'=> $host,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
'twofa_enabled' => 'boolean',
|
protected function domainMessages(): array
|
||||||
'rate_limit' => 'nullable|integer|min:1|max:100',
|
{
|
||||||
'password_min' => 'nullable|integer|min:6|max:128',
|
$fmt = 'Ungültige Domain — kein Schema (http://) und kein Slash am Ende erlaubt.';
|
||||||
|
$req = 'Dieses Feld darf nicht leer sein.';
|
||||||
|
return [
|
||||||
|
'ui_domain.required' => $req,
|
||||||
|
'mail_domain.required' => $req,
|
||||||
|
'webmail_domain.required' => $req,
|
||||||
|
'ui_domain.regex' => $fmt,
|
||||||
|
'mail_domain.regex' => $fmt,
|
||||||
|
'webmail_domain.regex' => $fmt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
// Anzeige-Name der Instanz – lies was du hast (z.B. config('app.name'))
|
$this->instance_name = (string) (config('app.name') ?? 'Mailwolt');
|
||||||
$this->instance_name = (string) (config('app.name') ?? 'MailWolt');
|
$this->locale = (string) Setting::get('locale', $this->locale);
|
||||||
|
$this->timezone = (string) Setting::get('timezone', $this->timezone);
|
||||||
|
$this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout);
|
||||||
|
$base = env('BASE_DOMAIN', '');
|
||||||
|
$persist = [];
|
||||||
|
|
||||||
// Laden aus unserem einfachen Store
|
$uiSetting = Setting::get('ui_domain', null);
|
||||||
$this->locale = Setting::get('locale', $this->locale);
|
if ($uiSetting !== null) {
|
||||||
$this->timezone = Setting::get('timezone', $this->timezone);
|
$this->ui_domain = (string) $uiSetting;
|
||||||
$this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout);
|
} else {
|
||||||
|
$this->ui_domain = (string) env('APP_HOST', '');
|
||||||
|
$persist['ui_domain'] = $this->ui_domain;
|
||||||
|
}
|
||||||
|
|
||||||
$this->ui_domain = Setting::get('ui_domain', $this->ui_domain);
|
$mailSetting = Setting::get('mail_domain', null);
|
||||||
$this->mail_domain = Setting::get('mail_domain', $this->mail_domain);
|
if ($mailSetting !== null) {
|
||||||
$this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain);
|
$this->mail_domain = (string) $mailSetting;
|
||||||
$this->ssl_auto = (bool) Setting::get('ssl_auto', $this->ssl_auto);
|
} else {
|
||||||
|
$mtaSub = env('MTA_SUB', '');
|
||||||
|
$this->mail_domain = $mtaSub && $base ? "{$mtaSub}.{$base}" : '';
|
||||||
|
$persist['mail_domain'] = $this->mail_domain;
|
||||||
|
}
|
||||||
|
|
||||||
$this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled);
|
$webmailSetting = Setting::get('webmail_domain', null);
|
||||||
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
|
if ($webmailSetting !== null) {
|
||||||
$this->password_min = (int) Setting::get('password_min', $this->password_min);
|
$this->webmail_domain = (string) $webmailSetting;
|
||||||
|
} else {
|
||||||
|
$webmailSub = env('WEBMAIL_SUB', '');
|
||||||
|
$webmailDomain = env('WEBMAIL_DOMAIN', '');
|
||||||
|
$this->webmail_domain = $webmailDomain ?: ($webmailSub && $base ? "{$webmailSub}.{$base}" : '');
|
||||||
|
$persist['webmail_domain'] = $this->webmail_domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($persist) {
|
||||||
|
Setting::setMany($persist);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadSslStatus();
|
||||||
|
$defaultSenderName = ($this->instance_name ?: 'Mailwolt') . ' Benachrichtigung';
|
||||||
|
$defaultAdminEmail = auth()->user()?->system_notify_email ?? '';
|
||||||
|
$this->notify_sender_name = (string) Setting::get('notify_sender_name', $defaultSenderName);
|
||||||
|
$this->notify_admin_email = (string) Setting::get('notify_admin_email', $defaultAdminEmail);
|
||||||
|
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
|
||||||
|
$this->password_min = (int) Setting::get('password_min', $this->password_min);
|
||||||
|
|
||||||
|
// Backup aus BackupPolicy laden
|
||||||
|
$policy = BackupPolicy::first();
|
||||||
|
if ($policy) {
|
||||||
|
$this->backup_enabled = $policy->enabled;
|
||||||
|
$this->backup_retention = $policy->retention_count;
|
||||||
|
$this->backup_cron = $policy->schedule_cron;
|
||||||
|
$this->backup_include_db = (bool) $policy->include_db;
|
||||||
|
$this->backup_include_maildirs = (bool) $policy->include_maildirs;
|
||||||
|
$this->backup_include_configs = (bool) $policy->include_configs;
|
||||||
|
$this->backup_mail_dir = (string) Setting::get('backup_mail_dir', '');
|
||||||
|
$this->parseCronToPreset($policy->schedule_cron);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(): void
|
public function save(): void
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
Setting::put('locale', $this->locale);
|
Setting::setMany([
|
||||||
Setting::put('timezone', $this->timezone);
|
'locale' => $this->locale,
|
||||||
Setting::put('session_timeout', $this->session_timeout);
|
'timezone' => $this->timezone,
|
||||||
|
'session_timeout' => $this->session_timeout,
|
||||||
|
'notify_sender_name' => $this->notify_sender_name,
|
||||||
|
'notify_admin_email' => $this->notify_admin_email,
|
||||||
|
'rate_limit' => $this->rate_limit,
|
||||||
|
'password_min' => $this->password_min,
|
||||||
|
]);
|
||||||
|
|
||||||
Setting::put('ui_domain', $this->ui_domain);
|
// MAIL_FROM_NAME in .env synchronisieren
|
||||||
Setting::put('mail_domain', $this->mail_domain);
|
if ($this->notify_sender_name) {
|
||||||
Setting::put('webmail_domain', $this->webmail_domain);
|
$this->writeEnv(['MAIL_FROM_NAME' => $this->notify_sender_name]);
|
||||||
Setting::put('ssl_auto', $this->ssl_auto);
|
}
|
||||||
|
|
||||||
Setting::put('twofa_enabled', $this->twofa_enabled);
|
// Backup-Policy speichern
|
||||||
Setting::put('rate_limit', $this->rate_limit);
|
$cron = $this->buildCron();
|
||||||
Setting::put('password_min', $this->password_min);
|
BackupPolicy::updateOrCreate([], [
|
||||||
|
'enabled' => $this->backup_enabled,
|
||||||
|
'schedule_cron' => $cron,
|
||||||
|
'retention_count' => $this->backup_retention,
|
||||||
|
'include_db' => $this->backup_include_db,
|
||||||
|
'include_maildirs' => $this->backup_include_maildirs,
|
||||||
|
'include_configs' => $this->backup_include_configs,
|
||||||
|
]);
|
||||||
|
Setting::setMany(['backup_mail_dir' => $this->backup_mail_dir]);
|
||||||
|
$this->backup_cron = $cron;
|
||||||
|
|
||||||
$this->dispatch('toast', type: 'success', message: 'Einstellungen gespeichert'); // optional
|
$this->dispatch('toast', type: 'done', badge: 'Einstellungen',
|
||||||
|
title: 'Gespeichert',
|
||||||
|
text: 'Alle Einstellungen wurden übernommen.', duration: 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveDomains(): void
|
||||||
|
{
|
||||||
|
$this->validate($this->domainRules(), $this->domainMessages());
|
||||||
|
|
||||||
|
$this->ui_domain = $this->cleanDomain($this->ui_domain);
|
||||||
|
$this->mail_domain = $this->cleanDomain($this->mail_domain);
|
||||||
|
$this->webmail_domain = $this->cleanDomain($this->webmail_domain);
|
||||||
|
|
||||||
|
Setting::setMany([
|
||||||
|
'ui_domain' => $this->ui_domain,
|
||||||
|
'mail_domain' => $this->mail_domain,
|
||||||
|
'webmail_domain' => $this->webmail_domain,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncDomainsToEnv();
|
||||||
|
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) {
|
||||||
|
$nginxOk = $this->applyDomains(false);
|
||||||
|
if ($nginxOk === false) {
|
||||||
|
$warnings[] = 'Nginx konnte nicht neu geladen werden — DNS noch nicht aktiv oder Helper fehlt.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sysmailErr = $this->syncSysmailDomain();
|
||||||
|
if ($sysmailErr) {
|
||||||
|
$warnings[] = 'System-Domain: ' . $sysmailErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->mail_domain) {
|
||||||
|
$this->applyPostfixHostname($this->mail_domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadSslStatus();
|
||||||
|
|
||||||
|
if ($warnings) {
|
||||||
|
$this->dispatch('toast', type: 'warn', badge: 'Domains',
|
||||||
|
title: 'Domains gespeichert — mit Hinweisen',
|
||||||
|
text: implode(' · ', $warnings),
|
||||||
|
duration: 0);
|
||||||
|
} else {
|
||||||
|
$this->dispatch('toast', type: 'done', badge: 'Domains',
|
||||||
|
title: 'Domains gespeichert',
|
||||||
|
text: 'Konfiguration wurde übernommen.',
|
||||||
|
duration: 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncSysmailDomain(): ?string
|
||||||
|
{
|
||||||
|
// BASE_DOMAIN aus config, env oder mail_domain ableiten
|
||||||
|
$platformBase = strtolower((string) config('mailpool.platform_zone', ''));
|
||||||
|
if (!$platformBase || $platformBase === 'example.com') {
|
||||||
|
$platformBase = strtolower((string) env('BASE_DOMAIN', ''));
|
||||||
|
}
|
||||||
|
if (!$platformBase || $platformBase === 'example.com') {
|
||||||
|
// Fallback: aus mail_domain den Hostnamen ohne ersten Subdomain-Teil
|
||||||
|
$mailDomain = strtolower(trim((string) Setting::get('mail_domain', '')));
|
||||||
|
if ($mailDomain) {
|
||||||
|
$parts = explode('.', $mailDomain);
|
||||||
|
$platformBase = count($parts) > 2
|
||||||
|
? implode('.', array_slice($parts, 1))
|
||||||
|
: $mailDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$platformBase || $platformBase === 'example.com') return null;
|
||||||
|
|
||||||
|
$systemSub = strtolower(config('mailpool.platform_system_zone', 'sysmail'));
|
||||||
|
$expectedFqdn = "{$systemSub}.{$platformBase}";
|
||||||
|
|
||||||
|
$existing = \App\Models\Domain::where('is_system', true)
|
||||||
|
->where(fn($q) => $q->whereNull('is_server')->orWhere('is_server', false))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing && $existing->domain === $expectedFqdn) return null;
|
||||||
|
|
||||||
|
if ($existing && $existing->domain !== $expectedFqdn) {
|
||||||
|
\App\Models\MailUser::where('domain_id', $existing->id)->forceDelete();
|
||||||
|
$existing->forceDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Artisan::call('db:seed', [
|
||||||
|
'--class' => 'Database\\Seeders\\SystemDomainSeeder',
|
||||||
|
'--force' => true,
|
||||||
|
]);
|
||||||
|
\Illuminate\Support\Facades\Log::info('SystemDomainSeeder ok', ['fqdn' => $expectedFqdn]);
|
||||||
|
return null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::error('SystemDomainSeeder failed', [
|
||||||
|
'fqdn' => $expectedFqdn,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanDomain(string $value): string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
$value = preg_replace('#^https?://#i', '', $value);
|
||||||
|
return rtrim($value, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadSslStatus(): void
|
||||||
|
{
|
||||||
|
clearstatcache(true);
|
||||||
|
|
||||||
|
$domains = [
|
||||||
|
'ui' => $this->ui_domain,
|
||||||
|
'webmail' => $this->webmail_domain,
|
||||||
|
'mail' => $this->mail_domain,
|
||||||
|
];
|
||||||
|
|
||||||
|
$certs = [];
|
||||||
|
foreach ($domains as $key => $domain) {
|
||||||
|
if (! $domain) {
|
||||||
|
$certs[$key] = ['domain' => '', 'has_cert' => false, 'expiry' => null, 'status' => 'nodomain'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$certFile = "/etc/letsencrypt/live/{$domain}/fullchain.pem";
|
||||||
|
$certDir = "/etc/letsencrypt/live/{$domain}";
|
||||||
|
// renewal/ ist immer 755 und world-readable — zuverlässigster Existenzcheck.
|
||||||
|
// is_dir() als Fallback falls renewal-Datei fehlt (manuell ausgestellte Certs).
|
||||||
|
$renewalConf = "/etc/letsencrypt/renewal/{$domain}.conf";
|
||||||
|
$certExists = file_exists($renewalConf) || is_dir($certDir);
|
||||||
|
|
||||||
|
if (! $certExists) {
|
||||||
|
$certs[$key] = ['domain' => $domain, 'has_cert' => false, 'expiry' => null, 'status' => 'missing'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sudo nötig: archive/ ist 700 (Symlink-Ziel), voller Pfad für sudoers-Matching
|
||||||
|
$expiry = trim((string) @shell_exec(
|
||||||
|
"sudo -n /usr/bin/openssl x509 -enddate -noout -in " . escapeshellarg($certFile) . " 2>/dev/null | sed 's/notAfter=//'"
|
||||||
|
));
|
||||||
|
$expiryTs = $expiry ? strtotime($expiry) : null;
|
||||||
|
$daysLeft = $expiryTs ? (int) round(($expiryTs - time()) / 86400) : null;
|
||||||
|
$certStatus = match (true) {
|
||||||
|
$daysLeft === null => 'ok',
|
||||||
|
$daysLeft <= 0 => 'expired',
|
||||||
|
$daysLeft <= 14 => 'expiring',
|
||||||
|
default => 'ok',
|
||||||
|
};
|
||||||
|
$certs[$key] = [
|
||||||
|
'domain' => $domain,
|
||||||
|
'has_cert' => true,
|
||||||
|
'expiry' => $expiryTs ? date('d.m.Y', $expiryTs) : null,
|
||||||
|
'days' => $daysLeft,
|
||||||
|
'status' => $certStatus,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->sslCerts = $certs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedUiDomain(): void { $this->validateOnly('ui_domain', $this->domainRules(), $this->domainMessages()); }
|
||||||
|
public function updatedMailDomain(): void { $this->validateOnly('mail_domain', $this->domainRules(), $this->domainMessages()); }
|
||||||
|
public function updatedWebmailDomain(): void { $this->validateOnly('webmail_domain', $this->domainRules(), $this->domainMessages()); }
|
||||||
|
|
||||||
|
// Aktualisiert die Cron-Vorschau live wenn der User Felder ändert
|
||||||
|
public function updatedBackupPreset(): void { $this->backup_cron = $this->buildCron(); }
|
||||||
|
public function updatedBackupTime(): void { $this->backup_cron = $this->buildCron(); }
|
||||||
|
public function updatedBackupWeekday(): void { $this->backup_cron = $this->buildCron(); }
|
||||||
|
public function updatedBackupMonthday(): void{ $this->backup_cron = $this->buildCron(); }
|
||||||
|
|
||||||
|
private function buildCron(): string
|
||||||
|
{
|
||||||
|
[$h, $m] = array_pad(explode(':', $this->backup_time ?? '03:00'), 2, '00');
|
||||||
|
$h = (int)$h; $m = (int)$m;
|
||||||
|
|
||||||
|
return match($this->backup_preset) {
|
||||||
|
'hourly' => '0 * * * *',
|
||||||
|
'daily' => sprintf('%d %d * * *', $m, $h),
|
||||||
|
'weekly' => sprintf('%d %d * * %d', $m, $h, (int)$this->backup_weekday),
|
||||||
|
'monthly' => sprintf('%d %d %d * *', $m, $h, (int)$this->backup_monthday),
|
||||||
|
default => $this->backup_cron ?: '0 3 * * *',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseCronToPreset(string $cron): void
|
||||||
|
{
|
||||||
|
$p = preg_split('/\s+/', trim($cron));
|
||||||
|
if (count($p) !== 5) { $this->backup_preset = 'custom'; return; }
|
||||||
|
[$min, $hour, $dom, $mon, $dow] = $p;
|
||||||
|
|
||||||
|
if ($cron === '0 * * * *') {
|
||||||
|
$this->backup_preset = 'hourly';
|
||||||
|
} elseif ($dom === '*' && $mon === '*' && $dow === '*' && is_numeric($hour)) {
|
||||||
|
$this->backup_preset = 'daily';
|
||||||
|
$this->backup_time = sprintf('%02d:%02d', $hour, $min);
|
||||||
|
} elseif ($dom === '*' && $mon === '*' && is_numeric($dow) && is_numeric($hour)) {
|
||||||
|
$this->backup_preset = 'weekly';
|
||||||
|
$this->backup_time = sprintf('%02d:%02d', $hour, $min);
|
||||||
|
$this->backup_weekday = $dow;
|
||||||
|
} elseif ($mon === '*' && $dow === '*' && is_numeric($dom) && is_numeric($hour)) {
|
||||||
|
$this->backup_preset = 'monthly';
|
||||||
|
$this->backup_time = sprintf('%02d:%02d', $hour, $min);
|
||||||
|
$this->backup_monthday = $dom;
|
||||||
|
} else {
|
||||||
|
$this->backup_preset = 'custom';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyDomains(bool $sslAuto = false): bool
|
||||||
|
{
|
||||||
|
// DNS prüfen — Warnung anzeigen, aber Nginx trotzdem versuchen
|
||||||
|
$unreachable = [];
|
||||||
|
foreach ([$this->ui_domain, $this->webmail_domain, $this->mail_domain] as $host) {
|
||||||
|
if (! checkdnsrr($host, 'A') && ! checkdnsrr($host, 'AAAA')) {
|
||||||
|
$unreachable[] = $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($unreachable) {
|
||||||
|
Log::info('mailwolt-apply-domains skipped (DNS not ready)', ['unreachable' => $unreachable]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$helper = '/usr/local/sbin/mailwolt-apply-domains';
|
||||||
|
$cmd = sprintf(
|
||||||
|
'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d 2>&1',
|
||||||
|
escapeshellarg($helper),
|
||||||
|
escapeshellarg($this->ui_domain),
|
||||||
|
escapeshellarg($this->webmail_domain),
|
||||||
|
escapeshellarg($this->mail_domain),
|
||||||
|
$sslAuto ? 1 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$output = shell_exec($cmd);
|
||||||
|
$ok = str_contains((string) $output, 'fertig');
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::info('mailwolt-apply-domains', ['output' => $output]);
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
Setting::set('ssl_configured', '1');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('mailwolt-apply-domains failed', ['output' => $output]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncDomainsToEnv(): void
|
||||||
|
{
|
||||||
|
// BASE_DOMAIN aus mail_domain ableiten (mx.pxo.at → pxo.at)
|
||||||
|
$derivedBase = '';
|
||||||
|
if ($this->mail_domain) {
|
||||||
|
$parts = explode('.', strtolower(trim($this->mail_domain)));
|
||||||
|
$derivedBase = count($parts) > 2
|
||||||
|
? implode('.', array_slice($parts, 1))
|
||||||
|
: implode('.', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = $derivedBase ?: rtrim((string) config('clubird.domain.base', ''), '.');
|
||||||
|
|
||||||
|
$sub = function (string $full) use ($base): string {
|
||||||
|
$full = strtolower(trim($full));
|
||||||
|
if ($base && str_ends_with($full, '.' . $base)) {
|
||||||
|
return substr($full, 0, -(strlen($base) + 1));
|
||||||
|
}
|
||||||
|
$parts = explode('.', $full);
|
||||||
|
return count($parts) > 1 ? $parts[0] : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
$env = [
|
||||||
|
'WEBMAIL_SUB' => $this->webmail_domain ? $sub($this->webmail_domain) : '',
|
||||||
|
'WEBMAIL_DOMAIN' => $this->webmail_domain ?: '',
|
||||||
|
'UI_SUB' => $this->ui_domain ? $sub($this->ui_domain) : '',
|
||||||
|
'MTA_SUB' => $this->mail_domain ? $sub($this->mail_domain) : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($derivedBase && $derivedBase !== 'example.com') {
|
||||||
|
$env['BASE_DOMAIN'] = $derivedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeEnv($env);
|
||||||
|
|
||||||
|
Artisan::call('config:clear');
|
||||||
|
Artisan::call('route:clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyPostfixHostname(string $mailHost): void
|
||||||
|
{
|
||||||
|
$safeHost = preg_replace('/[^a-zA-Z0-9.\-]/', '', $mailHost);
|
||||||
|
if (!$safeHost) return;
|
||||||
|
|
||||||
|
$helper = '/usr/local/sbin/mailwolt-apply-hostname';
|
||||||
|
@shell_exec(sprintf('sudo -n %s %s 2>/dev/null', escapeshellarg($helper), escapeshellarg($safeHost)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.ui.system.settings-form');
|
if (!in_array($this->tab, self::VALID_TABS, true)) {
|
||||||
|
$this->tab = 'allgemein';
|
||||||
|
}
|
||||||
|
|
||||||
|
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
|
||||||
|
$backupMeta = BackupPolicy::first();
|
||||||
|
return view('livewire.ui.system.settings-form', compact('timezones', 'backupMeta'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
|
use App\Services\TotpService;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class TwoFaStatus extends Component
|
||||||
|
{
|
||||||
|
public bool $enabled = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->enabled = app(TotpService::class)->isEnabled(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('2fa-status-changed')]
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
$this->enabled = app(TotpService::class)->isEnabled(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disable(): void
|
||||||
|
{
|
||||||
|
app(TotpService::class)->disable(Auth::user());
|
||||||
|
session()->forget('2fa_verified');
|
||||||
|
$this->enabled = false;
|
||||||
|
|
||||||
|
$this->dispatch('toast', type: 'done', badge: '2FA',
|
||||||
|
title: 'TOTP deaktiviert',
|
||||||
|
text: '2FA wurde deaktiviert. Melde dich erneut an um es wieder zu aktivieren.', duration: 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.ui.system.two-fa-status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,8 @@ class UpdateCard extends Component
|
||||||
$this->recompute();
|
$this->recompute();
|
||||||
$this->progressLine = '';
|
$this->progressLine = '';
|
||||||
|
|
||||||
if ($this->running) {
|
if ($this->running || $this->lowState === 'done') {
|
||||||
|
// Update läuft oder abgeschlossen aber Post-Actions noch nicht ausgeführt
|
||||||
$this->state = 'running';
|
$this->state = 'running';
|
||||||
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
||||||
}
|
}
|
||||||
|
|
@ -66,24 +67,45 @@ class UpdateCard extends Component
|
||||||
|
|
||||||
public function runUpdate(): void
|
public function runUpdate(): void
|
||||||
{
|
{
|
||||||
// evtl. alte Einträge aufräumen
|
// State-Dateien VOR dem Start zurücksetzen — verhindert dass pollUpdate()
|
||||||
|
// das "done" vom letzten Update liest und sofort als "fertig" wertet.
|
||||||
|
@mkdir('/var/lib/mailwolt/update', 0755, true);
|
||||||
|
@file_put_contents('/var/lib/mailwolt/update/state', 'starting');
|
||||||
|
@unlink('/var/lib/mailwolt/update/rc');
|
||||||
|
|
||||||
Cache::forget('mailwolt.update_available');
|
Cache::forget('mailwolt.update_available');
|
||||||
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
|
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
|
||||||
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
||||||
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
|
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
|
||||||
|
|
||||||
// Sofort ins Running gehen
|
// Sofort ins Running gehen
|
||||||
$this->latest = null;
|
$this->latest = null;
|
||||||
$this->displayLatest = null;
|
$this->displayLatest = null;
|
||||||
$this->hasUpdate = false;
|
$this->hasUpdate = false;
|
||||||
$this->state = 'running';
|
$this->state = 'running';
|
||||||
$this->running = true;
|
$this->running = true;
|
||||||
$this->errorLine = null;
|
$this->rc = null;
|
||||||
|
$this->lowState = 'starting';
|
||||||
|
$this->errorLine = null;
|
||||||
$this->progressLine = 'Update gestartet …';
|
$this->progressLine = 'Update gestartet …';
|
||||||
|
$this->postActionsDone = false;
|
||||||
|
|
||||||
$this->recomputeUi();
|
$this->recomputeUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function openLogs(): void
|
||||||
|
{
|
||||||
|
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshVersions(): void
|
||||||
|
{
|
||||||
|
if ($this->state === 'running') return;
|
||||||
|
$this->reloadVersionsAndStatus();
|
||||||
|
$this->recompute();
|
||||||
|
$this->recomputeUi();
|
||||||
|
}
|
||||||
|
|
||||||
public function pollUpdate(): void
|
public function pollUpdate(): void
|
||||||
{
|
{
|
||||||
$this->refreshLowLevelState();
|
$this->refreshLowLevelState();
|
||||||
|
|
@ -112,22 +134,30 @@ class UpdateCard extends Component
|
||||||
|
|
||||||
if ($this->rc === 0 && !$this->postActionsDone) {
|
if ($this->rc === 0 && !$this->postActionsDone) {
|
||||||
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
|
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
|
||||||
|
@shell_exec('nohup php /var/www/mailwolt/artisan optimize >/dev/null 2>&1 &');
|
||||||
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
|
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
|
||||||
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
|
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
|
||||||
@shell_exec('nohup php /var/www/mailwolt/artisan spamav:collect >/dev/null 2>&1 &');
|
@shell_exec('nohup php /var/www/mailwolt/artisan spamav:collect >/dev/null 2>&1 &');
|
||||||
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
|
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
|
||||||
$this->postActionsDone = true;
|
$this->postActionsDone = true;
|
||||||
|
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
|
||||||
|
|
||||||
$ver = $this->displayCurrent ?? 'aktuelle Version';
|
$ver = $this->displayCurrent ?? 'aktuelle Version';
|
||||||
$this->progressLine = 'Update abgeschlossen: ' . $ver;
|
$this->progressLine = 'Update abgeschlossen: ' . $ver;
|
||||||
// Optional: NICHT sofort reloaden – Nutzer entscheidet
|
|
||||||
// $this->dispatch('reload-page', delay: 6000);
|
$this->dispatch('toast', type: 'done', badge: 'System',
|
||||||
|
title: 'Update abgeschlossen',
|
||||||
|
text: "Mailwolt wurde auf {$ver} aktualisiert.",
|
||||||
|
duration: 6000);
|
||||||
|
$this->dispatch('closeModal');
|
||||||
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
|
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
|
||||||
$this->postActionsDone = true;
|
$this->postActionsDone = true;
|
||||||
|
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
|
||||||
$this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
|
$this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
|
||||||
$this->dispatch('toast', type:'error', title:'Update fehlgeschlagen',
|
$this->dispatch('toast', type: 'error', title: 'Update fehlgeschlagen',
|
||||||
text:$this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
|
text: $this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
|
||||||
badge:'System', duration:0);
|
badge: 'System', duration: 0);
|
||||||
|
$this->dispatch('closeModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->state = 'idle';
|
$this->state = 'idle';
|
||||||
|
|
@ -200,7 +230,7 @@ class UpdateCard extends Component
|
||||||
// Update-Checker schreibt:
|
// Update-Checker schreibt:
|
||||||
// - updates:latest (normiert)
|
// - updates:latest (normiert)
|
||||||
// - updates:latest_raw (original)
|
// - updates:latest_raw (original)
|
||||||
$latNorm = Cache::get('updates:latest');
|
$latNorm = $this->normalizeVersion(Cache::get('updates:latest') ?? '');
|
||||||
$latRaw = Cache::get('updates:latest_raw');
|
$latRaw = Cache::get('updates:latest_raw');
|
||||||
|
|
||||||
// Legacy-Fallback
|
// Legacy-Fallback
|
||||||
|
|
@ -226,7 +256,9 @@ class UpdateCard extends Component
|
||||||
|
|
||||||
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
|
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
|
||||||
|
|
||||||
if (!$this->displayLatest && $latNorm) {
|
if (!$this->hasUpdate) {
|
||||||
|
$this->displayLatest = null;
|
||||||
|
} elseif (!$this->displayLatest && $latNorm) {
|
||||||
$this->displayLatest = 'v' . $latNorm;
|
$this->displayLatest = 'v' . $latNorm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -306,11 +338,10 @@ class UpdateCard extends Component
|
||||||
|
|
||||||
$this->lowState = $state !== '' ? $state : null;
|
$this->lowState = $state !== '' ? $state : null;
|
||||||
|
|
||||||
// running: solange NICHT 'done'
|
$this->running = ($this->lowState === 'running');
|
||||||
$this->running = ($this->lowState !== 'done');
|
|
||||||
|
|
||||||
// rc erst freigeben, wenn wirklich done
|
// rc freigeben wenn 'done' (Post-Actions ausstehend) oder 'complete' (bereits gelaufen)
|
||||||
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int)$rcRaw : null;
|
$this->rc = (in_array($this->lowState, ['done', 'complete']) && is_numeric($rcRaw)) ? (int)$rcRaw : null;
|
||||||
|
|
||||||
$this->progressLine = $this->tailUpdateLog();
|
$this->progressLine = $this->tailUpdateLog();
|
||||||
}
|
}
|
||||||
|
|
@ -318,27 +349,24 @@ class UpdateCard extends Component
|
||||||
protected function readCurrentVersion(): ?string
|
protected function readCurrentVersion(): ?string
|
||||||
{
|
{
|
||||||
// 1) normierte Version vom Wrapper
|
// 1) normierte Version vom Wrapper
|
||||||
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
|
$fileVer = $this->normalizeVersion(@trim(@file_get_contents(self::VERSION_FILE) ?: ''));
|
||||||
if ($v !== '') return $v;
|
|
||||||
|
|
||||||
// 2) raw -> normieren
|
// 2) git-Tag (bevorzugt wenn neuer als Datei – z.B. auf Dev-Server)
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
if ($fileVer) return $fileVer;
|
||||||
|
|
||||||
|
// 3) raw -> normieren
|
||||||
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
|
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
|
||||||
if ($raw !== '') return $this->normalizeVersion($raw);
|
if ($raw !== '') return $this->normalizeVersion($raw);
|
||||||
|
|
||||||
// 3) build.info
|
|
||||||
$build = @file_get_contents(self::BUILD_INFO);
|
|
||||||
if ($build) {
|
|
||||||
foreach (preg_split('/\R+/', $build) as $line) {
|
|
||||||
if (str_starts_with($line, 'version=')) {
|
|
||||||
$v = $this->normalizeVersion(trim(substr($line, 8)));
|
|
||||||
if ($v) return $v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Fallback auf config(app.version)
|
// 4) Fallback auf config(app.version)
|
||||||
$v = $this->normalizeVersion(config('app.version') ?: '');
|
return $this->normalizeVersion(config('app.version') ?: '') ?: null;
|
||||||
return $v ?: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function normalizeVersion(?string $v): ?string
|
protected function normalizeVersion(?string $v): ?string
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('Updates · Mailwolt')]
|
||||||
|
class UpdatePage extends Component
|
||||||
|
{
|
||||||
|
/* ===== Version ===== */
|
||||||
|
public ?string $current = null;
|
||||||
|
public ?string $latest = null;
|
||||||
|
public ?string $displayCurrent = null;
|
||||||
|
public ?string $displayLatest = null;
|
||||||
|
public bool $hasUpdate = false;
|
||||||
|
|
||||||
|
/* ===== Run state ===== */
|
||||||
|
public string $state = 'idle'; // idle | running
|
||||||
|
public bool $running = false;
|
||||||
|
public ?int $rc = null;
|
||||||
|
public ?string $lowState = null; // running | done | null
|
||||||
|
public array $logLines = [];
|
||||||
|
public int $progressPct = 0;
|
||||||
|
|
||||||
|
public bool $postActionsDone = false;
|
||||||
|
|
||||||
|
protected string $cacheStartedAtKey = 'mw.update.started_at';
|
||||||
|
protected int $failsafeSeconds = 20 * 60;
|
||||||
|
|
||||||
|
private const VERSION_FILE = '/var/lib/mailwolt/version';
|
||||||
|
private const VERSION_FILE_RAW = '/var/lib/mailwolt/version_raw';
|
||||||
|
private const BUILD_INFO = '/etc/mailwolt/build.info';
|
||||||
|
private const UPDATE_LOG = '/var/log/mailwolt-update.log';
|
||||||
|
private const STATE_DIR = '/var/lib/mailwolt/update';
|
||||||
|
|
||||||
|
/* ========================================================= */
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->reloadVersionsAndStatus();
|
||||||
|
$this->recompute();
|
||||||
|
$this->readLogLines();
|
||||||
|
|
||||||
|
if ($this->running) {
|
||||||
|
$this->state = 'running';
|
||||||
|
}
|
||||||
|
$this->recalcProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.ui.system.update-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================== Aktionen ================== */
|
||||||
|
|
||||||
|
public function checkForUpdates(): void
|
||||||
|
{
|
||||||
|
@shell_exec('php ' . base_path('artisan') . ' clubird:check-updates 2>&1');
|
||||||
|
$this->reloadVersionsAndStatus();
|
||||||
|
$this->recompute();
|
||||||
|
|
||||||
|
if ($this->hasUpdate) {
|
||||||
|
$this->dispatch('toast', type: 'done', badge: 'Updates',
|
||||||
|
title: 'Update verfügbar',
|
||||||
|
text: "Version {$this->displayLatest} ist verfügbar.",
|
||||||
|
duration: 4000);
|
||||||
|
} else {
|
||||||
|
$this->dispatch('toast', type: 'done', badge: 'Updates',
|
||||||
|
title: 'Alles aktuell',
|
||||||
|
text: 'Es sind keine Updates verfügbar.',
|
||||||
|
duration: 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runUpdate(): void
|
||||||
|
{
|
||||||
|
if ($this->running || $this->state === 'running') {
|
||||||
|
$this->dispatch('toast', type: 'warn', badge: 'Updates',
|
||||||
|
title: 'Läuft bereits',
|
||||||
|
text: 'Ein Update-Prozess ist bereits aktiv.',
|
||||||
|
duration: 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::forget('mailwolt.update_available');
|
||||||
|
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
|
||||||
|
|
||||||
|
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
|
||||||
|
|
||||||
|
$this->latest = null;
|
||||||
|
$this->displayLatest = null;
|
||||||
|
$this->hasUpdate = false;
|
||||||
|
$this->state = 'running';
|
||||||
|
$this->running = true;
|
||||||
|
$this->rc = null;
|
||||||
|
$this->postActionsDone = false;
|
||||||
|
$this->logLines = ['Update gestartet …'];
|
||||||
|
$this->progressPct = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pollStatus(): void
|
||||||
|
{
|
||||||
|
$this->refreshLowLevelState();
|
||||||
|
$this->readLogLines();
|
||||||
|
$this->recalcProgress();
|
||||||
|
|
||||||
|
if ($this->rc !== null) {
|
||||||
|
$this->running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failsafe
|
||||||
|
$started = (int) Cache::get($this->cacheStartedAtKey, 0);
|
||||||
|
if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
|
||||||
|
$this->running = false;
|
||||||
|
$this->lowState = 'done';
|
||||||
|
$this->rc ??= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->lowState === 'done') {
|
||||||
|
Cache::forget($this->cacheStartedAtKey);
|
||||||
|
usleep(300_000);
|
||||||
|
|
||||||
|
$this->reloadVersionsAndStatus();
|
||||||
|
$this->recompute();
|
||||||
|
$this->readLogLines();
|
||||||
|
$this->progressPct = 100;
|
||||||
|
|
||||||
|
if ($this->rc === 0 && !$this->postActionsDone) {
|
||||||
|
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
|
||||||
|
@shell_exec('nohup php /var/www/mailwolt/artisan config:cache >/dev/null 2>&1 &');
|
||||||
|
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
|
||||||
|
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
|
||||||
|
$this->postActionsDone = true;
|
||||||
|
|
||||||
|
$ver = $this->displayCurrent ?? 'aktuelle Version';
|
||||||
|
$this->dispatch('toast', type: 'done', badge: 'Updates',
|
||||||
|
title: 'Update abgeschlossen',
|
||||||
|
text: "Mailwolt wurde auf {$ver} aktualisiert.",
|
||||||
|
duration: 6000);
|
||||||
|
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
|
||||||
|
$this->postActionsDone = true;
|
||||||
|
$this->dispatch('toast', type: 'error', badge: 'Updates',
|
||||||
|
title: 'Update fehlgeschlagen',
|
||||||
|
text: "Rückgabecode: {$this->rc}. Bitte Log prüfen.",
|
||||||
|
duration: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearLog(): void
|
||||||
|
{
|
||||||
|
// Only admins / allow via gate if you have policy; basic protection:
|
||||||
|
@file_put_contents(self::UPDATE_LOG, '');
|
||||||
|
$this->logLines = [];
|
||||||
|
$this->dispatch('toast', type: 'done', badge: 'Updates',
|
||||||
|
title: 'Log geleert', text: '', duration: 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================== Helpers ================== */
|
||||||
|
|
||||||
|
protected function reloadVersionsAndStatus(): void
|
||||||
|
{
|
||||||
|
$this->current = $this->readCurrentVersion();
|
||||||
|
|
||||||
|
$latNorm = $this->normalizeVersion(Cache::get('updates:latest') ?? '');
|
||||||
|
$latRaw = Cache::get('updates:latest_raw');
|
||||||
|
|
||||||
|
if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) {
|
||||||
|
$latNorm = $this->normalizeVersion($legacy);
|
||||||
|
$latRaw = $legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->latest = $latNorm ?: null;
|
||||||
|
$this->displayLatest = $latRaw ?: ($latNorm ? 'v' . $latNorm : null);
|
||||||
|
|
||||||
|
$this->refreshLowLevelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function recompute(): void
|
||||||
|
{
|
||||||
|
$curNorm = $this->normalizeVersion($this->current);
|
||||||
|
$latNorm = $this->normalizeVersion($this->latest);
|
||||||
|
|
||||||
|
$this->hasUpdate = ($curNorm && $latNorm)
|
||||||
|
? version_compare($latNorm, $curNorm, '>')
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
|
||||||
|
|
||||||
|
if (!$this->hasUpdate) {
|
||||||
|
$this->displayLatest = null;
|
||||||
|
} elseif (!$this->displayLatest && $latNorm) {
|
||||||
|
$this->displayLatest = 'v' . $latNorm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === 'running');
|
||||||
|
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function readLogLines(): void
|
||||||
|
{
|
||||||
|
$p = self::UPDATE_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 ([
|
||||||
|
'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->lowState === 'done') {
|
||||||
|
$pct = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->progressPct = $pct;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function readCurrentVersion(): ?string
|
||||||
|
{
|
||||||
|
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
|
||||||
|
if ($v !== '') return $v;
|
||||||
|
|
||||||
|
// Fallback: git tag (lokal immer, production wenn Datei fehlt)
|
||||||
|
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
|
||||||
|
$v = $this->normalizeVersion($tag);
|
||||||
|
if ($v) return $v;
|
||||||
|
|
||||||
|
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
|
||||||
|
if ($raw !== '') return $this->normalizeVersion($raw);
|
||||||
|
|
||||||
|
$build = @file_get_contents(self::BUILD_INFO);
|
||||||
|
if ($build) {
|
||||||
|
foreach (preg_split('/\R+/', $build) as $line) {
|
||||||
|
if (str_starts_with($line, 'version=')) {
|
||||||
|
$v = $this->normalizeVersion(trim(substr($line, 8)));
|
||||||
|
if ($v) return $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = $this->normalizeVersion(config('app.version') ?: '');
|
||||||
|
return $v ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeVersion(?string $v): ?string
|
||||||
|
{
|
||||||
|
if ($v === null) return null;
|
||||||
|
$v = trim($v);
|
||||||
|
if ($v === '') return null;
|
||||||
|
$v = ltrim($v, "vV \t\n\r\0\x0B");
|
||||||
|
$v = preg_replace('/-.*$/', '', $v);
|
||||||
|
return $v !== '' ? $v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getBuildTimestamp(): ?string
|
||||||
|
{
|
||||||
|
$build = @file_get_contents(self::BUILD_INFO);
|
||||||
|
if (!$build) return null;
|
||||||
|
foreach (preg_split('/\R+/', $build) as $line) {
|
||||||
|
if (str_starts_with($line, 'built_at=') || str_starts_with($line, 'date=')) {
|
||||||
|
$parts = explode('=', $line, 2);
|
||||||
|
return trim($parts[1] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('Benutzer · Mailwolt')]
|
||||||
|
class UserTable extends Component
|
||||||
|
{
|
||||||
|
#[Url(as: 'q', keep: true)]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url(as: 'role', keep: true)]
|
||||||
|
public string $filterRole = '';
|
||||||
|
|
||||||
|
public function toggleActive(int $id): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
if ($user->id === auth()->id()) return;
|
||||||
|
|
||||||
|
$user->update(['is_active' => !$user->is_active]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$query = User::query()
|
||||||
|
->when($this->search !== '', fn($q) =>
|
||||||
|
$q->where(fn($q) =>
|
||||||
|
$q->where('name', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('email', 'like', "%{$this->search}%")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->when($this->filterRole !== '', fn($q) =>
|
||||||
|
$q->where('role', $this->filterRole)
|
||||||
|
)
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
$users = $query->get();
|
||||||
|
$roles = Role::cases();
|
||||||
|
|
||||||
|
return view('livewire.ui.system.user-table', compact('users', 'roles'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\System;
|
||||||
|
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dvx')]
|
||||||
|
#[Title('Webhooks · Mailwolt')]
|
||||||
|
class WebhookTable extends Component
|
||||||
|
{
|
||||||
|
public function toggleActive(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = Webhook::findOrFail($id);
|
||||||
|
$webhook->update(['is_active' => !$webhook->is_active]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.ui.system.webhook-table', [
|
||||||
|
'webhooks' => Webhook::orderByDesc('created_at')->get(),
|
||||||
|
'allEvents' => Webhook::allEvents(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ui\V2\Mail;
|
||||||
|
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class MailboxList extends Component
|
||||||
|
{
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[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
|
||||||
|
{
|
||||||
|
dispatch(fn() => Artisan::call('mail:update-stats'));
|
||||||
|
$this->dispatch('$refresh');
|
||||||
|
$this->dispatch('toast',
|
||||||
|
type: 'done',
|
||||||
|
badge: 'Mailbox',
|
||||||
|
title: 'Statistiken aktualisiert',
|
||||||
|
text: 'Die Mailbox-Statistiken wurden aktualisiert.',
|
||||||
|
duration: 5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_active', true)
|
||||||
|
->where('is_system', false)
|
||||||
|
->where('localpart', 'like', $needle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->withCount(['mailUsers as mail_users_count' => fn($u) => $u
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('is_system', false)
|
||||||
|
])
|
||||||
|
->with(['mailUsers' => function ($q) use ($hasTerm, $needle) {
|
||||||
|
$q->where('is_active', true)->where('is_system', false);
|
||||||
|
if ($hasTerm) {
|
||||||
|
$q->where('localpart', 'like', $needle);
|
||||||
|
}
|
||||||
|
$q->orderBy('localpart');
|
||||||
|
}])
|
||||||
|
->orderBy('domain')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
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_active', true)
|
||||||
|
->where('is_system', false)
|
||||||
|
->orderBy('localpart')
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($domains as $d) {
|
||||||
|
$prepared = [];
|
||||||
|
$domainActive = (bool)($d->is_active ?? true);
|
||||||
|
|
||||||
|
foreach ($d->mailUsers as $u) {
|
||||||
|
$email = $u->email ?? null;
|
||||||
|
$stats = $email ? (Setting::get("mailbox.{$email}", []) ?: []) : [];
|
||||||
|
|
||||||
|
$usedBytes = (int)($stats['used_bytes'] ?? 0);
|
||||||
|
$messageCount = (int)($stats['message_count'] ?? 0);
|
||||||
|
$usedMiB = round($usedBytes / 1048576, 2);
|
||||||
|
$quotaMiB = (int)($u->quota_mb ?? 0);
|
||||||
|
$usage = $quotaMiB > 0
|
||||||
|
? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$mailboxActive = (bool)($u->is_active ?? true);
|
||||||
|
$effective = $domainActive && $mailboxActive;
|
||||||
|
$reason = null;
|
||||||
|
if (!$effective) {
|
||||||
|
$reason = !$domainActive ? 'Domain inaktiv' : '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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalMailboxes = $domains->sum('mail_users_count');
|
||||||
|
|
||||||
|
return view('livewire.ui.v2.mail.mailbox-list', [
|
||||||
|
'domains' => $domains,
|
||||||
|
'totalMailboxes'=> $totalMailboxes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue