Compare commits
386 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 | |
|
|
792f0e3528 | |
|
|
46591669d6 | |
|
|
8690067d9c | |
|
|
beb2f863a3 | |
|
|
e833033074 | |
|
|
02e558bf4b | |
|
|
d9867db546 | |
|
|
e77d9f64bb | |
|
|
ee44ff3def | |
|
|
4d1fd64158 | |
|
|
6b0dd7d176 | |
|
|
67b6e1fa02 | |
|
|
8b4f2d9fe8 | |
|
|
e3c7e8de33 | |
|
|
385b67c3c5 | |
|
|
251f2d9c8f | |
|
|
aaae226c8d | |
|
|
3c1093311c | |
|
|
0cb7212d4b | |
|
|
ab13bab984 | |
|
|
47bca4c8de | |
|
|
9074904683 | |
|
|
659f3cb7ae | |
|
|
c8cae445c5 | |
|
|
2ef27b5e8f | |
|
|
d200e3e73f | |
|
|
e23713a5c6 | |
|
|
56e7453f8d | |
|
|
8790cffeb4 | |
|
|
2ed1d1cd36 | |
|
|
10b4872a04 | |
|
|
4645b168f7 | |
|
|
3152dc94e2 | |
|
|
59c495af84 | |
|
|
a5d3ac08c6 | |
|
|
4197b61905 | |
|
|
dd645aed68 | |
|
|
703843a9c2 | |
|
|
3108d521a5 | |
|
|
dd3f413e6a | |
|
|
09117fe1e9 | |
|
|
ddd96eb9f2 | |
|
|
da30b80056 | |
|
|
074d2da4ec | |
|
|
d76ea0b703 | |
|
|
adea3c5275 | |
|
|
9d3ca94b87 | |
|
|
520617d9b3 | |
|
|
d6f0c5d7cb | |
|
|
3b816e2198 | |
|
|
8e35c617b8 | |
|
|
d65aaf9a5d | |
|
|
a5e745ca4a | |
|
|
cdb16fc4a5 | |
|
|
988de01e82 | |
|
|
3bf7db585a | |
|
|
93c87b8d89 | |
|
|
18447dbf21 | |
|
|
c11d330c38 | |
|
|
a943b42fec | |
|
|
e997d5374d | |
|
|
ecbe123088 | |
|
|
7a636fb496 | |
|
|
f99790b1a5 | |
|
|
1bf41063ae | |
|
|
1443a693bd | |
|
|
9d2e8d2606 | |
|
|
ea12b97497 | |
|
|
64afb9d9af | |
|
|
0d41b6b658 | |
|
|
8d5cf8b3c5 | |
|
|
7e5a9a42d5 | |
|
|
b8caf3506a | |
|
|
16f3db7dcc | |
|
|
ca934a8588 | |
|
|
c84843a4a1 | |
|
|
e66c7809de | |
|
|
3504ca59c8 | |
|
|
f4e03fab82 | |
|
|
da54fbc7f3 | |
|
|
1f2634b054 | |
|
|
b8013d198b | |
|
|
facf4bf844 | |
|
|
d3a949ea80 | |
|
|
ee2c0dfd79 | |
|
|
2e40e109a3 | |
|
|
a9609d358b | |
|
|
e67c8613b3 | |
|
|
812f91202f | |
|
|
42dce7bde9 | |
|
|
c1ecc90ec3 | |
|
|
3396aab47f | |
|
|
56736a648b | |
|
|
020f55f53d | |
|
|
529979f078 | |
|
|
a8e7aedadf | |
|
|
300928851a | |
|
|
57b01654cc | |
|
|
f69ea2ddcb | |
|
|
6fc74a0906 | |
|
|
95ab70e251 | |
|
|
8a85735397 | |
|
|
7f4f2adbe5 | |
|
|
40340c709a | |
|
|
323c6032af | |
|
|
2fecb09984 | |
|
|
a25de8c7b7 | |
|
|
878dae8876 |
14
.env.example
14
.env.example
|
|
@ -31,9 +31,11 @@ SESSION_DRIVER=database
|
|||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
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
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
|
|
@ -63,3 +65,11 @@ AWS_BUCKET=
|
|||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Mailwolt domain config
|
||||
BASE_DOMAIN=example.com
|
||||
UI_SUB=admin
|
||||
MTA_SUB=mail
|
||||
# Custom webmail subdomain — users access webmail at WEBMAIL_SUB.BASE_DOMAIN
|
||||
# Leave empty to use only the path-based fallback (/webmail on main domain)
|
||||
WEBMAIL_SUB=webmail
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/backups
|
||||
/storage/pail
|
||||
/vendor
|
||||
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
|
||||
```
|
||||
|
|
@ -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,34 +6,176 @@ use Illuminate\Console\Command;
|
|||
|
||||
class CheckUpdates extends Command
|
||||
{
|
||||
protected $signature = 'mailwolt:check-updates';
|
||||
protected $description = 'Check for newer MailWolt releases via git tags';
|
||||
protected $signature = 'clubird:check-updates';
|
||||
protected $aliases = ['mailwolt:check-updates'];
|
||||
protected $description = 'Check for newer CluBird releases via git tags';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$appPath = base_path();
|
||||
$current = trim(@file_get_contents(base_path('VERSION'))) ?: '0.0.0';
|
||||
// newest tag from origin (sorted semver-friendly)
|
||||
$latest = trim(shell_exec(
|
||||
"cd {$appPath} && git fetch --tags --quiet origin && git tag --list | sort -V | tail -n1"
|
||||
) ?? '');
|
||||
$currentNorm = $this->readInstalledVersionNorm();
|
||||
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
|
||||
|
||||
// Tags haben usually ein 'v' Prefix – entfernen
|
||||
$latest = ltrim($latest, 'v');
|
||||
$appPath = base_path();
|
||||
$remoteFile = '/var/lib/mailwolt/version_remote';
|
||||
|
||||
if (!$latest) {
|
||||
// Fallback-Kette für Remote-Tags (erste funktionierende Methode gewinnt):
|
||||
// 1. mailwolt-update --check-only (sudoers: mailwolt-update)
|
||||
// 2. mailwolt-fetch-tags (sudoers: mailwolt-fetch-tags)
|
||||
// 3. Gitea/GitHub API (kein Auth nötig wenn Repo öffentlich)
|
||||
// 4. git ls-remote (klappt wenn Credentials im git-Config)
|
||||
// 5. git fetch --tags direkt
|
||||
$updateBin = '/usr/local/sbin/mailwolt-update';
|
||||
$fetchHelper = '/usr/local/sbin/mailwolt-fetch-tags';
|
||||
|
||||
$fetched = false;
|
||||
|
||||
if (file_exists($updateBin) && str_contains((string) @file_get_contents($updateBin), '--check-only')) {
|
||||
@exec('sudo -n ' . escapeshellarg($updateBin) . ' --check-only 2>/dev/null', $_, $rc);
|
||||
$fetched = ($rc === 0);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!$latestNorm) {
|
||||
cache()->forget('updates:latest');
|
||||
cache()->forget('updates:latest_raw');
|
||||
cache()->forget('mailwolt.update_available');
|
||||
$this->warn('Keine Release-Tags gefunden.');
|
||||
cache()->forget('mailwolt.update_available');
|
||||
return 0;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (version_compare($latest, $current, '>')) {
|
||||
cache()->forever('mailwolt.update_available', $latest);
|
||||
$this->info("Update verfügbar: {$latest} (installiert: {$current})");
|
||||
if ($currentNorm && version_compare($latestNorm, $currentNorm, '>')) {
|
||||
cache()->forever('updates:latest', $latestNorm);
|
||||
cache()->forever('updates:latest_raw', $latestTagRaw ?: ('v'.$latestNorm));
|
||||
cache()->forever('mailwolt.update_available', $latestNorm);
|
||||
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
|
||||
} else {
|
||||
cache()->forget('updates:latest');
|
||||
cache()->forget('updates:latest_raw');
|
||||
cache()->forget('mailwolt.update_available');
|
||||
$this->info("Aktuell (installiert: {$current}).");
|
||||
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
|
||||
}
|
||||
return 0;
|
||||
|
||||
cache()->put('updates:last_checked_at', now(), now()->addMinutes(10));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
|
||||
private function readInstalledVersionNorm(): ?string
|
||||
{
|
||||
// version_raw ist die zuverlässigste Quelle – wird vom Update-Script geschrieben
|
||||
$rawVer = $this->normalizeVersion($this->readInstalledVersionRaw() ?? '');
|
||||
|
||||
$fileVer = null;
|
||||
foreach (['/var/lib/mailwolt/version', base_path('VERSION')] as $p) {
|
||||
$raw = @trim(@file_get_contents($p) ?: '');
|
||||
if ($raw !== '') { $fileVer = $this->normalizeVersion($raw); break; }
|
||||
}
|
||||
|
||||
// version_raw bevorzugen (echter installierter Stand)
|
||||
// Nur wenn version_raw fehlt: version-Datei oder git-Tag als Fallback
|
||||
if ($rawVer) {
|
||||
return $rawVer;
|
||||
}
|
||||
|
||||
// git-Tag als letzter Fallback (funktioniert auf Dev-Servern)
|
||||
$out = [];
|
||||
@exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
|
||||
$gitVer = $this->normalizeVersion(trim($out[0] ?? ''));
|
||||
|
||||
if ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) {
|
||||
return $gitVer;
|
||||
}
|
||||
return $fileVer ?: null;
|
||||
}
|
||||
|
||||
private function readInstalledVersionRaw(): ?string
|
||||
{
|
||||
$p = '/var/lib/mailwolt/version_raw';
|
||||
$raw = @trim(@file_get_contents($p) ?: '');
|
||||
return $raw !== '' ? $raw : null;
|
||||
}
|
||||
|
||||
private function normalizeVersion(?string $v): ?string
|
||||
{
|
||||
if (!$v) return null;
|
||||
$v = trim($v);
|
||||
if ($v === '') return null;
|
||||
$v = ltrim($v, "vV \t\n\r\0\x0B");
|
||||
$v = preg_replace('/-.*$/', '', $v);
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\HealthUpdated;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CollectHealth extends Command
|
||||
{
|
||||
protected $signature = 'health:collect';
|
||||
protected $description = 'Sammelt Systemmetriken und broadcastet sie';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$system = [
|
||||
// CPU-Last (1/5/15) aus PHP
|
||||
'cpu_load_1' => $this->loadavg(0),
|
||||
'cpu_load_5' => $this->loadavg(1),
|
||||
'cpu_load_15' => $this->loadavg(2),
|
||||
|
||||
// Kerne
|
||||
'cores' => $this->cpuCores(),
|
||||
|
||||
// RAM % + Details aus /proc/meminfo (used = MemTotal - MemAvailable)
|
||||
'ram' => $this->ramInfo(),
|
||||
|
||||
// Uptime in Sekunden aus /proc/uptime
|
||||
'uptime_seconds' => $this->uptimeSeconds(),
|
||||
|
||||
// IPs aus ENV (falls gesetzt)
|
||||
'ipv4' => env('SERVER_PUBLIC_IPV4'),
|
||||
'ipv6' => env('SERVER_PUBLIC_IPV6'),
|
||||
];
|
||||
|
||||
// Optional: eine “CPU-%”-Schätzung aus Load(1m)/Cores (damit dein UI einen %-Wert hat)
|
||||
if (is_numeric($system['cpu_load_1']) && $system['cores'] > 0) {
|
||||
$system['cpu_percent'] = (int) round(
|
||||
100 * max(0, min(1, $system['cpu_load_1'] / $system['cores']))
|
||||
);
|
||||
}
|
||||
|
||||
$meta = [
|
||||
'system' => $system,
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
Cache::put('health:meta', $meta, 60);
|
||||
event(new HealthUpdated($meta));
|
||||
|
||||
$this->info('Health aktualisiert & gesendet.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function loadavg(int $index): ?float
|
||||
{
|
||||
$la = @sys_getloadavg();
|
||||
return (is_array($la) && isset($la[$index])) ? (float) $la[$index] : null;
|
||||
}
|
||||
|
||||
private function cpuCores(): int
|
||||
{
|
||||
$n = (int) @shell_exec('nproc 2>/dev/null');
|
||||
return $n > 0 ? $n : 1;
|
||||
// Alternativ: count(preg_grep('/^processor\s*:/', @file('/proc/cpuinfo') ?: [])) ?: 1;
|
||||
}
|
||||
|
||||
private function ramInfo(): array
|
||||
{
|
||||
$mem = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$kv = [];
|
||||
foreach ($mem as $ln) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $ln, $m)) {
|
||||
$kv[$m[1]] = (int) $m[2]; // kB
|
||||
}
|
||||
}
|
||||
$totalKB = $kv['MemTotal'] ?? 0;
|
||||
$availKB = $kv['MemAvailable'] ?? 0; // besser als “free”
|
||||
$usedKB = max(0, $totalKB - $availKB);
|
||||
|
||||
$totalGB = $totalKB / 1024 / 1024;
|
||||
$usedGB = $usedKB / 1024 / 1024;
|
||||
$percent = $totalKB > 0 ? (int) round(100 * $usedKB / $totalKB) : null;
|
||||
|
||||
return [
|
||||
'percent' => $percent,
|
||||
'used_gb' => (int) round($usedGB),
|
||||
'total_gb' => (int) round($totalGB),
|
||||
];
|
||||
}
|
||||
|
||||
private function uptimeSeconds(): ?int
|
||||
{
|
||||
$s = @file_get_contents('/proc/uptime');
|
||||
if (!$s) return null;
|
||||
$parts = explode(' ', trim($s));
|
||||
return isset($parts[0]) ? (int) floor((float) $parts[0]) : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MailwoltRestart extends Command
|
||||
{
|
||||
protected $signature = 'clubird:restart-services';
|
||||
protected $aliases = ['mailwolt:restart-services'];
|
||||
protected $description = 'Restart or reload MailWolt-related system services';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$units = config('clubird.units', []);
|
||||
|
||||
foreach ($units as $u) {
|
||||
$base = (string)($u['name'] ?? '');
|
||||
$unit = str_ends_with($base, '.service') ? $base : $base . '.service';
|
||||
$action = (string)($u['action'] ?? 'try-reload-or-restart');
|
||||
|
||||
$this->info("→ {$unit} ({$action})");
|
||||
|
||||
// Existiert die Unit?
|
||||
$existsCmd = sprintf('systemctl status %s >/dev/null 2>&1', escapeshellarg($unit));
|
||||
exec($existsCmd, $_o, $existsRc);
|
||||
if ($existsRc !== 0) {
|
||||
$this->line(" {$unit} existiert nicht – übersprungen");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restart/Reload via sudo (ohne Passwort)
|
||||
$cmd = sprintf('sudo -n /usr/bin/systemctl %s %s', escapeshellarg($action), escapeshellarg($unit));
|
||||
exec($cmd . ' 2>&1', $out, $rc);
|
||||
|
||||
foreach ($out as $line) {
|
||||
$this->line(" $line");
|
||||
}
|
||||
$rc === 0 ? $this->line(' [✓] Erfolgreich') : $this->warn(" [!] Fehler (rc={$rc})");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//class MailwoltRestart extends Command
|
||||
//{
|
||||
// protected $signature = 'clubird:restart-services';
|
||||
// protected $description = 'Restart or reload MailWolt-related system services';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $units = config('clubird.units', []);
|
||||
// $allowed = ['reload','restart','try-reload-or-restart'];
|
||||
//
|
||||
// foreach ($units as $u) {
|
||||
// $base = (string)($u['name'] ?? '');
|
||||
// $unit = str_ends_with($base, '.service') ? $base : $base . '.service';
|
||||
// $action = $u['action'] ?? 'try-reload-or-restart';
|
||||
// if (!in_array($action, $allowed, true)) $action = 'try-reload-or-restart';
|
||||
//
|
||||
// // existiert Unit überhaupt?
|
||||
// $probe = Process::fromShellCommandline("systemctl status $unit >/dev/null 2>&1");
|
||||
// $probe->run();
|
||||
// if ($probe->getExitCode() !== 0) {
|
||||
// $this->warn("→ {$unit} existiert nicht – übersprungen");
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// $this->info("→ {$unit} ({$action})");
|
||||
// $p = new Process(['sudo','-n','/usr/bin/systemctl',$action,$unit]);
|
||||
// $p->setTimeout(15);
|
||||
// $p->run();
|
||||
//
|
||||
// if (!$p->isSuccessful()) {
|
||||
// $this->warn(" [!] Fehler (rc={$p->getExitCode()})");
|
||||
// Log::warning('service restart failed', [
|
||||
// 'unit'=>$unit,'action'=>$action,
|
||||
// 'out'=>$p->getOutput(), 'err'=>$p->getErrorOutput()
|
||||
// ]);
|
||||
// } else {
|
||||
// $this->line(" [✓] Erfolgreich");
|
||||
// }
|
||||
// }
|
||||
// return self::SUCCESS;
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//use Illuminate\Console\Command;
|
||||
//
|
||||
//class MailwoltRestart extends Command
|
||||
//{
|
||||
// protected $signature = 'clubird:restart-services';
|
||||
// protected $description = 'Restart or reload MailWolt-related system services';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $units = config('clubird.units', []);
|
||||
//
|
||||
// foreach ($units as $u) {
|
||||
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';
|
||||
// $action = $u['action'] ?? 'try-reload-or-restart';
|
||||
//
|
||||
// $cmd = sprintf('sudo -n /usr/bin/systemctl %s %s', escapeshellarg($action), escapeshellarg($unit));
|
||||
// $this->info("→ {$unit} ({$action})");
|
||||
//
|
||||
// exec($cmd . ' 2>&1', $out, $rc);
|
||||
// foreach ($out as $line) {
|
||||
// $this->line(" $line");
|
||||
// }
|
||||
//
|
||||
// if ($rc !== 0) {
|
||||
// $this->warn(" [!] Fehler beim Neustart von {$unit} (rc={$rc})");
|
||||
// } else {
|
||||
// $this->line(" [✓] Erfolgreich");
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return self::SUCCESS;
|
||||
// }
|
||||
//}
|
||||
|
|
@ -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\"'");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Models\Setting;
|
||||
|
||||
class ProbeRbl extends Command
|
||||
{
|
||||
protected $signature = 'rbl:probe {--force : Ignoriert Intervalle und prüft sofort}';
|
||||
protected $description = 'Prüft öffentliche RBLs und speichert das Ergebnis in settings:health.rbl';
|
||||
|
||||
// Intervalle
|
||||
private int $minIntervalDays = 7; // frühestens alle 7 Tage neu prüfen
|
||||
private int $ttlDays = 14; // Ergebnis 14 Tage gültig
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$existing = (array) Setting::get('health.rbl', []) ?: [];
|
||||
$lastAt = isset($existing['checked_at']) ? \Illuminate\Support\Carbon::parse($existing['checked_at']) : null;
|
||||
$nextDue = $lastAt ? $lastAt->copy()->addDays($this->minIntervalDays) : null;
|
||||
|
||||
if (!$this->option('force') && $nextDue && $now->lt($nextDue)) {
|
||||
$this->info("Übersprungen: nächste Prüfung erst ab {$nextDue->toIso8601String()} (force mit --force).");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// IPs ermitteln (Installer-ENV bevorzugt)
|
||||
[$ipv4, $ipv6] = $this->resolvePublicIpsFromInstallerEnv();
|
||||
$ipv4 = $ipv4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
|
||||
$ipv6 = $ipv6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
|
||||
|
||||
// Kandidat für RBL (nur IPv4)
|
||||
$ip = $this->validIPv4($ipv4) ? $ipv4 : null;
|
||||
if (!$ip) {
|
||||
$file = trim((string) @file_get_contents('/etc/mailwolt/public_ip'));
|
||||
if ($this->validIPv4($file)) $ip = $file;
|
||||
}
|
||||
if (!$ip) {
|
||||
$curl = trim((string) @shell_exec('curl -fsS --max-time 2 ifconfig.me 2>/dev/null'));
|
||||
if ($this->validIPv4($curl)) $ip = $curl;
|
||||
}
|
||||
if (!$ip) $ip = '0.0.0.0';
|
||||
|
||||
// Abfragen (DNS)
|
||||
[$lists, $meta] = $this->queryRblLists($ip);
|
||||
|
||||
$payload = [
|
||||
'ip' => $ip,
|
||||
'ipv4' => $ipv4,
|
||||
'ipv6' => $ipv6,
|
||||
'hits' => count($lists),
|
||||
'lists' => array_values($lists), // nur die tatsächlich gelisteten Zonen
|
||||
'meta' => $meta, // {zone:{status, txt?}}
|
||||
'checked_at' => $now->toIso8601String(),
|
||||
'valid_until' => $now->copy()->addDays($this->ttlDays)->toIso8601String(),
|
||||
'min_next' => $now->copy()->addDays($this->minIntervalDays)->toIso8601String(),
|
||||
];
|
||||
|
||||
// Persistieren (DB) + in Redis spiegeln
|
||||
Setting::set('health.rbl', $payload);
|
||||
Cache::put('health.rbl', $payload, now()->addDays($this->ttlDays));
|
||||
|
||||
$this->info(sprintf(
|
||||
'RBL: ip=%s hits=%d lists=[%s]',
|
||||
$payload['ip'], $payload['hits'], implode(',', $payload['lists'])
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
private function resolvePublicIpsFromInstallerEnv(): array
|
||||
{
|
||||
$file = '/etc/mailwolt/installer.env';
|
||||
if (!is_readable($file)) return [null, null];
|
||||
|
||||
$ipv4 = $ipv6 = null;
|
||||
foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
if (!str_contains($line, '=')) continue;
|
||||
[$k, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$v = trim($v, " \t\n\r\0\x0B\"'");
|
||||
if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
|
||||
if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
|
||||
}
|
||||
return [ $ipv4, $ipv6 ];
|
||||
}
|
||||
|
||||
private function validIPv4(?string $ip): bool
|
||||
{
|
||||
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
|
||||
}
|
||||
private function validIPv6(?string $ip): bool
|
||||
{
|
||||
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt [listedZones, meta] zurück.
|
||||
* meta[zone] = ['status'=>'listed|clean|blocked|nx', 'txt'=>?string]
|
||||
*/
|
||||
private function queryRblLists(string $ip): array
|
||||
{
|
||||
if (!$this->validIPv4($ip)) return [[], []];
|
||||
|
||||
$rev = implode('.', array_reverse(explode('.', $ip)));
|
||||
|
||||
// Kuratierte, erreichbare Zonen (ohne kaputte Subdomains)
|
||||
$zones = [
|
||||
// Spamhaus ZEN – rate-limitiert, liefert „blocked“ bei Open Resolver
|
||||
'zen.spamhaus.org',
|
||||
// PSBL
|
||||
'psbl.surriel.com',
|
||||
// UCEPROTECT Level 1
|
||||
'dnsbl-1.uceprotect.net',
|
||||
// s5h
|
||||
'bl.s5h.net',
|
||||
];
|
||||
|
||||
$listed = [];
|
||||
$meta = [];
|
||||
|
||||
foreach ($zones as $zone) {
|
||||
$q = "{$rev}.{$zone}.";
|
||||
|
||||
$txt = @dns_get_record($q, DNS_TXT) ?: [];
|
||||
$a = @dns_get_record($q, DNS_A) ?: [];
|
||||
|
||||
// Spamhaus „blocked“ Heuristik
|
||||
$blocked = false;
|
||||
if ($zone === 'zen.spamhaus.org') {
|
||||
foreach ($a as $rec) {
|
||||
if (!empty($rec['ip']) && in_array($rec['ip'], ['127.255.255.254','127.255.255.255'], true)) {
|
||||
$blocked = true; break;
|
||||
}
|
||||
}
|
||||
if (!$blocked) {
|
||||
foreach ($txt as $rec) {
|
||||
$t = implode('', $rec['txt'] ?? []);
|
||||
if (stripos($t, 'open resolver') !== false) { $blocked = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($blocked) {
|
||||
$meta[$zone] = ['status' => 'blocked', 'txt' => 'Spamhaus blockt – nutze privaten Resolver'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasA = !empty($a);
|
||||
$hasTXT = !empty($txt);
|
||||
if ($hasA || $hasTXT) {
|
||||
$listed[] = $zone;
|
||||
$meta[$zone] = ['status' => 'listed', 'txt' => $hasTXT ? ($txt[0]['txt'][0] ?? null) : null];
|
||||
} else {
|
||||
// NXDOMAIN / sauber
|
||||
$meta[$zone] = ['status' => 'clean', 'txt' => null];
|
||||
}
|
||||
}
|
||||
|
||||
return [$listed, $meta];
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
//class ProvisionCert extends Command
|
||||
//{
|
||||
//// protected $signature = 'mailwolt:provision-cert
|
||||
//// protected $signature = 'clubird:provision-cert
|
||||
//// {domain : z.B. mail.example.com}
|
||||
//// {--email= : E-Mail für Let\'s Encrypt}
|
||||
//// {--self-signed : Statt LE ein self-signed Zertifikat erzeugen}';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SpamAvCollectCommand extends Command
|
||||
{
|
||||
protected $signature = 'spamav:collect';
|
||||
protected $description = 'Collect Rspamd/ClamAV metrics and persist to Settings (DB→Redis)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Collecting Spam/AV metrics…');
|
||||
|
||||
$rpC = '/usr/bin/rspamc';
|
||||
$rpA = '/usr/bin/rspamadm';
|
||||
|
||||
// --- RSPAMD: erst 'stat' (sicher), sonst 'counters' als Fallback ---
|
||||
$ham = $spam = $reject = 0;
|
||||
|
||||
$stat = trim(@shell_exec("$rpC -h 127.0.0.1:11334 stat 2>/dev/null") ?? '');
|
||||
if ($stat !== '') {
|
||||
$ham = preg_match('/Messages treated as ham:\s*(\d+)/i', $stat, $m) ? (int)$m[1] : 0;
|
||||
$spam = preg_match('/Messages treated as spam:\s*(\d+)/i', $stat, $m) ? (int)$m[1] : 0;
|
||||
$reject = preg_match('/Messages with action reject:\s*(\d+)/i', $stat, $m) ? (int)$m[1] : 0;
|
||||
} else {
|
||||
$cnt = trim(@shell_exec("$rpC -h 127.0.0.1:11334 counters 2>/dev/null") ?? '');
|
||||
$ham = preg_match('/\bham\s*:\s*(\d+)/i', $cnt, $m) ? (int)$m[1] : 0;
|
||||
$spam = preg_match('/\bspam\s*:\s*(\d+)/i', $cnt, $m) ? (int)$m[1] : 0;
|
||||
$reject = preg_match('/\breject\s*:\s*(\d+)/i', $cnt, $m) ? (int)$m[1] : 0;
|
||||
}
|
||||
|
||||
// Rspamd Version
|
||||
$rspamdVer = trim(@shell_exec("$rpA --version 2>/dev/null") ?? '');
|
||||
if ($rspamdVer === '') {
|
||||
$rspamdVer = trim(@shell_exec("dpkg-query -W -f='\${Version}\n' rspamd 2>/dev/null") ?? '–');
|
||||
}
|
||||
|
||||
// $clamLine = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '–';
|
||||
// $clamVer = $clamLine;
|
||||
//
|
||||
// // Versuch, Datum aus der Versionzeile zu ziehen (kein Zugriff auf freshclam.log nötig)
|
||||
// $sigUpdated = null;
|
||||
// if (preg_match('#/([^/]+ [0-9]{2} [0-9:]{8} [0-9]{4})$#', $clamLine, $m)) {
|
||||
// $sigUpdated = $m[1]; // z.B. "Sun Oct 26 09:42:43 2025"
|
||||
// }
|
||||
|
||||
// --- CLAMAV: Versionzeile inkl. Signaturdatum ausgeben ---
|
||||
$clamLine = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '–';
|
||||
|
||||
$parts = explode('/', $clamLine, 3);
|
||||
$clamVer = $parts[0] ?? 'ClamAV';
|
||||
$sigUpdated = null;
|
||||
if (isset($parts[2])) {
|
||||
$sigUpdated = trim($parts[2]);
|
||||
} else {
|
||||
// Fallback: journalctl (falls Gruppe adm)
|
||||
$jl = trim(@shell_exec('journalctl -u freshclam -n 50 --no-pager 2>/dev/null | grep -i "Database updated" | tail -n1') ?? '');
|
||||
if ($jl) $sigUpdated = $jl;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'ts' => time(),
|
||||
'ham' => $ham,
|
||||
'spam' => $spam,
|
||||
'reject' => $reject,
|
||||
'rspamdVer' => $rspamdVer ?: '–',
|
||||
'clamVer' => $clamVer,
|
||||
'sigUpdated' => $sigUpdated,
|
||||
];
|
||||
|
||||
Setting::set('spamav.metrics', $data);
|
||||
Cache::put('dash.spamav', $data, now()->addMinutes(10));
|
||||
|
||||
$this->info(sprintf(
|
||||
'ham=%d spam=%d reject=%d | rspamd=%s | clam=%s',
|
||||
$ham, $spam, $reject, $data['rspamdVer'], $clamVer
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
//
|
||||
//namespace App\Console\Commands;
|
||||
//
|
||||
//use Illuminate\Console\Command;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use App\Models\Setting;
|
||||
//
|
||||
//class SpamAvCollectCommand extends Command
|
||||
//{
|
||||
// protected $signature = 'spamav:collect';
|
||||
// protected $description = 'Collect Rspamd/ClamAV metrics and persist to Settings (DB→Redis)';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $this->info('Collecting Spam/AV metrics…');
|
||||
//
|
||||
// // Rspamd counters (kein Root nötig)
|
||||
// $out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
|
||||
// $ham = preg_match('/\bham\s*:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
|
||||
// $spam = preg_match('/\bspam\s*:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
|
||||
// $reject = preg_match('/\breject\s*:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
|
||||
//
|
||||
// // ClamAV Version + Signatur-Datum robust ohne Datei-Zugriff
|
||||
// $clamLine = trim((string) @shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null'));
|
||||
// $clamVer = $clamLine !== '' ? $clamLine : '–';
|
||||
//
|
||||
// // Aus clamd/clamscan-Output das Datum am Ende herausziehen (Format: ".../Sun Oct 26 09:42:43 2025")
|
||||
// $sigUpdated = null;
|
||||
// if ($clamLine && preg_match('#/([^/]+\d{4})$#', $clamLine, $m)) {
|
||||
// // $m[1] ist z.B. "Sun Oct 26 09:42:43 2025"
|
||||
// $sigUpdated = $m[1];
|
||||
// }
|
||||
//
|
||||
// $data = [
|
||||
// 'ts' => time(),
|
||||
// 'ham' => $ham,
|
||||
// 'spam' => $spam,
|
||||
// 'reject' => $reject,
|
||||
// 'rspamdVer' => trim((string) @shell_exec('rspamadm version 2>/dev/null')) ?: '–',
|
||||
// 'clamVer' => $clamVer,
|
||||
// 'sigUpdated' => $sigUpdated,
|
||||
// ];
|
||||
//
|
||||
// // Persistieren (DB→Redis) + kurzer UI-Cache
|
||||
// Setting::set('spamav.metrics', $data);
|
||||
// Cache::put('dash.spamav', $data, 60);
|
||||
//
|
||||
// $this->info(sprintf(
|
||||
// 'ham=%d spam=%d reject=%d | rspamd=%s | clam=%s',
|
||||
// $data['ham'], $data['spam'], $data['reject'], $data['rspamdVer'], $data['clamVer']
|
||||
// ));
|
||||
//
|
||||
// return self::SUCCESS;
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Setting;
|
||||
|
||||
class StorageProbe extends Command
|
||||
{
|
||||
protected $signature = 'health:probe-disk {target=/}';
|
||||
protected $description = 'Speichert Storage-Werte (inkl. Breakdown) in settings:health.disk';
|
||||
|
||||
// Quelle für vorberechnete Mail-Summen (kommt aus mail:update-stats)
|
||||
private const MAILBOX_TOTALS_KEY = 'mailbox.totals';
|
||||
// Wie lange dürfen Mail-Summen alt sein, bevor wir auf du-Fallback gehen (Sekunden)
|
||||
private const MAILBOX_TOTALS_STALE = 900; // 15 Min
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$target = $this->argument('target') ?: '/';
|
||||
$data = $this->probe($target);
|
||||
|
||||
Setting::set('health.disk', $data);
|
||||
Setting::set('health.disk_updated_at', now()->toIso8601String());
|
||||
|
||||
// hübsche Konsole
|
||||
$hb = function (int $bytes): string {
|
||||
$b = max(0, $bytes);
|
||||
if ($b >= 1024 ** 3) return number_format($b / 1024 ** 3, 1) . ' GB';
|
||||
if ($b >= 1024 ** 2) return number_format($b / 1024 ** 2, 2) . ' MiB';
|
||||
if ($b >= 1024) return number_format($b / 1024, 0) . ' KiB';
|
||||
return $b . ' B';
|
||||
};
|
||||
|
||||
$bd = $data['breakdown_bytes'] ?? ['system' => 0, 'mails' => 0, 'backup' => 0];
|
||||
|
||||
$this->info(sprintf(
|
||||
'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%s mails=%s backups=%s',
|
||||
$data['mount'],
|
||||
$data['total_gb'],
|
||||
$data['used_gb'],
|
||||
$data['free_gb'],
|
||||
$data['free_plus_reserve_gb'],
|
||||
$data['percent_used_total'],
|
||||
$hb((int)$bd['system']), $hb((int)$bd['mails']), $hb((int)$bd['backup'])
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function probe(string $target): array
|
||||
{
|
||||
// ── 1) df lesen (Gesamt/Frei inkl. Reserve) ──────────────────────────
|
||||
$line = trim((string)@shell_exec('LC_ALL=C df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
|
||||
|
||||
$device = $mount = '';
|
||||
$totalKb = $usedKb = $availKb = 0;
|
||||
if ($line !== '') {
|
||||
$p = preg_split('/\s+/', $line);
|
||||
if (count($p) >= 6) {
|
||||
$device = $p[0];
|
||||
$totalKb = (int)$p[1]; // TOTAL (inkl. Reserve)
|
||||
$usedKb = (int)$p[2]; // Used
|
||||
$availKb = (int)$p[3]; // Avail (User-sicht)
|
||||
$mount = $p[5];
|
||||
}
|
||||
}
|
||||
|
||||
$toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // Ganzzahl für Kopfzahlen
|
||||
$totalGb = $toGiB_i($totalKb);
|
||||
$freeGb = $toGiB_i($availKb);
|
||||
$usedGb = max(0, $totalGb - $freeGb);
|
||||
$res5Gb = (int)round($totalGb * 0.05);
|
||||
$freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
|
||||
$percentUsed = $totalGb > 0 ? (int)round($usedGb * 100 / $totalGb) : 0;
|
||||
|
||||
// Bytes für Breakdown rechnen
|
||||
$totalBytes = (int)$totalKb * 1024;
|
||||
$freeBytes = (int)$availKb * 1024;
|
||||
$usedBytes = max(0, $totalBytes - $freeBytes);
|
||||
|
||||
// ── 2) Mails: bevorzugt aus Cache (mail:update-stats) ────────────────
|
||||
[$mailUsersBytes, $mailSystemBytes] = $this->readMailTotals();
|
||||
|
||||
// ── 3) Backups schnell messen ────────────────────────────────────────
|
||||
$bytesBackup = $this->duBytesDir('/var/backups/mailwolt');
|
||||
|
||||
// ── 4) Breakdown auflösen und konsistent machen ─────────────────────
|
||||
// alles, was nicht Mails/Backups ist, dem System zuordnen
|
||||
$bytesMails = max(0, (int)$mailUsersBytes); // nur user_mail in "Mails"
|
||||
$bytesSystem = max(0, $usedBytes - ($bytesMails + $bytesBackup));
|
||||
|
||||
// Wenn Vorberechnung system_mail existiert, zähle sie explizit zum System.
|
||||
$bytesSystem += max(0, (int)$mailSystemBytes);
|
||||
|
||||
// negativ verhindern (Messrauschen)
|
||||
if ($bytesSystem < 0) {
|
||||
$bytesSystem = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'device' => $device ?: 'unknown',
|
||||
'mount' => $mount ?: $target,
|
||||
|
||||
'total_gb' => $totalGb,
|
||||
'used_gb' => $usedGb,
|
||||
'free_gb' => $freeGb,
|
||||
'reserve5_gb' => $res5Gb,
|
||||
'free_plus_reserve_gb' => $freePlusReserveGb,
|
||||
|
||||
'percent_used_total' => $percentUsed,
|
||||
|
||||
'breakdown_bytes' => [
|
||||
'system' => $bytesSystem,
|
||||
'mails' => $bytesMails,
|
||||
'backup' => $bytesBackup,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest vorberechnete Mail-Summen aus settings: mail.totals.
|
||||
* Fallback: wenn nicht vorhanden/zu alt, ermittelt Mails per du (nur dann).
|
||||
*
|
||||
* @return array{0:int,1:int} [users_bytes, system_bytes]
|
||||
*/
|
||||
private function readMailTotals(): array
|
||||
{
|
||||
$totals = (array)(Setting::get(self::MAILBOX_TOTALS_KEY, []) ?: []);
|
||||
$ts = isset($totals['updated_at']) ? strtotime((string)$totals['updated_at']) : null;
|
||||
$fresh = $ts && (time() - $ts) <= self::MAILBOX_TOTALS_STALE;
|
||||
|
||||
if ($fresh) {
|
||||
$users = (int)($totals['users_bytes'] ?? 0);
|
||||
$system = (int)($totals['system_bytes'] ?? 0);
|
||||
return [max(0, $users), max(0, $system)];
|
||||
}
|
||||
|
||||
// Fallback EINMAL: grob mails via detectMailRoot() messen
|
||||
$root = $this->detectMailRoot();
|
||||
$usersBytes = $root ? $this->duBytesDir($root) : 0;
|
||||
|
||||
return [max(0, $usersBytes), 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht, das Wurzelverzeichnis der Maildaten zu finden (Dovecot/Postfix),
|
||||
* ohne auf konkrete Setups festgenagelt zu sein.
|
||||
*/
|
||||
private function detectMailRoot(): ?string
|
||||
{
|
||||
// Dovecot bevorzugt
|
||||
$ml = trim((string)@shell_exec('doveconf -n 2>/dev/null | awk -F= \'/^mail_location/ {print $2}\''));
|
||||
if ($ml !== '') {
|
||||
if (preg_match('~^(?:maildir|mdbox|sdbox):([^%]+)~i', $ml, $m)) {
|
||||
$root = rtrim($m[1]);
|
||||
foreach (['/Maildir', '/mdbox', '/sdbox'] as $suffix) {
|
||||
if (str_ends_with($root, $suffix)) {
|
||||
$root = dirname($root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_dir($root)) return $root;
|
||||
}
|
||||
}
|
||||
|
||||
// Postfix-Konfiguration
|
||||
$vmb = trim((string)@shell_exec('postconf -n 2>/dev/null | awk -F= \'/^virtual_mailbox_base/ {print $2}\''));
|
||||
if ($vmb !== '' && is_dir($vmb)) return $vmb;
|
||||
|
||||
// Fallbacks
|
||||
foreach (['/var/vmail', '/var/mail/vhosts', '/srv/mail/vhosts', '/home/vmail'] as $cand) {
|
||||
if (is_dir($cand)) return $cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Summe in Bytes für ein Verzeichnisbaum; robust & schnell genug. */
|
||||
private function duBytesDir(string $path): int
|
||||
{
|
||||
if (!is_dir($path)) return 0;
|
||||
$out = @shell_exec('LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
|
||||
return max(0, (int)trim((string)$out));
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Console\Commands;
|
||||
//
|
||||
//use Illuminate\Console\Command;
|
||||
//use App\Models\Setting;
|
||||
//
|
||||
//class StorageProbe extends Command
|
||||
//{
|
||||
// protected $signature = 'health:probe-disk {target=/}';
|
||||
// protected $description = 'Speichert Storage-Werte (inkl. Breakdown) in settings:health.disk';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $target = $this->argument('target') ?: '/';
|
||||
// $data = $this->probe($target);
|
||||
//
|
||||
// Setting::set('health.disk', $data);
|
||||
// Setting::set('health.disk_updated_at', now()->toIso8601String());
|
||||
//
|
||||
// $hb = function (int $bytes): string {
|
||||
// $b = max(0, $bytes);
|
||||
// if ($b >= 1024**3) return number_format($b / 1024**3, 1).' GB';
|
||||
// if ($b >= 1024**2) return number_format($b / 1024**2, 2).' MiB';
|
||||
// if ($b >= 1024) return number_format($b / 1024, 0).' KiB';
|
||||
// return $b.' B';
|
||||
// };
|
||||
//
|
||||
// $this->info(sprintf(
|
||||
// 'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%s mails=%s backups=%s',
|
||||
// $data['mount'],
|
||||
// $data['total_gb'],
|
||||
// $data['used_gb'],
|
||||
// $data['free_gb'],
|
||||
// $data['free_plus_reserve_gb'],
|
||||
// $data['percent_used_total'],
|
||||
// $hb((int)$data['breakdown_bytes']['system']),
|
||||
// $hb((int)$data['breakdown_bytes']['mails']),
|
||||
// $hb((int)$data['breakdown_bytes']['backup']),
|
||||
// ));
|
||||
// return self::SUCCESS;
|
||||
// }
|
||||
//
|
||||
// private function detectMailRoot(): ?string
|
||||
// {
|
||||
// // Dovecot
|
||||
// $ml = trim((string) @shell_exec('doveconf -n 2>/dev/null | awk -F= \'/^mail_location/ {print $2}\''));
|
||||
// if ($ml !== '') {
|
||||
// if (preg_match('~^(?:maildir|mdbox|sdbox):([^%]+)~i', $ml, $m)) {
|
||||
// $root = rtrim($m[1]);
|
||||
// foreach (['/Maildir', '/mdbox', '/sdbox'] as $suffix) {
|
||||
// if (str_ends_with($root, $suffix)) { $root = dirname($root); break; }
|
||||
// }
|
||||
// if (is_dir($root)) return $root;
|
||||
// }
|
||||
// }
|
||||
// // Postfix
|
||||
// $vmb = trim((string) @shell_exec('postconf -n 2>/dev/null | awk -F= \'/^virtual_mailbox_base/ {print $2}\''));
|
||||
// if ($vmb !== '' && is_dir($vmb)) return $vmb;
|
||||
//
|
||||
// // Fallbacks
|
||||
// foreach (['/var/vmail', '/var/mail/vhosts', '/srv/mail/vhosts', '/home/vmail'] as $cand) {
|
||||
// if (is_dir($cand)) return $cand;
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// private function duBytesDir(string $path): int
|
||||
// {
|
||||
// if (!is_dir($path)) return 0;
|
||||
// $out = @shell_exec('LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
|
||||
// return max(0, (int) trim((string) $out));
|
||||
// }
|
||||
//
|
||||
// protected function probe(string $target): array
|
||||
// {
|
||||
// // --- df: Gesamtdaten des Filesystems (inkl. Reserve) -----------------
|
||||
// $line = trim((string)@shell_exec('LC_ALL=C df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
|
||||
//
|
||||
// $device = $mount = '';
|
||||
// $totalKb = $usedKb = $availKb = 0;
|
||||
//
|
||||
// if ($line !== '') {
|
||||
// $p = preg_split('/\s+/', $line);
|
||||
// if (count($p) >= 6) {
|
||||
// $device = $p[0];
|
||||
// $totalKb = (int)$p[1]; // TOTAL (inkl. Reserve)
|
||||
// $usedKb = (int)$p[2]; // Used
|
||||
// $availKb = (int)$p[3]; // Avail (User-sicht)
|
||||
// $mount = $p[5];
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // ganzzahlig (UI: Gesamt/Genutzt/Frei)
|
||||
// $toGiB_f = static fn($kb) => round(max(0, (int)$kb) / (1024 * 1024), 1); // eine Nachkommastelle (Breakdown/Legende)
|
||||
//
|
||||
// $totalGb = $toGiB_i($totalKb);
|
||||
// $freeGb = $toGiB_i($availKb); // user-verfügbar
|
||||
// $usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
|
||||
// $res5Gb = (int)round($totalGb * 0.05); // 5% von Gesamt
|
||||
// $freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
|
||||
// $percentUsed = $totalGb > 0 ? (int)round($usedGb * 100 / $totalGb) : 0;
|
||||
//
|
||||
// $duBytes = function (string $path): int {
|
||||
// if (!is_dir($path)) return 0;
|
||||
// $b = (int) trim((string) @shell_exec(
|
||||
// 'LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1'
|
||||
// ));
|
||||
// return max(0, $b);
|
||||
// };
|
||||
//
|
||||
// $mailRoot = $this->detectMailRoot();
|
||||
// $bytesMails = $mailRoot ? $this->duBytesDir($mailRoot) : 0;
|
||||
// $bytesBackup = $duBytes('/var/backups/mailwolt');
|
||||
//
|
||||
// $totalBytes = (int) $totalKb * 1024;
|
||||
// $freeBytes = (int) $availKb * 1024;
|
||||
// $usedBytes = max(0, $totalBytes - $freeBytes);
|
||||
//
|
||||
// $bytesSystem = max(0, $usedBytes - ($bytesMails + $bytesBackup));
|
||||
//
|
||||
// return [
|
||||
// 'device' => $device ?: 'unknown',
|
||||
// 'mount' => $mount ?: $target,
|
||||
//
|
||||
// 'total_gb' => $totalGb,
|
||||
// 'used_gb' => $usedGb, // inkl. Reserve
|
||||
// 'free_gb' => $freeGb, // User-sicht
|
||||
// 'reserve5_gb' => $res5Gb, // Info
|
||||
// 'free_plus_reserve_gb' => $freePlusReserveGb, // Anzeige „Frei“
|
||||
//
|
||||
// 'percent_used_total' => $percentUsed,
|
||||
//
|
||||
// // Reale Breakdown-Werte
|
||||
// 'breakdown_bytes' => [
|
||||
// 'system' => $bytesSystem,
|
||||
// 'mails' => $bytesMails,
|
||||
// 'backup' => $bytesBackup,
|
||||
// ],
|
||||
// ];
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
|
||||
//
|
||||
//namespace App\Console\Commands;
|
||||
//
|
||||
//use Illuminate\Console\Command;
|
||||
//use App\Models\Setting;
|
||||
//
|
||||
//class StorageProbe extends Command
|
||||
//{
|
||||
// protected $signature = 'health:probe-disk {target=/}';
|
||||
// protected $description = 'Speichert Storage-Werte (inkl. Frei+5%) in settings:health.disk';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $target = $this->argument('target') ?: '/';
|
||||
// $data = $this->probe($target);
|
||||
//
|
||||
// // Persistiert (DB + Redis) über dein Settings-Model
|
||||
// Setting::set('health.disk', $data);
|
||||
// Setting::set('health.disk_updated_at', now()->toIso8601String());
|
||||
//
|
||||
// $this->info(sprintf(
|
||||
// 'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d)',
|
||||
// $data['mount'],
|
||||
// $data['total_gb'],
|
||||
// $data['used_gb'],
|
||||
// $data['free_gb'],
|
||||
// $data['free_plus_reserve_gb'],
|
||||
// $data['percent_used_total'],
|
||||
// ));
|
||||
//
|
||||
// return self::SUCCESS;
|
||||
// }
|
||||
//
|
||||
// protected function probe(string $target): array
|
||||
// {
|
||||
// $line = trim((string) @shell_exec('df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
|
||||
//
|
||||
// $device = $mount = '';
|
||||
// $totalKb = $usedKb = $availKb = 0;
|
||||
//
|
||||
// if ($line !== '') {
|
||||
// $p = preg_split('/\s+/', $line);
|
||||
// if (count($p) >= 6) {
|
||||
// $device = $p[0];
|
||||
// $totalKb = (int) $p[1]; // TOTAL (inkl. Reserve)
|
||||
// $usedKb = (int) $p[2]; // Used
|
||||
// $availKb = (int) $p[3]; // Avail (User-sicht)
|
||||
// $mount = $p[5];
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $toGiB = static fn($kb) => (int) round(max(0, (int)$kb) / (1024*1024));
|
||||
//
|
||||
// $totalGb = $toGiB($totalKb);
|
||||
// $freeGb = $toGiB($availKb); // user-verfügbar
|
||||
// $usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
|
||||
// $res5Gb = (int) round($totalGb * 0.05); // 5% von Gesamt
|
||||
// $freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
|
||||
//
|
||||
// $percentUsed = $totalGb > 0 ? (int) round($usedGb * 100 / $totalGb) : 0;
|
||||
//
|
||||
// return [
|
||||
// 'device' => $device ?: 'unknown',
|
||||
// 'mount' => $mount ?: $target,
|
||||
//
|
||||
// 'total_gb' => $totalGb,
|
||||
// 'used_gb' => $usedGb, // inkl. Reserve
|
||||
// 'free_gb' => $freeGb, // User-sicht
|
||||
// 'reserve5_gb' => $res5Gb, // Info
|
||||
// 'free_plus_reserve_gb' => $freePlusReserveGb, // ← das willst du anzeigen
|
||||
//
|
||||
// 'percent_used_total' => $percentUsed, // fürs Donut (~15%)
|
||||
// 'breakdown' => [
|
||||
// 'system_gb' => 5.2, // OS, App, Logs …
|
||||
// 'mails_gb' => 2.8, // /var/mail/vhosts
|
||||
// 'backup_gb' => 1.0, // /var/backups/mailwolt (oder wohin du sicherst)
|
||||
// ],
|
||||
// ];
|
||||
// }
|
||||
//}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
{
|
||||
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
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(fn($r) => ['value' => $r->value, 'label' => $r->label()], self::cases());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
|
||||
class CertPing implements ShouldBroadcastNow
|
||||
{
|
||||
public function __construct(public string $message) {}
|
||||
|
||||
public function broadcastOn(): Channel
|
||||
{ return new Channel('system.tasks'); }
|
||||
|
||||
public function broadcastAs(): string
|
||||
{ return 'cert.ping'; }
|
||||
|
||||
public function broadcastWith(): array
|
||||
{ return ['message' => $this->message]; }
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
// App/Events/HealthUpdated.php
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HealthUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public array $meta) {}
|
||||
|
||||
public function broadcastOn(): PrivateChannel
|
||||
{
|
||||
return new PrivateChannel('health');
|
||||
}
|
||||
|
||||
// Wichtig: garantiert, dass "data" ein Objekt mit "meta" ist (kein JSON-String)
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return ['meta' => $this->meta];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Test
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('channel-name'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Session\TokenMismatchException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
public function render($request, \Throwable $e)
|
||||
{
|
||||
if ($e instanceof TokenMismatchException) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'session_expired',
|
||||
'redirect' => route('login'),
|
||||
], 419);
|
||||
}
|
||||
return redirect()
|
||||
->route('login')
|
||||
->with('warning', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
return parent::render($request, $e);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ if (!function_exists('webmail_host')) {
|
|||
if (!function_exists('mta_host')) {
|
||||
function mta_host(?int $domainId = null): string
|
||||
{
|
||||
// 1️⃣ Vorrang: Datenbankwert (z. B. aus der domains-Tabelle)
|
||||
if ($domainId) {
|
||||
try {
|
||||
$domain = \App\Models\Domain::find($domainId);
|
||||
|
|
@ -39,17 +38,25 @@ if (!function_exists('mta_host')) {
|
|||
return $domain->mta_host;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// DB evtl. noch nicht migriert — fallback auf env
|
||||
// DB evtl. noch nicht migriert — fallback auf env
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ ENV-Variante (z. B. MTA_SUB=mail01)
|
||||
$sub = env('MTA_SUB');
|
||||
if ($sub) {
|
||||
return domain_host($sub);
|
||||
}
|
||||
|
||||
// 3️⃣ Notfall: statischer Fallback
|
||||
return domain_host('mx');
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('countryFlag')) {
|
||||
function countryFlag(string $code): string
|
||||
{
|
||||
$code = strtoupper($code);
|
||||
return implode('', array_map(
|
||||
fn($char) => mb_chr(ord($char) + 127397, 'UTF-8'),
|
||||
str_split($code)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Domain;
|
||||
use App\Models\MailUser;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// ggf. Prefetch für Blade, sonst alles via Livewire
|
||||
return view('ui.dashboard.index');
|
||||
}
|
||||
|
||||
public function redesign()
|
||||
{
|
||||
$services = [
|
||||
['name' => 'Postfix', 'type' => 'MTA', 'online' => $this->isRunning('postfix')],
|
||||
['name' => 'Dovecot', 'type' => 'IMAP', 'online' => $this->isRunning('dovecot')],
|
||||
['name' => 'Rspamd', 'type' => 'Spam', 'online' => $this->isRunning('rspamd')],
|
||||
['name' => 'OpenDKIM', 'type' => 'DKIM', 'online' => $this->isRunning('opendkim')],
|
||||
['name' => 'MariaDB', 'type' => 'DB', 'online' => $this->isRunning('mariadb')],
|
||||
['name' => 'Redis', 'type' => 'Cache', 'online' => $this->isRunning('redis')],
|
||||
['name' => 'Nginx', 'type' => 'Web', 'online' => $this->isRunning('nginx')],
|
||||
['name' => 'ClamAV', 'type' => 'AV', 'online' => $this->isRunning('clamav')],
|
||||
];
|
||||
|
||||
$servicesActive = count(array_filter($services, fn($s) => $s['online']));
|
||||
$servicesTotal = count($services);
|
||||
|
||||
[$cpu, $cpuCores, $cpuMhz] = $this->getCpu();
|
||||
[$ramPercent, $ramUsed, $ramTotal] = $this->getRam();
|
||||
[$load1, $load5, $load15] = $this->getLoad();
|
||||
[$uptimeDays, $uptimeHours] = $this->getUptime();
|
||||
[$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->getDisk();
|
||||
|
||||
return view('ui.dashboard.redesign', [
|
||||
'domainCount' => Domain::count(),
|
||||
'mailboxCount' => MailUser::count(),
|
||||
'servicesActive' => $servicesActive,
|
||||
'servicesTotal' => $servicesTotal,
|
||||
'alertCount' => 0,
|
||||
'mailHostname' => gethostname() ?: 'mailserver',
|
||||
'services' => $services,
|
||||
|
||||
'cpu' => $cpu,
|
||||
'cpuCores' => $cpuCores,
|
||||
'cpuMhz' => $cpuMhz,
|
||||
|
||||
'ramPercent' => $ramPercent,
|
||||
'ramUsed' => $ramUsed,
|
||||
'ramTotal' => $ramTotal,
|
||||
|
||||
'load1' => $load1,
|
||||
'load5' => $load5,
|
||||
'load15' => $load15,
|
||||
|
||||
'uptimeDays' => $uptimeDays,
|
||||
'uptimeHours' => $uptimeHours,
|
||||
|
||||
'diskUsedPercent' => $diskUsedPercent,
|
||||
'diskUsedGb' => $diskUsedGb,
|
||||
'diskFreeGb' => $diskFreeGb,
|
||||
'diskTotalGb' => $diskTotalGb,
|
||||
|
||||
'bounceCount' => 0,
|
||||
'spamCount' => 0,
|
||||
'lastBackup' => '—',
|
||||
'backupSize' => '—',
|
||||
'backupDuration' => '—',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isRunning(string $service): bool
|
||||
{
|
||||
exec("systemctl is-active --quiet " . escapeshellarg($service) . " 2>/dev/null", $out, $code);
|
||||
return $code === 0;
|
||||
}
|
||||
|
||||
private function getCpu(): array
|
||||
{
|
||||
$cores = (int) shell_exec("nproc 2>/dev/null") ?: 1;
|
||||
$mhz = round((float) shell_exec("awk '/^cpu MHz/{sum+=$4; n++} END{if(n)print sum/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1);
|
||||
|
||||
$s1 = $this->readStat();
|
||||
usleep(400000);
|
||||
$s2 = $this->readStat();
|
||||
|
||||
// /proc/stat fields: user(0) nice(1) system(2) idle(3) iowait(4) irq(5) softirq(6) steal(7)
|
||||
$idle1 = $s1[3] + ($s1[4] ?? 0);
|
||||
$idle2 = $s2[3] + ($s2[4] ?? 0);
|
||||
$total1 = array_sum($s1);
|
||||
$total2 = array_sum($s2);
|
||||
$dt = $total2 - $total1;
|
||||
$di = $idle2 - $idle1;
|
||||
$cpu = $dt > 0 ? max(0, min(100, round(($dt - $di) / $dt * 100))) : 0;
|
||||
|
||||
return [$cpu, $cores, $mhz ?: '—'];
|
||||
}
|
||||
|
||||
private function readStat(): array
|
||||
{
|
||||
$raw = trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: '');
|
||||
$parts = preg_split('/\s+/', $raw);
|
||||
array_shift($parts); // remove 'cpu' label
|
||||
return array_map('intval', $parts);
|
||||
}
|
||||
|
||||
private function getRam(): array
|
||||
{
|
||||
$raw = shell_exec("cat /proc/meminfo 2>/dev/null") ?: '';
|
||||
preg_match('/MemTotal:\s+(\d+)/', $raw, $mt);
|
||||
preg_match('/MemAvailable:\s+(\d+)/', $raw, $ma);
|
||||
$total = isset($mt[1]) ? (int)$mt[1] : 0;
|
||||
$avail = isset($ma[1]) ? (int)$ma[1] : 0;
|
||||
$used = $total - $avail;
|
||||
$percent = $total > 0 ? round($used / $total * 100) : 0;
|
||||
return [
|
||||
$percent,
|
||||
round($used / 1048576, 1),
|
||||
round($total / 1048576, 1),
|
||||
];
|
||||
}
|
||||
|
||||
private function getLoad(): array
|
||||
{
|
||||
$raw = trim(shell_exec("cat /proc/loadavg 2>/dev/null") ?: '');
|
||||
$p = explode(' ', $raw);
|
||||
return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00'];
|
||||
}
|
||||
|
||||
private function getUptime(): array
|
||||
{
|
||||
$secs = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime 2>/dev/null") ?: 0);
|
||||
return [intdiv($secs, 86400), intdiv($secs % 86400, 3600)];
|
||||
}
|
||||
|
||||
private function getDisk(): array
|
||||
{
|
||||
$raw = trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: '');
|
||||
$p = preg_split('/\s+/', $raw);
|
||||
$total = isset($p[1]) ? (int)$p[1] : 0;
|
||||
$used = isset($p[2]) ? (int)$p[2] : 0;
|
||||
$free = isset($p[3]) ? (int)$p[3] : 0;
|
||||
$percent = $total > 0 ? round($used / $total * 100) : 0;
|
||||
return [$percent, $used, $free, $total];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticatedMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class GuestOnlyMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check()) {
|
||||
// Eingeloggt → z. B. Dashboard weiterleiten
|
||||
return redirect()->route('ui.dashboard');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 string $privPath,
|
||||
public string $dnsTxtContent,
|
||||
public string $selector = 'mwl1',
|
||||
public string $selector = 'clb1',
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
if ($this->useLetsEncrypt) {
|
||||
$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,
|
||||
'--email' => $this->email ?? '',
|
||||
]);
|
||||
|
|
@ -83,14 +83,14 @@ class ProvisionCertJob implements ShouldQueue
|
|||
|
||||
// Fallback → self-signed
|
||||
$mode = 'self-signed';
|
||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
||||
$exit = Artisan::call('clubird:provision-cert', [
|
||||
'domain' => $this->domain,
|
||||
'--self-signed' => true,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$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,
|
||||
'--self-signed' => true,
|
||||
]);
|
||||
|
|
@ -120,7 +120,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
// $task->update(['message' => 'Let’s Encrypt wird ausgeführt…']);
|
||||
// $this->syncCache($task);
|
||||
//
|
||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
||||
// $exit = Artisan::call('clubird:provision-cert', [
|
||||
// 'domain' => $this->domain,
|
||||
// '--email' => $this->email ?? '',
|
||||
// ]);
|
||||
|
|
@ -131,7 +131,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
// $this->syncCache($task);
|
||||
//
|
||||
// // Fallback: Self-Signed
|
||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
||||
// $exit = Artisan::call('clubird:provision-cert', [
|
||||
// 'domain' => $this->domain,
|
||||
// '--self-signed' => true,
|
||||
// ]);
|
||||
|
|
@ -140,7 +140,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
// $task->update(['message' => 'Self-Signed wird erstellt…']);
|
||||
// $this->syncCache($task);
|
||||
//
|
||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
||||
// $exit = Artisan::call('clubird:provision-cert', [
|
||||
// 'domain' => $this->domain,
|
||||
// '--self-signed' => true,
|
||||
// ]);
|
||||
|
|
|
|||
|
|
@ -2,63 +2,35 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting as SettingsModel;
|
||||
use App\Support\CacheVer;
|
||||
use App\Support\WoltGuard\MonitClient;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class RunHealthChecks implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 10; // safety
|
||||
public int $timeout = 10;
|
||||
public int $tries = 1;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$services = [
|
||||
$this->safe(fn() => $this->service('postfix'), ['name'=>'postfix']),
|
||||
$this->safe(fn() => $this->service('dovecot'), ['name'=>'dovecot']),
|
||||
$this->safe(fn() => $this->service('rspamd'), ['name'=>'rspamd']),
|
||||
$this->safe(fn() => $this->tcp('127.0.0.1', 6379), ['name'=>'redis']),
|
||||
$this->safe(fn() => $this->db(), ['name'=>'db']),
|
||||
// $this->safe(fn() => $this->queueWorkers(), ['name'=>'queue']),
|
||||
$this->safe(fn() => $this->tcp('127.0.0.1', 8080), ['name'=>'reverb']),
|
||||
];
|
||||
$monit = new MonitClient();
|
||||
$svcRows = $monit->services();
|
||||
|
||||
$meta = [
|
||||
'app_version' => config('app.version', app()->version()),
|
||||
'pending_migs' => $this->safe(fn() => $this->pendingMigrationsCount(), 0),
|
||||
'cert_soon' => $this->safe(fn() => $this->certificatesDue(30), ['count'=>0,'nearest_days'=>null]),
|
||||
'disk' => $this->safe(fn() => $this->diskUsage(), ['percent'=>null,'free_gb'=>null]),
|
||||
'system' => $this->systemLoad(),
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
Cache::put('health:services', array_values($services), 300);
|
||||
Cache::put('health:meta', $meta, 300);
|
||||
Cache::put('metrics:queues', [
|
||||
'outgoing' => 19,
|
||||
'incoming' => 5,
|
||||
'today_ok' => 834,
|
||||
'today_err'=> 12,
|
||||
'trend' => [
|
||||
'outgoing' => [2,1,0,4,3,5,4,0,0,0], // letzte 10 Zeitfenster
|
||||
'incoming' => [1,0,0,1,0,2,1,0,0,0],
|
||||
'ok' => [50,62,71,88,92,110,96,120,130,115],
|
||||
'err' => [1,0,0,2,1,0,1,3,2,2],
|
||||
],
|
||||
], 120);
|
||||
Cache::put('events:recent', $this->safe(fn() => $this->recentAlerts(), []), 300);
|
||||
} catch (\Throwable $e) {
|
||||
// Last-resort catch: never allow the job to fail hard
|
||||
Log::error('RunHealthChecks fatal', ['ex' => $e]);
|
||||
if (empty($svcRows)) {
|
||||
Log::warning('WG: Monit nicht erreichbar – kein Update');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = ['ts' => time(), 'rows' => $svcRows];
|
||||
Cache::put(CacheVer::k('health:services'), $payload, 300);
|
||||
SettingsModel::set('woltguard.services', $payload);
|
||||
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 */
|
||||
|
|
@ -94,9 +66,16 @@ class RunHealthChecks implements ShouldQueue
|
|||
|
||||
protected function queueWorkers(): array
|
||||
{
|
||||
$r = Process::run("systemctl is-active supervisor");
|
||||
$raw = trim($r->output() ?: $r->errorOutput());
|
||||
return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
|
||||
$okQueue = $this->probeSystemd('mailwolt-queue.service');
|
||||
$okSched = $this->probeSystemd('mailwolt-schedule.service');
|
||||
|
||||
$ok = $okQueue && $okSched;
|
||||
$raw = sprintf('queue:%s sched:%s', $okQueue ? 'active' : 'down', $okSched ? 'active' : 'down');
|
||||
|
||||
return ['name' => 'queue', 'ok' => $ok, 'raw' => $raw];
|
||||
// $r = Process::run("systemctl is-active supervisor");
|
||||
// $raw = trim($r->output() ?: $r->errorOutput());
|
||||
// return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
|
||||
}
|
||||
|
||||
protected function diskUsage(): array
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class LoginForm extends Component
|
|||
|
||||
public string $name = '';
|
||||
public string $password = '';
|
||||
public bool $remember = false;
|
||||
public ?string $error = null;
|
||||
public bool $show = false;
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ class LoginForm extends Component
|
|||
|
||||
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
|
||||
request()->session()->regenerate();
|
||||
Auth::user()->update(['last_login_at' => now()]);
|
||||
return redirect()->intended(route('ui.dashboard'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use App\Jobs\ProvisionCertJob;
|
||||
use App\Support\Setting;
|
||||
use App\Models\SystemTask;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Support\EnvWriter;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.setup')]
|
||||
#[Title('Einrichtung · Mailwolt')]
|
||||
class Wizard extends Component
|
||||
{
|
||||
public int $step = 1;
|
||||
public int $step = 1;
|
||||
public int $totalSteps = 5;
|
||||
|
||||
// Step 1
|
||||
#[Validate('required|string|min:3')]
|
||||
public string $form_domain = '';
|
||||
// Schritt 1 — System
|
||||
public string $instance_name = 'Mailwolt';
|
||||
public string $locale = 'de';
|
||||
public string $timezone = 'Europe/Berlin';
|
||||
|
||||
#[Validate('required|timezone')]
|
||||
public string $form_timezone = 'UTC';
|
||||
// Schritt 2 — Domains
|
||||
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
|
||||
#[Validate('required|string|min:3')]
|
||||
public string $form_admin_name = '';
|
||||
// Schritt 3 — Admin-Account
|
||||
public string $admin_name = '';
|
||||
public string $admin_email = '';
|
||||
public string $admin_password = '';
|
||||
public string $admin_password_confirmation = '';
|
||||
|
||||
#[Validate('required|email')]
|
||||
public string $form_admin_email = '';
|
||||
// Schritt 5 — Domain-Setup Status
|
||||
public array $domainStatus = [
|
||||
'ui' => 'pending',
|
||||
'mail' => 'pending',
|
||||
'webmail' => 'pending',
|
||||
];
|
||||
public bool $setupDone = false;
|
||||
|
||||
/** optional: wenn du Login auch über username erlauben willst */
|
||||
public ?string $form_admin_username = null;
|
||||
private const STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||
|
||||
#[Validate('required|string|min:8|same:form_admin_password_confirmation')]
|
||||
public string $form_admin_password = '';
|
||||
|
||||
public string $form_admin_password_confirmation = '';
|
||||
|
||||
// Step 3 (Zertifikat)
|
||||
public bool $form_cert_create_now = false;
|
||||
|
||||
#[Validate('required_if:form_cert_create_now,true|email')]
|
||||
public string $form_cert_email = '';
|
||||
|
||||
public function nextStep()
|
||||
public function mount()
|
||||
{
|
||||
if ($this->step === 1) {
|
||||
$this->validateOnly('form_domain');
|
||||
$this->validateOnly('form_timezone');
|
||||
} elseif ($this->step === 2) {
|
||||
$this->validate([
|
||||
'form_admin_name' => 'required|string|min:3',
|
||||
'form_admin_email' => 'required|email',
|
||||
'form_admin_password' => 'required|string|min:8|same:form_admin_password_confirmation',
|
||||
]);
|
||||
$this->instance_name = config('app.name', 'Mailwolt');
|
||||
try {
|
||||
$this->timezone = Setting::get('timezone', 'Europe/Berlin');
|
||||
$this->locale = Setting::get('locale', 'de');
|
||||
$this->ui_domain = Setting::get('ui_domain', '');
|
||||
$this->mail_domain = Setting::get('mail_domain', '');
|
||||
$this->webmail_domain = Setting::get('webmail_domain', '');
|
||||
} catch (\Throwable) {
|
||||
// DB noch nicht migriert — Standardwerte bleiben
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
public function finish()
|
||||
public function finish(): void
|
||||
{
|
||||
// Step 3 Validierung (nur wenn sofort erstellen)
|
||||
if ($this->form_cert_create_now) {
|
||||
$this->validateOnly('form_cert_email');
|
||||
}
|
||||
|
||||
// 1) Settings persistieren
|
||||
Setting::set('app.domain', $this->form_domain);
|
||||
Setting::set('app.timezone', $this->form_timezone);
|
||||
Setting::set('app.force_https', (bool)$this->form_cert_force_https);
|
||||
|
||||
// Optional: .env spiegeln, damit URLs/HMR etc. sofort passen
|
||||
$scheme = $this->form_cert_force_https ? 'https' : 'http';
|
||||
EnvWriter::set([
|
||||
'APP_HOST' => $this->form_domain,
|
||||
'APP_URL' => "{$scheme}://{$this->form_domain}",
|
||||
'APP_TIMEZONE' => $this->form_timezone,
|
||||
$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',
|
||||
]);
|
||||
|
||||
// 2) Admin anlegen/aktualisieren
|
||||
$user = User::query()
|
||||
->where('email', $this->form_admin_email)
|
||||
->when($this->form_admin_username, fn($q) => $q->orWhere('username', $this->form_admin_username)
|
||||
)
|
||||
->first();
|
||||
// Settings + .env speichern
|
||||
Setting::setMany([
|
||||
'locale' => $this->locale,
|
||||
'timezone' => $this->timezone,
|
||||
'ui_domain' => $this->ui_domain,
|
||||
'mail_domain' => $this->mail_domain,
|
||||
'webmail_domain' => $this->webmail_domain,
|
||||
'setup_completed' => '1',
|
||||
]);
|
||||
|
||||
if (!$user) {
|
||||
$user = new User();
|
||||
$user->email = $this->form_admin_email;
|
||||
if ($this->form_admin_username) {
|
||||
$user->username = $this->form_admin_username;
|
||||
}
|
||||
} else {
|
||||
// vorhandene Email/Username harmonisieren
|
||||
$user->email = $this->form_admin_email;
|
||||
if ($this->form_admin_username) {
|
||||
$user->username = $this->form_admin_username;
|
||||
}
|
||||
}
|
||||
$this->writeEnv([
|
||||
'APP_NAME' => $this->instance_name,
|
||||
'APP_HOST' => $this->ui_domain,
|
||||
'APP_URL' => 'https://' . $this->ui_domain,
|
||||
'MTA_SUB' => explode('.', $this->mail_domain)[0] ?? '',
|
||||
'WEBMAIL_DOMAIN' => $this->webmail_domain,
|
||||
]);
|
||||
|
||||
$user->name = $this->form_admin_name;
|
||||
$user->is_admin = true;
|
||||
$user->password = Hash::make($this->form_admin_password);
|
||||
$user->required_change_password = true;
|
||||
// Admin anlegen
|
||||
$user = User::where('email', $this->admin_email)->first() ?? new User();
|
||||
$user->name = $this->admin_name;
|
||||
$user->email = $this->admin_email;
|
||||
$user->password = Hash::make($this->admin_password);
|
||||
$user->role = 'admin';
|
||||
$user->save();
|
||||
|
||||
// 3) Zertifikat jetzt ausstellen (optional)
|
||||
$taskKey = 'issue-cert:' . $this->form_domain;
|
||||
|
||||
if ($this->form_cert_create_now) {
|
||||
SystemTask::updateOrCreate(
|
||||
['key' => $taskKey],
|
||||
[
|
||||
'type' => 'issue-cert',
|
||||
'status' => 'queued',
|
||||
'message' => 'Warte auf Ausführung…',
|
||||
'payload' => [
|
||||
'domain' => $this->form_domain,
|
||||
'email' => $this->form_cert_email,
|
||||
'mode' => 'letsencrypt'
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
Cache::store('redis')->put($taskKey, [
|
||||
'type' => 'issue-cert',
|
||||
'status' => 'queued',
|
||||
'message' => 'Warte auf Ausführung…',
|
||||
'payload' => [
|
||||
'domain' => $this->form_domain,
|
||||
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
|
||||
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
|
||||
],
|
||||
], now()->addMinutes(30));
|
||||
|
||||
Redis::sadd('ui:toasts', $taskKey);
|
||||
|
||||
ProvisionCertJob::dispatch(
|
||||
domain: $this->form_domain,
|
||||
email: $this->form_cert_email,
|
||||
taskKey: $taskKey,
|
||||
useLetsEncrypt: true
|
||||
);
|
||||
session()->flash('task_key', $taskKey);
|
||||
session()->flash('banner_ok', '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…');
|
||||
// Status-Verzeichnis leeren und Domain-Setup im Hintergrund starten
|
||||
@mkdir(self::STATE_DIR, 0755, true);
|
||||
@unlink(self::STATE_DIR . '/done');
|
||||
foreach (['ui', 'mail', 'webmail'] as $k) {
|
||||
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
return view('livewire.setup.wizard');
|
||||
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
|
||||
return view('livewire.setup.wizard', compact('timezones'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,292 +1,351 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Dashboard;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class HealthCard extends Component
|
||||
{
|
||||
// Rohdaten
|
||||
public array $services = [];
|
||||
public array $meta = [];
|
||||
|
||||
// UI: Balken/Segmente
|
||||
public int $barSegments = 24;
|
||||
public array $cpuSeg = [];
|
||||
public array $ramSeg = [];
|
||||
|
||||
// UI: Load & Uptime
|
||||
public string $loadBadgeText = '';
|
||||
public array $loadDots = [];
|
||||
public array $uptimeChips = [];
|
||||
public string $uptimeIcon = 'ph ph-clock';
|
||||
|
||||
// UI: Services kompakt
|
||||
public array $servicesCompact = [];
|
||||
|
||||
// Storage
|
||||
public ?int $diskUsedGb = null;
|
||||
public ?int $diskTotalGb = null;
|
||||
public ?int $diskPercent = null;
|
||||
public ?int $diskFreeGb = null;
|
||||
public array $diskSegments = [];
|
||||
public int $diskInnerSize = 160; // grauer Kreis
|
||||
public int $diskSegOuterRadius = 92; // Abstand der Ticks
|
||||
public array $diskCenterText = ['percent'=>'–%','label'=>'Speicher belegt'];
|
||||
|
||||
// Anzeige oben
|
||||
public ?string $ramSummary = null;
|
||||
public ?string $loadText = null;
|
||||
public ?string $uptimeText = null;
|
||||
public ?int $cpuPercent = null;
|
||||
public ?int $ramPercent = null;
|
||||
public ?string $updatedAtHuman = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
$this->services = Cache::get('health:services', []);
|
||||
$this->meta = Cache::get('health:meta', []);
|
||||
|
||||
$this->hydrateSystem();
|
||||
$this->hydrateDisk();
|
||||
$this->hydrateUpdatedAt();
|
||||
$this->decorateServicesCompact();
|
||||
$this->decorateDisk();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.dashboard.health-card');
|
||||
}
|
||||
|
||||
/* ---------------- Aufbereitung ---------------- */
|
||||
|
||||
protected function hydrateSystem(): void
|
||||
{
|
||||
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
|
||||
// RAM %
|
||||
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
|
||||
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
|
||||
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
|
||||
}
|
||||
|
||||
// RAM Summary "used / total"
|
||||
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
|
||||
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
|
||||
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
|
||||
|
||||
// Load (1/5/15)
|
||||
$load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
|
||||
$load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
|
||||
$load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
|
||||
|
||||
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
|
||||
if (is_array($loadMixed)) {
|
||||
$vals = array_values($loadMixed);
|
||||
$load1 ??= $this->numFloat($vals[0] ?? null);
|
||||
$load5 ??= $this->numFloat($vals[1] ?? null);
|
||||
$load15 ??= $this->numFloat($vals[2] ?? null);
|
||||
}
|
||||
$this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
|
||||
|
||||
// CPU %
|
||||
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
|
||||
if ($this->cpuPercent === null && $load1 !== null) {
|
||||
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
|
||||
$this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
|
||||
}
|
||||
|
||||
// Uptime
|
||||
$this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
|
||||
if (!$this->uptimeText) {
|
||||
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
|
||||
}
|
||||
|
||||
// Segment-Balken
|
||||
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
|
||||
$this->ramSeg = $this->buildSegments($this->ramPercent);
|
||||
|
||||
// Load: Dots (1/5/15 relativ zu Kernen)
|
||||
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
|
||||
$ratio1 = $load1 !== null ? $load1 / $cores : null;
|
||||
$ratio5 = $load5 !== null ? $load5 / $cores : null;
|
||||
$ratio15 = $load15 !== null ? $load15 / $cores : null;
|
||||
|
||||
$this->loadBadgeText = $this->loadText ?? '–';
|
||||
$this->loadDots = [
|
||||
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
|
||||
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
|
||||
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
|
||||
];
|
||||
|
||||
// Uptime Chips
|
||||
$chips = [];
|
||||
if ($this->uptimeText) {
|
||||
$d = $h = $m = null;
|
||||
if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
|
||||
if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
|
||||
if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
|
||||
if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
|
||||
if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
|
||||
if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
|
||||
}
|
||||
$this->uptimeChips = $chips ?: [['v'=>'–','u'=>'']];
|
||||
}
|
||||
|
||||
protected function hydrateDisk(): void
|
||||
{
|
||||
$disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
|
||||
|
||||
$this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
|
||||
$this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
|
||||
|
||||
// total/used berechnen, falls nicht geliefert
|
||||
if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
|
||||
$p = max(0, min(100, $this->diskPercent)) / 100;
|
||||
$estTotal = (int) round($this->diskFreeGb / (1 - $p));
|
||||
$this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
|
||||
}
|
||||
if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
|
||||
$u = $this->diskTotalGb - $this->diskFreeGb;
|
||||
$this->diskUsedGb = $u >= 0 ? $u : null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function hydrateUpdatedAt(): void
|
||||
{
|
||||
$updated = $this->meta['updated_at'] ?? null;
|
||||
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–';
|
||||
} catch (\Throwable) { $this->updatedAtHuman = '–'; }
|
||||
}
|
||||
|
||||
protected function decorateServicesCompact(): void
|
||||
{
|
||||
$nameMap = [
|
||||
'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
|
||||
'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
|
||||
'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
|
||||
'127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
|
||||
'127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
|
||||
];
|
||||
|
||||
$this->servicesCompact = collect($this->services)
|
||||
->map(function ($srv) use ($nameMap) {
|
||||
$key = (string)($srv['name'] ?? '');
|
||||
$ok = (bool) ($srv['ok'] ?? false);
|
||||
$label = $nameMap[$key]['label'] ?? ucfirst($key);
|
||||
$hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'hint' => $hint,
|
||||
'ok' => $ok,
|
||||
'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
'pillText' => $ok ? 'ok' : 'down',
|
||||
'pillClass' => $ok
|
||||
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function decorateDisk(): void
|
||||
{
|
||||
$percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
|
||||
$this->diskCenterText = [
|
||||
'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '–%',
|
||||
'label' => 'Speicher belegt',
|
||||
];
|
||||
|
||||
$count = 48;
|
||||
$activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
|
||||
$step = 360 / $count;
|
||||
|
||||
$activeClass = match (true) {
|
||||
$percent >= 90 => 'bg-rose-400',
|
||||
$percent >= 70 => 'bg-amber-300',
|
||||
default => 'bg-emerald-400',
|
||||
};
|
||||
|
||||
$this->diskSegments = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$angle = ($i * $step) - 90; // Start bei 12 Uhr
|
||||
$this->diskSegments[] = [
|
||||
'angle' => $angle,
|
||||
'class' => $i < $activeN ? $activeClass : 'bg-white/16',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Helpers ---------------- */
|
||||
|
||||
protected function pick(array $arr, array $keys)
|
||||
{
|
||||
foreach ($keys as $k) {
|
||||
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
|
||||
return $arr[$k];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
|
||||
protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
|
||||
|
||||
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
|
||||
{
|
||||
if ($l1 !== null || $l5 !== null || $l15 !== null) {
|
||||
$fmt = fn($x) => $x === null ? '–' : number_format($x, 2);
|
||||
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
|
||||
}
|
||||
return $fallback ?: null;
|
||||
}
|
||||
|
||||
protected function secondsToHuman(int $s): string
|
||||
{
|
||||
$d = intdiv($s, 86400); $s %= 86400;
|
||||
$h = intdiv($s, 3600); $s %= 3600;
|
||||
$m = intdiv($s, 60);
|
||||
if ($d > 0) return "{$d}d {$h}h";
|
||||
if ($h > 0) return "{$h}h {$m}m";
|
||||
return "{$m}m";
|
||||
}
|
||||
|
||||
protected function toneByPercent(?int $p): string {
|
||||
if ($p === null) return 'white';
|
||||
if ($p >= 90) return 'rose';
|
||||
if ($p >= 70) return 'amber';
|
||||
return 'emerald';
|
||||
}
|
||||
|
||||
protected function buildSegments(?int $percent): array {
|
||||
$n = max(6, $this->barSegments);
|
||||
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
|
||||
$tone = $this->toneByPercent($percent);
|
||||
$fillCls = match($tone) {
|
||||
'rose' => 'bg-rose-500/80',
|
||||
'amber' => 'bg-amber-400/80',
|
||||
'emerald'=> 'bg-emerald-500/80',
|
||||
default => 'bg-white/20',
|
||||
};
|
||||
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
|
||||
}
|
||||
|
||||
protected function loadDotClass(?float $ratio): string {
|
||||
if ($ratio === null) return 'bg-white/25';
|
||||
if ($ratio >= 0.9) return 'bg-rose-500';
|
||||
if ($ratio >= 0.7) return 'bg-amber-400';
|
||||
return 'bg-emerald-400';
|
||||
}
|
||||
}
|
||||
//
|
||||
//namespace App\Livewire\Ui\Dashboard;
|
||||
//
|
||||
//use Carbon\Carbon;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class HealthCard extends Component
|
||||
//{
|
||||
// // Rohdaten
|
||||
// public array $services = [];
|
||||
// public array $meta = [];
|
||||
//
|
||||
// // UI: Balken/Segmente
|
||||
// public int $barSegments = 24;
|
||||
// public array $cpuSeg = [];
|
||||
// public array $ramSeg = [];
|
||||
//
|
||||
// // UI: Load & Uptime
|
||||
// public string $loadBadgeText = '';
|
||||
// public array $loadDots = [];
|
||||
// public array $uptimeChips = [];
|
||||
// public string $uptimeIcon = 'ph ph-clock';
|
||||
//
|
||||
// // UI: Services kompakt
|
||||
// public array $servicesCompact = [];
|
||||
//
|
||||
// // Storage
|
||||
// public ?int $diskUsedGb = null;
|
||||
// public ?int $diskTotalGb = null;
|
||||
// public ?int $diskPercent = null;
|
||||
// public ?int $diskFreeGb = null;
|
||||
// public array $diskSegments = [];
|
||||
// public int $diskInnerSize = 160; // grauer Kreis
|
||||
// public int $diskSegOuterRadius = 92; // Abstand der Ticks
|
||||
// public array $diskCenterText = ['percent'=>'–%','label'=>'Speicher belegt'];
|
||||
//
|
||||
// // Anzeige oben
|
||||
// public ?string $ramSummary = null;
|
||||
// public ?string $loadText = null;
|
||||
// public ?string $uptimeText = null;
|
||||
// public ?int $cpuPercent = null;
|
||||
// public ?int $ramPercent = null;
|
||||
// public ?string $updatedAtHuman = null;
|
||||
//
|
||||
// public $guardOk = [];
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->loadData();
|
||||
// }
|
||||
//
|
||||
// public function loadData(): void
|
||||
// {
|
||||
// $this->services = Cache::get('health:services', []);
|
||||
// $this->meta = Cache::get('health:meta', []);
|
||||
//
|
||||
// $this->hydrateSystem();
|
||||
// $this->hydrateDisk();
|
||||
// $this->hydrateUpdatedAt();
|
||||
// $this->decorateServicesCompact();
|
||||
// $this->decorateDisk();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.dashboard.health-card');
|
||||
// }
|
||||
//
|
||||
// /* ---------------- Aufbereitung ---------------- */
|
||||
//
|
||||
// protected function hydrateSystem(): void
|
||||
// {
|
||||
// $sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
//
|
||||
// // RAM %
|
||||
// $this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
|
||||
// if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
|
||||
// $this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
|
||||
// }
|
||||
//
|
||||
// // RAM Summary "used / total"
|
||||
// $used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
|
||||
// $tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
|
||||
// $this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
|
||||
//
|
||||
// // Load (1/5/15)
|
||||
// $load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
|
||||
// $load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
|
||||
// $load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
|
||||
//
|
||||
// $loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
|
||||
// if (is_array($loadMixed)) {
|
||||
// $vals = array_values($loadMixed);
|
||||
// $load1 ??= $this->numFloat($vals[0] ?? null);
|
||||
// $load5 ??= $this->numFloat($vals[1] ?? null);
|
||||
// $load15 ??= $this->numFloat($vals[2] ?? null);
|
||||
// }
|
||||
// $this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
|
||||
//
|
||||
// // CPU %
|
||||
// $this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
|
||||
// if ($this->cpuPercent === null && $load1 !== null) {
|
||||
// $cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
|
||||
// $this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
|
||||
// }
|
||||
//
|
||||
// // Uptime
|
||||
// $this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
|
||||
// if (!$this->uptimeText) {
|
||||
// $uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
// if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
|
||||
// }
|
||||
//
|
||||
// // Segment-Balken
|
||||
// $this->cpuSeg = $this->buildSegments($this->cpuPercent);
|
||||
// $this->ramSeg = $this->buildSegments($this->ramPercent);
|
||||
//
|
||||
// // Load: Dots (1/5/15 relativ zu Kernen)
|
||||
// $cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
|
||||
// $ratio1 = $load1 !== null ? $load1 / $cores : null;
|
||||
// $ratio5 = $load5 !== null ? $load5 / $cores : null;
|
||||
// $ratio15 = $load15 !== null ? $load15 / $cores : null;
|
||||
//
|
||||
// $this->loadBadgeText = $this->loadText ?? '–';
|
||||
// $this->loadDots = [
|
||||
// ['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
|
||||
// ['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
|
||||
// ['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
|
||||
// ];
|
||||
//
|
||||
// // Uptime Chips
|
||||
// $chips = [];
|
||||
// if ($this->uptimeText) {
|
||||
// $d = $h = $m = null;
|
||||
// if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
|
||||
// if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
|
||||
// if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
|
||||
// if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
|
||||
// if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
|
||||
// if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
|
||||
// }
|
||||
// $this->uptimeChips = $chips ?: [['v'=>'–','u'=>'']];
|
||||
// }
|
||||
//
|
||||
// protected function hydrateDisk(): void
|
||||
// {
|
||||
// $disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
|
||||
//
|
||||
// $this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
|
||||
// $this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
|
||||
//
|
||||
// // total/used berechnen, falls nicht geliefert
|
||||
// if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
|
||||
// $p = max(0, min(100, $this->diskPercent)) / 100;
|
||||
// $estTotal = (int) round($this->diskFreeGb / (1 - $p));
|
||||
// $this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
|
||||
// }
|
||||
// if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
|
||||
// $u = $this->diskTotalGb - $this->diskFreeGb;
|
||||
// $this->diskUsedGb = $u >= 0 ? $u : null;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// protected function hydrateUpdatedAt(): void
|
||||
// {
|
||||
// $updated = $this->meta['updated_at'] ?? null;
|
||||
// try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–';
|
||||
// } catch (\Throwable) { $this->updatedAtHuman = '–'; }
|
||||
// }
|
||||
//
|
||||
// protected function decorateServicesCompact(): void
|
||||
// {
|
||||
// $nameMap = [
|
||||
// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
|
||||
// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
|
||||
// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
|
||||
// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
|
||||
// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
|
||||
// ];
|
||||
//
|
||||
//// $nameMap = [
|
||||
//// // --- Mail ---
|
||||
//// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA / Versand'],
|
||||
//// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP / POP3'],
|
||||
//// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
//// 'clamav' => ['label' => 'ClamAV', 'hint' => 'Virenscanner'],
|
||||
////
|
||||
//// // --- Daten & Cache ---
|
||||
//// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL / MariaDB'],
|
||||
//// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache / Queue'],
|
||||
////
|
||||
//// // --- Web / PHP ---
|
||||
//// 'php8.2-fpm' => ['label' => 'PHP-FPM', 'hint' => 'PHP Runtime'],
|
||||
//// 'nginx' => ['label' => 'Nginx', 'hint' => 'Webserver'],
|
||||
////
|
||||
//// // --- MailWolt spezifisch ---
|
||||
//// 'mailwolt-queue' => ['label' => 'MailWolt Queue', 'hint' => 'Job Worker'],
|
||||
//// 'mailwolt-schedule'=> ['label' => 'MailWolt Schedule','hint' => 'Task Scheduler'],
|
||||
////
|
||||
//// // --- Sonstige Infrastruktur ---
|
||||
//// 'fail2ban' => ['label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection'],
|
||||
//// 'systemd-journald'=> ['label' => 'System Logs', 'hint' => 'Logging / Journal'],
|
||||
////
|
||||
//// // --- WebSocket & Echtzeit ---
|
||||
//// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket Server'],
|
||||
//// ];
|
||||
//
|
||||
// $existing = collect($this->services)->keyBy('name');
|
||||
//
|
||||
// $this->servicesCompact = collect($nameMap)
|
||||
// ->map(function ($meta, $key) use ($existing) {
|
||||
// $srv = $existing->get($key, []);
|
||||
// $ok = (bool)($srv['ok'] ?? false);
|
||||
//
|
||||
// return [
|
||||
// 'label' => $meta['label'],
|
||||
// 'hint' => $meta['hint'],
|
||||
// 'ok' => $ok,
|
||||
//
|
||||
// // Punktfarbe
|
||||
// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
//
|
||||
// // ✅ Bessere Status-Texte
|
||||
// 'pillText' => $ok ? 'Aktiv' : 'Offline',
|
||||
//
|
||||
// // Farbe für Pill
|
||||
// 'pillClass' => $ok
|
||||
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
// ];
|
||||
// })
|
||||
// ->values()
|
||||
// ->all();
|
||||
//
|
||||
// $this->guardOk = collect($this->services)->every(
|
||||
// fn($s) => (bool)($s['ok'] ?? false)
|
||||
// );
|
||||
//// $this->servicesCompact = collect($this->services)
|
||||
//// ->map(function ($srv) use ($nameMap) {
|
||||
//// $key = (string)($srv['name'] ?? '');
|
||||
//// $ok = (bool) ($srv['ok'] ?? false);
|
||||
//// $label = $nameMap[$key]['label'] ?? ucfirst($key);
|
||||
//// $hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
|
||||
////
|
||||
//// var_dump($srv);
|
||||
//// return [
|
||||
//// 'label' => $label,
|
||||
//// 'hint' => $hint,
|
||||
//// 'ok' => $ok,
|
||||
//// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
//// 'pillText' => $ok ? 'Läuft' : 'Down',
|
||||
//// 'pillClass' => $ok
|
||||
//// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
//// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
//// ];
|
||||
//// })
|
||||
//// ->values()
|
||||
//// ->all();
|
||||
// }
|
||||
//
|
||||
// protected function decorateDisk(): void
|
||||
// {
|
||||
// $percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
|
||||
// $this->diskCenterText = [
|
||||
// 'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '–%',
|
||||
// 'label' => 'Speicher belegt',
|
||||
// ];
|
||||
//
|
||||
// $count = 48;
|
||||
// $activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
|
||||
// $step = 360 / $count;
|
||||
//
|
||||
// $activeClass = match (true) {
|
||||
// $percent >= 90 => 'bg-rose-400',
|
||||
// $percent >= 70 => 'bg-amber-300',
|
||||
// default => 'bg-emerald-400',
|
||||
// };
|
||||
//
|
||||
// $this->diskSegments = [];
|
||||
// for ($i = 0; $i < $count; $i++) {
|
||||
// $angle = ($i * $step) - 90; // Start bei 12 Uhr
|
||||
// $this->diskSegments[] = [
|
||||
// 'angle' => $angle,
|
||||
// 'class' => $i < $activeN ? $activeClass : 'bg-white/16',
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /* ---------------- Helpers ---------------- */
|
||||
//
|
||||
// protected function pick(array $arr, array $keys)
|
||||
// {
|
||||
// foreach ($keys as $k) {
|
||||
// if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
|
||||
// return $arr[$k];
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
|
||||
// protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
|
||||
//
|
||||
// protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
|
||||
// {
|
||||
// if ($l1 !== null || $l5 !== null || $l15 !== null) {
|
||||
// $fmt = fn($x) => $x === null ? '–' : number_format($x, 2);
|
||||
// return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
|
||||
// }
|
||||
// return $fallback ?: null;
|
||||
// }
|
||||
//
|
||||
// protected function secondsToHuman(int $s): string
|
||||
// {
|
||||
// $d = intdiv($s, 86400); $s %= 86400;
|
||||
// $h = intdiv($s, 3600); $s %= 3600;
|
||||
// $m = intdiv($s, 60);
|
||||
// if ($d > 0) return "{$d}d {$h}h";
|
||||
// if ($h > 0) return "{$h}h {$m}m";
|
||||
// return "{$m}m";
|
||||
// }
|
||||
//
|
||||
// protected function toneByPercent(?int $p): string {
|
||||
// if ($p === null) return 'white';
|
||||
// if ($p >= 90) return 'rose';
|
||||
// if ($p >= 70) return 'amber';
|
||||
// return 'emerald';
|
||||
// }
|
||||
//
|
||||
// protected function buildSegments(?int $percent): array {
|
||||
// $n = max(6, $this->barSegments);
|
||||
// $filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
|
||||
// $tone = $this->toneByPercent($percent);
|
||||
// $fillCls = match($tone) {
|
||||
// 'rose' => 'bg-rose-500/80',
|
||||
// 'amber' => 'bg-amber-400/80',
|
||||
// 'emerald'=> 'bg-emerald-500/80',
|
||||
// default => 'bg-white/20',
|
||||
// };
|
||||
// return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
|
||||
// }
|
||||
//
|
||||
// protected function loadDotClass(?float $ratio): string {
|
||||
// if ($ratio === null) return 'bg-white/25';
|
||||
// if ($ratio >= 0.9) return 'bg-rose-500';
|
||||
// if ($ratio >= 0.7) return 'bg-amber-400';
|
||||
// return 'bg-emerald-400';
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class TopBar extends Component
|
|||
public function mount(): void
|
||||
{
|
||||
// Domains + Zertifikate (passe an deinen Storage an)
|
||||
$this->domainsCount = Domain::count(); // oder aus Cache/Repo
|
||||
$this->domainsCount = Domain::where('is_server', false)->where('is_system', false)->count(); // oder aus Cache/Repo
|
||||
|
||||
// Beispiel: Domain::select('cert_expires_at','cert_issuer')...
|
||||
// -> hier simple Annahme: im Cache 'domains:certs' liegt [{domain, days_left, type}]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class DkimStatus extends Component
|
|||
?: optional(
|
||||
$domain->dkimKeys()->where('is_active', true)->latest()->first()
|
||||
)->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
|
||||
// {
|
||||
// $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', [
|
||||
// 'domain' => $this->domain->domain,
|
||||
|
|
@ -79,7 +79,7 @@ class DkimStatus extends Component
|
|||
public function regenerate(?string $selector = null): void
|
||||
{
|
||||
$selector = $selector
|
||||
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
|
||||
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
|
||||
|
||||
Log::info('DKIM regenerate() CLICKED', [
|
||||
'domain' => $this->domain->domain,
|
||||
|
|
@ -126,7 +126,7 @@ class DkimStatus extends Component
|
|||
|
||||
public function render(): View
|
||||
{
|
||||
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
|
||||
|
||||
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->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);
|
||||
|
||||
// Speicherpool-Grenze
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,14 +30,14 @@ class AliasList extends Component
|
|||
{
|
||||
// nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente)
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
|
||||
$aliasId,
|
||||
'aliasId' => $aliasId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function openAliasDelete(int $aliasId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
|
||||
$aliasId,
|
||||
'aliasId' => $aliasId,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +48,7 @@ class AliasList extends Component
|
|||
|
||||
$domains = Domain::query()
|
||||
->where('is_system', false)
|
||||
->where('is_server', false)
|
||||
->withCount(['mailAliases'])
|
||||
->with([
|
||||
'mailAliases' => fn ($q) => $q
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class BounceCard extends Component
|
||||
{
|
||||
public int $bounces24h = 0;
|
||||
public array $topCodes = []; // [['code'=>'550', 'count'=>12], ...]
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.mail.bounce-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
// Parse last 2000 lines of mail log for status=bounced / defer / reject
|
||||
$log = @shell_exec('tail -n 2000 /var/log/mail.log 2>/dev/null') ?? '';
|
||||
$this->bounces24h = preg_match_all('/status=bounced/i', $log);
|
||||
|
||||
$counts = [];
|
||||
if ($log) {
|
||||
if (preg_match_all('/\s([45]\d\d)\s/m', $log, $m)) {
|
||||
foreach ($m[1] as $c) $counts[$c] = ($counts[$c] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
arsort($counts);
|
||||
$this->topCodes = collect($counts)->take(5)->map(fn($v,$k)=>['code'=>$k,'count'=>$v])->values()->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DnsHealthCard extends Component
|
||||
{
|
||||
public array $rows = []; // [{id,name,ok,missing:[...]}]
|
||||
public string $mtaHost = '';
|
||||
public bool $tlsa = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.mail.dns-health-card');
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function openDnsModal(int $domainId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $domainId]);
|
||||
}
|
||||
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// [$this->mtaHost, $this->tlsa, $this->rows] = Cache::remember('dash.dnshealth.v2', $force ? 1 : 600, function () {
|
||||
//
|
||||
// $base = trim((string) env('BASE_DOMAIN', ''));
|
||||
// $mtaSub = trim((string) env('MTA_SUB', 'mx'));
|
||||
// $mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub; // z.B. mx.nexlab.at
|
||||
//
|
||||
// // ▼ gewünschter Filter:
|
||||
// $domains = Domain::query()
|
||||
// ->where('is_active', true)
|
||||
// ->where('is_server', false) // <<< Server-Domain sauber ausschließen
|
||||
// ->orderBy('domain')
|
||||
// ->get(['id', 'domain']);
|
||||
//
|
||||
// $rows = [];
|
||||
// foreach ($domains as $d) {
|
||||
// $dom = $d->domain;
|
||||
//
|
||||
// // DKIM-Selector ermitteln: .env > DB > Fallback null
|
||||
// $selector = trim((string) env('DKIM_SELECTOR', ''));
|
||||
// if ($selector === '') {
|
||||
// $selector = (string) DB::table('dkim_keys')
|
||||
// ->where('domain_id', $d->id)
|
||||
// ->where('is_active', 1)
|
||||
// ->orderByDesc('id')
|
||||
// ->value('selector') ?? '';
|
||||
// }
|
||||
//
|
||||
// $missing = [];
|
||||
//
|
||||
// if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
|
||||
// if (!$this->hasSpf($dom)) $missing[] = 'SPF';
|
||||
// if (!$this->hasDkim($dom, $selector)) $missing[] = 'DKIM';
|
||||
// if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
|
||||
//
|
||||
// $rows[] = [
|
||||
// 'id' => (int) $d->id,
|
||||
// 'name' => $dom,
|
||||
// 'ok' => empty($missing),
|
||||
// 'missing' => $missing,
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// // Hostweites TLSA (nur Hinweis)
|
||||
// $tlsa = $this->hasTlsa("_25._tcp.$mtaHost") || $this->hasTlsa("_465._tcp.$mtaHost") || $this->hasTlsa("_587._tcp.$mtaHost");
|
||||
//
|
||||
// return [$mtaHost, $tlsa, $rows];
|
||||
// });
|
||||
// }
|
||||
|
||||
protected function load(): void
|
||||
{
|
||||
$base = trim((string) env('BASE_DOMAIN', ''));
|
||||
$mtaSub = trim((string) env('MTA_SUB', 'mx'));
|
||||
$mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub; // z.B. mx.nexlab.at
|
||||
|
||||
// nur aktive, NICHT-Server-Domains (System + Custom, solange is_server = false)
|
||||
$domains = Domain::query()
|
||||
->where('is_active', true)
|
||||
->where('is_server', false)
|
||||
->orderBy('domain')
|
||||
->get(['id','domain']);
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($domains as $d) {
|
||||
$dom = $d->domain;
|
||||
|
||||
// DKIM-Selector: .env > DB > leer
|
||||
$selector = trim((string) env('DKIM_SELECTOR', ''));
|
||||
if ($selector === '') {
|
||||
$selector = (string) DB::table('dkim_keys')
|
||||
->where('domain_id', $d->id)
|
||||
->where('is_active', 1)
|
||||
->orderByDesc('id')
|
||||
->value('selector') ?? '';
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
|
||||
if (!$this->hasSpf($dom)) $missing[] = 'SPF';
|
||||
if (!$this->hasDkim($dom, $selector)) $missing[] = 'DKIM';
|
||||
if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
|
||||
|
||||
$rows[] = [
|
||||
'id' => (int) $d->id,
|
||||
'name' => $dom,
|
||||
'ok' => empty($missing),
|
||||
'missing' => $missing,
|
||||
];
|
||||
}
|
||||
|
||||
// Hostweites TLSA (nur Info)
|
||||
$tlsa = $this->hasTlsa("_25._tcp.$mtaHost")
|
||||
|| $this->hasTlsa("_465._tcp.$mtaHost")
|
||||
|| $this->hasTlsa("_587._tcp.$mtaHost");
|
||||
|
||||
$this->mtaHost = $mtaHost;
|
||||
$this->tlsa = $tlsa;
|
||||
$this->rows = $rows;
|
||||
}
|
||||
/* ── DNS Helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
protected function digShort(string $type, string $name): string
|
||||
{
|
||||
$cmd = "timeout 2 dig +short " . escapeshellarg($name) . ' ' . escapeshellarg(strtoupper($type)) . " 2>/dev/null";
|
||||
return (string) @shell_exec($cmd) ?: '';
|
||||
}
|
||||
|
||||
protected function hasTxt(string $name): bool
|
||||
{
|
||||
return trim($this->digShort('TXT', $name)) !== '';
|
||||
}
|
||||
|
||||
protected function hasTlsa(string $name): bool
|
||||
{
|
||||
return trim($this->digShort('TLSA', $name)) !== '';
|
||||
}
|
||||
|
||||
protected function hasSpf(string $domain): bool
|
||||
{
|
||||
$out = $this->digShort('TXT', $domain);
|
||||
foreach (preg_split('/\R+/', trim($out)) as $line) {
|
||||
if (stripos($line, 'v=spf1') !== false) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ▼ DKIM: bevorzugt konkreten Selector prüfen; wenn leer, versuche Policy (_domainkey)
|
||||
protected function hasDkim(string $domain, string $selector = ''): bool
|
||||
{
|
||||
if ($selector !== '' && $this->hasTxt("{$selector}._domainkey.$domain")) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: irgendein _domainkey-TXT vorhanden
|
||||
return $this->hasTxt("_domainkey.$domain");
|
||||
}
|
||||
|
||||
// protected function hasDkim(string $domain, string $selector = ''): bool
|
||||
// {
|
||||
// if ($selector !== '') {
|
||||
// return $this->hasTxt("{$selector}._domainkey.$domain");
|
||||
// }
|
||||
// // Manche Betreiber veröffentlichen eine Policy auf _domainkey.<dom>
|
||||
// return $this->hasTxt("_domainkey.$domain");
|
||||
// }
|
||||
|
||||
protected function mxPointsTo(string $domain, array $allowedHosts): bool
|
||||
{
|
||||
$out = $this->digShort('MX', $domain);
|
||||
if ($out === '') return false;
|
||||
|
||||
$targets = [];
|
||||
foreach (preg_split('/\R+/', trim($out)) as $line) {
|
||||
if (preg_match('~\s+([A-Za-z0-9\.\-]+)\.?$~', trim($line), $m)) {
|
||||
$targets[] = strtolower($m[1]);
|
||||
}
|
||||
}
|
||||
if (!$targets) return false;
|
||||
|
||||
$allowed = array_map(fn ($h) => strtolower(rtrim($h, '.')), $allowedHosts);
|
||||
foreach ($targets as $t) {
|
||||
$t = rtrim($t, '.');
|
||||
if (in_array($t, $allowed, true)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use Livewire\Attributes\On;
|
||||
//use Livewire\Component;
|
||||
//use App\Models\Domain;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class DnsHealthCard extends Component
|
||||
//{
|
||||
// public array $rows = []; // [{id,name,ok,missing:[...]}]
|
||||
// public string $mtaHost = ''; // z.B. mx.nexlab.at
|
||||
// public bool $tlsa = false; // hostweit
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.dns-health-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// public function openDnsModal(int $domainId): void
|
||||
// {
|
||||
// $this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $domainId]);
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// [$this->mtaHost, $this->tlsa, $this->rows] = Cache::remember('dash.dnshealth.v1', $force ? 1 : 600, function () {
|
||||
//
|
||||
// $base = trim((string)env('BASE_DOMAIN', ''));
|
||||
// $mtaSub = trim((string)env('MTA_SUB', 'mx'));
|
||||
// $mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub;
|
||||
//
|
||||
// $rows = [];
|
||||
// $domains = Domain::query()
|
||||
// ->where('is_system', false)
|
||||
// ->where('is_active', true)
|
||||
// ->orderBy('domain')
|
||||
// ->get(['id', 'domain']);
|
||||
//
|
||||
// foreach ($domains as $d) {
|
||||
// $dom = $d->domain;
|
||||
//
|
||||
// $missing = [];
|
||||
//
|
||||
// // Pflicht-Checks
|
||||
// if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
|
||||
// if (!$this->hasSpf($dom)) $missing[] = 'SPF';
|
||||
// if (!$this->hasDkim($dom)) $missing[] = 'DKIM';
|
||||
// if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
|
||||
//
|
||||
// $rows[] = [
|
||||
// 'id' => (int)$d->id,
|
||||
// 'name' => $dom,
|
||||
// 'ok' => empty($missing),
|
||||
// 'missing' => $missing,
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// // TLSA (hostweit, nur Info)
|
||||
// $tlsa = $this->hasTlsa("_25._tcp.$mtaHost") || $this->hasTlsa("_465._tcp.$mtaHost") || $this->hasTlsa("_587._tcp.$mtaHost");
|
||||
//
|
||||
// return [$mtaHost, $tlsa, $rows];
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// /* ── DNS Helpers (mit Timeout, damit UI nicht hängt) ───────────────── */
|
||||
//
|
||||
// protected function digShort(string $type, string $name): string
|
||||
// {
|
||||
// $cmd = "timeout 2 dig +short " . escapeshellarg($name) . " " . escapeshellarg(strtoupper($type)) . " 2>/dev/null";
|
||||
// return (string)@shell_exec($cmd) ?: '';
|
||||
// }
|
||||
//
|
||||
// protected function hasTxt(string $name): bool
|
||||
// {
|
||||
// return trim($this->digShort('TXT', $name)) !== '';
|
||||
// }
|
||||
//
|
||||
// protected function hasTlsa(string $name): bool
|
||||
// {
|
||||
// return trim($this->digShort('TLSA', $name)) !== '';
|
||||
// }
|
||||
//
|
||||
// protected function hasSpf(string $domain): bool
|
||||
// {
|
||||
// $out = $this->digShort('TXT', $domain);
|
||||
// foreach (preg_split('/\R+/', trim($out)) as $line) {
|
||||
// if (stripos($line, 'v=spf1') !== false) return true;
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
//
|
||||
// // DKIM: wenn spezifischer Selector vorhanden → prüfe den, sonst akzeptiere _domainkey-Policy als “vorhanden”
|
||||
// protected function hasDkim(string $domain): bool
|
||||
// {
|
||||
// $sel = trim((string)env('DKIM_SELECTOR', ''));
|
||||
// if ($sel !== '' && $this->hasTxt("{$sel}._domainkey.$domain")) return true;
|
||||
// return $this->hasTxt("_domainkey.$domain");
|
||||
// }
|
||||
//
|
||||
// protected function mxPointsTo(string $domain, array $allowedHosts): bool
|
||||
// {
|
||||
// $out = $this->digShort('MX', $domain);
|
||||
// if ($out === '') return false;
|
||||
//
|
||||
// $targets = [];
|
||||
// foreach (preg_split('/\R+/', trim($out)) as $line) {
|
||||
// // Format: "10 mx.example.com."
|
||||
// if (preg_match('~\s+([A-Za-z0-9\.\-]+)\.?$~', trim($line), $m)) {
|
||||
// $targets[] = strtolower($m[1]);
|
||||
// }
|
||||
// }
|
||||
// if (!$targets) return false;
|
||||
//
|
||||
// $allowed = array_map('strtolower', $allowedHosts);
|
||||
// foreach ($targets as $t) {
|
||||
// if (in_array($t, $allowed, true)) return true;
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use App\Models\Domain;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class DnsHealthCard extends Component
|
||||
//{
|
||||
// public array $domains = []; // [['name'=>..., 'dkim'=>bool, 'dmarc'=>bool], ...]
|
||||
// public string $host = ''; // z.B. mx.nexlab.at
|
||||
// public bool $tlsa = false; // hostbasiert (einmalig)
|
||||
// public ?string $ipv4 = null;
|
||||
// public ?string $ipv6 = null;
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.dns-health-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// [$this->host, $this->tlsa, $this->domains, $this->ipv4, $this->ipv6] =
|
||||
// Cache::remember('dash.dnshealth', $force ? 1 : 900, function () {
|
||||
//
|
||||
// // ── ENV lesen ────────────────────────────────────────────────
|
||||
// $base = trim((string)env('BASE_DOMAIN', ''));
|
||||
// $mtaSub = trim((string)env('MTA_SUB', 'mx'));
|
||||
// $host = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub;
|
||||
//
|
||||
// $ipv4 = trim((string)env('SERVER_PUBLIC_IPV4', '')) ?: null;
|
||||
// $ipv6 = trim((string)env('SERVER_PUBLIC_IPV6', '')) ?: null;
|
||||
//
|
||||
// // ── Domains laden (nur aktive, nicht-system) ────────────────
|
||||
// $rows = [];
|
||||
// $domains = Domain::query()
|
||||
// ->where('is_system', false)
|
||||
// ->where('is_active', true)
|
||||
// ->orderBy('domain')
|
||||
// ->get(['domain']);
|
||||
//
|
||||
// foreach ($domains as $d) {
|
||||
// $dom = $d->domain;
|
||||
// $rows[] = [
|
||||
// 'name' => $dom,
|
||||
// 'dkim' => $this->hasTxt("_domainkey.$dom"),
|
||||
// 'dmarc' => $this->hasTxt("_dmarc.$dom"),
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// // ── TLSA nur hostbasiert prüfen (25/465/587) ────────────────
|
||||
// $tlsa = $this->hasTlsa("_25._tcp.$host")
|
||||
// || $this->hasTlsa("_465._tcp.$host")
|
||||
// || $this->hasTlsa("_587._tcp.$host");
|
||||
//
|
||||
// return [$host, $tlsa, $rows, $ipv4, $ipv6];
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// /* ───────────────────────── DNS Helpers ───────────────────────── */
|
||||
//
|
||||
// protected function hasTxt(string $name): bool
|
||||
// {
|
||||
// $out = @shell_exec("timeout 2 dig +short TXT " . escapeshellarg($name) . " 2>/dev/null");
|
||||
// return is_string($out) && trim($out) !== '';
|
||||
// }
|
||||
//
|
||||
// protected function hasTlsa(string $name): bool
|
||||
// {
|
||||
// $out = @shell_exec("timeout 2 dig +short TLSA " . escapeshellarg($name) . " 2>/dev/null");
|
||||
// return is_string($out) && trim($out) !== '';
|
||||
// }
|
||||
//}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use App\Models\Domain;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class DnsHealthCard extends Component
|
||||
//{
|
||||
// public array $rows = []; // pro Domain: ['dom','dkim','dmarc']
|
||||
// public string $host = ''; // z.B. mx.nexlab.at
|
||||
// public bool $tlsa = false; // TLSA-Status für den Host
|
||||
// public ?string $ipv4 = null;
|
||||
// public ?string $ipv6 = null;
|
||||
//
|
||||
// public function mount(): void { $this->load(); }
|
||||
// public function render() { return view('livewire.ui.mail.dns-health-card'); }
|
||||
// public function refresh(): void { $this->load(true); }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// // Werte aus .env
|
||||
// $this->ipv4 = trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
|
||||
// $this->ipv6 = trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
|
||||
//
|
||||
// $base = trim((string) env('BASE_DOMAIN', ''));
|
||||
// $mta = trim((string) env('MTA_SUB', 'mx'));
|
||||
// $host = $base ? "{$mta}.{$base}" : $mta; // z.B. mx.nexlab.at
|
||||
// $this->host = $host;
|
||||
//
|
||||
// // Neuer Cache-Key, damit altes Format keinen Crash verursacht
|
||||
// [$calcHost, $calcTlsa, $calcRows] = Cache::remember(
|
||||
// 'dash.dnshealth.v2',
|
||||
// $force ? 1 : 900,
|
||||
// function () use ($host) {
|
||||
// // TLSA: nur 1× pro Host prüfen
|
||||
// $tlsa = $this->hasTlsa("_25._tcp.{$host}")
|
||||
// || $this->hasTlsa("_465._tcp.{$host}")
|
||||
// || $this->hasTlsa("_587._tcp.{$host}");
|
||||
//
|
||||
// // Domains: nur DKIM/DMARC
|
||||
// $rows = [];
|
||||
// $domains = Domain::query()
|
||||
// ->where('is_system', false)
|
||||
// ->where('is_active', true)
|
||||
// ->get(['domain']);
|
||||
//
|
||||
// foreach ($domains as $d) {
|
||||
// $dom = $d->domain;
|
||||
// $dkim = $this->hasTxt("_domainkey.{$dom}");
|
||||
// $dmarc = $this->hasTxt("_dmarc.{$dom}");
|
||||
// $rows[] = compact('dom','dkim','dmarc');
|
||||
// }
|
||||
//
|
||||
// return [$host, $tlsa, $rows];
|
||||
// }
|
||||
// );
|
||||
//
|
||||
// // Defensive: falls mal falsches Datenformat im Cache war
|
||||
// if (!is_string($calcHost) || !is_bool($calcTlsa) || !is_array($calcRows)) {
|
||||
// Cache::forget('dash.dnshealth.v2');
|
||||
// $this->load(true);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $this->host = $calcHost;
|
||||
// $this->tlsa = $calcTlsa;
|
||||
// $this->rows = $calcRows;
|
||||
// }
|
||||
//
|
||||
// protected function hasTxt(string $name): bool
|
||||
// {
|
||||
// $out = @shell_exec("dig +short TXT " . escapeshellarg($name) . " 2>/dev/null");
|
||||
// return is_string($out) && trim($out) !== '';
|
||||
// }
|
||||
//
|
||||
// protected function hasTlsa(string $name): bool
|
||||
// {
|
||||
// $out = @shell_exec("dig +short TLSA " . escapeshellarg($name) . " 2>/dev/null");
|
||||
// return is_string($out) && trim($out) !== '';
|
||||
// }
|
||||
//}
|
||||
|
||||
//
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use App\Models\Domain;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class DnsHealthCard extends Component
|
||||
//{
|
||||
// public array $rows = []; // [ ['domain'=>..., 'dkim'=>bool, 'dmarc'=>bool, 'tlsa'=>bool], ... ]
|
||||
//
|
||||
// public function mount(): void { $this->load(); }
|
||||
// public function render() { return view('livewire.ui.mail.dns-health-card'); }
|
||||
// public function refresh(): void { $this->load(true); }
|
||||
//
|
||||
// protected function load(bool $force=false): void
|
||||
// {
|
||||
// $this->rows = Cache::remember('dash.dnshealth', $force ? 1 : 600, function () {
|
||||
// $rows = [];
|
||||
// $domains = Domain::query()->where('is_system', false)->where('is_active', true)->get(['domain']);
|
||||
// foreach ($domains as $d) {
|
||||
// $dom = $d->domain;
|
||||
// $dkim = $this->hasTxt("_domainkey.$dom"); // rough: just any dkim TXT exists
|
||||
// $dmarc = $this->hasTxt("_dmarc.$dom");
|
||||
// $tlsa = $this->hasTlsa("_25._tcp.$dom") || $this->hasTlsa("_465._tcp.$dom") || $this->hasTlsa("_587._tcp.$dom");
|
||||
// $rows[] = compact('dom','dkim','dmarc','tlsa');
|
||||
// }
|
||||
// return $rows;
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// protected function hasTxt(string $name): bool
|
||||
// {
|
||||
// $out = @shell_exec("dig +short TXT ".escapeshellarg($name)." 2>/dev/null");
|
||||
// return is_string($out) && trim($out) !== '';
|
||||
// }
|
||||
// protected function hasTlsa(string $name): bool
|
||||
// {
|
||||
// $out = @shell_exec("dig +short TLSA ".escapeshellarg($name)." 2>/dev/null");
|
||||
// return is_string($out) && trim($out) !== '';
|
||||
// }
|
||||
//}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
|
|
@ -12,7 +13,6 @@ use Livewire\Component;
|
|||
class MailboxList extends Component
|
||||
{
|
||||
public string $search = '';
|
||||
public bool $showSystemCard = false;
|
||||
|
||||
#[On('mailbox:updated')]
|
||||
#[On('mailbox:deleted')]
|
||||
|
|
@ -22,20 +22,6 @@ class MailboxList extends Component
|
|||
$this->dispatch('$refresh');
|
||||
}
|
||||
|
||||
#[On('focus:domain')]
|
||||
public function focusDomain(int $id): void
|
||||
{
|
||||
// z. B. Domain nach oben holen / scrollen / highlighten
|
||||
// oder direkt den "+ Postfach" Dialog:
|
||||
// $this->openMailboxCreate($id);
|
||||
}
|
||||
|
||||
#[On('focus:user')]
|
||||
public function focusUser(int $id): void
|
||||
{
|
||||
// später: Benutzerseite / Filter setzen ...
|
||||
}
|
||||
|
||||
public function openMailboxCreate(int $domainId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
|
||||
|
|
@ -43,25 +29,25 @@ class MailboxList extends Component
|
|||
]);
|
||||
}
|
||||
|
||||
public function openMailboxEdit(int $domainId): void
|
||||
public function openMailboxEdit(int $mailUserId): void
|
||||
{
|
||||
// $domainId == mailbox_id
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
|
||||
$domainId, // <— nur der Wert, kein Key!
|
||||
$mailUserId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function openMailboxDelete(int $domainId): void
|
||||
public function openMailboxDelete(int $mailUserId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
|
||||
$domainId, // <— nur der Wert, kein Key!
|
||||
$mailUserId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateMailboxStats()
|
||||
public function updateMailboxStats(): void
|
||||
{
|
||||
// führe Artisan-Command direkt aus
|
||||
Artisan::call('mail:update-stats');
|
||||
// läuft asynchron → UI blockiert nicht
|
||||
dispatch(fn() => Artisan::call('mail:update-stats'));
|
||||
|
||||
$this->dispatch('$refresh');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
|
|
@ -74,128 +60,429 @@ class MailboxList extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
$system = Domain::query()->where('is_system', true)->first();
|
||||
$term = trim($this->search);
|
||||
$hasTerm = $term !== '';
|
||||
$needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
|
||||
$term = trim($this->search);
|
||||
$hasTerm = $term !== '';
|
||||
$needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
|
||||
|
||||
// Nur Domains, die NICHT system und NICHT server sind
|
||||
$domains = Domain::query()
|
||||
->when($system, fn ($q) => $q->whereKeyNot($system->id))
|
||||
|
||||
// Domain selbst ODER MailUser/ Aliasse müssen matchen
|
||||
->where('is_system', false)
|
||||
->where('is_server', false)
|
||||
->when($hasTerm, function ($q) use ($needle) {
|
||||
$q->where(function ($w) use ($needle) {
|
||||
$w->where('domain', 'like', $needle)
|
||||
->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
|
||||
->orWhereHas('mailUsers', fn($u) => $u
|
||||
->where('is_active', true)
|
||||
->where('is_system', false)
|
||||
->where('localpart', 'like', $needle)
|
||||
);
|
||||
});
|
||||
})
|
||||
|
||||
->withCount(['mailUsers'])
|
||||
|
||||
// Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
|
||||
->with([
|
||||
'mailUsers' => function ($q) use ($hasTerm, $needle) {
|
||||
if ($hasTerm) $q->where('localpart', 'like', $needle);
|
||||
$q->orderBy('localpart');
|
||||
},
|
||||
->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();
|
||||
|
||||
// Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
|
||||
// Wenn Domain direkt matcht → alle Benutzer dieser Domain zeigen
|
||||
if ($hasTerm) {
|
||||
$lower = Str::lower($term);
|
||||
foreach ($domains as $d) {
|
||||
if (Str::contains(Str::lower($d->domain), $lower)) {
|
||||
// volle Relationen nachladen (überschreibt die gefilterten)
|
||||
$d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
|
||||
$d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
|
||||
$d->setRelation('mailUsers', $d->mailUsers()
|
||||
->where('is_active', true)
|
||||
->where('is_system', false)
|
||||
->orderBy('localpart')
|
||||
->get()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
|
||||
// ---- Anzeige vorbereiten ----
|
||||
foreach ($domains as $d) {
|
||||
$prepared = [];
|
||||
$prepared = [];
|
||||
$domainActive = (bool)($d->is_active ?? true);
|
||||
|
||||
foreach ($d->mailUsers as $u) {
|
||||
$usedMB = (int) round(($u->used_bytes ?? 0) / 1024 / 1024);
|
||||
$quota = (int)($u->quota_mb ?? 0);
|
||||
$usage = $quota > 0 ? min(100, (int) round($usedMB / max(1,$quota) * 100)) : 0;
|
||||
$email = $u->email ?? ($u->address ?? 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);
|
||||
|
||||
// $quota = (int)($u->quota_mb ?? 0);
|
||||
// $used = (int)($u->used_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
|
||||
$usage = $quotaMiB > 0
|
||||
? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100))
|
||||
: 0;
|
||||
|
||||
$mailboxActive = (bool)($u->is_active ?? true);
|
||||
$effective = $domainActive && $mailboxActive;
|
||||
$effective = $domainActive && $mailboxActive;
|
||||
|
||||
$reason = null;
|
||||
if (!$effective) {
|
||||
$reason = !$domainActive ? 'Domain inaktiv'
|
||||
$reason = !$domainActive
|
||||
? 'Domain inaktiv'
|
||||
: (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
}
|
||||
|
||||
$prepared[] = [
|
||||
'id' => $u->id,
|
||||
'localpart' => (string)$u->localpart,
|
||||
'quota_mb' => $quota,
|
||||
'usage_percent' => $usage,
|
||||
'used_mb' => $usedMB,
|
||||
'message_count' => (int) ($u->message_count ?? 0),
|
||||
// 'used_mb' => $used,
|
||||
// 'message_count' => (int)($u->message_count ?? $u->mails_count ?? 0),
|
||||
'is_active' => $mailboxActive,
|
||||
'id' => $u->id,
|
||||
'localpart' => (string)$u->localpart,
|
||||
'quota_mb' => $quotaMiB,
|
||||
'used_mb' => $usedMiB,
|
||||
'usage_percent' => $usage,
|
||||
'message_count' => $messageCount,
|
||||
'is_active' => $mailboxActive,
|
||||
'is_effective_active' => $effective,
|
||||
'inactive_reason' => $reason,
|
||||
'inactive_reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
// für Blade
|
||||
$d->prepared_mailboxes = $prepared;
|
||||
}
|
||||
|
||||
return view('livewire.ui.mail.mailbox-list', [
|
||||
'domains' => $domains,
|
||||
'system' => $this->showSystemCard ? $system : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use App\Models\Setting;
|
||||
//use Illuminate\Support\Facades\Artisan;
|
||||
//use Illuminate\Support\Str;
|
||||
//use Livewire\Attributes\On;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class MailboxList extends Component
|
||||
//{
|
||||
// public string $search = '';
|
||||
// public bool $showSystemCard = false;
|
||||
//
|
||||
// #[On('mailbox:updated')]
|
||||
// #[On('mailbox:deleted')]
|
||||
// #[On('mailbox:created')]
|
||||
// public function refreshMailboxList(): void
|
||||
// {
|
||||
// $this->dispatch('$refresh');
|
||||
// }
|
||||
//
|
||||
// #[On('focus:domain')]
|
||||
// public function focusDomain(int $id): void
|
||||
// {
|
||||
// // optional: Domain hervorheben
|
||||
// }
|
||||
//
|
||||
// #[On('focus:user')]
|
||||
// public function focusUser(int $id): void
|
||||
// {
|
||||
// // optional
|
||||
// }
|
||||
//
|
||||
// public function openMailboxCreate(int $domainId): void
|
||||
// {
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
|
||||
// 'domainId' => $domainId,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function openMailboxEdit(int $domainId): void
|
||||
// {
|
||||
// // $domainId == mailbox_id
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
|
||||
// $domainId,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function openMailboxDelete(int $domainId): void
|
||||
// {
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
|
||||
// $domainId,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function updateMailboxStats(): void
|
||||
// {
|
||||
//// Artisan::call('mail:update-stats');
|
||||
// dispatch(fn () => Artisan::call('mail:update-stats'));
|
||||
//
|
||||
// $this->dispatch('$refresh');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Mailbox',
|
||||
// title: 'Mailbox aktualisiert',
|
||||
// text: 'Die Mailbox-Statistiken wurden aktualisiert.',
|
||||
// duration: 6000,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// $system = Domain::query()->where('is_system', true)->first();
|
||||
// $term = trim($this->search);
|
||||
// $hasTerm = $term !== '';
|
||||
// $needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
|
||||
//
|
||||
// $domains = Domain::query()
|
||||
// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
|
||||
// ->withCount(['mailUsers','mailAliases'])
|
||||
// ->with([
|
||||
// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
|
||||
// 'mailAliases' => fn ($q) => $q->orderBy('local'),
|
||||
// ])
|
||||
// ->when($term !== '', function ($q) use ($term) {
|
||||
// $q->where(function ($w) use ($term) {
|
||||
// $w->where('domain', 'like', "%{$term}%")
|
||||
// ->orWhereHas('mailUsers', fn($u) =>
|
||||
// $u->where('localpart', 'like', "%{$term}%")
|
||||
// );
|
||||
// ->when($system, fn($q) => $q->whereKeyNot($system->id))
|
||||
// ->when($hasTerm, function ($q) use ($needle) {
|
||||
// $q->where(function ($w) use ($needle) {
|
||||
// $w->where('domain', 'like', $needle)
|
||||
// ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
|
||||
// });
|
||||
// })
|
||||
// ->withCount(['mailUsers'])
|
||||
// ->with([
|
||||
// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
|
||||
// if ($hasTerm) {
|
||||
// $q->where('localpart', 'like', $needle);
|
||||
// }
|
||||
// $q->orderBy('localpart');
|
||||
// },
|
||||
// ])
|
||||
// ->orderBy('domain')
|
||||
// ->get();
|
||||
//
|
||||
// // Vorbereitung für Blade (unverändert)
|
||||
// if ($hasTerm) {
|
||||
// $lower = Str::lower($term);
|
||||
// foreach ($domains as $d) {
|
||||
// if (Str::contains(Str::lower($d->domain), $lower)) {
|
||||
// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
|
||||
// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // ---- Anzeige vorbereiten (mit korrekter Prozent-Berechnung) ----
|
||||
// foreach ($domains as $d) {
|
||||
// $prepared = [];
|
||||
// $domainActive = (bool)($d->is_active ?? true);
|
||||
//
|
||||
// foreach ($d->mailUsers as $u) {
|
||||
// $quota = (int) ($u->quota_mb ?? 0);
|
||||
// $used = (int) ($u->used_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
|
||||
// // Stats aus Settings (Redis → DB Fallback)
|
||||
// $stats = Setting::get("mailbox.{$u->email}", []);
|
||||
// $usedBytes = (int)($stats['used_bytes'] ?? ($u->used_bytes ?? 0));
|
||||
// $messageCount = (int)($stats['message_count'] ?? ($u->message_count ?? 0));
|
||||
//
|
||||
// // Anzeige in MiB (nur fürs UI runden)
|
||||
// $usedMiB = (float)round($usedBytes / 1048576, 2);
|
||||
// $quotaMiB = (int)($u->quota_mb ?? 0);
|
||||
//
|
||||
// // Prozent aus Bytes/Quota (ohne Vorab-Rundung auf MiB!)
|
||||
// $usage = $quotaMiB > 0
|
||||
// ? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100))
|
||||
// : 0;
|
||||
//
|
||||
// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
// $effective = $domainActive && $mailboxActive;
|
||||
//
|
||||
// $reason = null;
|
||||
// if (!$effective) {
|
||||
// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
// }
|
||||
//
|
||||
// $prepared[] = [
|
||||
// 'id' => $u->id,
|
||||
// 'localpart' => (string)$u->localpart,
|
||||
// 'quota_mb' => $quotaMiB,
|
||||
// 'used_mb' => $usedMiB,
|
||||
// 'usage_percent' => $usage,
|
||||
// 'message_count' => $messageCount,
|
||||
// 'is_active' => $mailboxActive,
|
||||
// 'is_effective_active' => $effective,
|
||||
// 'inactive_reason' => $reason,
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// $d->prepared_mailboxes = $prepared;
|
||||
// }
|
||||
//
|
||||
// return view('livewire.ui.mail.mailbox-list', [
|
||||
// 'domains' => $domains,
|
||||
// 'system' => $this->showSystemCard ? $system : null,
|
||||
// ]);
|
||||
// }
|
||||
//}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use App\Models\Setting;
|
||||
//use Illuminate\Support\Facades\Artisan;
|
||||
//use Illuminate\Support\Facades\Log;
|
||||
//use Illuminate\Support\Str;
|
||||
//use Livewire\Attributes\On;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class MailboxList extends Component
|
||||
//{
|
||||
// public string $search = '';
|
||||
// public bool $showSystemCard = false;
|
||||
//
|
||||
// #[On('mailbox:updated')]
|
||||
// #[On('mailbox:deleted')]
|
||||
// #[On('mailbox:created')]
|
||||
// public function refreshMailboxList(): void
|
||||
// {
|
||||
// $this->dispatch('$refresh');
|
||||
// }
|
||||
//
|
||||
// #[On('focus:domain')]
|
||||
// public function focusDomain(int $id): void
|
||||
// {
|
||||
// // z. B. Domain nach oben holen / scrollen / highlighten
|
||||
// // oder direkt den "+ Postfach" Dialog:
|
||||
// // $this->openMailboxCreate($id);
|
||||
// }
|
||||
//
|
||||
// #[On('focus:user')]
|
||||
// public function focusUser(int $id): void
|
||||
// {
|
||||
// // später: Benutzerseite / Filter setzen ...
|
||||
// }
|
||||
//
|
||||
// public function openMailboxCreate(int $domainId): void
|
||||
// {
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
|
||||
// 'domainId' => $domainId,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function openMailboxEdit(int $domainId): void
|
||||
// {
|
||||
// // $domainId == mailbox_id
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
|
||||
// $domainId, // <— nur der Wert, kein Key!
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function openMailboxDelete(int $domainId): void
|
||||
// {
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
|
||||
// $domainId, // <— nur der Wert, kein Key!
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function updateMailboxStats()
|
||||
// {
|
||||
// $started = microtime(true);
|
||||
//
|
||||
// Log::channel('mailstats')->info('UI: updateMailboxStats() geklickt', [
|
||||
// 'actor' => 'web',
|
||||
// 'ip' => request()->ip() ?? null,
|
||||
// ]);
|
||||
//
|
||||
// // Command ausführen
|
||||
// $rc = Artisan::call('mail:update-stats');
|
||||
// $output = Artisan::output();
|
||||
//
|
||||
// Log::channel('mailstats')->info('UI: Command beendet', [
|
||||
// 'rc' => $rc,
|
||||
// 'ms' => (int)((microtime(true) - $started) * 1000),
|
||||
// 'output' => trim($output),
|
||||
// ]);
|
||||
//
|
||||
// // UI auffrischen
|
||||
// $this->dispatch('$refresh');
|
||||
//
|
||||
// // Ergebnis toaster
|
||||
// $this->dispatch('toast',
|
||||
// type: $rc === 0 ? 'done' : 'warn',
|
||||
// badge: 'Mailbox',
|
||||
// title: $rc === 0 ? 'Mailbox aktualisiert' : 'Aktualisierung fehlgeschlagen',
|
||||
// text: $rc === 0 ? 'Statistiken wurden aktualisiert.' : 'Siehe logs/mailstats.log',
|
||||
// duration: 6000,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public function updateMailboxStatsOne(string $email)
|
||||
// {
|
||||
// Artisan::call('mail:update-stats', ['--user' => $email]);
|
||||
// $this->dispatch('$refresh');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Mailbox',
|
||||
// title: 'Mailbox aktualisiert',
|
||||
// text: 'Die Mailbox-Statistiken wurden aktualisiert.',
|
||||
// duration: 6000
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// $system = Domain::query()->where('is_system', true)->first();
|
||||
// $term = trim($this->search);
|
||||
// $hasTerm = $term !== '';
|
||||
// $needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
|
||||
//
|
||||
// $domains = Domain::query()
|
||||
// ->when($system, fn ($q) => $q->whereKeyNot($system->id))
|
||||
//
|
||||
// // Domain selbst ODER MailUser müssen matchen
|
||||
// ->when($hasTerm, function ($q) use ($needle) {
|
||||
// $q->where(function ($w) use ($needle) {
|
||||
// $w->where('domain', 'like', $needle)
|
||||
// ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
|
||||
// });
|
||||
// })
|
||||
//
|
||||
// ->withCount(['mailUsers'])
|
||||
//
|
||||
// // Relationen zunächst ggf. gefiltert laden
|
||||
// ->with([
|
||||
// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
|
||||
// if ($hasTerm) $q->where('localpart', 'like', $needle);
|
||||
// $q->orderBy('localpart');
|
||||
// },
|
||||
// ])
|
||||
//
|
||||
// ->orderBy('domain')
|
||||
// ->get();
|
||||
//
|
||||
// // Wenn der Domainname selbst matched → alle Mailboxen/Aliasse vollständig nachladen
|
||||
// if ($hasTerm) {
|
||||
// $lower = Str::lower($term);
|
||||
// foreach ($domains as $d) {
|
||||
// if (Str::contains(Str::lower($d->domain), $lower)) {
|
||||
// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
|
||||
// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Vorbereitung für Blade
|
||||
// foreach ($domains as $d) {
|
||||
// $prepared = [];
|
||||
// $domainActive = (bool)($d->is_active ?? true);
|
||||
//
|
||||
// foreach ($d->mailUsers as $u) {
|
||||
// $email = trim($u->email ?? '') !== ''
|
||||
// ? $u->email
|
||||
// : ($u->localpart !== '' ? ($u->localpart.'@'.$d->domain) : null);
|
||||
//
|
||||
// $stats = $email ? Setting::get("mailbox.$email") : null;
|
||||
//
|
||||
// $usedBytes = is_array($stats) && isset($stats['used_bytes']) ? (int)$stats['used_bytes'] : (int)($u->used_bytes ?? 0);
|
||||
// $messageCount = is_array($stats) && isset($stats['message_count']) ? (int)$stats['message_count'] : (int)($u->message_count ?? 0);
|
||||
//
|
||||
// $usedMB = (int) round($usedBytes / 1024 / 1024);
|
||||
// $quota = (int)($u->quota_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, (int) round($usedMB / max(1, $quota) * 100)) : 0;
|
||||
//
|
||||
// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
// $effective = $domainActive && $mailboxActive;
|
||||
|
|
@ -208,11 +495,11 @@ class MailboxList extends Component
|
|||
//
|
||||
// $prepared[] = [
|
||||
// 'id' => $u->id,
|
||||
// 'localpart' => (string) $u->localpart,
|
||||
// 'localpart' => (string)$u->localpart,
|
||||
// 'quota_mb' => $quota,
|
||||
// 'used_mb' => $used,
|
||||
// 'usage_percent' => $usage,
|
||||
// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
|
||||
// 'used_mb' => $usedMB, // MiB fürs UI
|
||||
// 'message_count' => $messageCount,
|
||||
// 'is_active' => $mailboxActive,
|
||||
// 'is_effective_active' => $effective,
|
||||
// 'inactive_reason' => $reason,
|
||||
|
|
@ -227,72 +514,228 @@ class MailboxList extends Component
|
|||
// 'system' => $this->showSystemCard ? $system : null,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
// public function render()
|
||||
// {
|
||||
// $system = Domain::query()->where('is_system', true)->first();
|
||||
//// public function render()
|
||||
//// {
|
||||
//// $system = Domain::query()->where('is_system', true)->first();
|
||||
//// $term = trim($this->search);
|
||||
//// $hasTerm = $term !== '';
|
||||
//// $needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
|
||||
////
|
||||
//// $domains = Domain::query()
|
||||
//// ->when($system, fn ($q) => $q->whereKeyNot($system->id))
|
||||
////
|
||||
//// // Domain selbst ODER MailUser/ Aliasse müssen matchen
|
||||
//// ->when($hasTerm, function ($q) use ($needle) {
|
||||
//// $q->where(function ($w) use ($needle) {
|
||||
//// $w->where('domain', 'like', $needle)
|
||||
//// ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
|
||||
//// });
|
||||
//// })
|
||||
////
|
||||
//// ->withCount(['mailUsers'])
|
||||
////
|
||||
//// // Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
|
||||
//// ->with([
|
||||
//// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
|
||||
//// if ($hasTerm) $q->where('localpart', 'like', $needle);
|
||||
//// $q->orderBy('localpart');
|
||||
//// },
|
||||
//// ])
|
||||
////
|
||||
//// ->orderBy('domain')
|
||||
//// ->get();
|
||||
////
|
||||
//// // Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
|
||||
//// if ($hasTerm) {
|
||||
//// $lower = Str::lower($term);
|
||||
//// foreach ($domains as $d) {
|
||||
//// if (Str::contains(Str::lower($d->domain), $lower)) {
|
||||
//// // volle Relationen nachladen (überschreibt die gefilterten)
|
||||
//// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
|
||||
//// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
|
||||
//// }
|
||||
//// }
|
||||
//// }
|
||||
////
|
||||
//// // Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
|
||||
//// foreach ($domains as $d) {
|
||||
//// $prepared = [];
|
||||
//// $domainActive = (bool)($d->is_active ?? true);
|
||||
////
|
||||
//// foreach ($d->mailUsers as $u) {
|
||||
//// $stats = Setting::get("mailbox.{$u->email}");
|
||||
//// $usedBytes = $stats['used_bytes'] ?? ($u->used_bytes ?? 0);
|
||||
//// $messageCount = $stats['message_count'] ?? ($u->message_count ?? 0);
|
||||
//// $usedMB = (int) round(($usedBytes) / 1024 / 1024);
|
||||
//// $quota = (int)($u->quota_mb ?? 0);
|
||||
//// $usage = $quota > 0 ? min(100, (int) round($usedMB / max(1,$quota) * 100)) : 0;
|
||||
////
|
||||
////
|
||||
////// $quota = (int)($u->quota_mb ?? 0);
|
||||
////// $used = (int)($u->used_mb ?? 0);
|
||||
////// $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
|
||||
////
|
||||
//// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
//// $effective = $domainActive && $mailboxActive;
|
||||
////
|
||||
//// $reason = null;
|
||||
//// if (!$effective) {
|
||||
//// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
//// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
//// }
|
||||
////
|
||||
//// $prepared[] = [
|
||||
//// 'id' => $u->id,
|
||||
//// 'localpart' => (string)$u->localpart,
|
||||
//// 'quota_mb' => $quota,
|
||||
//// 'usage_percent' => $usage,
|
||||
//// 'used_mb' => $usedMB,
|
||||
//// 'message_count' => $messageCount,
|
||||
//// 'is_active' => $mailboxActive,
|
||||
//// 'is_effective_active' => $effective,
|
||||
//// 'inactive_reason' => $reason,
|
||||
//// ];
|
||||
//// }
|
||||
////
|
||||
//// // für Blade
|
||||
//// $d->prepared_mailboxes = $prepared;
|
||||
//// }
|
||||
////
|
||||
//// return view('livewire.ui.mail.mailbox-list', [
|
||||
//// 'domains' => $domains,
|
||||
//// 'system' => $this->showSystemCard ? $system : null,
|
||||
//// ]);
|
||||
//// }
|
||||
//
|
||||
// $term = trim($this->search);
|
||||
//// public function render()
|
||||
//// {
|
||||
//// $system = Domain::query()->where('is_system', true)->first();
|
||||
//// $term = trim($this->search);
|
||||
////
|
||||
//// $domains = Domain::query()
|
||||
//// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
|
||||
//// ->withCount(['mailUsers','mailAliases'])
|
||||
//// ->with([
|
||||
//// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
|
||||
//// 'mailAliases' => fn ($q) => $q->orderBy('local'),
|
||||
//// ])
|
||||
//// ->when($term !== '', function ($q) use ($term) {
|
||||
//// $q->where(function ($w) use ($term) {
|
||||
//// $w->where('domain', 'like', "%{$term}%")
|
||||
//// ->orWhereHas('mailUsers', fn($u) =>
|
||||
//// $u->where('localpart', 'like', "%{$term}%")
|
||||
//// );
|
||||
//// });
|
||||
//// })
|
||||
//// ->orderBy('domain')
|
||||
//// ->get();
|
||||
////
|
||||
//// // Vorbereitung für Blade (unverändert)
|
||||
//// foreach ($domains as $d) {
|
||||
//// $prepared = [];
|
||||
//// $domainActive = (bool)($d->is_active ?? true);
|
||||
////
|
||||
//// foreach ($d->mailUsers as $u) {
|
||||
//// $quota = (int) ($u->quota_mb ?? 0);
|
||||
//// $used = (int) ($u->used_mb ?? 0);
|
||||
//// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
|
||||
////
|
||||
//// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
//// $effective = $domainActive && $mailboxActive;
|
||||
////
|
||||
//// $reason = null;
|
||||
//// if (!$effective) {
|
||||
//// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
//// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
//// }
|
||||
////
|
||||
//// $prepared[] = [
|
||||
//// 'id' => $u->id,
|
||||
//// 'localpart' => (string) $u->localpart,
|
||||
//// 'quota_mb' => $quota,
|
||||
//// 'used_mb' => $used,
|
||||
//// 'usage_percent' => $usage,
|
||||
//// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
|
||||
//// 'is_active' => $mailboxActive,
|
||||
//// 'is_effective_active' => $effective,
|
||||
//// 'inactive_reason' => $reason,
|
||||
//// ];
|
||||
//// }
|
||||
////
|
||||
//// $d->prepared_mailboxes = $prepared;
|
||||
//// }
|
||||
////
|
||||
//// return view('livewire.ui.mail.mailbox-list', [
|
||||
//// 'domains' => $domains,
|
||||
//// 'system' => $this->showSystemCard ? $system : null,
|
||||
//// ]);
|
||||
//// }
|
||||
//
|
||||
// $domains = Domain::query()
|
||||
// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
|
||||
// ->withCount(['mailUsers','mailAliases'])
|
||||
// ->with([
|
||||
// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
|
||||
// 'mailAliases' => fn ($q) => $q->orderBy('source'),
|
||||
// ])
|
||||
// ->when($term !== '', function ($q) use ($term) {
|
||||
// $q->where(function ($w) use ($term) {
|
||||
// $w->where('domain', 'like', "%{$term}%")
|
||||
// ->orWhereHas('mailUsers', fn($u) =>
|
||||
// $u->where('localpart', 'like', "%{$term}%")
|
||||
// );
|
||||
// });
|
||||
// })
|
||||
// ->orderBy('domain')
|
||||
// ->get();
|
||||
//
|
||||
// // Für das Blade vorbereiten (ohne Relations zu mutieren)
|
||||
// foreach ($domains as $d) {
|
||||
// $prepared = [];
|
||||
// $domainActive = (bool)($d->is_active ?? true);
|
||||
//
|
||||
// foreach ($d->mailUsers as $u) {
|
||||
// $quota = (int) ($u->quota_mb ?? 0);
|
||||
// $used = (int) ($u->used_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
|
||||
//
|
||||
// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
// $effective = $domainActive && $mailboxActive;
|
||||
//
|
||||
// $reason = null;
|
||||
// if (!$effective) {
|
||||
// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
// }
|
||||
//
|
||||
// $prepared[] = [
|
||||
// 'id' => $u->id,
|
||||
// 'localpart' => (string) $u->localpart,
|
||||
// 'quota_mb' => $quota,
|
||||
// 'used_mb' => $used,
|
||||
// 'usage_percent' => $usage,
|
||||
// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
|
||||
// 'is_active' => $mailboxActive, // ursprünglicher Flag (falls du ihn brauchst)
|
||||
// 'is_effective_active' => $effective, // ← NEU: Domain & Mailbox aktiv?
|
||||
// 'inactive_reason' => $reason, // ← NEU: warum gesperrt
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// $d->prepared_mailboxes = $prepared;
|
||||
// }
|
||||
//
|
||||
// return view('livewire.ui.mail.mailbox-list', [
|
||||
// 'domains' => $domains,
|
||||
// 'system' => $this->showSystemCard ? $system : null,
|
||||
// ]);
|
||||
// }
|
||||
}
|
||||
//// public function render()
|
||||
//// {
|
||||
//// $system = Domain::query()->where('is_system', true)->first();
|
||||
////
|
||||
//// $term = trim($this->search);
|
||||
////
|
||||
//// $domains = Domain::query()
|
||||
//// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
|
||||
//// ->withCount(['mailUsers','mailAliases'])
|
||||
//// ->with([
|
||||
//// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
|
||||
//// 'mailAliases' => fn ($q) => $q->orderBy('source'),
|
||||
//// ])
|
||||
//// ->when($term !== '', function ($q) use ($term) {
|
||||
//// $q->where(function ($w) use ($term) {
|
||||
//// $w->where('domain', 'like', "%{$term}%")
|
||||
//// ->orWhereHas('mailUsers', fn($u) =>
|
||||
//// $u->where('localpart', 'like', "%{$term}%")
|
||||
//// );
|
||||
//// });
|
||||
//// })
|
||||
//// ->orderBy('domain')
|
||||
//// ->get();
|
||||
////
|
||||
//// // Für das Blade vorbereiten (ohne Relations zu mutieren)
|
||||
//// foreach ($domains as $d) {
|
||||
//// $prepared = [];
|
||||
//// $domainActive = (bool)($d->is_active ?? true);
|
||||
////
|
||||
//// foreach ($d->mailUsers as $u) {
|
||||
//// $quota = (int) ($u->quota_mb ?? 0);
|
||||
//// $used = (int) ($u->used_mb ?? 0);
|
||||
//// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
|
||||
////
|
||||
//// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
//// $effective = $domainActive && $mailboxActive;
|
||||
////
|
||||
//// $reason = null;
|
||||
//// if (!$effective) {
|
||||
//// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
//// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
//// }
|
||||
////
|
||||
//// $prepared[] = [
|
||||
//// 'id' => $u->id,
|
||||
//// 'localpart' => (string) $u->localpart,
|
||||
//// 'quota_mb' => $quota,
|
||||
//// 'used_mb' => $used,
|
||||
//// 'usage_percent' => $usage,
|
||||
//// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
|
||||
//// 'is_active' => $mailboxActive, // ursprünglicher Flag (falls du ihn brauchst)
|
||||
//// 'is_effective_active' => $effective, // ← NEU: Domain & Mailbox aktiv?
|
||||
//// 'inactive_reason' => $reason, // ← NEU: warum gesperrt
|
||||
//// ];
|
||||
//// }
|
||||
////
|
||||
//// $d->prepared_mailboxes = $prepared;
|
||||
//// }
|
||||
////
|
||||
//// return view('livewire.ui.mail.mailbox-list', [
|
||||
//// 'domains' => $domains,
|
||||
//// 'system' => $this->showSystemCard ? $system : null,
|
||||
//// ]);
|
||||
//// }
|
||||
//}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -92,7 +92,7 @@ class MailboxCreateModal extends ModalComponent
|
|||
{
|
||||
// alle Nicht-System-Domains in Select
|
||||
$this->domains = Domain::query()
|
||||
->where('is_system', false)
|
||||
->where('is_system', false)->where('is_server', false)
|
||||
->orderBy('domain')->get(['id', 'domain'])->toArray();
|
||||
|
||||
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
|
||||
|
|
@ -291,251 +291,3 @@ class MailboxCreateModal extends ModalComponent
|
|||
return view('livewire.ui.mail.modal.mailbox-create-modal');
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail\Modal;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use App\Models\MailUser;
|
||||
//use Illuminate\Database\QueryException;
|
||||
//use Illuminate\Support\Facades\Hash;
|
||||
//use Illuminate\Validation\Rule;
|
||||
//use Livewire\Attributes\On;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class MailboxCreateModal extends ModalComponent
|
||||
//{
|
||||
// // optional vorselektierte Domain
|
||||
// public ?int $domain_id = null;
|
||||
//
|
||||
// // Anzeige
|
||||
// public string $domain_name = '';
|
||||
// /** @var array<int,array{id:int,domain:string}> */
|
||||
// public array $domains = [];
|
||||
// public string $email_preview = '';
|
||||
//
|
||||
// public string $localpart = '';
|
||||
// public ?string $display_name = null;
|
||||
// public ?string $password = null;
|
||||
// public int $quota_mb = 0;
|
||||
// public ?int $rate_limit_per_hour = null;
|
||||
// public bool $is_active = true;
|
||||
// public bool $must_change_pw = true;
|
||||
//
|
||||
// // Limits / Status
|
||||
// public ?int $limit_max_mailboxes = null;
|
||||
// public ?int $limit_default_quota_mb = null;
|
||||
// public ?int $limit_max_quota_per_mb = null;
|
||||
// public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
|
||||
// public ?int $limit_domain_rate_per_hour = null;
|
||||
// public bool $allow_rate_limit_override = false;
|
||||
//
|
||||
// public int $mailbox_count_used = 0;
|
||||
// public int $domain_storage_used_mb = 0;
|
||||
//
|
||||
// // Hints/Flags
|
||||
// public string $quota_hint = '';
|
||||
// public bool $rate_limit_readonly = false;
|
||||
// public bool $no_mailbox_slots = false;
|
||||
// public bool $no_storage_left = false;
|
||||
// public bool $can_create = true;
|
||||
// public string $block_reason = '';
|
||||
//
|
||||
// /* ---------- Validation ---------- */
|
||||
// protected function rules(): array
|
||||
// {
|
||||
// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
|
||||
// $remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
|
||||
// ? PHP_INT_MAX
|
||||
// : max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
|
||||
// $cap = min($maxPerMailbox, $remainingByTotal);
|
||||
//
|
||||
// return [
|
||||
// 'domain_id' => ['required', Rule::exists('domains', 'id')],
|
||||
// 'localpart' => [
|
||||
// 'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
|
||||
// Rule::unique('mail_users', 'localpart')->where(fn($q) => $q->where('domain_id', $this->domain_id)),
|
||||
// ],
|
||||
// 'display_name' => ['nullable', 'max:191'],
|
||||
// 'password' => ['nullable', 'min:8'],
|
||||
// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
|
||||
// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
|
||||
// 'is_active' => ['boolean'],
|
||||
// 'must_change_pw' => ['boolean'],
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// /* ---------- Lifecycle ---------- */
|
||||
// public function mount(?int $domainId = null): void
|
||||
// {
|
||||
// // alle Nicht-System-Domains in Select
|
||||
// $this->domains = Domain::query()
|
||||
// ->where('is_system', false)
|
||||
// ->orderBy('domain')->get(['id', 'domain'])->toArray();
|
||||
//
|
||||
// // vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
|
||||
// $this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
|
||||
//
|
||||
// // Limits + Anzeige laden
|
||||
// $this->syncDomainContext();
|
||||
// }
|
||||
//
|
||||
// public function updatedDomainId(): void
|
||||
// {
|
||||
// $this->resetErrorBag(); // scoped unique etc.
|
||||
// $this->syncDomainContext();
|
||||
// }
|
||||
//
|
||||
// public function updatedLocalpart(): void
|
||||
// {
|
||||
// $this->localpart = strtolower(trim($this->localpart));
|
||||
// $this->rebuildEmailPreview();
|
||||
// }
|
||||
//
|
||||
// public function updatedQuotaMb(): void
|
||||
// {
|
||||
// $this->recomputeQuotaHints();
|
||||
// $this->recomputeBlockers();
|
||||
// }
|
||||
//
|
||||
// /* ---------- Helpers ---------- */
|
||||
// private function syncDomainContext(): void
|
||||
// {
|
||||
// if (!$this->domain_id) return;
|
||||
//
|
||||
// $d = Domain::query()
|
||||
// ->withCount('mailUsers')
|
||||
// ->withSum('mailUsers as used_storage_mb', 'quota_mb')
|
||||
// ->findOrFail($this->domain_id);
|
||||
//
|
||||
// $this->domain_name = $d->domain;
|
||||
// $this->limit_max_mailboxes = (int)$d->max_mailboxes;
|
||||
// $this->limit_default_quota_mb = (int)$d->default_quota_mb;
|
||||
// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
|
||||
// $this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
|
||||
// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
|
||||
// $this->allow_rate_limit_override = (bool)$d->rate_limit_override;
|
||||
//
|
||||
// $this->mailbox_count_used = (int)$d->mail_users_count;
|
||||
// $this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
|
||||
//
|
||||
// // Defaults
|
||||
// $this->quota_mb = $this->limit_default_quota_mb ?? 0;
|
||||
// if (!$this->allow_rate_limit_override) {
|
||||
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
// $this->rate_limit_readonly = true;
|
||||
// } else {
|
||||
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
// $this->rate_limit_readonly = false;
|
||||
// }
|
||||
//
|
||||
// $this->rebuildEmailPreview();
|
||||
// $this->recomputeQuotaHints();
|
||||
// $this->recomputeBlockers();
|
||||
// }
|
||||
//
|
||||
// private function rebuildEmailPreview(): void
|
||||
// {
|
||||
// $this->email_preview = $this->localpart && $this->domain_name
|
||||
// ? ($this->localpart . '@' . $this->domain_name) : '';
|
||||
// }
|
||||
//
|
||||
// private function recomputeQuotaHints(): void
|
||||
// {
|
||||
// $parts = [];
|
||||
//
|
||||
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
|
||||
// $remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
|
||||
// $remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
|
||||
// $parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
|
||||
// $parts[] = "nach Speichern: {$remainingAfter} MiB";
|
||||
// }
|
||||
// if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
|
||||
// if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
|
||||
//
|
||||
// $this->quota_hint = implode(' · ', $parts);
|
||||
// }
|
||||
//
|
||||
// private function recomputeBlockers(): void
|
||||
// {
|
||||
// // Slots
|
||||
// $this->no_mailbox_slots = false;
|
||||
// if (!is_null($this->limit_max_mailboxes)) {
|
||||
// $free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
|
||||
// if ($free <= 0) $this->no_mailbox_slots = true;
|
||||
// }
|
||||
//
|
||||
// // Speicher
|
||||
// $this->no_storage_left = false;
|
||||
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
|
||||
// $remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
|
||||
// if ($remaining <= 0) $this->no_storage_left = true;
|
||||
// }
|
||||
//
|
||||
// $reasons = [];
|
||||
// if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
|
||||
// if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
|
||||
// $this->block_reason = implode(' ', $reasons);
|
||||
// $this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
|
||||
// }
|
||||
//
|
||||
// /* ---------- Save ---------- */
|
||||
// #[On('mailbox:create')]
|
||||
// public function save(): void
|
||||
// {
|
||||
// $this->recomputeBlockers();
|
||||
// if (!$this->can_create) {
|
||||
// $this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $data = $this->validate();
|
||||
// $email = $data['localpart'] . '@' . $this->domain_name;
|
||||
//
|
||||
// try {
|
||||
// $u = new MailUser();
|
||||
// $u->domain_id = $data['domain_id'];
|
||||
// $u->localpart = $data['localpart'];
|
||||
// $u->email = $email;
|
||||
// $u->display_name = $this->display_name ?: null;
|
||||
// $u->password_hash = $this->password ? Hash::make($this->password) : null;
|
||||
// $u->is_system = false;
|
||||
// $u->is_active = (bool)$data['is_active'];
|
||||
// $u->must_change_pw = (bool)$data['must_change_pw'];
|
||||
// $u->quota_mb = (int)$data['quota_mb'];
|
||||
// $u->rate_limit_per_hour = $data['rate_limit_per_hour'];
|
||||
// $u->save();
|
||||
// } catch (QueryException $e) {
|
||||
// $msg = strtolower($e->getMessage());
|
||||
// if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
|
||||
// $this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
|
||||
// return;
|
||||
// }
|
||||
// if (str_contains($msg, 'mail_users_email_unique')) {
|
||||
// $this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
|
||||
// return;
|
||||
// }
|
||||
// throw $e;
|
||||
// }
|
||||
//
|
||||
// $this->dispatch('mailbox:created');
|
||||
// $this->dispatch('closeModal');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Postfach',
|
||||
// title: 'Postfach angelegt',
|
||||
// text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
|
||||
// duration: 6000
|
||||
// );
|
||||
//
|
||||
// }
|
||||
//
|
||||
// public static function modalMaxWidth(): string
|
||||
// {
|
||||
// return '3xl';
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.modal.mailbox-create-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class QueueCard extends Component
|
||||
{
|
||||
public int $active = 0;
|
||||
public int $deferred = 0;
|
||||
public ?string $oldestAge = null;
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.mail.queue-card'); }
|
||||
public function refresh(): void { $this->load(); }
|
||||
|
||||
protected function load(): void
|
||||
{
|
||||
$out = trim(@shell_exec('postqueue -p 2>/dev/null') ?? '');
|
||||
$this->active = preg_match_all('/^[A-F0-9]{10}\*?\s+/mi', $out); // grob
|
||||
$this->deferred = preg_match_all('/\s+(\(deferred\))/mi', $out);
|
||||
// älteste Mail grob: erste Queue-ID-Zeile → Zeit parsen (optional)
|
||||
$this->oldestAge = $this->active + $this->deferred > 0 ? '~'.date('H:i') : '–';
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
@shell_exec('postqueue -f >/dev/null 2>&1 &');
|
||||
$this->dispatch('toast', type:'info', title:'Queue flush gestartet');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
// 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') {
|
||||
$this->dispatch('focus:domain', id: $id);
|
||||
$component = 'ui.domain.modal.domain-edit-modal';
|
||||
$arguments = ['domainId' => $id];
|
||||
} elseif ($type === 'mailbox') {
|
||||
// direkt Edit-Modal auf
|
||||
$this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]);
|
||||
} elseif ($type === 'user') {
|
||||
$this->dispatch('focus:user', id: $id);
|
||||
$component = 'ui.mail.modal.mailbox-edit-modal';
|
||||
$arguments = ['mailboxId' => $id];
|
||||
} else {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,12 +2,82 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('Audit-Logs · Mailwolt')]
|
||||
class AuditLogsTable extends Component
|
||||
{
|
||||
#[Url(as: 'q', keep: true)]
|
||||
public string $search = '';
|
||||
|
||||
#[Url(as: 'lvl', keep: true)]
|
||||
public string $level = '';
|
||||
|
||||
public int $limit = 200;
|
||||
|
||||
public function loadMore(): void
|
||||
{
|
||||
$this->limit += 200;
|
||||
}
|
||||
|
||||
private function parseLogs(): array
|
||||
{
|
||||
$logFile = storage_path('logs/laravel.log');
|
||||
if (!is_readable($logFile)) return [];
|
||||
|
||||
$fp = @fopen($logFile, 'r');
|
||||
if (!$fp) return [];
|
||||
$size = filesize($logFile);
|
||||
$chunk = 250_000;
|
||||
fseek($fp, max(0, $size - $chunk));
|
||||
$raw = fread($fp, $chunk);
|
||||
fclose($fp);
|
||||
if (!$raw) return [];
|
||||
|
||||
$lines = explode("\n", $raw);
|
||||
|
||||
$lines = array_slice($lines, -2000);
|
||||
|
||||
$entries = [];
|
||||
$current = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.+)/', $line, $m)) {
|
||||
if ($current !== null) $entries[] = $current;
|
||||
$current = [
|
||||
'time' => str_replace('T', ' ', $m[1]),
|
||||
'level' => strtolower($m[2]),
|
||||
'message' => trim($m[3]),
|
||||
];
|
||||
} elseif ($current !== null) {
|
||||
$current['message'] .= "\n" . trim($line);
|
||||
}
|
||||
}
|
||||
if ($current !== null) $entries[] = $current;
|
||||
|
||||
$entries = array_reverse($entries);
|
||||
|
||||
$filtered = [];
|
||||
foreach ($entries as $e) {
|
||||
if ($this->level && $e['level'] !== $this->level) continue;
|
||||
if ($this->search !== '' && !str_contains(strtolower($e['message']), strtolower($this->search))) continue;
|
||||
$e['message'] = Str::limit(preg_replace('/\s+/', ' ', $e['message']), 300);
|
||||
$filtered[] = $e;
|
||||
if (count($filtered) >= $this->limit) break;
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
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,964 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Fail2BanCard extends Component
|
||||
{
|
||||
public bool $available = true;
|
||||
public bool $permDenied = false;
|
||||
public bool $error = false;
|
||||
public int $activeBans = 0;
|
||||
public array $jails = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.fail2-ban-card');
|
||||
}
|
||||
|
||||
#[On('f2b:refresh-banlist')]
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->load(true);
|
||||
}
|
||||
|
||||
public function openDetails(string $jail): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
|
||||
}
|
||||
|
||||
/* ---------------- intern ---------------- */
|
||||
|
||||
protected function load(bool $force = false): void
|
||||
{
|
||||
$this->available = $this->permDenied = $this->error = false;
|
||||
$this->activeBans = 0;
|
||||
$this->jails = [];
|
||||
|
||||
$bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
|
||||
if ($bin === '') {
|
||||
$this->available = false;
|
||||
return;
|
||||
}
|
||||
$this->available = true;
|
||||
|
||||
[, $ping] = $this->f2b('ping');
|
||||
if ($this->looksDenied($ping)) {
|
||||
$this->permDenied = true;
|
||||
return;
|
||||
}
|
||||
|
||||
[, $status] = $this->f2b('status');
|
||||
if ($this->looksDenied($status)) {
|
||||
$this->permDenied = true;
|
||||
return;
|
||||
}
|
||||
if (!preg_match('/Jail list:\s*(.+)$/mi', $status, $mm)) {
|
||||
$this->error = true;
|
||||
Log::warning('Fail2BanCard: unexpected status output', ['status' => $status]);
|
||||
return;
|
||||
}
|
||||
|
||||
$jails = array_filter(array_map('trim', preg_split('/\s*,\s*/', $mm[1] ?? '')));
|
||||
$sum = 0;
|
||||
$rows = [];
|
||||
|
||||
foreach ($jails as $j) {
|
||||
$jEsc = escapeshellarg($j);
|
||||
[, $s] = $this->f2b("status {$jEsc}");
|
||||
if ($this->looksDenied($s)) {
|
||||
$this->permDenied = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
$bantime = $this->getBantime($j);
|
||||
|
||||
$rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime, 'ips' => []];
|
||||
$sum += $banned;
|
||||
}
|
||||
|
||||
$this->activeBans = $sum;
|
||||
$this->jails = $rows;
|
||||
}
|
||||
|
||||
private function f2b(string $args): array
|
||||
{
|
||||
$sudo = $this->bin('sudo');
|
||||
$f2b = $this->bin('fail2ban-client');
|
||||
$cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
|
||||
$out = (string)@shell_exec($cmd);
|
||||
|
||||
$ok = stripos($out, 'Status') !== false
|
||||
|| stripos($out, 'Jail list') !== false
|
||||
|| stripos($out, 'pong') !== false;
|
||||
|
||||
return [$ok, $out];
|
||||
}
|
||||
|
||||
private function getBantime(string $jail): int
|
||||
{
|
||||
[, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
|
||||
if ($this->looksDenied($out)) {
|
||||
$this->permDenied = true;
|
||||
return 600;
|
||||
}
|
||||
if (preg_match('/-?\d+/', trim($out), $m)) return (int)$m[0];
|
||||
return 600;
|
||||
}
|
||||
|
||||
private function looksDenied(string $out): bool
|
||||
{
|
||||
return (bool)preg_match('/(permission denied|not allowed to execute|a password is required)/i', $out);
|
||||
}
|
||||
|
||||
private function firstMatch(string $pattern, string $haystack): ?string
|
||||
{
|
||||
return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
|
||||
}
|
||||
|
||||
private function bin(string $name): string
|
||||
{
|
||||
$p = trim((string)@shell_exec("command -v " . escapeshellarg($name) . " 2>/dev/null"));
|
||||
return $p !== '' ? $p : $name;
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Illuminate\Support\Facades\Log;
|
||||
//use Livewire\Attributes\On;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class Fail2BanCard extends Component
|
||||
//{
|
||||
// public bool $available = true; // fail2ban-client vorhanden?
|
||||
// public bool $permDenied = false; // sudo / Socket-Rechte fehlen?
|
||||
// public bool $error = false; // anderer Fehler (Output unerwartet)
|
||||
// public int $activeBans = 0;
|
||||
// public array $jails = []; // [['name','banned','bantime','ips'=>[...]]]
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.fail2-ban-card');
|
||||
// }
|
||||
//
|
||||
// #[On('f2b:refresh-banlist')]
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// public function openDetails(string $jail): void
|
||||
// {
|
||||
// // wire-elements/modal (v2): Event-Namen + Component + Params
|
||||
// $this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
|
||||
// }
|
||||
//
|
||||
// /* ------------------- intern ------------------- */
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// $this->available = true;
|
||||
// $this->permDenied = false;
|
||||
// $this->error = false;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
//
|
||||
// // existiert fail2ban-client?
|
||||
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
|
||||
// if ($bin === '') {
|
||||
// $this->available = false;
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Rechte / Erreichbarkeit
|
||||
// [, $ping] = $this->f2b('ping');
|
||||
// if ($this->looksDenied($ping)) {
|
||||
// $this->permDenied = true;
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Jails lesen
|
||||
// [, $status] = $this->f2b('status');
|
||||
// if ($this->looksDenied($status)) {
|
||||
// $this->permDenied = true;
|
||||
// return;
|
||||
// }
|
||||
// if (!preg_match('/Jail list:\s*(.+)$/mi', $status, $mm)) {
|
||||
// // etwas stimmt nicht – loggen und „error“ zeigen
|
||||
// $this->error = true;
|
||||
// Log::warning('Fail2BanCard: unexpected status output', ['status' => $status]);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $jails = array_filter(array_map('trim', preg_split('/\s*,\s*/', $mm[1] ?? '')));
|
||||
// $sum = 0;
|
||||
// $rows = [];
|
||||
//
|
||||
// foreach ($jails as $j) {
|
||||
// $jEsc = escapeshellarg($j);
|
||||
// [, $s] = $this->f2b("status {$jEsc}");
|
||||
// if ($this->looksDenied($s)) {
|
||||
// $this->permDenied = true;
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
// $bantime = $this->getBantime($j);
|
||||
// $ipLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||
// $ips = $ipLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipLine)))) : [];
|
||||
//
|
||||
// $rows[] = [
|
||||
// 'name' => $j,
|
||||
// 'banned' => $banned,
|
||||
// 'bantime' => $bantime,
|
||||
// // wir zeigen IPs NICHT mehr in der Card; Details sind im Modal
|
||||
// 'ips' => [],
|
||||
// ];
|
||||
// $sum += $banned;
|
||||
// }
|
||||
//
|
||||
// $this->activeBans = $sum;
|
||||
// $this->jails = $rows;
|
||||
// }
|
||||
//
|
||||
// private function f2b(string $args): array
|
||||
// {
|
||||
// $sudo = '/usr/bin/sudo';
|
||||
// $f2b = '/usr/bin/fail2ban-client';
|
||||
// $cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
|
||||
// $out = (string)@shell_exec($cmd);
|
||||
//
|
||||
// $ok = stripos($out, 'Status') !== false
|
||||
// || stripos($out, 'Jail list') !== false
|
||||
// || stripos($out, 'pong') !== false;
|
||||
//
|
||||
// return [$ok, $out];
|
||||
// }
|
||||
//
|
||||
// private function getBantime(string $jail): int
|
||||
// {
|
||||
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
|
||||
// if ($this->looksDenied($out)) {
|
||||
// $this->permDenied = true;
|
||||
// return 600;
|
||||
// }
|
||||
// $val = trim($out);
|
||||
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
|
||||
// return 600;
|
||||
// }
|
||||
//
|
||||
// private function looksDenied(string $out): bool
|
||||
// {
|
||||
// return preg_match('/(permission denied|not allowed to execute|a password is required)/i', $out) === 1;
|
||||
// }
|
||||
//
|
||||
// private function firstMatch(string $pattern, string $haystack): ?string
|
||||
// {
|
||||
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
|
||||
// }
|
||||
//}
|
||||
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Attributes\On;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class Fail2BanCard extends Component
|
||||
//{
|
||||
// public bool $available = true;
|
||||
// public bool $permDenied = false;
|
||||
// public int $activeBans = 0;
|
||||
// public array $jails = [];
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.fail2-ban-card');
|
||||
// }
|
||||
//
|
||||
// #[On('f2b:refresh-banlist')]
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// public function openDetails(string $jail): void
|
||||
// {
|
||||
// // KORREKTER DISPATCH für wire-elements/modal
|
||||
// $this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
|
||||
// }
|
||||
//
|
||||
// /* ------------------- intern ------------------- */
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
|
||||
// if ($bin === '') {
|
||||
// $this->available = false;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Rechte prüfen
|
||||
// [$ok, $raw] = $this->f2b('ping');
|
||||
// if (!$ok && stripos($raw, 'permission denied') !== false) {
|
||||
// $this->available = true;
|
||||
// $this->permDenied = true;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Jail-Liste
|
||||
// [, $status] = $this->f2b('status');
|
||||
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
|
||||
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
|
||||
//
|
||||
// $rows = [];
|
||||
// $sum = 0;
|
||||
//
|
||||
// foreach ($jails as $j) {
|
||||
// $jEsc = escapeshellarg($j);
|
||||
// [, $s] = $this->f2b("status {$jEsc}");
|
||||
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
// $bantime = $this->getBantime($j);
|
||||
// $ipListLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||
// $ips = $ipListLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipListLine)))) : [];
|
||||
//
|
||||
// // Details inkl. Restzeit je IP
|
||||
// $ipDetails = $this->buildIpDetails($j, $ips, $bantime);
|
||||
//
|
||||
// $rows[] = [
|
||||
// 'name' => $j,
|
||||
// 'banned' => $banned,
|
||||
// 'bantime' => $bantime, // Sek. (-1 = permanent)
|
||||
// 'ips' => $ipDetails, // [['ip'=>..., 'remaining'=>..., 'until'=>...], ...]
|
||||
// ];
|
||||
// $sum += $banned;
|
||||
// }
|
||||
//
|
||||
// $this->available = true;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = $sum;
|
||||
// $this->jails = $rows;
|
||||
// }
|
||||
//
|
||||
// private function f2b(string $args): array
|
||||
// {
|
||||
// $sudo = '/usr/bin/sudo';
|
||||
// $f2b = '/usr/bin/fail2ban-client';
|
||||
// $cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
|
||||
// $out = (string)@shell_exec($cmd);
|
||||
//
|
||||
// $ok = stripos($out, 'Status') !== false
|
||||
// || stripos($out, 'Jail list') !== false
|
||||
// || stripos($out, 'pong') !== false;
|
||||
//
|
||||
// return [$ok, $out];
|
||||
// }
|
||||
//
|
||||
// /** konfig. Bantime des Jails in Sekunden (-1 = permanent) */
|
||||
// private function getBantime(string $jail): int
|
||||
// {
|
||||
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
|
||||
// $val = trim($out);
|
||||
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
|
||||
// return 600; // konservativer Fallback
|
||||
// }
|
||||
//
|
||||
// /** Letzten Ban-Zeitpunkt (Unix-Timestamp) aus /var/log/fail2ban.log ermitteln. */
|
||||
// private function lastBanTimestamp(string $jail, string $ip): ?int
|
||||
// {
|
||||
// $file = '/var/log/fail2ban.log';
|
||||
// if (!is_readable($file)) return null;
|
||||
//
|
||||
// // nur das Ende der Datei lesen (Performance, auch bei Rotation groß genug wählen)
|
||||
// $tailBytes = 400000; // 400 KB
|
||||
// $size = @filesize($file) ?: 0;
|
||||
// $seek = max(0, $size - $tailBytes);
|
||||
//
|
||||
// $fh = @fopen($file, 'rb');
|
||||
// if (!$fh) return null;
|
||||
// if ($seek > 0) fseek($fh, $seek);
|
||||
// $data = stream_get_contents($fh) ?: '';
|
||||
// fclose($fh);
|
||||
//
|
||||
// // Beispielzeile:
|
||||
// // 2025-10-30 22:34:20,797 fail2ban.actions [...] NOTICE [sshd] Ban 193.46.255.244
|
||||
// $j = preg_quote($jail, '/');
|
||||
// $p = preg_quote($ip, '/');
|
||||
// $pattern = '/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}),\d+.*\['.$j.'\]\s+Ban\s+'.$p.'\s*$/m';
|
||||
//
|
||||
// if (preg_match_all($pattern, $data, $m) && !empty($m[1])) {
|
||||
// $date = end($m[1]); // YYYY-MM-DD
|
||||
// $time = end($m[2]); // HH:MM:SS
|
||||
// $dt = \DateTime::createFromFormat('Y-m-d H:i:s', "$date $time", new \DateTimeZone(date_default_timezone_get()));
|
||||
// return $dt ? $dt->getTimestamp() : null;
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// /** Baut Details inkl. Restzeit (Sekunden; -1 = permanent). */
|
||||
// private function buildIpDetails(string $jail, array $ips, int $bantime): array
|
||||
// {
|
||||
// $now = time();
|
||||
// $out = [];
|
||||
//
|
||||
// foreach ($ips as $ip) {
|
||||
// $banAt = $this->lastBanTimestamp($jail, $ip);
|
||||
// $remaining = null;
|
||||
// $until = null;
|
||||
//
|
||||
// if ($bantime === -1) {
|
||||
// $remaining = -1; // permanent
|
||||
// } elseif ($banAt !== null) {
|
||||
// $remaining = max(0, $bantime - ($now - $banAt));
|
||||
// $until = $remaining > 0 ? ($banAt + $bantime) : null;
|
||||
// }
|
||||
//
|
||||
// $out[] = [
|
||||
// 'ip' => $ip,
|
||||
// 'remaining' => $remaining, // -1 = permanent, null = Ban-Zeitpunkt nicht gefunden, >=0 = Sekunden
|
||||
// 'until' => $until, // Unix-Timestamp oder null
|
||||
// ];
|
||||
// }
|
||||
// return $out;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// private function firstMatch(string $pattern, string $haystack): ?string
|
||||
// {
|
||||
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class Fail2BanCard extends Component
|
||||
//{
|
||||
// public bool $available = true; // fail2ban-client vorhanden?
|
||||
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
|
||||
// public int $activeBans = 0; // Summe gebannter IPs
|
||||
// /** @var array<int,array{name:string,banned:int,bantime:int}> */
|
||||
// public array $jails = [];
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.fail2-ban-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// // Optional: öffnet später dein Detail-Modal/Tab
|
||||
// public function openDetails(string $jail): void
|
||||
// {
|
||||
// $this->dispatch('openModal', 'ui.security.modal.fail2-ban-jail-modal', ['jail' => $jail]);
|
||||
// }
|
||||
// /* ---------------- intern ---------------- */
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
|
||||
// if ($bin === '') {
|
||||
// $this->available = false;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Rechtecheck
|
||||
// [$ok, $raw] = $this->f2b('ping');
|
||||
// if (!$ok && stripos($raw, 'permission denied') !== false) {
|
||||
// $this->available = true;
|
||||
// $this->permDenied = true;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Jails laden
|
||||
// [, $status] = $this->f2b('status');
|
||||
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
|
||||
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
|
||||
//
|
||||
// $rows = [];
|
||||
// $sum = 0;
|
||||
//
|
||||
// foreach ($jails as $j) {
|
||||
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
|
||||
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
// $bantime = $this->getBantime($j); // Sek.; -1 = permanent
|
||||
// $rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime];
|
||||
// $sum += $banned;
|
||||
// }
|
||||
//
|
||||
// $this->available = true;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = $sum;
|
||||
// $this->jails = $rows;
|
||||
// }
|
||||
//
|
||||
// /** sudo + fail2ban-client ausführen; [ok, output] */
|
||||
// private function f2b(string $args): array
|
||||
// {
|
||||
// $sudo = '/usr/bin/sudo';
|
||||
// $f2b = '/usr/bin/fail2ban-client';
|
||||
// $out = (string)@shell_exec("timeout 2 $sudo -n $f2b $args 2>&1");
|
||||
// $ok = stripos($out, 'Status') !== false
|
||||
// || stripos($out, 'Jail list') !== false
|
||||
// || stripos($out, 'pong') !== false;
|
||||
// return [$ok, $out];
|
||||
// }
|
||||
//
|
||||
// private function getBantime(string $jail): int
|
||||
// {
|
||||
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
|
||||
// $val = trim($out);
|
||||
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
|
||||
// return 600; // defensiver Default
|
||||
// }
|
||||
//
|
||||
// private function firstMatch(string $pattern, string $haystack): ?string
|
||||
// {
|
||||
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class Fail2BanCard extends Component
|
||||
//{
|
||||
// public bool $available = true; // fail2ban-client vorhanden?
|
||||
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
|
||||
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
|
||||
// public array $jails = []; // [['name','banned','bantime','ips'=>[['ip','remaining','until'],...]],...]
|
||||
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.fail2-ban-card');
|
||||
// }
|
||||
//
|
||||
// /** Button „Neu prüfen“ */
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// /* --------------------- intern --------------------- */
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// // existiert fail2ban-client?
|
||||
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
|
||||
// if ($bin === '') {
|
||||
// $this->available = false;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// $this->topIps = [];
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
|
||||
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
|
||||
// if (!$ok && stripos($raw, 'permission denied') !== false) {
|
||||
// $this->available = true;
|
||||
// $this->permDenied = true;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// $this->topIps = $this->collectTopIps();
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Jails auflisten
|
||||
// [, $status] = $this->f2b('status');
|
||||
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
|
||||
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
|
||||
//
|
||||
// $total = 0;
|
||||
// $rows = [];
|
||||
//
|
||||
// foreach ($jails as $j) {
|
||||
// $bantimeSecs = $this->getBantime($j); // Sek., -1 = permanent
|
||||
//
|
||||
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
|
||||
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
||||
//
|
||||
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
|
||||
// $ipDetails = [];
|
||||
// foreach (array_slice($ips, 0, 50) as $ip) {
|
||||
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
|
||||
// $remaining = null;
|
||||
// $until = null;
|
||||
//
|
||||
// if ($banAt !== null) {
|
||||
// if ((int)$bantimeSecs === -1) {
|
||||
// $remaining = -1; // permanent
|
||||
// } else {
|
||||
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
|
||||
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $ipDetails[] = [
|
||||
// 'ip' => $ip,
|
||||
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
|
||||
// 'until' => $until, // Unix-Timestamp oder null
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// $rows[] = [
|
||||
// 'name' => $j,
|
||||
// 'banned' => $banned,
|
||||
// 'ips' => $ipDetails,
|
||||
// 'bantime' => (int)$bantimeSecs,
|
||||
// ];
|
||||
// $total += $banned;
|
||||
// }
|
||||
//
|
||||
// $this->available = true;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = $total;
|
||||
// $this->jails = $rows;
|
||||
// $this->topIps = $this->collectTopIps();
|
||||
// }
|
||||
//
|
||||
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
|
||||
// private function f2b(string $args): array
|
||||
// {
|
||||
// $sudo = '/usr/bin/sudo';
|
||||
// $f2b = '/usr/bin/fail2ban-client';
|
||||
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
|
||||
// $out = (string)@shell_exec($cmd);
|
||||
//
|
||||
// $ok = stripos($out, 'Status') !== false
|
||||
// || stripos($out, 'Jail list') !== false
|
||||
// || stripos($out, 'pong') !== false;
|
||||
//
|
||||
// return [$ok, $out];
|
||||
// }
|
||||
//
|
||||
// private function getBantime(string $jail): int
|
||||
// {
|
||||
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
|
||||
// $val = trim($out);
|
||||
// if (preg_match('/-?\d+/', $val, $m)) {
|
||||
// return (int)$m[0];
|
||||
// }
|
||||
// return 600; // defensiver Default
|
||||
// }
|
||||
//
|
||||
// /** letzte Ban-Zeile aus /var/log/fail2ban.log → Unix-Timestamp */
|
||||
// private function lastBanTimestamp(string $jail, string $ip): ?int
|
||||
// {
|
||||
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
|
||||
// $line = trim((string)@shell_exec($cmd));
|
||||
// if ($line === '') return null;
|
||||
//
|
||||
// // "YYYY-MM-DD HH:MM:SS,mmm ..."
|
||||
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
|
||||
// $ts = strtotime($m[1] . ' ' . $m[2]);
|
||||
// return $ts ?: null;
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// private function firstMatch(string $pattern, string $haystack): ?string
|
||||
// {
|
||||
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
|
||||
// }
|
||||
//
|
||||
// /** Top-IPs grob zählen (aus der aktuellen Jail-Liste; Fallback: Log) */
|
||||
// private function collectTopIps(): array
|
||||
// {
|
||||
// $map = [];
|
||||
// foreach ($this->jails as $jail) {
|
||||
// foreach ($jail['ips'] as $row) {
|
||||
// $ip = $row['ip'] ?? null;
|
||||
// if (!$ip) continue;
|
||||
// $map[$ip] = ($map[$ip] ?? 0) + 1;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (!empty($map)) {
|
||||
// arsort($map);
|
||||
// $out = [];
|
||||
// foreach (array_slice($map, 0, 5, true) as $ip => $count) {
|
||||
// $out[] = ['ip' => $ip, 'count' => $count];
|
||||
// }
|
||||
// return $out;
|
||||
// }
|
||||
//
|
||||
// // Fallback: aus fail2ban.log
|
||||
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
|
||||
// . ' | sort | uniq -c | sort -nr | head -5';
|
||||
// $log = (string)@shell_exec($cmd);
|
||||
// $rows = [];
|
||||
// if ($log !== '') {
|
||||
// foreach (preg_split('/\R+/', trim($log)) as $l) {
|
||||
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
|
||||
// $rows[] = ['ip' => $m[2], 'count' => (int)$m[1]];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return $rows;
|
||||
// }
|
||||
//}
|
||||
|
||||
//
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class Fail2BanCard extends Component
|
||||
//{
|
||||
// public bool $available = true; // fail2ban-client vorhanden?
|
||||
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
|
||||
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
|
||||
// public array $jails = []; // [['name'=>'sshd','banned'=>2,'ips'=>['1.2.3.4',...]], ...]
|
||||
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.fail2-ban-card');
|
||||
// }
|
||||
//
|
||||
// // Wird vom Button "Neu prüfen" genutzt
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// /* --------------------- intern --------------------- */
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// // existiert fail2ban-client?
|
||||
// $bin = trim((string) @shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
|
||||
// if ($bin === '') {
|
||||
// $this->available = false;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// $this->topIps = [];
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
|
||||
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
|
||||
// if (!$ok && stripos($raw, 'permission denied') !== false) {
|
||||
// $this->available = true;
|
||||
// $this->permDenied = true;
|
||||
// $this->activeBans = 0;
|
||||
// $this->jails = [];
|
||||
// $this->topIps = $this->collectTopIps();
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Jails auflisten
|
||||
// [, $status] = $this->f2b('status');
|
||||
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
|
||||
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
|
||||
//
|
||||
// $total = 0; $rows = [];
|
||||
//// ... in load() NACH dem Einlesen der Jail-Liste:
|
||||
// $rows = [];
|
||||
// foreach ($jails as $j) {
|
||||
// $bantimeSecs = $this->getBantime($j); // konfigurierter Wert (Sekunden, -1 = permanent)
|
||||
//
|
||||
// [, $s] = $this->f2b('status '.escapeshellarg($j));
|
||||
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
||||
//
|
||||
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
|
||||
// $ipDetails = [];
|
||||
// foreach (array_slice($ips, 0, 50) as $ip) {
|
||||
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
|
||||
// $remaining = null;
|
||||
// $until = null;
|
||||
//
|
||||
// if ($banAt !== null) {
|
||||
// if ((int)$bantimeSecs === -1) {
|
||||
// $remaining = -1; // permanent
|
||||
// } else {
|
||||
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
|
||||
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $ipDetails[] = [
|
||||
// 'ip' => $ip,
|
||||
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
|
||||
// 'until' => $until, // Unix-Timestamp oder null
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// $rows[] = [
|
||||
// 'name' => $j,
|
||||
// 'banned' => $banned,
|
||||
// 'ips' => $ipDetails, // jetzt mit Details
|
||||
// 'bantime' => (int)$bantimeSecs,
|
||||
// ];
|
||||
// $total += $banned;
|
||||
// }
|
||||
//
|
||||
// // foreach ($jails as $j) {
|
||||
//// [, $s] = $this->f2b('status '.escapeshellarg($j));
|
||||
//// $banned = (int) ($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
|
||||
//// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
|
||||
//// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
|
||||
//// $rows[] = ['name'=>$j,'banned'=>$banned,'ips'=>array_slice($ips, 0, 8)];
|
||||
//// $total += $banned;
|
||||
//// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// $this->available = true;
|
||||
// $this->permDenied = false;
|
||||
// $this->activeBans = $total;
|
||||
// $this->jails = $rows;
|
||||
// $this->topIps = $this->collectTopIps();
|
||||
// }
|
||||
//
|
||||
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
|
||||
// private function f2b(string $args): array
|
||||
// {
|
||||
// $sudo = '/usr/bin/sudo';
|
||||
// $f2b = '/usr/bin/fail2ban-client';
|
||||
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
|
||||
// $out = (string) @shell_exec($cmd);
|
||||
//
|
||||
// $ok = stripos($out, 'Status') !== false
|
||||
// || stripos($out, 'Jail list') !== false
|
||||
// || stripos($out, 'pong') !== false;
|
||||
//
|
||||
// return [$ok, $out];
|
||||
// }
|
||||
//
|
||||
// private function getBantime(string $jail): int
|
||||
// {
|
||||
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
|
||||
// // fail2ban liefert Seconds als Zahl (oder mit Newline)
|
||||
// $val = trim($out);
|
||||
// // Fallback: manche Versionen geben nur Zahl ohne Kontext zurück,
|
||||
// // sonst aus jail.local ermitteln wäre overkill -> einfache Zahl extrahieren:
|
||||
// if (preg_match('/-?\d+/', $val, $m)) {
|
||||
// return (int)$m[0];
|
||||
// }
|
||||
// // wenn nicht ermittelbar: 600 Sekunden als conservative default
|
||||
// return 600;
|
||||
// }
|
||||
//
|
||||
// /** Sucht die letzte "Ban <IP>"-Zeile für Jail in /var/log/fail2ban.log und gibt Unix-Timestamp zurück. */
|
||||
// private function lastBanTimestamp(string $jail, string $ip): ?int
|
||||
// {
|
||||
// // Beispiel-Logzeilen:
|
||||
// // 2025-10-29 18:07:11,436 fail2ban.actions [12345]: NOTICE [sshd] Ban 1.2.3.4
|
||||
// // Wir holen die letzte passende Zeile (tail mit grep), dann parsen Datum.
|
||||
// $pattern = escapeshellarg(sprintf('\\[%s\\] Ban %s', $jail, $ip));
|
||||
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
|
||||
// $line = (string)@shell_exec($cmd);
|
||||
// $line = trim($line);
|
||||
// if ($line === '') {
|
||||
// return null;
|
||||
// }
|
||||
// // Datumsformat am Anfang: "YYYY-MM-DD HH:MM:SS,mmm"
|
||||
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
|
||||
// $ts = strtotime($m[1].' '.$m[2]);
|
||||
// return $ts ?: null;
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// private function firstMatch(string $pattern, string $haystack): ?string
|
||||
// {
|
||||
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
|
||||
// }
|
||||
//
|
||||
// /** Zählt die häufigsten IPs aus den letzten Fail2Ban-Logs (ban/unban Events) */
|
||||
// private function collectTopIps(): array
|
||||
// {
|
||||
// // 1. Versuch: IPs direkt aus den Jails
|
||||
// $rows = [];
|
||||
// foreach ($this->jails as $jail) {
|
||||
// foreach ($jail['ips'] as $ip) {
|
||||
// $rows[$ip] = ($rows[$ip] ?? 0) + 1;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (!empty($rows)) {
|
||||
// arsort($rows);
|
||||
// return collect($rows)
|
||||
// ->map(fn($count, $ip) => ['ip' => $ip, 'count' => $count])
|
||||
// ->values()
|
||||
// ->take(5)
|
||||
// ->toArray();
|
||||
// }
|
||||
//
|
||||
// // 2. Fallback: Falls keine Jails/IPs → Logdatei
|
||||
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
|
||||
// . ' | sort | uniq -c | sort -nr | head -5';
|
||||
// $log = (string) @shell_exec($cmd);
|
||||
//
|
||||
// $rows = [];
|
||||
// if ($log !== '') {
|
||||
// foreach (preg_split('/\R+/', trim($log)) as $l) {
|
||||
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
|
||||
// $rows[] = ['ip'=>$m[2],'count'=>(int)$m[1]];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return $rows;
|
||||
// }
|
||||
//}
|
||||
|
|
@ -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');
|
||||
// }
|
||||
//}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Security\Modal\Fail2BanJailModal;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Php extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.modal.fail2-ban-jail-modal.php');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
use App\Services\TotpService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Vectorface\GoogleAuthenticator;
|
||||
|
||||
class TotpSetupModal extends ModalComponent
|
||||
{
|
||||
public string $secret;
|
||||
public string $otp = '';
|
||||
public string $qrPng; // PNG Data-URI
|
||||
public bool $alreadyActive = false;
|
||||
public string $step = 'scan';
|
||||
public string $code = '';
|
||||
public string $secret = '';
|
||||
public array $recoveryCodes = [];
|
||||
public string $qrSvg = '';
|
||||
|
||||
// << Wichtig: je Modal eigene Breite >>
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
|
||||
return 'xl'; // kompakt für TOTP
|
||||
}
|
||||
public static function modalMaxWidth(): string { return 'md'; }
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$ga = new GoogleAuthenticator();
|
||||
|
||||
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
|
||||
$this->secret = $user->totp_secret ?: $ga->createSecret();
|
||||
|
||||
$issuer = config('app.name', 'MailWolt');
|
||||
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
|
||||
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
|
||||
|
||||
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
|
||||
$totp = app(TotpService::class);
|
||||
$this->secret = $totp->generateSecret();
|
||||
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
|
||||
}
|
||||
|
||||
#[On('security:totp:enable')]
|
||||
public function verifyAndEnable(string $code): void
|
||||
public function verify(): void
|
||||
{
|
||||
$code = preg_replace('/\D/', '', $code ?? '');
|
||||
if (strlen($code) !== 6) {
|
||||
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
|
||||
$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;
|
||||
}
|
||||
|
||||
$ga = new GoogleAuthenticator();
|
||||
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
|
||||
|
||||
if (!$ok) {
|
||||
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$user->totp_secret = $this->secret;
|
||||
$user->two_factor_enabled = true;
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('totp-enabled');
|
||||
$this->dispatch('toast', body: 'TOTP aktiviert.');
|
||||
$this->dispatch('closeModal');
|
||||
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
|
||||
$this->step = 'codes';
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
public function done(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
$user->totp_secret = null;
|
||||
$user->two_factor_enabled = false;
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('totp-disabled');
|
||||
$this->dispatch('toast', body: 'TOTP deaktiviert.');
|
||||
$this->dispatch('closeModal');
|
||||
$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 saveAccount() { /* $this->validate(..); user->update([...]) */ }
|
||||
public function changePassword() { /* validate & set */ }
|
||||
public function changeEmail() { /* validate, send verify link, etc. */ }
|
||||
|
||||
public function openRecovery() { /* optional modal or page */ }
|
||||
public function logoutOthers() { /* … */ }
|
||||
public function logoutSession(string $id) { /* … */ }
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.modal.totp-setup-modal');
|
||||
return view('livewire.ui.system.modal.totp-setup-modal');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
<?php
|
||||
// App\Livewire\Ui\Security\RblCard.php
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use App\Models\Setting;
|
||||
|
||||
class RblCard extends Component
|
||||
{
|
||||
public string $ip = '–';
|
||||
public ?string $ipv4 = null;
|
||||
public ?string $ipv6 = null;
|
||||
|
||||
public int $hits = 0;
|
||||
public array $lists = []; // nur gelistete Zonen
|
||||
public array $meta = []; // status je Zone
|
||||
public ?string $checkedAt = null;
|
||||
public ?string $validUntil = null;
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.security.rbl-card'); }
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
// Manuelles Re-Check via Command (asynchron, damit UI nicht blockiert)
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
|
||||
// Sofortige UI-Aktualisierung aus Settings (altes Ergebnis) …
|
||||
$this->load(true);
|
||||
// … und kurzer Hinweis
|
||||
$this->dispatch('toast', type:'info', title:'RBL-Prüfung gestartet', text:'Ergebnis wird aktualisiert, sobald verfügbar.', duration:2500);
|
||||
}
|
||||
|
||||
protected function load(bool $force = false): void
|
||||
{
|
||||
$payload = $force
|
||||
? (array) Setting::get('health.rbl', []) // direkt aus DB
|
||||
: (array) (Cache::get('health.rbl') ?: Setting::get('health.rbl', []));
|
||||
|
||||
$this->ip = (string)($payload['ip'] ?? '–');
|
||||
$this->ipv4 = $payload['ipv4'] ?? null;
|
||||
$this->ipv6 = $payload['ipv6'] ?? null;
|
||||
$this->hits = (int)($payload['hits'] ?? 0);
|
||||
$this->lists = (array)($payload['lists'] ?? []);
|
||||
$this->meta = (array)($payload['meta'] ?? []);
|
||||
$this->checkedAt = $payload['checked_at'] ?? null;
|
||||
$this->validUntil = $payload['valid_until'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class RblCard extends Component
|
||||
//{
|
||||
// public string $ip = '–';
|
||||
// public int $hits = 0;
|
||||
// public array $lists = [];
|
||||
//
|
||||
// public ?string $ipv4 = null;
|
||||
// public ?string $ipv6 = null;
|
||||
//
|
||||
// // Schalte registrierungspflichtige Listen (Barracuda etc.) optional zu
|
||||
// private bool $includeRegistered = false; // env('RBL_INCLUDE_REGISTERED', false) wenn du willst
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.rbl-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// Cache::forget('dash.rbl');
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// [$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
|
||||
//
|
||||
// $this->ipv4 = $ip4 ?: trim((string)env('SERVER_PUBLIC_IPV4', '')) ?: '–';
|
||||
// $this->ipv6 = $ip6 ?: trim((string)env('SERVER_PUBLIC_IPV6', '')) ?: '–';
|
||||
//
|
||||
// $data = Cache::remember('dash.rbl', $force ? 1 : 6 * 3600, function () {
|
||||
// $candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
|
||||
//
|
||||
// if (!$candidate) {
|
||||
// $fromFile = trim((string)@file_get_contents('/etc/mailwolt/public_ip'));
|
||||
// if ($this->validIPv4($fromFile)) $candidate = $fromFile;
|
||||
// }
|
||||
// if (!$candidate) {
|
||||
// $curl = trim((string)@shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null"));
|
||||
// if ($this->validIPv4($curl)) $candidate = $curl;
|
||||
// }
|
||||
//
|
||||
// $ip = $candidate ?: '0.0.0.0';
|
||||
// $lists = $this->queryRblLists($ip);
|
||||
//
|
||||
// return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
|
||||
// });
|
||||
//
|
||||
// foreach ($data as $k => $v) $this->$k = $v;
|
||||
// }
|
||||
//
|
||||
// /** bevorzugt Installer-ENV */
|
||||
// private function resolvePublicIpsFromInstallerEnv(): array
|
||||
// {
|
||||
// $file = '/etc/mailwolt/installer.env';
|
||||
// if (!is_readable($file)) return [null, null];
|
||||
//
|
||||
// $ipv4 = $ipv6 = null;
|
||||
// $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
// foreach ($lines as $line) {
|
||||
// if (preg_match('/^\s*#/', $line) || !str_contains($line, '=')) continue;
|
||||
// [$k, $v] = array_map('trim', explode('=', $line, 2));
|
||||
// $v = trim($v, " \t\n\r\0\x0B\"'");
|
||||
// if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
|
||||
// if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
|
||||
// }
|
||||
// return [$ipv4, $ipv6];
|
||||
// }
|
||||
//
|
||||
// private function validIPv4(?string $ip): bool
|
||||
// {
|
||||
// return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
|
||||
// }
|
||||
//
|
||||
// private function validIPv6(?string $ip): bool
|
||||
// {
|
||||
// return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Prüft die IP gegen gängige **öffentliche** RBLs.
|
||||
// * @return array<string> gelistete RBL-Zonen
|
||||
// */
|
||||
// private function queryRblLists(string $ip): array
|
||||
// {
|
||||
// if (!$this->validIPv4($ip)) return [];
|
||||
//
|
||||
// $rev = implode('.', array_reverse(explode('.', $ip)));
|
||||
//
|
||||
// // nur Zonen prüfen, die es wirklich gibt
|
||||
// $zones = [
|
||||
// 'zen.spamhaus.org',
|
||||
// 'psbl.surriel.com',
|
||||
// 'dnsbl-1.uceprotect.net',
|
||||
// 'all.s5h.net',
|
||||
// ];
|
||||
// $zones = array_values(array_filter($zones, fn($z) => @checkdnsrr($z.'.','NS')));
|
||||
//
|
||||
// $listed = [];
|
||||
// foreach ($zones as $zone) {
|
||||
// $q = "{$rev}.{$zone}.";
|
||||
//
|
||||
// $a = @dns_get_record($q, DNS_A) ?: [];
|
||||
// if (!count($a)) continue;
|
||||
//
|
||||
// $ips = array_column($a, 'ip');
|
||||
//
|
||||
// // --- WICHTIG: Spamhaus "blocked" / Ratelimit ignorieren
|
||||
// if (array_intersect($ips, ['127.255.255.254','127.255.255.255'])) {
|
||||
// // optional: merk dir, dass Spamhaus blockt -> UI-Hinweis
|
||||
// $listed[] = ['zone'=>$zone, 'code'=>'blocked', 'txt'=>null];
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// $txtRecs = @dns_get_record($q, DNS_TXT) ?: [];
|
||||
// $txt = $txtRecs[0]['txt'] ?? null;
|
||||
//
|
||||
// $listed[] = ['zone'=>$zone, 'code'=>$ips[0] ?? null, 'txt'=>$txt];
|
||||
// }
|
||||
//
|
||||
// // Nur echte Treffer zurückgeben; „blocked“ separat signalisieren
|
||||
// $real = array_values(array_filter($listed, fn($e) => ($e['code'] ?? null) !== 'blocked'));
|
||||
//
|
||||
// // Falls alles nur "blocked" war, gib leere Liste zurück
|
||||
// return array_map(fn($e) => $e['zone'].($e['code'] ? " ({$e['code']})" : ''), $real);
|
||||
// }
|
||||
//}
|
||||
////declare(strict_types=1);
|
||||
//
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class RblCard extends Component
|
||||
//{
|
||||
// public string $ip = '–';
|
||||
// public int $hits = 0;
|
||||
// public array $lists = [];
|
||||
//
|
||||
// public ?string $ipv4 = null;
|
||||
// public ?string $ipv6 = null;
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.security.rbl-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// Cache::forget('dash.rbl');
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// // 1) IPv4/IPv6 bevorzugt aus /etc/mailwolt/installer.env
|
||||
// [$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
|
||||
//
|
||||
// // 2) Fallback auf .env
|
||||
// $this->ipv4 = $ip4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: '–';
|
||||
// $this->ipv6 = $ip6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: '–';
|
||||
//
|
||||
// // 3) RBL-Ermittlung (cached)
|
||||
// $data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
|
||||
// // bevorzugt eine valide IPv4 für den RBL-Check
|
||||
// $candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
|
||||
//
|
||||
// if (!$candidate) {
|
||||
// $fromFile = @file_get_contents('/etc/mailwolt/public_ip') ?: '';
|
||||
// $fromFile = trim($fromFile);
|
||||
// if ($this->validIPv4($fromFile)) {
|
||||
// $candidate = $fromFile;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (!$candidate) {
|
||||
// // letzter Fallback – kann auf Hardened-Systemen geblockt sein
|
||||
// $curl = @shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?: '';
|
||||
// $curl = trim($curl);
|
||||
// if ($this->validIPv4($curl)) {
|
||||
// $candidate = $curl;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $ip = $candidate ?: '0.0.0.0';
|
||||
// $lists = $this->queryRblLists($ip);
|
||||
//
|
||||
// return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
|
||||
// });
|
||||
//
|
||||
// // 4) Werte ins Component-State
|
||||
// foreach ($data as $k => $v) {
|
||||
// $this->$k = $v;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /** Bevorzugt Installer-ENV; gibt [ipv4, ipv6] zurück oder [null, null]. */
|
||||
// private function resolvePublicIpsFromInstallerEnv(): array
|
||||
// {
|
||||
// $file = '/etc/mailwolt/installer.env';
|
||||
// if (!is_readable($file)) {
|
||||
// return [null, null];
|
||||
// }
|
||||
//
|
||||
// $ipv4 = null;
|
||||
// $ipv6 = null;
|
||||
//
|
||||
// $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
// foreach ($lines as $line) {
|
||||
// // Kommentare überspringen
|
||||
// if (preg_match('/^\s*#/', $line)) {
|
||||
// continue;
|
||||
// }
|
||||
// // KEY=VALUE (VALUE evtl. in "..." oder '...')
|
||||
// if (!str_contains($line, '=')) {
|
||||
// continue;
|
||||
// }
|
||||
// [$k, $v] = array_map('trim', explode('=', $line, 2));
|
||||
// $v = trim($v, " \t\n\r\0\x0B\"'");
|
||||
//
|
||||
// if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) {
|
||||
// $ipv4 = $v;
|
||||
// } elseif ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) {
|
||||
// $ipv6 = $v;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return [$ipv4, $ipv6];
|
||||
// }
|
||||
//
|
||||
// private function validIPv4(?string $ip): bool
|
||||
// {
|
||||
// return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
|
||||
// }
|
||||
//
|
||||
// private function validIPv6(?string $ip): bool
|
||||
// {
|
||||
// return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Prüft die IP gegen ein paar gängige RBLs.
|
||||
// * Nutzt PHP-DNS (checkdnsrr), keine externen Tools.
|
||||
// *
|
||||
// * @return array<string> gelistete RBL-Zonen
|
||||
// */
|
||||
// private function queryRblLists(string $ip): array
|
||||
// {
|
||||
// // Nur IPv4 prüfen (die meisten Listen hier sind v4)
|
||||
// if (!$this->validIPv4($ip)) {
|
||||
// return [];
|
||||
// }
|
||||
//
|
||||
// $rev = implode('.', array_reverse(explode('.', $ip)));
|
||||
// $sources = [
|
||||
// 'zen.spamhaus.org',
|
||||
// 'bl.spamcop.net',
|
||||
// 'dnsbl.sorbs.net',
|
||||
// 'b.barracudacentral.org',
|
||||
// ];
|
||||
//
|
||||
// $listed = [];
|
||||
// foreach ($sources as $zone) {
|
||||
// $qname = "{$rev}.{$zone}";
|
||||
// // A-Record oder TXT deuten auf Listing hin
|
||||
// if (@checkdnsrr($qname . '.', 'A') || @checkdnsrr($qname . '.', 'TXT')) {
|
||||
// $listed[] = $zone;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return $listed;
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class RblCard extends Component
|
||||
//{
|
||||
// public string $ip = '–';
|
||||
// public int $hits = 0;
|
||||
// public array $lists = [];
|
||||
//
|
||||
// public ?string $ipv4 = null;
|
||||
// public ?string $ipv6 = null;
|
||||
//
|
||||
//
|
||||
// public function mount(): void { $this->load(); }
|
||||
// public function render() { return view('livewire.ui.security.rbl-card'); }
|
||||
// public function refresh(): void { $this->load(true); }
|
||||
//
|
||||
// protected function load(bool $force=false): void
|
||||
// {
|
||||
// $this->ipv4 = trim(env('SERVER_PUBLIC_IPV4')) ?: '–';
|
||||
// $this->ipv6 = trim(env('SERVER_PUBLIC_IPV6')) ?: '–';
|
||||
//
|
||||
// $data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
|
||||
// $ip = trim(@file_get_contents('/etc/mailwolt/public_ip') ?: '');
|
||||
// if ($ip === '') $ip = trim(@shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?? '');
|
||||
// if (!preg_match('/^\d+\.\d+\.\d+\.\d+$/', $ip)) $ip = '0.0.0.0';
|
||||
//
|
||||
// $rev = implode('.', array_reverse(explode('.', $ip)));
|
||||
// $sources = [
|
||||
// 'zen.spamhaus.org',
|
||||
// 'bl.spamcop.net',
|
||||
// 'dnsbl.sorbs.net',
|
||||
// 'b.barracudacentral.org',
|
||||
// ];
|
||||
//
|
||||
// $lists = [];
|
||||
// foreach ($sources as $s) {
|
||||
// $q = "$rev.$s";
|
||||
// $res = trim(@shell_exec("dig +short ".escapeshellarg($q)." A 2>/dev/null") ?? '');
|
||||
// if ($res !== '') $lists[] = $s;
|
||||
// }
|
||||
//
|
||||
// return ['ip'=>$ip, 'hits'=>count($lists), 'lists'=>$lists];
|
||||
// });
|
||||
//
|
||||
// foreach ($data as $k=>$v) $this->$k = $v;
|
||||
// }
|
||||
//}
|
||||
|
|
@ -2,12 +2,85 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('Rspamd · Mailwolt')]
|
||||
class RspamdForm extends Component
|
||||
{
|
||||
public float $spam_score = 5.0;
|
||||
public float $greylist_score = 4.0;
|
||||
public float $reject_score = 15.0;
|
||||
public bool $enabled = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->spam_score = (float) Setting::get('rspamd.spam_score', 5.0);
|
||||
$this->greylist_score = (float) Setting::get('rspamd.greylist_score', 4.0);
|
||||
$this->reject_score = (float) Setting::get('rspamd.reject_score', 15.0);
|
||||
$this->enabled = (bool) Setting::get('rspamd.enabled', true);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'spam_score' => 'required|numeric|min:1|max:50',
|
||||
'greylist_score' => 'required|numeric|min:0|max:50',
|
||||
'reject_score' => 'required|numeric|min:1|max:100',
|
||||
]);
|
||||
|
||||
Setting::setMany([
|
||||
'rspamd.spam_score' => $this->spam_score,
|
||||
'rspamd.greylist_score' => $this->greylist_score,
|
||||
'rspamd.reject_score' => $this->reject_score,
|
||||
'rspamd.enabled' => $this->enabled,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->writeRspamdConfig();
|
||||
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload-or-restart', 'rspamd']);
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'Rspamd',
|
||||
title: 'Einstellungen gespeichert',
|
||||
text: 'Rspamd-Konfiguration wurde übernommen und neu geladen.', duration: 5000);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('toast', type: 'error', badge: 'Rspamd',
|
||||
title: 'Fehler', text: $e->getMessage(), duration: 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function writeRspamdConfig(): void
|
||||
{
|
||||
$target = '/etc/rspamd/local.d/actions.conf';
|
||||
$content = <<<CONF
|
||||
actions {
|
||||
reject = {$this->reject_score};
|
||||
add_header = {$this->spam_score};
|
||||
greylist = {$this->greylist_score};
|
||||
}
|
||||
CONF;
|
||||
|
||||
$proc = proc_open(
|
||||
sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)),
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes
|
||||
);
|
||||
if (!is_resource($proc)) throw new \RuntimeException('tee start fehlgeschlagen');
|
||||
fwrite($pipes[0], $content);
|
||||
fclose($pipes[0]);
|
||||
stream_get_contents($pipes[1]);
|
||||
stream_get_contents($pipes[2]);
|
||||
if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed writing to {$target}");
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SpamAvCard extends Component
|
||||
{
|
||||
public int $ham = 0;
|
||||
public int $spam = 0;
|
||||
public int $reject = 0;
|
||||
public string $rspamdVer = '–';
|
||||
public string $clamVer = '–';
|
||||
public ?string $sigUpdated = null;
|
||||
|
||||
// wie frisch müssen Zahlen mindestens sein (Sek.)
|
||||
protected int $maxAge = 300;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.spam-av-card');
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->load(true);
|
||||
}
|
||||
|
||||
protected function load(bool $force = false): void
|
||||
{
|
||||
// 1) Versuche erst aus Settings (DB/Redis), optional mit Cache-Shortcuts
|
||||
$data = $force ? null : Cache::get('dash.spamav');
|
||||
|
||||
if (!$data) {
|
||||
$data = Setting::get('spamav.metrics');
|
||||
}
|
||||
|
||||
// 2) Wenn keine Daten oder zu alt ⇒ neu sammeln
|
||||
$stale = !is_array($data) || (time() - (int)($data['ts'] ?? 0) > $this->maxAge);
|
||||
if ($force || $stale) {
|
||||
$data = $this->collectMetrics();
|
||||
// Persistenz: Settings (DB→Redis) + UI-Cache (kurz)
|
||||
Setting::set('spamav.metrics', $data);
|
||||
Cache::put('dash.spamav', $data, 60);
|
||||
}
|
||||
|
||||
// 3) Auf Properties mappen
|
||||
$this->ham = (int)($data['ham'] ?? 0);
|
||||
$this->spam = (int)($data['spam'] ?? 0);
|
||||
$this->reject = (int)($data['reject'] ?? 0);
|
||||
$this->rspamdVer = (string)($data['rspamdVer'] ?? '–'); // <- wichtig
|
||||
$this->clamVer = (string)($data['clamVer'] ?? '–');
|
||||
$this->sigUpdated = $data['sigUpdated'] ?? null;
|
||||
}
|
||||
|
||||
/** Sammelt live von rspamd/clamav – robust und schnell */
|
||||
protected function collectMetrics(): array
|
||||
{
|
||||
// rspamd counters
|
||||
$out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
|
||||
$ham = preg_match('/\bham\s*:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
|
||||
$spam = preg_match('/\bspam\s*:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
|
||||
$reject = preg_match('/\breject\s*:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
|
||||
|
||||
// Versionen
|
||||
$clamLine = trim((string) @shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null'));
|
||||
$clamVer = $clamLine !== '' ? $clamLine : '–';
|
||||
$sigUpdated = null;
|
||||
if ($clamLine && preg_match('#/([^/]+\d{4})$#', $clamLine, $m)) {
|
||||
$sigUpdated = $m[1];
|
||||
}
|
||||
|
||||
return [
|
||||
'ts' => time(),
|
||||
'ham' => $ham,
|
||||
'spam' => $spam,
|
||||
'reject' => $reject,
|
||||
'clamLine' => $clamLine,
|
||||
'clamVer' => $clamVer,
|
||||
'sigUpdated' => $sigUpdated,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
//namespace App\Livewire\Ui\Security;
|
||||
//
|
||||
//use Livewire\Component;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//
|
||||
//class SpamAvCard extends Component
|
||||
//{
|
||||
// public int $ham = 0;
|
||||
// public int $spam = 0;
|
||||
// public int $reject = 0;
|
||||
// public string $rspamdVer = '–';
|
||||
// public string $clamVer = '–';
|
||||
// public ?string $sigUpdated = null;
|
||||
//
|
||||
// public function mount(): void { $this->load(); }
|
||||
// public function render() { return view('livewire.ui.security.spam-av-card'); }
|
||||
// public function refresh(): void { $this->load(true); }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// $data = Cache::remember('dash.spamav', $force ? 1 : 60, function () {
|
||||
// $out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
|
||||
// // very rough counters (adapt to your setup)
|
||||
// $ham = preg_match('/ham:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
|
||||
// $spam = preg_match('/spam:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
|
||||
// $reject = preg_match('/reject:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
|
||||
//
|
||||
// $rspamdVer = trim(@shell_exec('rspamadm version 2>/dev/null') ?? '') ?: '–';
|
||||
// $clamVer = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '–';
|
||||
//
|
||||
// // last signatures update (freshclam log)
|
||||
// $sigUpdated = null;
|
||||
// $log = @shell_exec('grep -i "Database updated" /var/log/clamav/freshclam.log | tail -n1 2>/dev/null');
|
||||
// if ($log) $sigUpdated = trim($log);
|
||||
//
|
||||
// return compact('ham','spam','reject','rspamdVer','clamVer','sigUpdated');
|
||||
// });
|
||||
//
|
||||
// foreach ($data as $k => $v) $this->$k = $v;
|
||||
// }
|
||||
//}
|
||||
|
|
@ -2,10 +2,182 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('SSL/TLS · Mailwolt')]
|
||||
class SslCertificatesTable extends Component
|
||||
{
|
||||
public array $certs = [];
|
||||
|
||||
// Domains (aus Einstellungen)
|
||||
public string $uiDomain = '';
|
||||
public string $webmailDomain = '';
|
||||
public string $mailDomain = '';
|
||||
|
||||
// Provisioning
|
||||
public bool $sslProvisioning = false;
|
||||
public bool $sslDone = false;
|
||||
public array $sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
|
||||
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->uiDomain = (string) Setting::get('ui_domain', '');
|
||||
$this->webmailDomain = (string) Setting::get('webmail_domain', '');
|
||||
$this->mailDomain = (string) Setting::get('mail_domain', '');
|
||||
|
||||
$this->certs = $this->loadCertificates();
|
||||
$this->restoreSslProvisioningState();
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->certs = $this->loadCertificates();
|
||||
$this->dispatch('toast', type: 'done', badge: 'SSL', title: 'Aktualisiert',
|
||||
text: 'Zertifikatsliste wurde neu geladen.', duration: 3000);
|
||||
}
|
||||
|
||||
public function startSslProvisioning(): void
|
||||
{
|
||||
if (! ($this->uiDomain && $this->webmailDomain && $this->mailDomain)) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'SSL',
|
||||
title: 'Domains fehlen',
|
||||
text: 'Bitte erst alle Domains unter Einstellungen speichern.', duration: 6000);
|
||||
return;
|
||||
}
|
||||
|
||||
@mkdir(self::SSL_STATE_DIR, 0755, true);
|
||||
@unlink(self::SSL_STATE_DIR . '/done');
|
||||
foreach (['ui', 'mail', 'webmail'] as $k) {
|
||||
file_put_contents(self::SSL_STATE_DIR . "/{$k}", 'pending');
|
||||
}
|
||||
|
||||
$this->sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
$this->sslDone = false;
|
||||
$this->sslProvisioning = true;
|
||||
|
||||
$artisan = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=1 > /dev/null 2>&1 &',
|
||||
escapeshellarg($artisan),
|
||||
escapeshellarg($this->uiDomain),
|
||||
escapeshellarg($this->mailDomain),
|
||||
escapeshellarg($this->webmailDomain),
|
||||
);
|
||||
@shell_exec($cmd);
|
||||
}
|
||||
|
||||
public function pollSsl(): void
|
||||
{
|
||||
if (! $this->sslProvisioning || $this->sslDone) return;
|
||||
|
||||
foreach (['ui', 'mail', 'webmail'] as $key) {
|
||||
$file = self::SSL_STATE_DIR . "/{$key}";
|
||||
$this->sslProgress[$key] = is_readable($file)
|
||||
? trim((string) @file_get_contents($file))
|
||||
: 'pending';
|
||||
}
|
||||
|
||||
$done = @file_get_contents(self::SSL_STATE_DIR . '/done');
|
||||
if ($done !== false) {
|
||||
$this->sslDone = true;
|
||||
$this->sslProvisioning = false;
|
||||
$this->certs = $this->loadCertificates();
|
||||
}
|
||||
}
|
||||
|
||||
public function renew(string $name): void
|
||||
{
|
||||
$safe = preg_replace('/[^a-z0-9._-]/i', '', $name);
|
||||
if ($safe === '') return;
|
||||
|
||||
$out = (string) @shell_exec(
|
||||
"sudo -n /usr/bin/certbot renew --cert-name {$safe} --force-renewal 2>&1"
|
||||
);
|
||||
|
||||
$this->certs = $this->loadCertificates();
|
||||
|
||||
if (str_contains($out, 'Successfully renewed') || str_contains($out, 'success')) {
|
||||
$this->dispatch('toast', type: 'done', badge: 'SSL',
|
||||
title: 'Zertifikat erneuert', text: "Zertifikat <b>{$safe}</b> wurde erfolgreich erneuert.", duration: 5000);
|
||||
} else {
|
||||
$this->dispatch('toast', type: 'error', badge: 'SSL',
|
||||
title: 'Fehler', text: nl2br(htmlspecialchars(substr($out, 0, 300))), duration: 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function restoreSslProvisioningState(): void
|
||||
{
|
||||
$doneFile = self::SSL_STATE_DIR . '/done';
|
||||
if (! file_exists($doneFile)) return;
|
||||
|
||||
$this->sslDone = true;
|
||||
$this->sslProvisioning = false;
|
||||
foreach (['ui', 'mail', 'webmail'] as $key) {
|
||||
$file = self::SSL_STATE_DIR . "/{$key}";
|
||||
if (is_readable($file)) {
|
||||
$this->sslProgress[$key] = trim((string) @file_get_contents($file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadCertificates(): array
|
||||
{
|
||||
$out = (string) @shell_exec('sudo -n /usr/bin/certbot certificates 2>&1');
|
||||
if (empty(trim($out))) return [['_error' => 'unavailable']];
|
||||
if (str_contains($out, 'No certificates found')) return [];
|
||||
|
||||
$certs = [];
|
||||
$blocks = preg_split('/\n(?=\s*Certificate Name:)/m', $out);
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
if (!preg_match('/Certificate Name:\s*(.+)/i', $block, $nameM)) continue;
|
||||
|
||||
preg_match('/Domains:\s*(.+)/i', $block, $domainsM);
|
||||
preg_match('/Expiry Date:\s*(.+)/i', $block, $expiryM);
|
||||
preg_match('/Certificate Path:\s*(.+)/i', $block, $certM);
|
||||
|
||||
$expiryRaw = trim($expiryM[1] ?? '');
|
||||
$daysLeft = null;
|
||||
$expired = false;
|
||||
$expiryDate = null;
|
||||
|
||||
// Datum extrahieren (Format: "2026-07-24 10:30:00+00:00 (VALID: 88 days)")
|
||||
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $expiryRaw, $dateM)) {
|
||||
$ts = strtotime($dateM[1]);
|
||||
$expiryDate = $ts ? date('d.m.Y', $ts) : null;
|
||||
}
|
||||
|
||||
if (preg_match('/VALID: (\d+) days/i', $expiryRaw, $dM)) {
|
||||
$daysLeft = (int) $dM[1];
|
||||
} elseif (preg_match('/INVALID/i', $expiryRaw)) {
|
||||
$expired = true;
|
||||
$daysLeft = 0;
|
||||
}
|
||||
|
||||
$domainsRaw = trim($domainsM[1] ?? '');
|
||||
$domains = $domainsRaw !== '' ? array_values(array_filter(explode(' ', $domainsRaw))) : [];
|
||||
|
||||
$certs[] = [
|
||||
'name' => trim($nameM[1]),
|
||||
'domains' => $domains,
|
||||
'expiry_date' => $expiryDate,
|
||||
'days_left' => $daysLeft,
|
||||
'expired' => $expired,
|
||||
'cert_path' => trim($certM[1] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
usort($certs, fn($a, $b) => ($a['days_left'] ?? 999) <=> ($b['days_left'] ?? 999));
|
||||
|
||||
return $certs;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.ssl-certificates-table');
|
||||
|
|
|
|||
|
|
@ -2,12 +2,133 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('TLS-Ciphers · Mailwolt')]
|
||||
class TlsCiphersForm extends Component
|
||||
{
|
||||
public string $preset = 'intermediate';
|
||||
|
||||
public string $postfix_protocols = '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1';
|
||||
public string $postfix_ciphers = 'medium';
|
||||
|
||||
public string $dovecot_min_proto = 'TLSv1.2';
|
||||
public string $dovecot_ciphers = 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS';
|
||||
|
||||
private const PRESETS = [
|
||||
'modern' => [
|
||||
'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1, !TLSv1.2',
|
||||
'postfix_ciphers' => 'high',
|
||||
'dovecot_min_proto' => 'TLSv1.3',
|
||||
'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:!aNULL:!MD5',
|
||||
],
|
||||
'intermediate' => [
|
||||
'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
|
||||
'postfix_ciphers' => 'medium',
|
||||
'dovecot_min_proto' => 'TLSv1.2',
|
||||
'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS',
|
||||
],
|
||||
'old' => [
|
||||
'postfix_protocols' => '!SSLv2, !SSLv3',
|
||||
'postfix_ciphers' => 'low',
|
||||
'dovecot_min_proto' => 'TLSv1',
|
||||
'dovecot_ciphers' => 'HIGH:MEDIUM:!aNULL:!MD5',
|
||||
],
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->preset = Setting::get('tls.preset', 'intermediate');
|
||||
$this->postfix_protocols = Setting::get('tls.postfix_protocols', self::PRESETS['intermediate']['postfix_protocols']);
|
||||
$this->postfix_ciphers = Setting::get('tls.postfix_ciphers', self::PRESETS['intermediate']['postfix_ciphers']);
|
||||
$this->dovecot_min_proto = Setting::get('tls.dovecot_min_proto', self::PRESETS['intermediate']['dovecot_min_proto']);
|
||||
$this->dovecot_ciphers = Setting::get('tls.dovecot_ciphers', self::PRESETS['intermediate']['dovecot_ciphers']);
|
||||
}
|
||||
|
||||
public function applyPreset(string $preset): void
|
||||
{
|
||||
if (!isset(self::PRESETS[$preset])) return;
|
||||
$p = self::PRESETS[$preset];
|
||||
$this->preset = $preset;
|
||||
$this->postfix_protocols = $p['postfix_protocols'];
|
||||
$this->postfix_ciphers = $p['postfix_ciphers'];
|
||||
$this->dovecot_min_proto = $p['dovecot_min_proto'];
|
||||
$this->dovecot_ciphers = $p['dovecot_ciphers'];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'postfix_protocols' => 'required|string|max:200',
|
||||
'postfix_ciphers' => 'required|string|max:500',
|
||||
'dovecot_min_proto' => 'required|string|max:50',
|
||||
'dovecot_ciphers' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
Setting::setMany([
|
||||
'tls.preset' => $this->preset,
|
||||
'tls.postfix_protocols' => $this->postfix_protocols,
|
||||
'tls.postfix_ciphers' => $this->postfix_ciphers,
|
||||
'tls.dovecot_min_proto' => $this->dovecot_min_proto,
|
||||
'tls.dovecot_ciphers' => $this->dovecot_ciphers,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->writePostfixConfig();
|
||||
$this->writeDovecotConfig();
|
||||
|
||||
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'postfix']);
|
||||
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'dovecot']);
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'TLS',
|
||||
title: 'TLS-Konfiguration übernommen',
|
||||
text: 'Postfix und Dovecot wurden neu geladen.', duration: 5000);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('toast', type: 'error', badge: 'TLS',
|
||||
title: 'Fehler', text: $e->getMessage(), duration: 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function writePostfixConfig(): void
|
||||
{
|
||||
$target = '/etc/postfix/tls.cf';
|
||||
$content = "smtpd_tls_protocols = {$this->postfix_protocols}\n"
|
||||
. "smtp_tls_protocols = {$this->postfix_protocols}\n"
|
||||
. "smtpd_tls_ciphers = {$this->postfix_ciphers}\n"
|
||||
. "smtp_tls_ciphers = {$this->postfix_ciphers}\n";
|
||||
$this->tee($target, $content);
|
||||
}
|
||||
|
||||
private function writeDovecotConfig(): void
|
||||
{
|
||||
$target = '/etc/dovecot/conf.d/99-tls.conf';
|
||||
$content = "ssl_min_protocol = {$this->dovecot_min_proto}\n"
|
||||
. "ssl_cipher_list = {$this->dovecot_ciphers}\n";
|
||||
$this->tee($target, $content);
|
||||
}
|
||||
|
||||
private function tee(string $target, string $content): void
|
||||
{
|
||||
$proc = proc_open(
|
||||
sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)),
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes
|
||||
);
|
||||
if (!is_resource($proc)) throw new \RuntimeException('tee start fehlgeschlagen');
|
||||
fwrite($pipes[0], $content);
|
||||
fclose($pipes[0]);
|
||||
stream_get_contents($pipes[1]);
|
||||
stream_get_contents($pipes[2]);
|
||||
if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed: {$target}");
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.tls-ciphers-form');
|
||||
return view('livewire.ui.security.tls-ciphers-form', ['presets' => array_keys(self::PRESETS)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AlertsCard extends Component
|
||||
{
|
||||
/** Example structure you can fill from your scheduler/commands */
|
||||
public array $alerts = []; // [['level'=>'warn|error','msg'=>'text','when'=>'...'], ...]
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.system.alerts-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
$this->alerts = Cache::remember('dash.alerts', $force ? 1 : 60, function () {
|
||||
$a = [];
|
||||
|
||||
// examples: push items based on simple heuristics/files you already have
|
||||
if (is_file('/var/lib/mailwolt/update/state') && trim(@file_get_contents('/var/lib/mailwolt/update/state')) === 'running') {
|
||||
$a[] = ['level'=>'info','msg'=>'Update läuft …','when'=>date('H:i')];
|
||||
}
|
||||
$cert = '/etc/ssl/ui/fullchain.pem';
|
||||
if (is_file($cert)) {
|
||||
$end = trim(@shell_exec("openssl x509 -enddate -noout -in ".escapeshellarg($cert)." 2>/dev/null") ?? '');
|
||||
if (preg_match('/notAfter=(.+)/', $end, $m)) {
|
||||
$ts = strtotime($m[1] ?? '');
|
||||
if ($ts && $ts - time() < 14*86400) {
|
||||
$days = max(0, floor(($ts-time())/86400));
|
||||
$a[] = ['level'=>'warn','msg'=>"UI-Zertifikat läuft in {$days} Tagen ab",'when'=>date('H:i')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $a;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,750 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class BackupStatusCard extends Component
|
||||
{
|
||||
// Anzeige-Felder (nur Ausgabe im Blade)
|
||||
public ?string $lastAt = null; // "27.10.2025 18:27:35"
|
||||
public ?string $lastSize = null; // "93.0 MB"
|
||||
public ?string $lastDuration = null; // "11s" / "2m 03s"
|
||||
public ?bool $ok = null;
|
||||
|
||||
// Progress (nur wenn state=running)
|
||||
public bool $running = false;
|
||||
public int $percent = 0;
|
||||
public ?string $step = null; // z.B. "compress"
|
||||
|
||||
protected string $statusFile = '/var/lib/mailwolt/backup.status';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.system.backup-status-card');
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->load(true);
|
||||
}
|
||||
|
||||
public function runNow(): void
|
||||
{
|
||||
// asynchron starten (sudoers vorausgesetzt)
|
||||
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
|
||||
// Sofort UI auf „läuft“ setzen – Poll holt Echtstatus
|
||||
$this->running = true;
|
||||
$this->percent = 1;
|
||||
$this->step = 'start';
|
||||
}
|
||||
|
||||
private function load(bool $force = false): void
|
||||
{
|
||||
$state = $this->readStatus();
|
||||
|
||||
// Progress
|
||||
$this->running = ($state['state'] ?? null) === 'running';
|
||||
$this->percent = (int)($state['percent'] ?? 0);
|
||||
$this->step = $state['step'] ?? null;
|
||||
|
||||
// Abschlusswerte
|
||||
$fin = $state['finished_at'] ?? $state['start_at'] ?? null;
|
||||
$this->lastAt = $fin ? $this->fmtDate($fin) : null;
|
||||
|
||||
$sizeB = isset($state['size']) ? (int)$state['size'] : null;
|
||||
$this->lastSize = $sizeB !== null ? $this->fmtBytes($sizeB) : null;
|
||||
|
||||
$durS = isset($state['duration']) ? (int)$state['duration'] : null;
|
||||
$this->lastDuration = $durS !== null ? $this->fmtDuration($durS) : null;
|
||||
|
||||
$this->ok = isset($state['ok']) ? ((string)$state['ok'] === '1') : null;
|
||||
|
||||
// Wenn fertig → Balken aus
|
||||
if (!$this->running) {
|
||||
$this->percent = 0;
|
||||
$this->step = null;
|
||||
}
|
||||
}
|
||||
|
||||
private function readStatus(): array
|
||||
{
|
||||
if (!is_readable($this->statusFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
if (!str_contains($line, '=')) continue;
|
||||
[$k, $v] = array_map('trim', explode('=', $line, 2));
|
||||
// nur erwartete Keys
|
||||
if (in_array($k, ['state', 'pid', 'start_at', 'finished_at', 'step', 'percent', 'size', 'duration', 'ok'], true)) {
|
||||
$out[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function fmtDate(string $iso): string
|
||||
{
|
||||
// in App-Zeitzone anzeigen
|
||||
$tz = config('app.timezone', 'UTC');
|
||||
try {
|
||||
return Carbon::parse($iso)->setTimezone($tz)->format('d.m.Y H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return $iso;
|
||||
}
|
||||
}
|
||||
|
||||
private function fmtBytes(int $bytes): string
|
||||
{
|
||||
$u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = 0;
|
||||
$n = (float)$bytes;
|
||||
while ($n >= 1024 && $i < count($u) - 1) {
|
||||
$n /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return number_format($n, ($i <= 1 ? 0 : 1), ',', '.') . ' ' . $u[$i];
|
||||
}
|
||||
|
||||
private function fmtDuration(int $sec): string
|
||||
{
|
||||
if ($sec < 60) return $sec . 's';
|
||||
$m = intdiv($sec, 60);
|
||||
$s = $sec % 60;
|
||||
return sprintf('%dm %02ds', $m, $s);
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\System;
|
||||
//
|
||||
//use Illuminate\Support\Str;
|
||||
//use Livewire\Component;
|
||||
//use Carbon\Carbon;
|
||||
//
|
||||
//class BackupStatusCard extends Component
|
||||
//{
|
||||
// // Anzeige-Felder (fertig formatiert)
|
||||
// public ?string $lastAt = null;
|
||||
// public ?string $lastSize = null;
|
||||
// public ?string $lastDuration = null;
|
||||
// public ?bool $ok = null;
|
||||
//
|
||||
// // Laufstatus für Progress (nur zur Sichtbarkeit)
|
||||
// public bool $running = false;
|
||||
// public int $percent = 0;
|
||||
// public string $progressText = '';
|
||||
//
|
||||
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.system.backup-status-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// public function runNow(): void
|
||||
// {
|
||||
// // asynchron starten – sudoers wie bereits gesetzt
|
||||
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
// // UI direkt "laufend" schalten; echte Werte kommen über Poll
|
||||
// $this->running = true;
|
||||
// $this->percent = 1;
|
||||
// $this->progressText = 'Backup gestartet …';
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// $s = $this->readStatus();
|
||||
//
|
||||
// // Progress
|
||||
// $state = $s['state'] ?? null;
|
||||
// $this->running = in_array($state, ['running'], true);
|
||||
// $this->percent = (int)($s['percent'] ?? 0);
|
||||
// $step = $s['step'] ?? '';
|
||||
// $this->progressText = $this->mapStepText($step, $state);
|
||||
//
|
||||
// // Abschlusswerte
|
||||
// $finishedAt = $s['finished_at'] ?? ($state === 'done' ? ($s['start_at'] ?? null) : null);
|
||||
// $sizeBytes = isset($s['size']) ? (int)$s['size'] : null;
|
||||
// $durSec = isset($s['duration']) ? (int)$s['duration'] : null;
|
||||
// $okStr = $s['ok'] ?? null;
|
||||
//
|
||||
// $this->lastAt = $finishedAt ? $this->fmtDate($finishedAt) : null;
|
||||
// $this->lastSize = $sizeBytes !== null ? $this->fmtBytes($sizeBytes) : null;
|
||||
// $this->lastDuration = $durSec !== null ? $this->fmtDuration($durSec) : null;
|
||||
// $this->ok = $okStr !== null ? ($okStr === '1' || $okStr === 'true') : null;
|
||||
// }
|
||||
//
|
||||
// protected function readStatus(): array
|
||||
// {
|
||||
// if (!is_readable($this->statusFile)) {
|
||||
// return [];
|
||||
// }
|
||||
// $lines = @file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
// $out = [];
|
||||
// foreach ($lines as $ln) {
|
||||
// if (!str_contains($ln, '=')) continue;
|
||||
// [$k, $v] = array_map('trim', explode('=', $ln, 2));
|
||||
// $out[$k] = $v;
|
||||
// }
|
||||
// return $out;
|
||||
// }
|
||||
//
|
||||
// private function mapStepText(string $step, ?string $state): string
|
||||
// {
|
||||
// if ($state === 'done') return 'Backup abgeschlossen.';
|
||||
// if ($state === 'failed') return 'Backup fehlgeschlagen.';
|
||||
// return match ($step) {
|
||||
// 'start' => 'Backup wird vorbereitet …',
|
||||
// 'mysqldump' => 'Datenbank wird gesichert …',
|
||||
// 'maildir' => 'Maildir wird gesichert …',
|
||||
// 'app' => 'Applikation wird gesichert …',
|
||||
// 'configs' => 'Konfigurationen werden gesichert …',
|
||||
// 'compress' => 'Archiv wird komprimiert …',
|
||||
// 'retention' => 'Alte Backups werden aufgeräumt …',
|
||||
// default => $step ? Str::headline($step) . ' …' : 'Backup läuft …',
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// private function fmtDate(string $iso): string
|
||||
// {
|
||||
// return Carbon::parse($iso)->timezone(config('app.timezone', 'Europe/Berlin'))->format('d.m.Y H:i:s');
|
||||
// }
|
||||
//
|
||||
// private function fmtBytes(int $b): string
|
||||
// {
|
||||
// $units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
// $i = 0;
|
||||
// $val = $b;
|
||||
// while ($val >= 1024 && $i < count($units) - 1) {
|
||||
// $val /= 1024;
|
||||
// $i++;
|
||||
// }
|
||||
// return number_format($val, $val >= 10 ? 0 : ($val >= 1 ? 1 : 0)) . ' ' . $units[$i];
|
||||
// }
|
||||
//
|
||||
// private function fmtDuration(int $s): string
|
||||
// {
|
||||
// if ($s < 60) return $s . 's';
|
||||
// $m = intdiv($s, 60);
|
||||
// $r = $s % 60;
|
||||
// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
|
||||
// $h = intdiv($m, 60);
|
||||
// $m = $m % 60;
|
||||
// return sprintf('%dh %02dm', $h, $m);
|
||||
// }
|
||||
//}
|
||||
|
||||
//namespace App\Livewire\Ui\System;
|
||||
//
|
||||
//use Carbon\Carbon;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class BackupStatusCard extends Component
|
||||
//{
|
||||
// public string $lastAt = '–';
|
||||
// public string $lastSize = '–';
|
||||
// public string $lastDuration = '–';
|
||||
// public string $statusText = 'unbekannt';
|
||||
// public string $statusColor = 'text-white/60 border-white/20 bg-white/5';
|
||||
//
|
||||
// public string $progressText = '';
|
||||
// public string $progressPercent = '0';
|
||||
// public string $progressVisibleClass = 'hidden'; // <- Sichtbarkeit
|
||||
//
|
||||
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
|
||||
//
|
||||
// public function mount(): void { $this->load(); }
|
||||
// public function render() { return view('livewire.ui.system.backup-status-card'); }
|
||||
// public function refresh(): void { $this->load(true); }
|
||||
//
|
||||
// public function runNow(): void
|
||||
// {
|
||||
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
// // UI sofort auf "läuft" setzen
|
||||
// $this->progressText = 'Vorbereitung läuft...';
|
||||
// $this->progressPercent = '1';
|
||||
// $this->progressVisibleClass = 'block';
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// $kv = [];
|
||||
// if (is_file($this->statusFile)) {
|
||||
// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
|
||||
// $p = strpos($ln, '='); if ($p !== false) $kv[substr($ln,0,$p)] = substr($ln,$p+1);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $state = $kv['state'] ?? null; // running | done | failed
|
||||
// $step = $kv['step'] ?? null;
|
||||
// $percent = isset($kv['percent']) ? (int)$kv['percent'] : 0;
|
||||
// $ok = isset($kv['ok']) ? ((int)$kv['ok'] === 1) : null;
|
||||
//
|
||||
// // Anzeigeformatierungen wie gehabt …
|
||||
// // (deine bestehenden formatBytes/formatDuration/Timezone-Logik)
|
||||
//
|
||||
// $this->progressPercent = (string)max(0, min(100, $percent));
|
||||
// $this->progressText = $this->mapStep($step);
|
||||
//
|
||||
// // Sichtbarkeit steuern – KEINE Blade-Logik nötig
|
||||
// if ($state === 'running') {
|
||||
// $this->progressVisibleClass = 'block';
|
||||
// } else {
|
||||
// // bei done/failed: Balken verstecken und auf 100% / finalen Text setzen
|
||||
// $this->progressVisibleClass = 'hidden';
|
||||
// if ($percent >= 100 || $step === 'done') {
|
||||
// $this->progressPercent = '100';
|
||||
// $this->progressText = 'Backup abgeschlossen.';
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Status-Badge (wie gehabt)
|
||||
// if ($ok === true) {
|
||||
// $this->statusText = 'erfolgreich';
|
||||
// $this->statusColor = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
|
||||
// } elseif ($ok === false) {
|
||||
// $this->statusText = 'fehlgeschlagen';
|
||||
// $this->statusColor = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
|
||||
// } else {
|
||||
// $this->statusText = 'unbekannt';
|
||||
// $this->statusColor = 'text-white/60 border-white/20 bg-white/5';
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private function mapStep(?string $step): string
|
||||
// {
|
||||
// return match($step) {
|
||||
// 'mysqldump' => 'Datenbank wird gesichert...',
|
||||
// 'maildir' => 'Mail-Verzeichnis wird archiviert...',
|
||||
// 'app' => 'Anwendungsdaten werden gesichert...',
|
||||
// 'configs' => 'Konfigurationen werden gesichert...',
|
||||
// 'compress' => 'Backup wird komprimiert...',
|
||||
// 'retention' => 'Alte Backups werden gelöscht...',
|
||||
// 'done' => 'Backup abgeschlossen.',
|
||||
// default => 'Vorbereitung läuft...',
|
||||
// };
|
||||
// }
|
||||
//}
|
||||
|
||||
//
|
||||
//class BackupStatusCard extends Component
|
||||
//{
|
||||
// public string $lastAt = '–';
|
||||
// public string $lastSize = '–';
|
||||
// public string $lastDuration = '–';
|
||||
// public string $statusText = 'unbekannt';
|
||||
// public string $statusColor = 'text-white/60 border-white/20 bg-white/5';
|
||||
// public string $progressText = '';
|
||||
// public string $progressPercent = '0';
|
||||
// public bool $running = false;
|
||||
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->load();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.system.backup-status-card');
|
||||
// }
|
||||
//
|
||||
// public function refresh(): void
|
||||
// {
|
||||
// $this->load(true);
|
||||
// }
|
||||
//
|
||||
// public function runNow(): void
|
||||
// {
|
||||
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
// $this->running = true;
|
||||
// $this->progressText = 'Starte Backup...';
|
||||
// $this->progressPercent = '1';
|
||||
// }
|
||||
//
|
||||
// protected function load(bool $force = false): void
|
||||
// {
|
||||
// if (!is_file($this->statusFile)) {
|
||||
// $this->running = false;
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $kv = [];
|
||||
// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
|
||||
// $p = strpos($ln, '=');
|
||||
// if ($p !== false) {
|
||||
// $kv[substr($ln, 0, $p)] = substr($ln, $p + 1);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// $state = $kv['state'] ?? null;
|
||||
// $step = $kv['step'] ?? null;
|
||||
// $percent = isset($kv['percent']) ? (int)$kv['percent'] : 0;
|
||||
// $ok = isset($kv['ok']) ? ((int)$kv['ok'] === 1) : null;
|
||||
//
|
||||
// // Formatierung
|
||||
// $tz = config('app.timezone', 'Europe/Berlin');
|
||||
// $finished = $kv['finished_at'] ?? $kv['start_at'] ?? null;
|
||||
// $this->lastAt = $finished
|
||||
// ? Carbon::parse($finished)->setTimezone($tz)->format('d.m.Y H:i:s')
|
||||
// : '–';
|
||||
//
|
||||
// $this->lastSize = isset($kv['size'])
|
||||
// ? $this->formatBytes((int)$kv['size'])
|
||||
// : '–';
|
||||
//
|
||||
// $this->lastDuration = isset($kv['duration'])
|
||||
// ? $this->formatDuration((int)$kv['duration'])
|
||||
// : '–';
|
||||
//
|
||||
// // Fortschritt
|
||||
// $this->progressPercent = (string)$percent;
|
||||
// $this->progressText = $this->mapStep($step);
|
||||
//
|
||||
// // Status
|
||||
// $this->running = ($state === 'running');
|
||||
//
|
||||
// if ($ok === true) {
|
||||
// $this->statusText = 'erfolgreich';
|
||||
// $this->statusColor = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
|
||||
// } elseif ($ok === false) {
|
||||
// $this->statusText = 'fehlgeschlagen';
|
||||
// $this->statusColor = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
|
||||
// } else {
|
||||
// $this->statusText = 'unbekannt';
|
||||
// $this->statusColor = 'text-white/60 border-white/20 bg-white/5';
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private function formatBytes(int $b): string
|
||||
// {
|
||||
// if ($b >= 1024 * 1024 * 1024) return number_format($b / (1024 * 1024 * 1024), 1) . ' GB';
|
||||
// if ($b >= 1024 * 1024) return number_format($b / (1024 * 1024), 1) . ' MB';
|
||||
// if ($b >= 1024) return number_format($b / 1024, 0) . ' KB';
|
||||
// return $b . ' B';
|
||||
// }
|
||||
//
|
||||
// private function formatDuration(int $s): string
|
||||
// {
|
||||
// if ($s < 60) return $s . 's';
|
||||
// $m = intdiv($s, 60);
|
||||
// $r = $s % 60;
|
||||
// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
|
||||
// $h = intdiv($m, 60);
|
||||
// $m = $m % 60;
|
||||
// return sprintf('%dh %02dm %02ds', $h, $m, $r);
|
||||
// }
|
||||
//
|
||||
// private function mapStep(?string $step): string
|
||||
// {
|
||||
// return match ($step) {
|
||||
// 'mysqldump' => 'Datenbank wird gesichert...',
|
||||
// 'maildir' => 'Mail-Verzeichnis wird archiviert...',
|
||||
// 'app' => 'Anwendungsdaten werden gesichert...',
|
||||
// 'configs' => 'Konfigurationen werden gesichert...',
|
||||
// 'compress' => 'Backup wird komprimiert...',
|
||||
// 'retention' => 'Alte Backups werden gelöscht...',
|
||||
// 'done' => 'Backup abgeschlossen.',
|
||||
// default => 'Vorbereitung läuft...',
|
||||
// };
|
||||
// }
|
||||
//}
|
||||
//
|
||||
////
|
||||
////
|
||||
////namespace App\Livewire\Ui\System;
|
||||
////
|
||||
////use Carbon\CarbonImmutable;
|
||||
////use Livewire\Component;
|
||||
////
|
||||
////class BackupStatusCard extends Component
|
||||
////{
|
||||
//// public ?string $lastAt = null; // finale Zeit
|
||||
//// public ?string $lastSize = null; // menschenlesbar
|
||||
//// public ?string $lastDuration = null; // menschenlesbar
|
||||
//// public ?bool $ok = null;
|
||||
////
|
||||
//// // Live-Status
|
||||
//// public bool $running = false;
|
||||
//// public ?string $step = null;
|
||||
//// public int $percent = 0;
|
||||
////
|
||||
//// public function mount(): void
|
||||
//// {
|
||||
//// $this->load();
|
||||
//// }
|
||||
////
|
||||
//// public function render()
|
||||
//// {
|
||||
//// return view('livewire.ui.system.backup-status-card');
|
||||
//// }
|
||||
////
|
||||
//// public function refresh(): void
|
||||
//// {
|
||||
//// $this->load(true);
|
||||
//// }
|
||||
////
|
||||
//// public function runNow(): void
|
||||
//// {
|
||||
//// // Script asynchron starten (sudoers muss gesetzt sein)
|
||||
//// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
//// // Sofort UI auf "läuft" stellen – Poll holt echten Status nach
|
||||
//// $this->running = true;
|
||||
//// $this->step = 'start';
|
||||
//// $this->percent = 1;
|
||||
//// }
|
||||
////
|
||||
//// protected function load(bool $force = false): void
|
||||
//// {
|
||||
//// $f = '/var/lib/mailwolt/backup.status';
|
||||
//// if (!is_file($f)) {
|
||||
//// return;
|
||||
//// }
|
||||
////
|
||||
//// $data = [];
|
||||
//// foreach (@file($f, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
|
||||
//// if (strpos($ln, '=') !== false) {
|
||||
//// [$k, $v] = explode('=', $ln, 2);
|
||||
//// $data[$k] = $v;
|
||||
//// }
|
||||
//// }
|
||||
////
|
||||
//// $state = $data['state'] ?? null;
|
||||
//// $this->running = ($state === 'running');
|
||||
////
|
||||
//// // Progress
|
||||
//// $this->step = $data['step'] ?? null;
|
||||
//// $this->percent = (int)($data['percent'] ?? 0);
|
||||
////
|
||||
//// // Finale Werte
|
||||
//// if ($state === 'done' || $state === 'failed') {
|
||||
//// $this->ok = ($data['ok'] ?? '') === '1';
|
||||
//// $ts = $data['finished_at'] ?? $data['start_at'] ?? null;
|
||||
//// $this->lastAt = $ts ? $this->fmtTs($ts) : null;
|
||||
////
|
||||
//// $bytes = (int)($data['size'] ?? 0);
|
||||
//// $this->lastSize = $bytes ? $this->fmtBytes($bytes) : null;
|
||||
////
|
||||
//// $dur = (int)($data['duration'] ?? 0);
|
||||
//// $this->lastDuration = $dur ? $this->fmtDuration($dur) : null;
|
||||
//// }
|
||||
//// }
|
||||
////
|
||||
//// protected function fmtTs(string $iso): string
|
||||
//// {
|
||||
//// return CarbonImmutable::parse($iso)->tz(config('app.timezone'))
|
||||
//// ->format('d.m.Y H:i:s');
|
||||
//// }
|
||||
////
|
||||
//// protected function fmtBytes(int $b): string
|
||||
//// {
|
||||
//// $u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
//// $i = 0;
|
||||
//// while ($b >= 1024 && $i < count($u) - 1) {
|
||||
//// $b /= 1024;
|
||||
//// $i++;
|
||||
//// }
|
||||
//// return sprintf('%.1f %s', $b, $u[$i]);
|
||||
//// }
|
||||
////
|
||||
//// protected function fmtDuration(int $s): string
|
||||
//// {
|
||||
//// if ($s < 60) return $s . 's';
|
||||
//// $m = intdiv($s, 60);
|
||||
//// $r = $s % 60;
|
||||
//// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
|
||||
//// $h = intdiv($m, 60);
|
||||
//// $m %= 60;
|
||||
//// return sprintf('%dh %02dm', $h, $m);
|
||||
//// }
|
||||
////}
|
||||
////
|
||||
//////
|
||||
//////namespace App\Livewire\Ui\System;
|
||||
//////
|
||||
//////use Carbon\CarbonImmutable;
|
||||
//////use Illuminate\Support\Str;
|
||||
//////use Livewire\Component;
|
||||
//////
|
||||
//////class BackupStatusCard extends Component
|
||||
//////{
|
||||
////// public ?string $lastAt = null; // formatierte Zeit
|
||||
////// public ?string $lastSize = null; // human readable
|
||||
////// public ?string $lastDuration = null; // human readable
|
||||
////// public ?bool $ok = null;
|
||||
//////
|
||||
////// // Laufzeit/Progress
|
||||
////// public string $state = 'idle'; // idle|running|done|error
|
||||
////// public string $step = ''; // aktueller Schritt
|
||||
////// public array $steps = [
|
||||
////// 'mysqldump' => 'Datenbank sichern',
|
||||
////// 'maildir' => 'Maildir kopieren',
|
||||
////// 'app' => 'App sichern',
|
||||
////// 'configs' => 'Configs sichern',
|
||||
////// 'archive' => 'Archiv erstellen',
|
||||
////// 'compress' => 'Komprimieren',
|
||||
////// 'retention' => 'Aufräumen',
|
||||
////// 'finish' => 'Abschluss',
|
||||
////// ];
|
||||
//////
|
||||
////// protected string $statusFile = '/var/lib/mailwolt/backup.status';
|
||||
//////
|
||||
////// public function mount(): void
|
||||
////// {
|
||||
////// $this->load(true);
|
||||
////// }
|
||||
//////
|
||||
////// public function render()
|
||||
////// {
|
||||
////// return view('livewire.ui.system.backup-status-card');
|
||||
////// }
|
||||
//////
|
||||
////// public function refresh(): void
|
||||
////// {
|
||||
////// $this->load(true);
|
||||
////// }
|
||||
//////
|
||||
////// public function runNow(): void
|
||||
////// {
|
||||
////// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
////// // Sofort in "running" gehen – Poll übernimmt dann
|
||||
////// $this->state = 'running';
|
||||
////// $this->step = 'mysqldump';
|
||||
////// }
|
||||
//////
|
||||
////// public function load(bool $force = false): void
|
||||
////// {
|
||||
////// $raw = $this->readStatus();
|
||||
////// $this->state = $raw['state'] ?? 'idle';
|
||||
////// $this->step = $raw['step'] ?? '';
|
||||
//////
|
||||
////// // Datum/Zeit hübsch
|
||||
////// if (!empty($raw['time'])) {
|
||||
////// $this->lastAt = $this->fmtTime($raw['time']);
|
||||
////// } else {
|
||||
////// $this->lastAt = null;
|
||||
////// }
|
||||
//////
|
||||
////// // Größe/Dauer hübsch
|
||||
////// $bytes = isset($raw['size_bytes']) ? (int)$raw['size_bytes'] : null;
|
||||
////// $secs = isset($raw['dur_seconds']) ? (int)$raw['dur_seconds'] : null;
|
||||
//////
|
||||
////// $this->lastSize = $bytes !== null ? $this->humanBytes($bytes) : null;
|
||||
////// $this->lastDuration = $secs !== null ? $this->humanDuration($secs) : null;
|
||||
////// $this->ok = isset($raw['ok']) ? ((string)$raw['ok'] === '1') : null;
|
||||
////// }
|
||||
//////
|
||||
////// protected function readStatus(): array
|
||||
////// {
|
||||
////// if (!is_file($this->statusFile)) return [];
|
||||
////// $out = [];
|
||||
////// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
|
||||
////// if (!str_contains($ln, '=')) continue;
|
||||
////// [$k, $v] = array_map('trim', explode('=', $ln, 2));
|
||||
////// $out[$k] = $v;
|
||||
////// }
|
||||
//////
|
||||
////// // Backward compatibility (alte Keys)
|
||||
////// if (isset($out['size']) && !isset($out['size_bytes'])) {
|
||||
////// $out['size_bytes'] = (int)$out['size'];
|
||||
////// }
|
||||
////// if (isset($out['dur']) && !isset($out['dur_seconds'])) {
|
||||
////// $out['dur_seconds'] = (int)$out['dur'];
|
||||
////// }
|
||||
//////
|
||||
////// return $out;
|
||||
////// }
|
||||
//////
|
||||
////// protected function fmtTime(string $iso): string
|
||||
////// {
|
||||
////// try {
|
||||
////// $tz = config('app.timezone', 'UTC');
|
||||
////// // ISO aus Script ist idealerweise UTC (Z)
|
||||
////// $dt = CarbonImmutable::parse($iso)->timezone($tz);
|
||||
////// // z.B. 27.10.2025, 16:48:02 (CET)
|
||||
////// return $dt->isoFormat('L, LTS') . ' ' . $dt->format('T');
|
||||
////// } catch (\Throwable) {
|
||||
////// return $iso;
|
||||
////// }
|
||||
////// }
|
||||
//////
|
||||
////// protected function humanBytes(int $bytes): string
|
||||
////// {
|
||||
////// $units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
////// $i = 0;
|
||||
////// while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
////// $bytes /= 1024;
|
||||
////// $i++;
|
||||
////// }
|
||||
////// return number_format($bytes, $i === 0 ? 0 : 1, ',', '.') . ' ' . $units[$i];
|
||||
////// }
|
||||
//////
|
||||
////// protected function humanDuration(int $secs): string
|
||||
////// {
|
||||
////// if ($secs < 60) return $secs . ' s';
|
||||
////// $m = intdiv($secs, 60);
|
||||
////// $s = $secs % 60;
|
||||
////// if ($m < 60) return sprintf('%d min %02d s', $m, $s);
|
||||
////// $h = intdiv($m, 60);
|
||||
////// $m = $m % 60;
|
||||
////// return sprintf('%d h %02d min', $h, $m);
|
||||
////// }
|
||||
//////}
|
||||
//////
|
||||
////////
|
||||
////////namespace App\Livewire\Ui\System;
|
||||
////////
|
||||
////////use Livewire\Component;
|
||||
////////
|
||||
////////class BackupStatusCard extends Component
|
||||
////////{
|
||||
//////// public ?string $lastAt = null;
|
||||
//////// public ?string $lastSize = null;
|
||||
//////// public ?string $lastDuration = null;
|
||||
//////// public ?bool $ok = null;
|
||||
////////
|
||||
//////// public function mount(): void { $this->load(); }
|
||||
//////// public function render() { return view('livewire.ui.system.backup-status-card'); }
|
||||
//////// public function refresh(): void { $this->load(true); }
|
||||
////////
|
||||
//////// public function runNow(): void
|
||||
//////// {
|
||||
//////// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
////////// $this->dispatch('toast', type:'info', title:'Backup gestartet');
|
||||
//////// }
|
||||
////////
|
||||
//////// protected function load(bool $force=false): void
|
||||
//////// {
|
||||
//////// // Example: parse a tiny status file your backup script writes.
|
||||
//////// $f = '/var/lib/mailwolt/backup.status';
|
||||
//////// if (is_file($f)) {
|
||||
//////// $lines = @file($f, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
//////// foreach ($lines as $ln) {
|
||||
//////// if (str_starts_with($ln,'time=')) $this->lastAt = substr($ln,5);
|
||||
//////// if (str_starts_with($ln,'size=')) $this->lastSize = substr($ln,5);
|
||||
//////// if (str_starts_with($ln,'dur=')) $this->lastDuration = substr($ln,4);
|
||||
//////// if (str_starts_with($ln,'ok=')) $this->ok = (substr($ln,3) === '1');
|
||||
//////// }
|
||||
//////// }
|
||||
//////// }
|
||||
////////}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -10,12 +11,14 @@ class DomainsSslForm extends Component
|
|||
{
|
||||
/* ========= Basis & Hosts ========= */
|
||||
|
||||
public string $base_domain = 'example.com';
|
||||
public string $base_domain = '';
|
||||
|
||||
// nur Subdomain-Teile (ohne Punkte/Protokoll)
|
||||
public string $ui_sub = 'mail';
|
||||
public string $webmail_sub = 'webmail';
|
||||
public string $mta_sub = 'mx';
|
||||
public string $ui_sub = '';
|
||||
public string $webmail_sub = '';
|
||||
public string $mta_sub = '';
|
||||
|
||||
public bool $domainsSaving = false;
|
||||
|
||||
/* ========= TLS / Redirect ========= */
|
||||
public bool $force_https = true;
|
||||
|
|
@ -87,9 +90,9 @@ class DomainsSslForm extends Component
|
|||
{
|
||||
return [
|
||||
'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'],
|
||||
'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
|
||||
'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
|
||||
'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
|
||||
'ui_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
|
||||
'webmail_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
|
||||
'mta_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
|
||||
|
||||
'force_https' => ['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
|
||||
{
|
||||
$file = public_path('.well-known/mta-sts.txt');
|
||||
|
|
@ -154,9 +167,52 @@ class DomainsSslForm extends Component
|
|||
|
||||
public function saveDomains(): void
|
||||
{
|
||||
$this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']);
|
||||
// TODO: persist
|
||||
$this->dispatch('toast', body: 'Domains gespeichert.');
|
||||
$this->validate(['base_domain', 'ui_sub', 'webmail_sub', 'mta_sub']);
|
||||
|
||||
$this->domainsSaving = true;
|
||||
|
||||
$wmHost = $this->webmail_sub ? $this->webmail_sub.'.'.$this->base_domain : '';
|
||||
|
||||
$this->writeEnv([
|
||||
'BASE_DOMAIN' => $this->base_domain,
|
||||
'UI_SUB' => $this->ui_sub,
|
||||
'WEBMAIL_SUB' => $this->webmail_sub,
|
||||
'MTA_SUB' => $this->mta_sub,
|
||||
'WEBMAIL_DOMAIN' => $wmHost,
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('route:clear');
|
||||
|
||||
$this->domainsSaving = false;
|
||||
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Domains',
|
||||
title: 'Einstellungen gespeichert',
|
||||
text: 'Konfiguration wurde übernommen.',
|
||||
duration: 4000,
|
||||
);
|
||||
}
|
||||
|
||||
private function writeEnv(array $values): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
$content = file_get_contents($path);
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value);
|
||||
$line = $key . '=' . $escaped;
|
||||
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
|
||||
|
||||
if (preg_match($pattern, $content)) {
|
||||
$content = preg_replace($pattern, $line, $content);
|
||||
} else {
|
||||
$content .= "\n{$line}";
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
|
||||
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,274 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class HealthCard extends Component
|
||||
{
|
||||
// Inputquellen
|
||||
public array $meta = [];
|
||||
|
||||
// UI: Balken/Segmente
|
||||
public int $barSegments = 24;
|
||||
public array $cpuSeg = [];
|
||||
public array $ramSeg = [];
|
||||
|
||||
// Anzeige
|
||||
public ?int $cpuPercent = null;
|
||||
public ?int $ramPercent = null;
|
||||
public ?string $ramSummary = null;
|
||||
public ?string $loadText = null;
|
||||
public array $loadDots = [];
|
||||
public ?string $uptimeText = null;
|
||||
public ?int $uptimeDays = null;
|
||||
public ?int $uptimeHours = null;
|
||||
public ?string $uptimeDaysLabel = null;
|
||||
public ?string $uptimeHoursLabel = null;
|
||||
public string $uptimeIcon = 'ph ph-clock';
|
||||
public ?string $updatedAtHuman = null;
|
||||
|
||||
// NEW: IPs
|
||||
public ?string $ip4 = null;
|
||||
public ?string $ip6 = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
$this->meta = Cache::get('health:meta', []);
|
||||
|
||||
$this->hydrateSystem();
|
||||
$this->hydrateIps();
|
||||
$this->hydrateUpdatedAt();
|
||||
}
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
// exakt auf die Event-Klasse hören (kein Alias)
|
||||
return [
|
||||
'echo-private:health,HealthUpdated' => 'onHealthEcho',
|
||||
];
|
||||
}
|
||||
|
||||
public function onHealthEcho($payload): void
|
||||
{
|
||||
// ① Bei manchen Treibern kommt $payload als JSON-String
|
||||
if (is_string($payload)) {
|
||||
$decoded = json_decode($payload, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$payload = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// ② Manche Treiber kapseln erneut in "data"
|
||||
if (isset($payload['data']) && is_string($payload['data'])) {
|
||||
$decoded = json_decode($payload['data'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$payload = $decoded;
|
||||
}
|
||||
} elseif (isset($payload['data']) && is_array($payload['data'])) {
|
||||
$payload = $payload['data'];
|
||||
}
|
||||
|
||||
$meta = $payload['meta'] ?? [];
|
||||
if (!is_array($meta)) $meta = [];
|
||||
|
||||
$this->meta = $meta;
|
||||
|
||||
$this->hydrateSystem();
|
||||
$this->hydrateIps();
|
||||
$this->hydrateUpdatedAt();
|
||||
|
||||
// optionales Debug-Log (kein JS nötig)
|
||||
logger('Health WS received', ['meta' => $this->meta]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.system.health-card');
|
||||
}
|
||||
|
||||
/* --------------- Aufbereitung --------------- */
|
||||
|
||||
protected function hydrateSystem(): void
|
||||
{
|
||||
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
|
||||
// RAM % + Summary
|
||||
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
|
||||
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
|
||||
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
|
||||
}
|
||||
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
|
||||
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
|
||||
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
|
||||
|
||||
// Load (1/5/15)
|
||||
$l1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
|
||||
$l5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
|
||||
$l15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
|
||||
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
|
||||
if (is_array($loadMixed)) {
|
||||
$vals = array_values($loadMixed);
|
||||
$l1 ??= $this->numFloat($vals[0] ?? null);
|
||||
$l5 ??= $this->numFloat($vals[1] ?? null);
|
||||
$l15 ??= $this->numFloat($vals[2] ?? null);
|
||||
}
|
||||
$this->loadText = $this->fmtLoad($l1, $l5, $l15, is_string($loadMixed) ? $loadMixed : null);
|
||||
|
||||
// CPU %
|
||||
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
|
||||
if ($this->cpuPercent === null && $l1 !== null) {
|
||||
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
|
||||
$this->cpuPercent = (int) round(min(100, max(0, ($l1 / max(1,$cores)) * 100)));
|
||||
}
|
||||
|
||||
// Uptime
|
||||
// $this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
|
||||
// if (!$this->uptimeText) {
|
||||
// $uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
// if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
|
||||
// }
|
||||
|
||||
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
if ($uptimeSec !== null) {
|
||||
$parsed = $this->parseUptime($uptimeSec);
|
||||
$this->uptimeText = "{$parsed['days']}d {$parsed['hours']}h"; // bestehende Anzeige
|
||||
$this->uptimeDays = $parsed['days'];
|
||||
$this->uptimeHours = $parsed['hours'];
|
||||
$this->uptimeDaysLabel = $parsed['days_text'];
|
||||
$this->uptimeHoursLabel = $parsed['hours_text'];
|
||||
}
|
||||
|
||||
// Segmente
|
||||
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
|
||||
$this->ramSeg = $this->buildSegments($this->ramPercent);
|
||||
|
||||
// Load Dots relativ zu Kernen
|
||||
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
|
||||
$ratio1 = $l1 !== null ? $l1 / $cores : null;
|
||||
$ratio5 = $l5 !== null ? $l5 / $cores : null;
|
||||
$ratio15 = $l15 !== null ? $l15 / $cores : null;
|
||||
$this->loadDots = [
|
||||
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
|
||||
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
|
||||
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
|
||||
];
|
||||
}
|
||||
|
||||
protected function hydrateIps(): void
|
||||
{
|
||||
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
// Versuche diverse mögliche Keys
|
||||
$this->ip4 = $this->pick($sys, ['ipv4','ip4','public_ipv4','server_public_ipv4','lan_ipv4']);
|
||||
$this->ip6 = $this->pick($sys, ['ipv6','ip6','public_ipv6','server_public_ipv6','lan_ipv6']);
|
||||
|
||||
// Fallback: aus installer.env lesen
|
||||
if (!$this->ip4 || !$this->ip6) {
|
||||
$env = @file('/etc/mailwolt/installer.env', FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($env as $ln) {
|
||||
if (!$this->ip4 && str_starts_with($ln, 'SERVER_PUBLIC_IPV4=')) {
|
||||
$this->ip4 = trim(substr($ln, strlen('SERVER_PUBLIC_IPV4=')));
|
||||
}
|
||||
if (!$this->ip6 && str_starts_with($ln, 'SERVER_PUBLIC_IPV6=')) {
|
||||
$this->ip6 = trim(substr($ln, strlen('SERVER_PUBLIC_IPV6=')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auf hübsch: dünne Zwischenräume (wir lassen Blade das stylen – monospaced + tracking)
|
||||
$this->ip4 = $this->ip4 ?: '–';
|
||||
$this->ip6 = $this->ip6 ?: '–';
|
||||
}
|
||||
|
||||
protected function hydrateUpdatedAt(): void
|
||||
{
|
||||
$updated = $this->meta['updated_at'] ?? null;
|
||||
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–';
|
||||
} catch (\Throwable) { $this->updatedAtHuman = '–'; }
|
||||
}
|
||||
|
||||
/* --------------- Helpers --------------- */
|
||||
|
||||
protected function pick(array $arr, array $keys)
|
||||
{
|
||||
foreach ($keys as $k) {
|
||||
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
|
||||
return $arr[$k];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
|
||||
protected function numFloat($v): ?float { return is_numeric($v) ? (float)$v : null; }
|
||||
|
||||
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
|
||||
{
|
||||
if ($l1 !== null || $l5 !== null || $l15 !== null) {
|
||||
$fmt = fn($x) => $x === null ? '–' : number_format($x, 2);
|
||||
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
|
||||
}
|
||||
return $fallback ?: null;
|
||||
}
|
||||
|
||||
protected function secondsToHuman(int $s): string
|
||||
{
|
||||
$d = intdiv($s, 86400); $s %= 86400;
|
||||
$h = intdiv($s, 3600); $s %= 3600;
|
||||
$m = intdiv($s, 60);
|
||||
if ($d > 0) return "{$d}d {$h}h";
|
||||
if ($h > 0) return "{$h}h {$m}m";
|
||||
return "{$m}m";
|
||||
}
|
||||
|
||||
protected function parseUptime(int $seconds): array
|
||||
{
|
||||
$days = intdiv($seconds, 86400);
|
||||
$hours = intdiv($seconds % 86400, 3600);
|
||||
$minutes = intdiv($seconds % 3600, 60);
|
||||
|
||||
return [
|
||||
'days' => $days,
|
||||
'hours' => $hours,
|
||||
'minutes' => $minutes,
|
||||
'days_text' => $days === 1 ? 'Tag' : 'Tage', // string
|
||||
'hours_text' => $hours === 1 ? 'Stunde' : 'Stunden', // string
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
protected function toneByPercent(?int $p): string {
|
||||
if ($p === null) return 'white';
|
||||
if ($p >= 90) return 'rose';
|
||||
if ($p >= 70) return 'amber';
|
||||
return 'emerald';
|
||||
}
|
||||
|
||||
protected function buildSegments(?int $percent): array {
|
||||
$n = max(6, $this->barSegments);
|
||||
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
|
||||
$tone = $this->toneByPercent($percent);
|
||||
$fillCls = match($tone) {
|
||||
'rose' => 'bg-rose-500/80',
|
||||
'amber' => 'bg-amber-400/80',
|
||||
'emerald'=> 'bg-emerald-500/80',
|
||||
default => 'bg-white/20',
|
||||
};
|
||||
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
|
||||
}
|
||||
|
||||
protected function loadDotClass(?float $ratio): string {
|
||||
if ($ratio === null) return 'bg-white/25';
|
||||
if ($ratio >= 0.9) return 'bg-rose-500';
|
||||
if ($ratio >= 0.7) return 'bg-amber-400';
|
||||
return 'bg-emerald-400';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue