Compare commits

..

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

88 changed files with 7743 additions and 2420 deletions

View File

@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
# For cross-subdomain session sharing (e.g. webmail on mail.example.com):
# SESSION_DOMAIN=.example.com
SESSION_DOMAIN=
SESSION_DOMAIN=null
#BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local

1
.gitignore vendored
View File

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

102
CLAUDE.md
View File

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

View File

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

View File

@ -12,7 +12,6 @@ class LoginForm extends Component
public string $name = '';
public string $password = '';
public bool $remember = false;
public ?string $error = null;
public bool $show = false;

View File

@ -14,7 +14,7 @@ use Livewire\Component;
class Wizard extends Component
{
public int $step = 1;
public int $totalSteps = 5;
public int $totalSteps = 4;
// Schritt 1 — System
public string $instance_name = 'Mailwolt';
@ -26,49 +26,20 @@ class Wizard extends Component
public string $mail_domain = '';
public string $webmail_domain = '';
// Schritt 4 — Option
public bool $skipSsl = false;
// Schritt 3 — Admin-Account
public string $admin_name = '';
public string $admin_email = '';
public string $admin_password = '';
public string $admin_password_confirmation = '';
// Schritt 5 — Domain-Setup Status
public array $domainStatus = [
'ui' => 'pending',
'mail' => 'pending',
'webmail' => 'pending',
];
public bool $setupDone = false;
private const STATE_DIR = '/var/lib/mailwolt/wizard';
public function mount()
public function mount(): void
{
$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
}
}
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
@ -94,10 +65,10 @@ class Wizard extends Component
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' => 'required|string|min:10|same:admin_password_confirmation',
'admin_password_confirmation' => 'required',
], [
'admin_password.min' => 'Mindestens 6 Zeichen.',
'admin_password.min' => 'Mindestens 10 Zeichen.',
'admin_password.same' => 'Passwörter stimmen nicht überein.',
]),
default => null,
@ -111,15 +82,16 @@ class Wizard extends Component
$this->step = max($this->step - 1, 1);
}
public function finish(): void
public function finish(): mixed
{
// Schritt-3-Validierung nochmals sicherstellen
$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' => 'required|string|min:10|same:admin_password_confirmation',
]);
// Settings + .env speichern
// Settings speichern
Setting::setMany([
'locale' => $this->locale,
'timezone' => $this->timezone,
@ -129,6 +101,7 @@ class Wizard extends Component
'setup_completed' => '1',
]);
// .env aktualisieren
$this->writeEnv([
'APP_NAME' => $this->instance_name,
'APP_HOST' => $this->ui_domain,
@ -137,7 +110,7 @@ class Wizard extends Component
'WEBMAIL_DOMAIN' => $this->webmail_domain,
]);
// Admin anlegen
// Admin anlegen oder aktualisieren
$user = User::where('email', $this->admin_email)->first() ?? new User();
$user->name = $this->admin_name;
$user->email = $this->admin_email;
@ -145,75 +118,7 @@ class Wizard extends Component
$user->role = 'admin';
$user->save();
// Status-Verzeichnis leeren und Domain-Setup im Hintergrund starten
@mkdir(self::STATE_DIR, 0755, true);
@unlink(self::STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
$ssl = $this->skipSsl ? 0 : 1;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s mailwolt: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 mailwolt: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);
return redirect()->route('login')->with('setup_done', true);
}
private function writeEnv(array $values): void

View File

@ -6,7 +6,6 @@ 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;
@ -36,16 +35,12 @@ class Dashboard extends Component
$servicesActive = count(array_filter($services, fn($s) => $s['status'] === 'online'));
$sslConfigured = SettingModel::get('ssl_configured', '1') === '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(),
'alertCount' => 0,
'backup' => $this->backupData(),
'mailHostname' => gethostname() ?: 'mailserver',
'services' => $services,

View File

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

View File

@ -20,10 +20,6 @@ class ApiKeyCreateModal extends ModalComponent
'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([
@ -35,7 +31,10 @@ class ApiKeyCreateModal extends ModalComponent
'selected.min' => 'Bitte mindestens einen Scope auswählen.',
]);
$token = Auth::user()->createToken($this->name, $this->selected);
$token = Auth::user()->createToken(
$this->name,
$this->selected,
);
$pat = $token->accessToken;
if ($this->sandbox) {
@ -44,10 +43,7 @@ class ApiKeyCreateModal extends ModalComponent
}
$this->dispatch('token-created', plainText: $token->plainTextToken);
$this->dispatch('openModal',
component: 'ui.system.modal.api-key-show-modal',
arguments: ['plainText' => $token->plainTextToken],
);
$this->closeModal();
}
public function toggleAll(): void

View File

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

View File

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

View File

@ -13,18 +13,9 @@ class ApiKeyShowModal extends ModalComponent
$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';
return 'md';
}
public function render()

View File

@ -1,68 +0,0 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\SandboxRoute;
use App\Services\SandboxService;
use Livewire\Attributes\Computed;
use Livewire\Component;
class SandboxRoutes extends Component
{
public string $newType = 'domain';
public string $newTarget = '';
public ?string $syncMessage = null;
public bool $syncOk = false;
public function boot(SandboxService $sandboxService): void
{
$this->sandboxService = $sandboxService;
}
public function addRoute(): void
{
if ($this->newType !== 'global') {
$this->validate(['newTarget' => 'required|string|max:255']);
}
$target = $this->newType === 'global' ? null : trim($this->newTarget);
$this->sandboxService->enable($this->newType, $target);
$this->sandboxService->syncTransportFile();
$this->dispatch('sandbox-route-changed');
$this->newTarget = '';
}
public function removeRoute(int $id): void
{
$this->sandboxService->delete($id);
$this->sandboxService->syncTransportFile();
$this->dispatch('sandbox-route-changed');
}
public function toggleRoute(int $id): void
{
$route = SandboxRoute::findOrFail($id);
$route->is_active = !$route->is_active;
$route->save();
$this->sandboxService->syncTransportFile();
$this->dispatch('sandbox-route-changed');
}
public function syncPostfix(): void
{
$result = $this->sandboxService->syncTransportFile();
$this->syncOk = $result['ok'];
$this->syncMessage = $result['message'];
}
#[Computed]
public function routes()
{
return SandboxRoute::orderBy('type')->orderBy('target')->get();
}
public function render()
{
return view('livewire.ui.system.sandbox-routes');
}
}

View File

@ -306,7 +306,6 @@ class SettingsForm extends Component
\Illuminate\Support\Facades\Log::info('mailwolt-apply-domains', ['output' => $output]);
if ($ok) {
Setting::set('ssl_configured', '1');
$this->dispatch('toast', type: 'done', badge: 'Nginx',
title: 'Nginx aktualisiert',
text: 'Nginx-Konfiguration wurde neu geladen.',

View File

@ -205,7 +205,7 @@ class UpdatePage extends Component
$rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: '');
$this->lowState = $state !== '' ? $state : null;
$this->running = ($this->lowState === 'running');
$this->running = ($this->lowState !== 'done');
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null;
}
@ -255,13 +255,15 @@ class UpdatePage extends Component
protected function readCurrentVersion(): ?string
{
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
if ($v !== '') return $v;
// Fallback: git tag (lokal immer, production wenn Datei fehlt)
// Lokal: direkt aus git describe lesen damit Entwicklungsumgebung immer aktuell ist
if (app()->isLocal()) {
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
$v = $this->normalizeVersion($tag);
if ($v) return $v;
}
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
if ($v !== '') return $v;
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);

View File

@ -1,63 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
class SandboxRoute extends Model
{
protected $fillable = ['type', 'target', 'is_active'];
protected $casts = [
'is_active' => 'boolean',
];
/**
* All active routes ordered by type and target.
*/
public static function activeRoutes(): Collection
{
return static::where('is_active', true)
->orderBy('type')
->orderBy('target')
->get();
}
/**
* Check whether sandbox is active for a given domain.
* True if global sandbox is active OR domain-specific rule is active.
*/
public static function isActiveForDomain(string $domain): bool
{
return static::where('is_active', true)
->where(function ($q) use ($domain) {
$q->where('type', 'global')
->orWhere(function ($q2) use ($domain) {
$q2->where('type', 'domain')->where('target', $domain);
});
})
->exists();
}
/**
* Check whether sandbox is active for a given address.
* True if global is active OR domain matches OR address-specific rule exists.
*/
public static function isActiveForAddress(string $address): bool
{
$domain = substr(strrchr($address, '@'), 1) ?: '';
return static::where('is_active', true)
->where(function ($q) use ($address, $domain) {
$q->where('type', 'global')
->orWhere(function ($q2) use ($domain) {
$q2->where('type', 'domain')->where('target', $domain);
})
->orWhere(function ($q3) use ($address) {
$q3->where('type', 'address')->where('target', $address);
});
})
->exists();
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace App\Services;
use App\Models\SandboxRoute;
class SandboxService
{
/**
* Enable (or create) a sandbox route.
*/
public function enable(string $type, ?string $target): SandboxRoute
{
return SandboxRoute::updateOrCreate(
['type' => $type, 'target' => $target],
['is_active' => true]
);
}
/**
* Disable a sandbox route by id.
*/
public function disable(int $id): void
{
SandboxRoute::findOrFail($id)->update(['is_active' => false]);
}
/**
* Delete a sandbox route by id.
*/
public function delete(int $id): void
{
SandboxRoute::findOrFail($id)->delete();
}
/**
* Write /etc/postfix/transport.sandbox and run postmap.
* Returns ['ok' => bool, 'message' => string].
*/
public function syncTransportFile(): array
{
$file = config('sandbox.transport_file', '/etc/postfix/transport.sandbox');
$routes = SandboxRoute::where('is_active', true)
->orderBy('type')
->orderBy('target')
->get();
$lines = [];
$hasGlobal = false;
foreach ($routes as $route) {
if ($route->type === 'global') {
$hasGlobal = true;
} elseif ($route->type === 'domain') {
$lines[] = $route->target . ' sandbox:';
} elseif ($route->type === 'address') {
$lines[] = $route->target . ' sandbox:';
}
}
// Global catch-all goes at the end
if ($hasGlobal) {
$lines[] = '* sandbox:';
}
$content = implode("\n", $lines);
if ($lines) {
$content .= "\n";
}
try {
file_put_contents($file, $content);
} catch (\Throwable $e) {
return ['ok' => false, 'message' => 'Fehler beim Schreiben: ' . $e->getMessage()];
}
$out = [];
$code = 0;
exec('postmap ' . escapeshellarg($file) . ' 2>&1', $out, $code);
if ($code !== 0) {
return ['ok' => false, 'message' => 'postmap Fehler: ' . implode(' ', $out)];
}
return ['ok' => true, 'message' => 'Transport-Datei synchronisiert (' . count($lines) . ' Einträge).'];
}
}

View File

@ -22,10 +22,9 @@ return Application::configure(basePath: dirname(__DIR__))
->name('ui.webmail.')
->group(base_path('routes/webmail.php'));
// Path-based fallback mit eigenem Namen-Prefix — kein Konflikt mit web.php 'login'
// Path-based fallback (no names — avoids duplicate-name conflict)
Route::middleware('web')
->prefix('webmail')
->name('webmail.')
->group(base_path('routes/webmail.php'));
} else {
Route::middleware('web')

View File

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sandbox_routes', function (Blueprint $table) {
$table->id();
$table->enum('type', ['global', 'domain', 'address'])->default('domain');
$table->string('target')->nullable(); // domain or email, null = global
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['type', 'target']);
});
}
public function down(): void
{
Schema::dropIfExists('sandbox_routes');
}
};

View File

@ -44,10 +44,6 @@ NODE_SETUP="${NODE_SETUP:-deb}"
GREEN="\033[1;32m"; YELLOW="\033[1;33m"; RED="\033[1;31m"; CYAN="\033[1;36m"; GREY="\033[0;90m"; NC="\033[0m"
BAR="──────────────────────────────────────────────────────────────────────────────"
# ===== Install-Log =====
LOG_FILE="/var/log/mailwolt-install.log"
> "$LOG_FILE"
header() {
echo -e "${CYAN}${BAR}${NC}"
echo -e "${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}"
@ -67,72 +63,27 @@ footer_ok() {
local ip="$1"
local app_name="${2:-$APP_NAME}"
local app_dir="${3:-$APP_DIR}"
local cert_dir="${4:-$CERT_DIR}"
local nginx_site="${4:-$NGINX_SITE}"
local cert_dir="${5:-$CERT_DIR}"
echo
echo -e "${GREEN}${BAR}${NC}"
echo -e "${GREEN}${app_name} Installation erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${app_name} Bootstrap erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo -e ""
echo -e " ${CYAN}➜ Setup-Wizard jetzt öffnen:${NC}"
echo -e " ${CYAN}http://${ip}/setup${NC}"
echo -e ""
echo -e " Aufruf: ${CYAN}http://${ip}${NC} ${GREY}| https://${ip}${NC}"
echo -e " Laravel Root: ${GREY}${app_dir}${NC}"
echo -e " Mail-TLS Cert: ${GREY}${cert_dir}/{cert.pem,key.pem}${NC} (Postfix/Dovecot)"
echo -e " Postfix/Dovecot: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e " Nginx Site: ${GREY}${nginx_site}${NC}"
echo -e " Self-signed Cert: ${GREY}${cert_dir}/{cert.pem,key.pem}${NC}"
echo -e " Postfix/Dovecot Ports aktiv: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e " Rspamd/OpenDKIM: ${GREY}aktiv (DKIM-Keys später im Wizard)${NC}"
echo -e " Monit (Watchdog): ${GREY}installiert, NICHT aktiviert${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo
}
_STEP_T=0
_SPIN_PID=
_spin_bg() {
local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local n=${#chars} i=0
while true; do
printf "\r \033[0;90m${chars:$i:1}\033[0m"
i=$(( (i + 1) % n ))
sleep 0.1
done
}
start_spin() { _spin_bg & _SPIN_PID=$!; }
stop_spin() {
[[ -n "${_SPIN_PID:-}" ]] || return 0
kill "$_SPIN_PID" 2>/dev/null || true
wait "$_SPIN_PID" 2>/dev/null || true
_SPIN_PID=
printf "\r\033[K"
}
trap 'stop_spin' EXIT INT TERM
step() {
local msg="$1" dur="${2:-}"
[ -n "$dur" ] && dur=" ${GREY}(~${dur})${NC}" || dur=""
printf "\n${CYAN}${NC} %-42s%b\n" "$msg" "$dur"
_STEP_T=$SECONDS
}
ok() {
stop_spin
local t=$(( SECONDS - _STEP_T ))
[ $t -gt 1 ] && printf " ${GREEN}${NC} ${GREY}%ds${NC}\n" $t || printf " ${GREEN}${NC}\n"
}
warn() { stop_spin; printf " ${YELLOW}${NC} %s\n" "$*"; }
err() { stop_spin; printf " ${RED}${NC} %s\n" "$*"; }
quietly() {
start_spin
if ! "$@" >> "$LOG_FILE" 2>&1; then
stop_spin
printf " ${RED}${NC} Fehlgeschlagen. Letzte Log-Zeilen:\n\n"
tail -20 "$LOG_FILE" | sed 's/^/ /'
printf "\n ${GREY}Vollständiges Log: %s${NC}\n\n" "$LOG_FILE"
exit 1
fi
stop_spin
}
try_quiet() { "$@" >> "$LOG_FILE" 2>&1 || true; }
log() { :; }
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[x]${NC} $*"; }
require_root() { [ "$(id -u)" -eq 0 ] || { err "Bitte als root ausführen."; exit 1; }; }
# ===== IP ermitteln =====
@ -149,37 +100,30 @@ gen() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "${1:-28}" ||
pw() { gen 28; }
short() { gen 16; }
# ===== Argument-Parsing =====
while [[ $# -gt 0 ]]; do
case "$1" in
-dev) APP_ENV="local"; APP_DEBUG="true" ;;
-stag|-staging) APP_ENV="staging"; APP_DEBUG="false" ;;
esac
shift
done
# ===== Start =====
require_root
header
SERVER_IP="$(detect_ip)"
APP_PW="${APP_PW:-$(pw)}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-"bootstrap.local"}" # Wizard setzt später FQDN
TZ="${TZ:-""}" # leer; Wizard setzt final
echo -e "\n ${GREY}Server-IP: ${SERVER_IP} Log: ${LOG_FILE}${NC}"
echo -e "${GREY}Server-IP erkannt: ${SERVER_IP}${NC}"
[ -n "$TZ" ] && { ln -fs "/usr/share/zoneinfo/${TZ}" /etc/localtime || true; }
step "Paketquellen aktualisieren" "10 Sek"
log "Paketquellen aktualisieren…"
export DEBIAN_FRONTEND=noninteractive
quietly apt-get update -y
ok
apt-get update -y
# ---- MariaDB-Workaround (fix für mariadb-common prompt) ----
log "MariaDB-Workaround vorbereiten…"
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
[ -f /etc/mysql/mariadb.cnf ] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
step "Pakete installieren" "25 Min"
quietly apt-get -y -o Dpkg::Options::="--force-confdef" \
# ---- Basis-Pakete installieren ----
log "Pakete installieren… (dies kann einige Minuten dauern)"
#apt-get install -y \
apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
@ -188,20 +132,20 @@ quietly apt-get -y -o Dpkg::Options::="--force-confdef" \
rspamd \
opendkim opendkim-tools \
nginx \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd php-sqlite3 unzip curl acl \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd unzip curl \
composer \
certbot python3-certbot-nginx \
fail2ban \
ca-certificates rsyslog sudo openssl netcat-openbsd monit git
ok
ca-certificates rsyslog sudo openssl netcat-openbsd monit
step "Benutzer & Verzeichnisse anlegen" "5 Sek"
# ===== Verzeichnisse / User =====
log "Verzeichnisse und Benutzer anlegen…"
mkdir -p ${CERT_DIR} /etc/postfix /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
quietly bash -c "id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail"
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
chown -R vmail:vmail /var/mail
quietly bash -c "id '$APP_USER' >/dev/null 2>&1 || adduser --disabled-password --gecos '' '$APP_USER'"
quietly usermod -a -G www-data "$APP_USER"
ok
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
usermod -a -G www-data "$APP_USER"
# ===== Self-signed TLS (SAN = IP) =====
CERT="${CERT_DIR}/cert.pem"
@ -209,7 +153,7 @@ KEY="${CERT_DIR}/key.pem"
OSSL_CFG="${CERT_DIR}/openssl.cnf"
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
step "Self-Signed Zertifikat erstellen" "5 Sek"
log "Erzeuge Self-Signed TLS Zertifikat (SAN=IP:${SERVER_IP})…"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits = 2048
@ -229,26 +173,26 @@ subjectAltName = @alt_names
[alt_names]
IP.1 = ${SERVER_IP}
CFG
quietly openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
-keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chmod 600 "$KEY"; chmod 644 "$CERT"
ok
fi
# ===== MariaDB vorbereiten =====
step "Datenbank einrichten" "10 Sek"
quietly systemctl enable --now mariadb
log "MariaDB vorbereiten…"
systemctl enable --now mariadb
DB_NAME="${DB_USER}"
DB_USER="${DB_USER}"
DB_PASS="$(pw)"
quietly mysql -uroot <<SQL
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
ok
# ===== Postfix konfigurieren (25/465/587) =====
step "Mailserver konfigurieren (Postfix / Dovecot / Rspamd)" "15 Sek"
log "Postfix konfigurieren…"
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
@ -327,9 +271,10 @@ CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
try_quiet systemctl enable --now postfix
systemctl enable --now postfix
# ===== Dovecot konfigurieren (IMAP/POP3 + SSL) =====
log "Dovecot konfigurieren…"
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
@ -405,14 +350,15 @@ ssl_cert = <${CERT}
ssl_key = <${KEY}
CONF
try_quiet systemctl enable --now dovecot
systemctl enable --now dovecot
# ===== Rspamd & OpenDKIM =====
log "Rspamd + OpenDKIM aktivieren…"
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
try_quiet systemctl enable --now rspamd
systemctl enable --now rspamd || true
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
@ -428,17 +374,17 @@ LogWhy yes
OversignHeaders From
# KeyTable / SigningTable später im Wizard
CONF
try_quiet systemctl enable --now opendkim
try_quiet systemctl enable --now redis-server
ok
systemctl enable --now opendkim || true
# ===== Redis =====
systemctl enable --now redis-server
# ===== Nginx: Laravel vHost (80/443) =====
step "Webserver konfigurieren (Nginx)" "5 Sek"
log "Nginx konfigurieren…"
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_FPM_SOCK="/run/php/php${PHPV}-fpm.sock"
[ -S "$PHP_FPM_SOCK" ] || PHP_FPM_SOCK="/run/php/php-fpm.sock"
PHP_FPM_SOCK="/run/php/php-fpm.sock"
[ -S "/run/php/php8.2-fpm.sock" ] && PHP_FPM_SOCK="/run/php/php8.2-fpm.sock"
cat > ${NGINX_SITE} <<CONF
server {
@ -452,10 +398,6 @@ server {
access_log /var/log/nginx/${APP_USER}_access.log;
error_log /var/log/nginx/${APP_USER}_error.log;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files \$uri =404;
}
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
@ -464,7 +406,38 @@ server {
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files \$uri /index.php?\$query_string;
try_files $uri /index.php?$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
access_log off;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_ssl_access.log;
error_log /var/log/nginx/${APP_USER}_ssl_error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files $uri /index.php?$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
@ -473,26 +446,36 @@ server {
}
CONF
ln -sf ${NGINX_SITE} ${NGINX_SITE_LINK}
try_quiet nginx -t
try_quiet systemctl enable --now nginx
ok
nginx -t && systemctl enable --now nginx
step "Projekt herunterladen (Git)" "15 Sek"
while [[ $# -gt 0 ]]; do
case "$1" in
-dev)
APP_ENV="local"
APP_DEBUG="true"
;;
-stag|-staging)
APP_ENV="staging"
APP_DEBUG="false"
;;
esac
shift
done
# ===== Laravel installieren (als eigener User) =====
log "Laravel installieren…"
mkdir -p "$(dirname "$APP_DIR")"
chown "$APP_USER":$APP_GROUP "$(dirname "$APP_DIR")"
chown -R "$APP_USER":$APP_GROUP "$(dirname "$APP_DIR")"
if [ ! -d "${APP_DIR}/.git" ]; then
rm -rf "${APP_DIR}"
quietly sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git checkout ${GIT_BRANCH} && git pull --ff-only"
if [ ! -d "${APP_DIR}" ] || [ -z "$(ls -A "$APP_DIR" 2>/dev/null || true)" ]; then
sudo -u "$APP_USER" -H bash -lc "cd /var/www && COMPOSER_ALLOW_SUPERUSER=0 composer create-project laravel/laravel ${APP_USER} --no-interaction"
fi
ok
# ===== .env erstellen und befüllen =====
APP_URL="http://${SERVER_IP}"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force"
# .env befüllen (MariaDB & Redis Sessions)
sed -i "s|^APP_NAME=.*|APP_NAME=${APP_NAME}|g" "${APP_DIR}/.env"
sed -i "s|^APP_URL=.*|APP_URL=${APP_URL}|g" "${APP_DIR}/.env"
sed -i "s|^APP_ENV=.*|APP_ENV=${APP_ENV}|g" "${APP_DIR}/.env"
@ -513,356 +496,126 @@ sed -i "s|^REDIS_HOST=.*|REDIS_HOST=127.0.0.1|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=null|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_PORT=.*|REDIS_PORT=6379|g" "${APP_DIR}/.env"
REVERB_APP_ID="$(short)"
REVERB_APP_KEY="$(short)"
REVERB_APP_SECRET="$(short)"
grep -q '^REVERB_APP_ID=' "${APP_DIR}/.env" \
|| printf '\nBROADCAST_CONNECTION=reverb\nREVERB_APP_ID=%s\nREVERB_APP_KEY=%s\nREVERB_APP_SECRET=%s\nREVERB_HOST=127.0.0.1\nREVERB_PORT=8080\nREVERB_SCHEME=http\nVITE_REVERB_APP_KEY=%s\nVITE_REVERB_HOST=%s\nVITE_REVERB_PORT=8080\nVITE_REVERB_SCHEME=http\n' \
"$REVERB_APP_ID" "$REVERB_APP_KEY" "$REVERB_APP_SECRET" "$REVERB_APP_KEY" "$SERVER_IP" >> "${APP_DIR}/.env"
# Bootstrap-Admin für den ersten Login
# === Bootstrap-Admin für den ersten Login (nur .env, kein DB-User) ===
BOOTSTRAP_USER="${APP_USER}"
BOOTSTRAP_EMAIL="${APP_USER}@localhost"
BOOTSTRAP_PASS="$(openssl rand -base64 18 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 12)"
BOOTSTRAP_HASH="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$BOOTSTRAP_PASS")"
grep -q '^SETUP_PHASE=' "${APP_DIR}/.env" \
|| echo "SETUP_PHASE=bootstrap" >> "${APP_DIR}/.env"
grep -q '^SETUP_PHASE=' "${APP_DIR}/.env" || echo "SETUP_PHASE=bootstrap" >> "${APP_DIR}/.env"
sed -i "s|^SETUP_PHASE=.*|SETUP_PHASE=bootstrap|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_USER=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}" >> "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_USER=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_USER=.*|BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_EMAIL=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}" >> "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_EMAIL=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}" >> "$>
sed -i "s|^BOOTSTRAP_ADMIN_EMAIL=.*|BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_PASSWORD_HASH=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}" >> "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_PASSWORD_HASH=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_PASSWORD_HASH=.*|BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}|g" "${APP_DIR}/.env"
step "PHP-Abhängigkeiten installieren (Composer)" "12 Min"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-dev --optimize-autoloader --no-interaction"
ok
step "Datenbank migrieren" "15 Sek"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan migrate --force"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan storage:link --force"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan config:cache"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan route:cache"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan view:cache"
ok
if [ -f "${APP_DIR}/package.json" ]; then
step "Frontend bauen (npm)" "13 Min"
# Node/npm falls noch nicht installiert
if ! command -v node >/dev/null 2>&1; then
# ===== Node/NPM installieren (für Vite/Tailwind Build) =====
if [ "$NODE_SETUP" = "nodesource" ]; then
quietly bash -c "curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh && bash /tmp/nodesource_setup.sh && rm -f /tmp/nodesource_setup.sh"
quietly apt-get install -y nodejs
# LTS via NodeSource (empfohlen für aktuelle LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
else
quietly apt-get install -y nodejs npm
# Debian-Repo (ok für Basics, aber u.U. älter)
apt-get install -y nodejs npm
fi
fi
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
if ! sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build" >> "$LOG_FILE" 2>&1; then
warn "npm run build fehlgeschlagen — manuell nachholen: cd ${APP_DIR} && npm run build"
# ===== Projekt aus Git holen (PLATZHALTER) =====
# Falls dein Repo später bereitsteht, überschreibt dieser Block das leere/Standard-Laravel.
if [ "${GIT_REPO}" != "https://example.com/your-repo-placeholder.git" ]; then
if [ ! -d "${APP_DIR}/.git" ]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
ok
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git checkout ${GIT_BRANCH} && git pull --ff-only"
fi
fi
mkdir -p /var/lib/mailwolt/wizard
chown www-data:www-data /var/lib/mailwolt/wizard
chmod 775 /var/lib/mailwolt/wizard
step "Hilfsskripte & Konfiguration installieren" "5 Sek"
cat > /usr/local/sbin/mailwolt-apply-domains <<'HELPER'
#!/usr/bin/env bash
set -euo pipefail
UI_HOST=""; WEBMAIL_HOST=""; MAIL_HOST=""; SSL_AUTO=0
while [[ $# -gt 0 ]]; do
case "$1" in
--ui-host) UI_HOST="$2"; shift 2 ;;
--webmail-host) WEBMAIL_HOST="$2"; shift 2 ;;
--mail-host) MAIL_HOST="$2"; shift 2 ;;
--ssl-auto) SSL_AUTO="$2"; shift 2 ;;
*) shift ;;
esac
done
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_FPM_SOCK="/run/php/php${PHPV}-fpm.sock"
[ -S "$PHP_FPM_SOCK" ] || PHP_FPM_SOCK="/run/php/php-fpm.sock"
APP_DIR="/var/www/mailwolt"
NGINX_SITE="/etc/nginx/sites-available/mailwolt.conf"
ACME_ROOT="/var/www/letsencrypt"
mkdir -p "${ACME_ROOT}/.well-known/acme-challenge"
# --- Phase 1: HTTP-only Vhosts mit ACME-Challenge ---
cat > "${NGINX_SITE}" <<CONF
server {
listen 80;
listen [::]:80;
server_name ${UI_HOST} ${WEBMAIL_HOST};
root ${APP_DIR}/public;
index index.php index.html;
location /.well-known/acme-challenge/ {
root ${ACME_ROOT};
try_files \$uri =404;
}
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files \$uri /index.php?\$query_string;
}
}
CONF
nginx -t && systemctl reload nginx
# --- Phase 2: Let's Encrypt Zertifikate holen ---
# Prüfen ob Server globales IPv6 hat (nötig wenn AAAA-Records existieren)
has_global_ipv6() {
ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'
}
cert_needs_action() {
local domain="$1"
local cert="/etc/letsencrypt/live/${domain}/fullchain.pem"
[ ! -f "${cert}" ] && return 0
# 0 = gültig für >10 Tage → überspringen; 1 = läuft ab → erneuern
openssl x509 -checkend 864000 -noout -in "${cert}" 2>/dev/null && return 1
return 0
}
certbot_safe() {
local domain="$1"
local has_aaaa
has_aaaa=$(dig +short AAAA "${domain}" 2>/dev/null | head -1)
if [ -n "${has_aaaa}" ] && ! has_global_ipv6; then
echo "[!] ${domain}: AAAA-Record vorhanden aber kein IPv6 auf diesem Server — Let's Encrypt würde fehlschlagen. Self-signed wird verwendet." >&2
return 1
fi
certbot certonly --webroot \
-w "${ACME_ROOT}" \
-d "${domain}" \
--non-interactive --agree-tos \
--email "webmaster@${domain}" \
--no-eff-email
}
if [ "${SSL_AUTO}" = "1" ]; then
for DOMAIN in "${UI_HOST}" "${WEBMAIL_HOST}"; do
[ -z "${DOMAIN}" ] && continue
if cert_needs_action "${DOMAIN}"; then
certbot_safe "${DOMAIN}" || true
fi
done
# ===== Frontend Build (nur wenn package.json existiert) =====
if [ -f "${APP_DIR}/package.json" ]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
# Prod-Build (Vite/Tailwind)
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build || npm run build:prod || true"
fi
# --- Phase 3: Finale Vhosts ---
# Nur HTTPS wenn LE-Cert tatsächlich vorhanden, sonst HTTP-only (kein self-signed Fallback)
UI_HAS_CERT=0
WM_HAS_CERT=0
[ -f "/etc/letsencrypt/live/${UI_HOST}/fullchain.pem" ] && UI_HAS_CERT=1
[ -f "/etc/letsencrypt/live/${WEBMAIL_HOST}/fullchain.pem" ] && WM_HAS_CERT=1
(
if [ "${UI_HAS_CERT}" = "1" ] || [ "${WM_HAS_CERT}" = "1" ]; then
# Mindestens ein Cert vorhanden → HTTP-Redirect Block
cat <<CONF
server {
listen 80;
listen [::]:80;
server_name ${UI_HOST} ${WEBMAIL_HOST};
# ===== App-User/Gruppen & Rechte (am ENDE ausführen) =====
APP_USER="${APP_USER:-${APP_NAME}app}"
APP_GROUP="${APP_GROUP}"
APP_PW="${APP_PW:-changeme123}"
APP_DIR="${APP_DIR}"
location /.well-known/acme-challenge/ {
root ${ACME_ROOT};
try_files \$uri =404;
}
location / { return 301 https://\$host\$request_uri; }
}
CONF
else
# Kein Cert → HTTP-only, App läuft auf Port 80 weiter
cat <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${APP_DIR}/public;
index index.php index.html;
location /.well-known/acme-challenge/ {
root ${ACME_ROOT};
try_files \$uri =404;
}
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
if [ -n "${UI_HOST}" ] && [ "${UI_HAS_CERT}" = "1" ]; then
cat <<CONF
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${UI_HOST};
ssl_certificate /etc/letsencrypt/live/${UI_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${UI_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_param HTTPS on;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
if [ -n "${WEBMAIL_HOST}" ] && [ "${WM_HAS_CERT}" = "1" ]; then
cat <<CONF
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${WEBMAIL_HOST};
ssl_certificate /etc/letsencrypt/live/${WEBMAIL_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${WEBMAIL_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_param HTTPS on;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
) > "${NGINX_SITE}"
# State-Dateien VOR dem nginx-Switch schreiben:
# Browser-Poll (alle 2s) liest done=1 → Polling stoppt → "Zum Login" erscheint.
# Danach 6s sleep → nginx switchet auf HTTPS → User klickt Link → funktioniert.
STATE_DIR="/var/lib/mailwolt/wizard"
if [ -d "${STATE_DIR}" ]; then
for k in ui mail webmail; do
[ -f "${STATE_DIR}/${k}" ] && printf "done" > "${STATE_DIR}/${k}"
done
if [ "${UI_HAS_CERT}" = "1" ] || [ "${WM_HAS_CERT}" = "1" ]; then
printf "1" > "${STATE_DIR}/done"
else
printf "0" > "${STATE_DIR}/done"
fi
sleep 6
fi
nginx -t && systemctl reload nginx
HELPER
chmod 755 /usr/local/sbin/mailwolt-apply-domains
# ===== mailwolt-update installieren =====
install -m 755 "${APP_DIR}/update.sh" /usr/local/sbin/mailwolt-update
# ===== Sudoers für www-data (helper + update) =====
cat > /etc/sudoers.d/mailwolt-certbot <<'SUDOERS'
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-domains
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
www-data ALL=(root) NOPASSWD: /usr/bin/certbot
SUDOERS
chmod 440 /etc/sudoers.d/mailwolt-certbot
# git safe.directory damit spätere pulls als root möglich sind
git config --global --add safe.directory "${APP_DIR}" || true
# ===== Version-Datei schreiben =====
mkdir -p /var/lib/mailwolt
GIT_TAG="$(sudo -u "$APP_USER" -H bash -lc "git -C ${APP_DIR} ls-remote --tags --sort=-v:refname origin 'v*' 2>/dev/null | grep -v '\^{}' | head -1 | sed 's|.*refs/tags/||'")"
if [ -n "$GIT_TAG" ]; then
echo "${GIT_TAG#v}" > /var/lib/mailwolt/version
echo "$GIT_TAG" > /var/lib/mailwolt/version_raw
else
warn "Kein Git-Tag gefunden — Version-Datei wird nicht geschrieben"
fi
ok
step "Berechtigungen setzen & Dienste starten" "10 Sek"
# User anlegen (nur falls noch nicht vorhanden) + Passwort setzen + Gruppe
if ! id -u "$APP_USER" >/dev/null 2>&1; then
quietly adduser --disabled-password --gecos "" "$APP_USER"
adduser --disabled-password --gecos "" "$APP_USER"
echo "${APP_USER}:${APP_PW}" | chpasswd
fi
echo "${APP_USER}:${APP_PW}" | quietly chpasswd
quietly usermod -a -G "$APP_GROUP" "$APP_USER"
usermod -a -G "$APP_GROUP" "$APP_USER"
# Besitz & Rechte
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 775 {} \; 2>>"$LOG_FILE"
find "$APP_DIR" -type f -exec chmod 664 {} \; 2>>"$LOG_FILE"
find "$APP_DIR" -type d -exec chmod 775 {} \;
find "$APP_DIR" -type f -exec chmod 664 {} \;
chmod -R 775 "$APP_DIR"/storage "$APP_DIR"/bootstrap/cache
if command -v setfacl >/dev/null 2>&1; then
try_quiet setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
try_quiet setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
fi
echo -e "${YELLOW}[i] App-User: ${APP_USER} Passwort: ${APP_PW}${NC}"
# Optional: ACLs, falls verfügbar (robuster bei gemischten Schreibzugriffen)
if command -v setfacl >/dev/null 2>&1; then
setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
fi
grep -q 'umask 002' /home/${APP_USER}/.profile 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.profile
grep -q 'umask 002' /home/${APP_USER}/.bashrc 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.bashrc
try_quiet sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002"
# 7) npm respektiert umask zur Sicherheit direkt setzen (für APP_USER)
sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002" >/dev/null 2>&1 || true
# 8) PHP-FPM-Worker laufen als www-data (Standard). Stelle sicher, dass der FPM-Socket group-writable ist:
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
FPM_POOL="/etc/php/${PHPV}/fpm/pool.d/www.conf"
if [ -f "$FPM_POOL" ]; then
sed -i 's/^;*listen\.owner.*/listen.owner = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.group.*/listen.group = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.mode.*/listen.mode = 0660/' "$FPM_POOL"
try_quiet systemctl restart php${PHPV}-fpm
systemctl restart php${PHPV}-fpm || true
fi
# 9) Optional: deinem Shell-/IDE-User ebenfalls Schreibrechte geben
IDE_USER="${SUDO_USER:-}"
if [ -n "$IDE_USER" ] && id "$IDE_USER" >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then
try_quiet usermod -a -G "$APP_GROUP" "$IDE_USER"
try_quiet setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
try_quiet setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
usermod -a -G "$APP_GROUP" "$IDE_USER" || true
setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
echo -e "${YELLOW}[i]${NC} Benutzer '${IDE_USER}' wurde für Schreibzugriff freigeschaltet (ACL + Gruppe ${APP_GROUP})."
fi
try_quiet systemctl reload nginx
try_quiet systemctl restart php*-fpm
ok
# Webstack neu laden
systemctl reload nginx || true
systemctl restart php*-fpm || true
# ===== Monit =====
# Hinweis zur neuen Gruppenzugehörigkeit
echo -e "${YELLOW}[i]${NC} SHELL: Du kannst dich nun als Benutzer '${APP_USER}' mit dem Passwort '${APP_PW}' anmelden."
echo -e "${YELLOW}[i]${NC} Hinweis: Nach dem ersten Login solltest du das Passwort mit 'passwd ${APP_USER}' ändern."
echo -e "${YELLOW}[i]${NC} Damit die Gruppenrechte (${APP_GROUP}) aktiv werden, bitte einmal ab- und wieder anmelden."
# ===== Monit (Watchdog) installiert, aber NICHT aktiviert =====
log "Monit (Watchdog) installieren (deaktiviert)"
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
@ -871,127 +624,62 @@ set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed host 127.0.0.1 port 25 protocol smtp for 3 cycles then restart
if 5 restarts within 10 cycles then alert
if failed port 25 protocol smtp then restart
check process dovecot with pidfile /run/dovecot/master.pid
check process dovecot with pidfile /var/run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed host 127.0.0.1 port 143 type tcp for 3 cycles then restart
if failed host 127.0.0.1 port 993 type tcpssl for 3 cycles then restart
if 5 restarts within 10 cycles then alert
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
check process mariadb matching "mysqld"
check process mariadb with pidfile /var/run/mysqld/mysqld.pid
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed host 127.0.0.1 port 3306 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
if failed port 3306 type tcp then restart
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed host 127.0.0.1 port 6379 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
if failed port 6379 type tcp then restart
check process rspamd matching "rspamd: main process"
start program = "/bin/systemctl start rspamd" with timeout 60 seconds
check process rspamd with pidfile /run/rspamd/rspamd.pid
start program = "/bin/systemctl start rspamd"
stop program = "/bin/systemctl stop rspamd"
if failed host 127.0.0.1 port 11332 type tcp for 3 cycles then restart
if failed host 127.0.0.1 port 11334 type tcp for 3 cycles then restart
if 5 restarts within 10 cycles then alert
if failed port 11332 type tcp then restart
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 10 cycles then alert
if failed port 8891 type tcp then restart
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed host 127.0.0.1 port 80 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 10 cycles then alert
check process clamav matching "clamd"
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 10 cycles then unmonitor
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
EOF
chmod 600 /etc/monit/monitrc
monit -t || { warn "Monit-Config ungültig — prüfe /etc/monit/monitrc"; }
try_quiet systemctl enable --now monit
systemctl disable --now monit || true
apt-mark hold monit >/dev/null 2>&1 || true
# ===== Smoke-Test =====
step "Dienste prüfen (Port-Check)"
_sok=0; _sfail=0
smoke_smtp() {
local port="$1" label="$2"
local out
out=$(printf "EHLO localhost\r\nQUIT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -q '^220'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_tls() {
local port="$1" label="$2" extra="${3:-}"
local out
out=$(timeout 5s openssl s_client $extra -connect 127.0.0.1:"$port" -brief -quiet </dev/null 2>&1 || true)
if echo "$out" | grep -qiE '(CONNECTED|depth|Verify|^220|\+OK|OK)'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_imap() {
local port="$1" label="$2"
local out
out=$(printf ". CAPABILITY\r\n. LOGOUT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -qi 'CAPABILITY'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_pop3() {
local port="$1" label="$2"
local out
out=$(printf "QUIT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -qi '^\+OK'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_smtp 25 "SMTP"
smoke_tls 465 "SMTPS" ""
smoke_tls 587 "Submission" "-starttls smtp"
smoke_imap 143 "IMAP"
smoke_tls 993 "IMAPS" ""
smoke_pop3 110 "POP3"
smoke_tls 995 "POP3S" ""
printf "\n ${GREY}%d/%d Dienste erreichbar${NC}\n" "$_sok" "$(( _sok + _sfail ))"
# ===== Smoke-Test (alle Ports, mit Timeouts) =====
log "Smoke-Test (Ports & Banner):"
set +e
printf "[25] " && timeout 6s bash -lc 'printf "EHLO localhost\r\nQUIT\r\n" | nc -v -w 4 127.0.0.1 25 2>&1' || true
printf "[465] " && timeout 6s openssl s_client -connect 127.0.0.1:465 -brief -quiet </dev/null || echo "[465] Verbindung fehlgeschlagen"
printf "[587] " && timeout 6s openssl s_client -starttls smtp -connect 127.0.0.1:587 -brief -quiet </dev/null || echo "[587] Verbindung fehlgeschlagen"
printf "[110] " && timeout 6s bash -lc 'printf "QUIT\r\n" | nc -v -w 4 127.0.0.1 110 2>&1' || true
printf "[995] " && timeout 6s openssl s_client -connect 127.0.0.1:995 -brief -quiet </dev/null || echo "[995] Verbindung fehlgeschlagen"
printf "[143] " && timeout 6s bash -lc 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | nc -v -w 4 127.0.0.1 143 2>&1' || true
printf "[993] " && timeout 6s openssl s_client -connect 127.0.0.1:993 -brief -quiet </dev/null || echo "[993] Verbindung fehlgeschlagen"
set -e
echo
echo -e " ${GREY}Bootstrap-Login (nur für ERSTEN Login & Wizard):${NC}"
echo -e " ${CYAN}User: ${NC}${BOOTSTRAP_USER}"
echo -e " ${CYAN}Passwort: ${NC}${BOOTSTRAP_PASS}"
echo -e " ${GREY}Log: ${LOG_FILE}${NC}"
echo "=============================================================="
echo " Bootstrap-Login (nur für ERSTEN Login & Wizard):"
echo " User: ${BOOTSTRAP_USER}"
echo " Passwort: ${BOOTSTRAP_PASS}"
echo "=============================================================="
echo
footer_ok "$SERVER_IP"

8
mailwolt-installer/.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
</project>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/mailwolt-installer.iml" filepath="$PROJECT_DIR$/.idea/mailwolt-installer.iml" />
</modules>
</component>
</project>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -0,0 +1,17 @@
# ===================== HTTP (Port 80) =====================
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# ACME HTTP-01
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
allow all;
}
__HTTP_BODY__
}
# ===================== HTTPS (Port 443) ====================
__SSL_SERVER_BLOCK__

909
mailwolt-installer/install.sh Executable file
View File

@ -0,0 +1,909 @@
#!/usr/bin/env bash
(
export HISTFILE=
set +o history
set -euo pipefail
##############################################
# MailWolt #
# Bootstrap Installer v1.0 #
##############################################
# ===== CLI-Flags (-dev / -stag) =====
APP_ENV="${APP_ENV:-production}"
APP_DEBUG="${APP_DEBUG:-false}"
DEV_MODE=0
STAG_MODE=0
while [[ $# -gt 0 ]]; do
case "$1" in
-dev)
DEV_MODE=1
APP_ENV="local"
APP_DEBUG="true"
;;
-stag|-staging)
STAG_MODE=1
APP_ENV="staging"
APP_DEBUG="false"
;;
esac
shift
done
# ===== Branding & Pfade =====
APP_NAME="${APP_NAME:-MailWolt}"
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_DIR="/var/www/${APP_USER}"
ADMIN_USER="${APP_USER}"
ADMIN_EMAIL="admin@localhost"
ADMIN_PASS="ChangeMe"
CONF_BASE="/etc/${APP_USER}"
CERT_DIR="${CONF_BASE}/ssl"
CERT="${CERT_DIR}/cert.pem"
KEY="${CERT_DIR}/key.pem"
NGINX_SITE="/etc/nginx/sites-available/${APP_USER}.conf"
NGINX_SITE_LINK="/etc/nginx/sites-enabled/${APP_USER}.conf"
DB_NAME="${DB_NAME:-${APP_USER}}"
DB_USER="${DB_USER:-${APP_USER}}"
DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
GIT_REPO="${GIT_REPO:-http://10.10.20.81:3000/boban/mailwolt.git}"
GIT_BRANCH="${GIT_BRANCH:-main}"
NODE_SETUP="${NODE_SETUP:-deb}"
# ===== Styling =====
GREEN="\033[1;32m"; YELLOW="\033[1;33m"; RED="\033[1;31m"; CYAN="\033[1;36m"; GREY="\033[0;90m"; NC="\033[0m"
BAR="──────────────────────────────────────────────────────────────────────────────"
header() {
echo -e "${CYAN}${BAR}${NC}"
echo -e "${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}"
echo -e "${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888 ${NC}"
echo -e "${CYAN} 88888b.d88888 888 888 d8b 888 888 888 ${NC}"
echo -e "${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888 ${NC}"
echo -e "${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888 ${NC}"
echo -e "${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888 ${NC}"
echo -e "${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b. ${NC}"
echo -e "${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888 ${NC}"
echo -e "${CYAN}${BAR}${NC}"
echo
}
print_bootstrap_summary() {
local ip="$1"
local admin_user="$2"
local admin_pass="$3"
local GREEN="\033[1;32m"
local CYAN="\033[1;36m"
local GREY="\033[0;90m"
local YELLOW="\033[1;33m"
local RED="\033[1;31m"
local NC="\033[0m"
local BAR="${BAR:-──────────────────────────────────────────────────────────────────────────────}"
local scheme="http"
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then scheme="https"; fi
echo
echo -e "${GREEN}${BAR}${NC}"
echo -e "${GREEN}${APP_NAME} Bootstrap erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo -e " Bootstrap-Login (nur für ERSTEN Login & Wizard):"
echo -e " User: ${YELLOW}${admin_user}${NC}"
echo -e " Passwort: ${RED}${admin_pass}${NC}"
echo
echo -e " Aufruf: ${CYAN}${scheme}://${ip}${NC}"
echo -e " Laravel Root: ${GREY}${APP_DIR}${NC}"
echo -e " Nginx Site: ${GREY}${NGINX_SITE}${NC}"
echo -e " Self-signed Cert: ${GREY}${CERT_DIR}/{cert.pem,key.pem}${NC}"
echo -e " Postfix/Dovecot Ports aktiv: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e " Rspamd/OpenDKIM: ${GREY}aktiv (DKIM-Keys später im Wizard)${NC}"
echo -e " Monit (Watchdog): ${GREY}installiert, NICHT aktiviert${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo
}
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[x]${NC} $*"; }
require_root() { [ "$(id -u)" -eq 0 ] || { err "Bitte als root ausführen."; exit 1; }; }
detect_ip() {
local ip
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')" || true
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${ip:-}" ]] || { err "Konnte Server-IP nicht ermitteln."; exit 1; }
echo "$ip"
}
gen() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "${1:-28}" || true; }
pw() { gen 28; }
short() { gen 16; }
# ===== Start =====
require_root
header
SERVER_IP="$(detect_ip)"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-"bootstrap.local"}"
TZ="${TZ:-""}"
echo -e "${GREY}Server-IP erkannt: ${SERVER_IP}${NC}"
[ -n "$TZ" ] && { ln -fs "/usr/share/zoneinfo/${TZ}" /etc/localtime || true; }
log "Paketquellen aktualisieren…"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
# ---- MariaDB-Workaround ----
log "MariaDB-Workaround vorbereiten…"
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
[ -f /etc/mysql/mariadb.cnf ] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
# ---- Basis-Pakete installieren ----
log "Pakete installieren… (dies kann einige Minuten dauern)"
export DEBIAN_FRONTEND=noninteractive
apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server mariadb-client \
redis-server \
rspamd \
opendkim opendkim-tools \
nginx \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd unzip curl \
composer git \
certbot python3-certbot-nginx \
fail2ban \
ca-certificates rsyslog sudo openssl netcat-openbsd monit acl
NGINX_HTTP2_SUPPORTED=0
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUPPORTED=1
log "Nginx: HTTP/2-Unterstützung vorhanden ✅"
else
warn "Nginx: HTTP/2-Modul nicht gefunden wechsle auf 'nginx-full'…"
apt-get install -y nginx-full || true
systemctl restart nginx || true
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUPPORTED=1
log "Nginx: HTTP/2 jetzt verfügbar ✅"
else
warn "HTTP/2 weiterhin nicht verfügbar (verwende SSL ohne http2)."
fi
fi
if [ "$NGINX_HTTP2_SUPPORTED" = "1" ]; then
NGINX_HTTP2_SUFFIX=" http2"
else
NGINX_HTTP2_SUFFIX=""
fi
# ===== Verzeichnisse / User =====
log "Verzeichnisse und Benutzer anlegen…"
mkdir -p "${CERT_DIR}" /etc/postfix/sql /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
chown -R vmail:vmail /var/mail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
usermod -a -G "$APP_GROUP" "$APP_USER"
# ===== Self-signed TLS (SAN = IP) =====
OSSL_CFG="${CERT_DIR}/openssl.cnf"
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
log "Erzeuge Self-Signed TLS Zertifikat (SAN=IP:${SERVER_IP})…"
install -d -m 0750 -o root -g "${APP_USER}" "${CERT_DIR}"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[dn]
CN = ${SERVER_IP}
O = ${APP_NAME}
C = DE
[req_ext]
subjectAltName = @alt_names
[alt_names]
IP.1 = ${SERVER_IP}
CFG
openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
-keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chown root:"${APP_USER}" "$KEY" "$CERT"
chmod 640 "$KEY" "$CERT"
chmod 750 "${CERT_DIR}"
fi
DEV_USER="${SUDO_USER:-$USER}"
if command -v setfacl >/dev/null 2>&1; then
setfacl -m u:${DEV_USER}:x "${CONF_BASE}" "${CERT_DIR}" || true
setfacl -m u:${DEV_USER}:r "$CERT" "$KEY" || true
fi
# ===== MariaDB =====
log "MariaDB vorbereiten…"
systemctl enable --now mariadb
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME}
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1';
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
ALTER USER '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
# ===== Postfix =====
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = ipv4"
postconf -e "smtpd_banner = \$myhostname ESMTP"
postconf -e "smtpd_tls_cert_file = ${CERT}"
postconf -e "smtpd_tls_key_file = ${KEY}"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtp_tls_security_level = may"
postconf -e "smtpd_tls_received_header = yes"
postconf -e "disable_vrfy_command = yes"
postconf -e "smtpd_helo_required = yes"
postconf -e "milter_default_action = accept"
postconf -e "milter_protocol = 6"
postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
postconf -M "smtp/inet=smtp inet n - n - - smtpd -o smtpd_peername_lookup=no -o smtpd_timeout=30s"
postconf -M "submission/inet=submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_peername_lookup=no -o smtpd_tls_security_level=encrypt -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "smtps/inet=smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_peername_lookup=no -o smtpd_tls_wrappermode=yes -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "pickup/unix=pickup unix n - y 60 1 pickup"
postconf -M "cleanup/unix=cleanup unix n - y - 0 cleanup"
postconf -M "qmgr/unix=qmgr unix n - n 300 1 qmgr"
install -d -o root -g postfix -m 750 /etc/postfix/sql
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT 1 FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%s' AND u.is_active = 1 AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-alias-maps.cf
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT destination FROM mail_aliases a JOIN domains d ON d.id = a.domain_id WHERE a.source = '%s' AND a.is_active = 1 AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
systemctl restart postfix
systemctl enable --now postfix
# ===== Dovecot =====
log "Dovecot konfigurieren…"
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
cat > /etc/dovecot/conf.d/10-mail.conf <<'CONF'
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox { inbox = yes }
mail_privileged_group = mail
CONF
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
CONF
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
# password_query = SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%u' AND is_active = 1 LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext }
userdb { driver = static args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n }
CONF
sudo chown root:dovecot /etc/dovecot/conf.d/auth-sql.conf.ext
sudo chmod 640 /etc/dovecot/conf.d/auth-sql.conf.ext
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp { mode = 0600 user = postfix group = postfix }
}
service auth {
unix_listener /var/spool/postfix/private/auth { mode = 0660 user = postfix group = postfix }
}
service imap-login {
inet_listener imap { port = 143 }
inet_listener imaps { port = 993 ssl = yes }
}
service pop3-login {
inet_listener pop3 { port = 110 }
inet_listener pop3s { port = 995 ssl = yes }
}
CONF
cat > /etc/dovecot/conf.d/10-ssl.conf <<CONF
ssl = required
ssl_cert = <${CERT}
ssl_key = <${KEY}
CONF
sudo mkdir -p /var/spool/postfix/private
sudo chown postfix:postfix /var/spool/postfix /var/spool/postfix/private
sudo chmod 0755 /var/spool/postfix /var/spool/postfix/private
sudo systemctl restart dovecot
sudo systemctl restart postfix
systemctl enable --now dovecot
# ===== Rspamd & OpenDKIM =====
log "Rspamd + OpenDKIM aktivieren…"
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
systemctl enable --now rspamd || true
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
# KeyTable / SigningTable werden nach dem Wizard gesetzt
CONF
systemctl enable --now opendkim || true
# ===== Redis =====
log "Redis absichern (Passwort setzen & nur localhost)…"
REDIS_CONF="/etc/redis/redis.conf"
REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
sed -i 's/^\s*#\?\s*bind .*/bind 127.0.0.1/' "$REDIS_CONF"
sed -i 's/^\s*#\?\s*protected-mode .*/protected-mode yes/' "$REDIS_CONF"
if grep -qE '^\s*#?\s*requirepass ' "$REDIS_CONF"; then
sed -i "s/^\s*#\?\s*requirepass .*/requirepass ${REDIS_PASS}/" "$REDIS_CONF"
else
printf "\nrequirepass %s\n" "${REDIS_PASS}" >> "$REDIS_CONF"
fi
systemctl enable --now redis-server
systemctl restart redis-server
# ===== Nginx =====
log "Nginx konfigurieren…"
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
detect_php_fpm_sock() {
for v in 8.3 8.2 8.1 8.0 7.4; do s="/run/php/php${v}-fpm.sock"; [ -S "$s" ] && { echo "$s"; return; }; done
[ -S "/run/php/php-fpm.sock" ] && { echo "/run/php/php-fpm.sock"; return; }
echo "127.0.0.1:9000"
}
PHP_FPM_SOCK="$(detect_php_fpm_sock)"
install -d -m 0755 /var/www/letsencrypt
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then
cat > "${NGINX_SITE}" <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ { root /var/www/letsencrypt; allow all; }
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX};
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name _;
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_ssl_access.log;
error_log /var/log/nginx/${APP_USER}_ssl_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:${PHP_FPM_SOCK}; }
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
CONF
else
cat > "${NGINX_SITE}" <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_access.log;
error_log /var/log/nginx/${APP_USER}_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:${PHP_FPM_SOCK}; }
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
ln -sf "${NGINX_SITE}" "${NGINX_SITE_LINK}"
if nginx -t; then
systemctl enable --now nginx
systemctl reload nginx || true
else
echo "[x] Nginx-Konfiguration fehlerhaft bitte /var/log/nginx/* prüfen."
exit 1
fi
# ===== Laravel Projekt =====
log "Laravel bereitstellen…"
mkdir -p "$(dirname "$APP_DIR")"
chown -R "$APP_USER":"$APP_GROUP" "$(dirname "$APP_DIR")"
log "Git Repo vorbereiten…"
if [ "${GIT_REPO}" = "https://example.com/your-repo-placeholder.git" ]; then
if [ ! -d "${APP_DIR}" ] || [ -z "$(ls -A "$APP_DIR" 2>/dev/null || true)" ]; then
sudo -u "$APP_USER" -H bash -lc "cd /var/www && COMPOSER_ALLOW_SUPERUSER=0 composer create-project laravel/laravel ${APP_USER} --no-interaction"
fi
else
if [ ! -d "${APP_DIR}/.git" ]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
sudo -u "$APP_USER" -H bash -lc "
set -e
cd ${APP_DIR}
git checkout ${GIT_BRANCH} 2>/dev/null || git checkout -B ${GIT_BRANCH}
git fetch --depth=1 origin ${GIT_BRANCH}
if git merge-base --is-ancestor HEAD origin/${GIT_BRANCH}; then
git pull --ff-only
else
echo '[i] Non-fast-forward erkannt setze hart auf origin/${GIT_BRANCH}.' >&2
git reset --hard origin/${GIT_BRANCH}
git clean -fd
fi
"
fi
if [ -f "${APP_DIR}/composer.json" ]; then
if [ "${DEV_MODE}" = "1" ]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist --no-dev"
fi
fi
fi
# ===== Node / Frontend =====
if [ -f "${APP_DIR}/package.json" ]; then
log "Node/NPM installieren…"
if command -v node >/dev/null 2>&1; then
NODE_MAJ=$(node -v | sed 's/^v//' | cut -d. -f1)
NODE_MIN=$(node -v | sed 's/^v//' | cut -d. -f2)
if [ "$NODE_MAJ" -lt 20 ] || { [ "$NODE_MAJ" -eq 20 ] && [ "$NODE_MIN" -lt 19 ]; }; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
fi
else
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
fi
# .env anlegen & APP_KEY
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
if ! grep -q '^APP_KEY=' "${APP_DIR}/.env"; then echo "APP_KEY=" >> "${APP_DIR}/.env"; fi
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force || true"
fi
# ===== .env füllen =====
ENV_FILE="${APP_DIR}/.env"
upsert_env () {
local key="$1" val="$2"
local esc_key esc_val
esc_key="$(printf '%s' "$key" | sed -e 's/[.[\*^$(){}+?|/]/\\&/g')"
esc_val="$(printf '%s' "$val" | sed -e 's/[&/]/\\&/g')"
if grep -qE "^[#[:space:]]*${esc_key}=" "$ENV_FILE"; then
sed -Ei "s|^[#[:space:]]*${esc_key}=.*|${key}=${esc_val}|g" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then
upsert_env APP_URL "\"https://\${APP_HOST}\""
else
upsert_env APP_URL "\"http://\${APP_HOST}\""
fi
upsert_env APP_HOST "${SERVER_IP}"
upsert_env APP_ADMIN_USER "${ADMIN_USER}"
upsert_env APP_ADMIN_EMAIL "${ADMIN_EMAIL}"
upsert_env APP_ADMIN_PASS "${ADMIN_PASS}"
upsert_env APP_NAME "${APP_NAME}"
upsert_env APP_ENV "${APP_ENV}"
upsert_env APP_DEBUG "${APP_DEBUG}"
upsert_env DB_CONNECTION "mysql"
upsert_env DB_HOST "127.0.0.1"
upsert_env DB_PORT "3306"
upsert_env DB_DATABASE "${DB_NAME}"
upsert_env DB_USERNAME "${DB_USER}"
upsert_env DB_PASSWORD "${DB_PASS}"
# -------- WICHTIG: Cache-Store auf REDIS setzen (verhindert DB-Tabellenfehler) --------
upsert_env CACHE_SETTINGS_STORE "redis"
upsert_env CACHE_STORE "redis" # <- HIER geändert (vorher: database)
upsert_env CACHE_DRIVER "redis"
upsert_env CACHE_PREFIX "${APP_USER}_cache"
upsert_env SESSION_DRIVER "redis"
upsert_env REDIS_CLIENT "phpredis"
upsert_env REDIS_HOST "127.0.0.1"
upsert_env REDIS_PORT "6379"
upsert_env REDIS_PASSWORD "${REDIS_PASS}"
upsert_env REDIS_DB "0"
upsert_env REDIS_CACHE_DB "1"
upsert_env REDIS_CACHE_CONNECTION "cache"
upsert_env REDIS_CACHE_LOCK_CONNECTION "default"
# Reverb / Vite
upsert_env BROADCAST_DRIVER "reverb"
upsert_env QUEUE_CONNECTION "redis"
upsert_env REVERB_APP_ID "${APP_USER}"
upsert_env REVERB_APP_KEY "${APP_USER}-yhp47tbt1aebhr1fgvgj"
upsert_env REVERB_APP_SECRET "${APP_USER}-ulrdt9agwzkqwqsunbnb"
upsert_env REVERB_HOST "127.0.0.1"
upsert_env REVERB_PORT "443"
upsert_env REVERB_SCHEME "https"
upsert_env REVERB_PATH "/ws"
upsert_env VITE_REVERB_APP_KEY "\${REVERB_APP_KEY}"
upsert_env VITE_REVERB_PORT "\${REVERB_PORT}"
upsert_env VITE_REVERB_SCHEME "\${REVERB_SCHEME}"
upsert_env VITE_REVERB_PATH "\${REVERB_PATH}"
if [ "${DEV_MODE}" = "1" ]; then
sed -i '/^# --- MailWolt DEV/,/^# --- \/MailWolt DEV/d' "${ENV_FILE}"
cat >> "${ENV_FILE}" <<CONF
# --- MailWolt DEV ---
VITE_DEV_HOST=127.0.0.1
VITE_DEV_PORT=5173
VITE_HMR_PROTOCOL=wss
VITE_HMR_CLIENT_PORT=443
VITE_HMR_HOST=${APP_HOST}
VITE_DEV_ORIGIN=${APP_URL}
# --- /MailWolt DEV ---
CONF
cat > "${APP_DIR}/vite.config.js" <<'JS'
import { defineConfig, loadEnv } from 'vite'
import laravel from 'laravel-vite-plugin'
import tailwindcss from '@tailwindcss/vite'
export default ({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const host = env.VITE_DEV_HOST || '127.0.0.1'
const port = Number(env.VITE_DEV_PORT || 5173)
const origin = env.VITE_DEV_ORIGIN || env.APP_URL || 'https://localhost'
const hmrHost = env.VITE_HMR_HOST || (new URL(origin)).hostname
return defineConfig({
plugins: [laravel({ input: ['resources/css/app.css','resources/js/app.js'], refresh: true }), tailwindcss()],
server: { host, port, https:false, strictPort:true,
hmr:{ protocol: env.VITE_HMR_PROTOCOL || 'wss', host: hmrHost, clientPort:Number(env.VITE_HMR_CLIENT_PORT||443) },
origin
}
})
}
JS
chown "${APP_USER}:${APP_GROUP}" "${APP_DIR}/vite.config.js"
fi
# ===== Frontend Build =====
if [ -f "${APP_DIR}/package.json" ]; then
log "Frontend Build…"
if [ -f "${APP_DIR}/package-lock.json" ] || [ -f "${APP_DIR}/npm-shrinkwrap.json" ]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm install"
fi
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build || true"
rm -f ${APP_DIR}/bootstrap/cache/*.php
fi
# ===== Rechte / PHP-FPM =====
APP_PW="${APP_PW:-changeme}"
if ! id -u "$APP_USER" >/dev/null 2>&1; then
adduser --disabled-password --gecos "" "$APP_USER"
echo "${APP_USER}:${APP_PW}" | chpasswd
fi
usermod -a -G "$APP_GROUP" "$APP_USER"
# Sichert, dass alle nötigen Ordner existieren (idempotent)
install -d -m 0775 "${APP_DIR}/storage" \
"${APP_DIR}/storage/framework" \
"${APP_DIR}/storage/framework/cache" \
"${APP_DIR}/storage/framework/cache/data" \
"${APP_DIR}/storage/framework/sessions" \
"${APP_DIR}/storage/framework/views" \
"${APP_DIR}/bootstrap/cache"
# Besitz & Rechte
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 775 {} \;
find "$APP_DIR" -type f -exec chmod 664 {} \;
[ -f "$APP_DIR/artisan" ] && chmod 755 "$APP_DIR/artisan"
[ -d "$APP_DIR/vendor/bin" ] && chmod -R 755 "$APP_DIR/vendor/bin"
[ -d "$APP_DIR/node_modules/.bin" ] && chmod -R 755 "$APP_DIR/node_modules/.bin"
[ -f "$APP_DIR/node_modules/vite/bin/vite.js" ] && chmod 755 "$APP_DIR/node_modules/vite/bin/vite.js"
find "$APP_DIR" -type f -name "*.sh" -exec chmod 755 {} \;
chmod -R 775 "$APP_DIR"/storage "$APP_DIR"/bootstrap/cache
if command -v setfacl >/dev/null 2>&1; then
setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
fi
grep -q 'umask 002' /home/${APP_USER}/.profile 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.profile
grep -q 'umask 002' /home/${APP_USER}/.bashrc 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.bashrc
sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002" >/dev/null 2>&1 || true
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
FPM_POOL="/etc/php/${PHPV}/fpm/pool.d/www.conf"
if [ -f "$FPM_POOL" ]; then
sed -i 's/^;*listen\.owner.*/listen.owner = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.group.*/listen.group = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.mode.*/listen.mode = 0660/' "$FPM_POOL"
systemctl restart php${PHPV}-fpm || true
fi
IDE_USER="${SUDO_USER:-}"
if [ -n "$IDE_USER" ] && id "$IDE_USER" >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then
usermod -a -G "$APP_GROUP" "$IDE_USER" || true
setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
echo -e "${GREEN}[i]${NC} Benutzer '${IDE_USER}' wurde für Schreibzugriff freigeschaltet (ACL + Gruppe ${APP_GROUP})."
fi
# Webstack neu laden
systemctl reload nginx || true
systemctl restart php*-fpm || true
# ===== Reverb systemd =====
cat > /etc/systemd/system/mailwolt-ws.service <<EOF
[Unit]
Description=MailWolt WebSocket Backend
After=network.target
[Service]
Type=simple
Environment=NODE_ENV=production WS_PORT=8080
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
Restart=always
RestartSec=2
StandardOutput=append:/var/log/mailwolt-ws.log
StandardError=append:/var/log/mailwolt-ws.log
KillSignal=SIGINT
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
EOF
chown root:root /etc/systemd/system/mailwolt-ws.service
chmod 644 /etc/systemd/system/mailwolt-ws.service
touch /var/log/mailwolt-ws.log
chown ${APP_USER}:${APP_GROUP} /var/log/mailwolt-ws.log
chmod 664 /var/log/mailwolt-ws.log
install -d -m 775 -o "${APP_USER}" -g "${APP_GROUP}" \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
# ---- Laravel-Caches als APP_USER aufräumen (funktioniert jetzt ohne DB-Cache-Tabelle) ----
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache"
# HINWEIS: Wenn du unbedingt DATABASE als Cache-Store willst, dann vor obiger Zeile:
# sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan cache:table && php artisan migrate --force"
systemctl daemon-reload
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan list --no-ansi 2>/dev/null | grep -qE '(^| )reverb:start( |$)'"; then
systemctl enable --now mailwolt-ws
else
systemctl disable --now mailwolt-ws >/dev/null 2>&1 || true
fi
# ===== Monit =====
log "Monit konfigurieren & starten…"
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcp ssl then restart
if failed port 587 type tcp then restart
check process dovecot with pidfile /var/run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
if failed port 110 type tcp then restart
if failed port 995 type tcp ssl then restart
check process mariadb with pidfile /var/run/mysqld/mysqld.pid
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed port 3306 type tcp then restart
check process redis-server with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed port 6379 type tcp then restart
check process rspamd with pidfile /run/rspamd/rspamd.pid
start program = "/bin/systemctl start rspamd"
stop program = "/bin/systemctl stop rspamd"
if failed port 11332 type tcp then restart
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed port 8891 type tcp then restart
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
check process mailwolt-ws matching "reverb:start"
start program = "/bin/systemctl start mailwolt-ws"
stop program = "/bin/systemctl stop mailwolt-ws"
if failed host 127.0.0.1 port 8080 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then timeout
EOF
chmod 600 /etc/monit/monitrc
monit -t && systemctl enable --now monit
monit reload
monit summary || true
# ===== Healthchecks =====
GREEN="\033[1;32m"; RED="\033[1;31m"; GREY="\033[0;90m"; NC="\033[0m"
ok(){ echo -e " [${GREEN}OK${NC}]"; }
fail(){ echo -e " [${RED}FAIL${NC}]"; }
echo "[+] Quick-Healthchecks…"
printf " • MariaDB … " ; mysqladmin ping --silent >/dev/null 2>&1 && ok || fail
printf " • Redis … " ; if command -v redis-cli >/dev/null 2>&1; then
if [ -n "${REDIS_PASS:-}" ] && [ "${REDIS_PASS}" != "null" ]; then
redis-cli -a "${REDIS_PASS}" ping 2>/dev/null | grep -q PONG && ok || fail
else
redis-cli ping 2>/dev/null | grep -q PONG && ok || fail
fi
else fail; fi
printf " • PHP-FPM … " ; if [[ "$PHP_FPM_SOCK" == 127.0.0.1:9000 ]]; then ss -ltn | grep -q ":9000 " && ok || fail; else [ -S "$PHP_FPM_SOCK" ] && ok || fail; fi
printf " • App … " ; if command -v curl >/dev/null 2>&1; then
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then curl -skI "https://127.0.0.1" >/dev/null 2>&1 && ok || fail; else curl -sI "http://127.0.0.1" >/dev/null 2>&1 && ok || fail; fi
else echo -e " ${GREY}(curl fehlt)${NC}"; fi
check_port(){ local label="$1" cmd="$2"; printf " • %-5s … " "$label"; timeout 8s bash -lc "$cmd" >/dev/null 2>&1 && ok || fail; }
check_port "25" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 25'
check_port "465" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:465 -quiet -ign_eof'
check_port "587" 'printf "EHLO x\r\nSTARTTLS\r\nQUIT\r\n" | openssl s_client -starttls smtp -connect 127.0.0.1:587 -quiet -ign_eof'
check_port "110" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 110'
check_port "995" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:995 -quiet -ign_eof'
check_port "143" 'printf ". LOGOUT\r\n" | nc -w 3 127.0.0.1 143'
check_port "993" 'printf ". LOGOUT\r\n" | openssl s_client -connect 127.0.0.1:993 -quiet -ign_eof'
print_bootstrap_summary "$SERVER_IP" "$ADMIN_USER" "$ADMIN_PASS"
# ===== MOTD =====
install -d /usr/local/bin
cat >/usr/local/bin/mw-motd <<'SH'
#!/usr/bin/env bash
set -euo pipefail
NC="\033[0m"; CY="\033[1;36m"; GR="\033[1;32m"; YE="\033[1;33m"; RD="\033[1;31m"; GY="\033[0;90m"
printf "\033[1;36m"
cat <<'ASCII'
:::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::
+:+:+: :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
+:+ +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
+#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+#
### ### ### ### ########### ########## ### ### ######## ########## ###
ASCII
printf "\033[0m\n"
now="$(date '+%Y-%m-%d %H:%M:%S %Z')"
fqdn="$(hostname -f 2>/dev/null || hostname)"
ip_int="$(hostname -I 2>/dev/null | awk '{print $1}')"
ip_ext=""; command -v curl >/dev/null 2>&1 && ip_ext="$(curl -s --max-time 1 https://ifconfig.me || true)"
upt="$(uptime -p 2>/dev/null || true)"
cores="$(nproc 2>/dev/null || echo -n '?')"
mhz="$(LC_ALL=C lscpu 2>/dev/null | awk -F: '/MHz/{gsub(/ /,"",$2); printf("%.0f MHz",$2); exit}')"
[ -z "$mhz" ] && mhz="$(awk -F: '/cpu MHz/{printf("%.0f MHz",$2); exit}' /proc/cpuinfo 2>/dev/null)"
load="$(awk '{print $1" / "$2" / "$3}' /proc/loadavg 2>/dev/null)"
mem_total="$(awk '/MemTotal/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
mem_free="$(awk '/MemAvailable/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
svc_status(){ systemctl is-active --quiet "$1" && echo -e "${GR}OK${NC}" || echo -e "${RD}FAIL${NC}"; }
printf "${CY}Information as of:${NC} ${YE}%s${NC}\n" "$now"
printf "${GY}FQDN :${NC} %s\n" "$fqdn"
if [ -n "$ip_ext" ]; then printf "${GY}IP :${NC} %s ${GY}(external:${NC} %s${GY})${NC}\n" "${ip_int:-?}" "$ip_ext"; else printf "${GY}IP :${NC} %s\n" "${ip_int:-?}"; fi
printf "${GY}Uptime :${NC} %s\n" "${upt:-?}"
printf "${GY}Core(s) :${NC} %s core(s) at ${CY}%s${NC}\n" "$cores" "${mhz:-?}"
printf "${GY}Load :${NC} %s (1 / 5 / 15)\n" "${load:-?}"
printf "${GY}Memory :${NC} ${RD}%s${NC} ${GY}(free)${NC} / ${CY}%s${NC} ${GY}(total)${NC}\n" "${mem_free:-?}" "${mem_total:-?}"
echo
printf "${GY}Services :${NC} postfix: $(svc_status postfix) dovecot: $(svc_status dovecot) nginx: $(svc_status nginx) mariadb: $(svc_status mariadb) redis: $(svc_status redis)\n"
SH
chmod +x /usr/local/bin/mw-motd
if [ -d /etc/update-motd.d ]; then
cat >/etc/update-motd.d/10-mailwolt <<'SH'
#!/usr/bin/env bash
/usr/local/bin/mw-motd
SH
chmod +x /etc/update-motd.d/10-mailwolt
[ -f /etc/update-motd.d/50-motd-news ] && chmod -x /etc/update-motd.d/50-motd-news || true
[ -f /etc/update-motd.d/80-livepatch ] && chmod -x /etc/update-motd.d/80-livepatch || true
else
cat >/etc/profile.d/10-mailwolt-motd.sh <<'SH'
case "$-" in *i*) /usr/local/bin/mw-motd ;; esac
SH
fi
: > /etc/motd 2>/dev/null || true
)

View File

@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
if [ -r /etc/mailwolt/installer.env ]; then
. /etc/mailwolt/installer.env
fi
REDIS_PASS="${REDIS_PASS:-}"
SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
find "$SCRIPTS_DIR/.." -type f -name "*.sh" -exec sed -i 's/\r$//' {} \; || true
log "Pakete installieren …"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
# Minimal aber vollständig
apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server mariadb-client redis-server rspamd opendkim opendkim-tools opendmarc clamav \
clamav-daemon nginx php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql \
php-redis php-gd unzip curl composer git certbot python3-certbot-nginx fail2ban ca-certificates \
rsyslog sudo openssl monit acl netcat-openbsd jq sqlite3
# <<< Apache konsequent entfernen >>>
systemctl disable --now apache2 >/dev/null 2>&1 || true
apt-get -y purge 'apache2*' >/dev/null 2>&1 || true
apt-get -y autoremove >/dev/null 2>&1 || true
log "Systemuser/Dirs …"
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
# Systemuser/Dirs …
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
# --- FIX: Gruppen und Berechtigungen für Maildir und Dovecot-Zugriff ---
# vmail soll primär der Gruppe "mail" angehören, zusätzlich dovecot
usermod -g mail -a -G dovecot vmail || true
# App-User in relevante Gruppen
usermod -a -G "$APP_GROUP" "$APP_USER" || true
usermod -a -G mail,dovecot "$APP_USER" || true
# Maildir-Baum für Gruppe mail lesbar
chgrp -R mail /var/mail/vhosts || true
chmod -R g+rx /var/mail/vhosts || true
# ACLs setzen, damit neue Verzeichnisse automatisch passende Rechte bekommen
setfacl -R -m g:mail:rx /var/mail/vhosts || true
setfacl -dR -m g:mail:rx /var/mail/vhosts || true
usermod -a -G "$APP_GROUP" "$APP_USER" || true
install -d -m 0755 -o root -g root /var/www
install -d -m 0775 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR"
SUDOERS_DKIM="/etc/sudoers.d/mailwolt-dkim"
cat > "${SUDOERS_DKIM}" <<'EOF'
Defaults!/usr/local/sbin/mailwolt-install-dkim !requiretty
Defaults!/usr/local/sbin/mailwolt-remove-dkim !requiretty
Defaults!/usr/bin/systemctl !requiretty
Defaults!/usr/bin/test !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install-dkim *
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-remove-dkim *
www-data ALL=(root) NOPASSWD: /usr/bin/systemctl reload opendkim
www-data ALL=(root) NOPASSWD: /usr/bin/test *
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install-dkim *
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-remove-dkim *
mailwolt ALL=(root) NOPASSWD: /usr/bin/systemctl reload opendkim
mailwolt ALL=(root) NOPASSWD: /usr/bin/test *
EOF
chown root:root "${SUDOERS_DKIM}"
chmod 440 "${SUDOERS_DKIM}"
if ! visudo -c -f "${SUDOERS_DKIM}" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_DKIM} entferne Datei."
rm -f "${SUDOERS_DKIM}"
fi
SUDOERS_DOVEADM="/etc/sudoers.d/mailwolt-doveadm"
cat > "${SUDOERS_DOVEADM}" <<'EOF'
Cmnd_Alias MW_DOVEADM_STATUS = /usr/bin/doveadm -f tab mailbox status -u * messages INBOX, \
/usr/bin/doveadm mailbox status -u * messages INBOX
www-data ALL=(vmail) NOPASSWD: MW_DOVEADM_STATUS
mailwolt ALL=(vmail) NOPASSWD: MW_DOVEADM_STATUS
EOF
chown root:root "${SUDOERS_DOVEADM}"
chmod 440 "${SUDOERS_DOVEADM}"
visudo -c -f "${SUDOERS_DOVEADM}" || rm -f "${SUDOERS_DOVEADM}"
log "MariaDB include-fix …"
mkdir -p /etc/mysql/mariadb.conf.d
[[ -f /etc/mysql/mariadb.cnf ]] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
log "Redis absichern …"
if [[ -z "${REDIS_PASS:-}" || "${REDIS_PASS}" == "changeme" ]]; then
REDIS_PASS="$(openssl rand -hex 16)"
export REDIS_PASS
log "Neues Redis-Passwort generiert."
fi
# Aktiven Redis-Config-Pfad aus systemd holen (Fallback: Standard)
REDIS_CONF="$(systemctl show -p ExecStart redis-server \
| sed -n 's/^ExecStart=.*redis-server[[:space:]]\+\([^[:space:]]\+\).*/\1/p')"
REDIS_CONF="${REDIS_CONF:-/etc/redis/redis.conf}"
# Bind + protected-mode hart setzen
sed -i 's/^[[:space:]]*#\?[[:space:]]*bind .*/bind 127.0.0.1/' "$REDIS_CONF"
sed -i 's/^[[:space:]]*#\?[[:space:]]*protected-mode .*/protected-mode yes/' "$REDIS_CONF"
# Vorherige requirepass-Zeilen entfernen (kommentiert/unkommentiert), dann neu schreiben
sed -i '/^[[:space:]]*#\?[[:space:]]*requirepass[[:space:]]\+/d' "$REDIS_CONF"
printf '\nrequirepass %s\n' "${REDIS_PASS}" >> "$REDIS_CONF"
# Dienst aktivieren & neu starten
systemctl enable --now redis-server
systemctl restart redis-server || true
# Sanity-Check (kein harter Exit, nur Log)
if redis-cli -a "${REDIS_PASS}" ping 2>/dev/null | grep -q PONG; then
log "Redis mit Passwort OK."
else
warn "Redis PING mit Passwort fehlgeschlagen bitte /etc/redis/redis.conf prüfen."
fi

View File

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
CONF_BASE="/etc/${APP_USER}"
CERT_DIR="${CONF_BASE}/ssl"
UI_SSL_DIR="/etc/ssl/ui"; WEBMAIL_SSL_DIR="/etc/ssl/webmail"; MAIL_SSL_DIR="/etc/ssl/mail"
UI_CERT="${UI_SSL_DIR}/fullchain.pem"; UI_KEY="${UI_SSL_DIR}/privkey.pem"
WEBMAIL_CERT="${WEBMAIL_SSL_DIR}/fullchain.pem"; WEBMAIL_KEY="${WEBMAIL_SSL_DIR}/privkey.pem"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"; MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
install -d -m 0750 "$CERT_DIR"
CERT="${CERT_DIR}/cert.pem"; KEY="${CERT_DIR}/key.pem"
if [[ ! -s "$CERT" || ! -s "$KEY" ]]; then
log "Self-signed Zertifikat erzeugen …"
OSSL_CFG="${CERT_DIR}/openssl.cnf"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits=2048
prompt=no
default_md=sha256
req_extensions=req_ext
distinguished_name=dn
[dn]
CN=${SERVER_PUBLIC_IPV4}
O=${APP_NAME}
C=DE
[req_ext]
subjectAltName=@alt_names
[alt_names]
IP.1=${SERVER_PUBLIC_IPV4}
CFG
openssl req -x509 -newkey rsa:2048 -days 825 -nodes -keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chgrp www-data "$CERT" "$KEY" || true
chmod 640 "$KEY" "$CERT"
fi
install -d -m 0755 "$UI_SSL_DIR" "$WEBMAIL_SSL_DIR" "$MAIL_SSL_DIR"
ln -sf "$CERT" "$UI_CERT"; ln -sf "$KEY" "$UI_KEY"
ln -sf "$CERT" "$WEBMAIL_CERT";ln -sf "$KEY" "$WEBMAIL_KEY"
ln -sf "$CERT" "$MAIL_CERT"; ln -sf "$KEY" "$MAIL_KEY"
# --- Mail-Zertifikate: Rechte für Postfix & Dovecot -------------------------
# WICHTIG: Rechte am *Target* (KEY/CERT im $CERT_DIR) setzen, nicht an den Symlinks.
if [[ -f "$KEY" && -f "$CERT" ]]; then
echo "[+] Setze Berechtigungen für Mail-Zertifikate …"
# Key: nur root + Gruppe lesen. Gruppe → postfix
chgrp postfix "$KEY" || true
chmod 640 "$KEY" || true
# Dovecot zusätzlich Leserechte via ACL
setfacl -m u:dovecot:r "$KEY" || true
# Zertifikat darf weltweit lesbar sein
chmod 644 "$CERT" || true
else
echo "[!] Zertifikatsdateien fehlen: $KEY oder $CERT" >&2
fi
# Optional: kurze Info, wohin verlinkt wurde
echo "[i] Mail TLS: $MAIL_CERT -> $CERT ; $MAIL_KEY -> $KEY"

View File

@ -0,0 +1,588 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
# -------------------------------------------------------------------
# 2) POSIX-kompatibler Deploy-Wrapper (von Certbot aufgerufen)
# -------------------------------------------------------------------
cat >/usr/local/sbin/mailwolt-deploy.sh <<'WRAP'
#!/bin/sh
# POSIX-safe Certbot deploy-hook (ohne bashisms)
set -eu
# Installer-ENV laden (liefert UI_HOST/WEBMAIL_HOST/MAIL_HOSTNAME etc.)
if [ -r /etc/mailwolt/installer.env ]; then
. /etc/mailwolt/installer.env
fi
UI_HOST="${UI_HOST:-}"
WEBMAIL_HOST="${WEBMAIL_HOST:-}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
ACME_BASE="/etc/letsencrypt/live"
copy_cert() {
le_base="$1" # z.B. /etc/letsencrypt/live/ui.example.com
target_dir="$2" # z.B. /etc/ssl/ui
cert="${le_base}/fullchain.pem"
key="${le_base}/privkey.pem"
[ -s "$cert" ] || { echo "[deploy] missing $cert"; return 1; }
[ -s "$key" ] || { echo "[deploy] missing $key"; return 1; }
mkdir -p "$target_dir"
# echte Dateien (keine Symlinks), feste Rechte
install -m 0644 "$cert" "${target_dir}/fullchain.pem"
install -m 0600 "$key" "${target_dir}/privkey.pem"
echo "[+] Copied ${target_dir}/fullchain.pem und privkey.pem ← ${le_base}"
}
reload_services() {
kind="$1" # ui | mail
if command -v systemctl >/dev/null 2>&1; then
if [ "$kind" = "mail" ]; then
systemctl reload postfix 2>/dev/null || true
systemctl reload dovecot 2>/dev/null || true
else
systemctl reload nginx 2>/dev/null || true
fi
fi
}
# Certbot-Kontext
LINEAGE="${RENEWED_LINEAGE:-}"
HOST=""
if [ -n "$LINEAGE" ]; then
HOST="$(basename "$LINEAGE")"
fi
did_any=0
maybe_copy_for_host() {
host="$1"
dir="$2"
[ -n "$host" ] || return 0
# Fall A: Certbot liefert RENEWED_DOMAINS (Space-getrennt)
if [ -n "${RENEWED_DOMAINS:-}" ]; then
case " ${RENEWED_DOMAINS} " in
*" ${host} "*) copy_cert "${ACME_BASE}/${host}" "${dir}" && did_any=1 ;;
esac
return 0
fi
# Fall B: Erst-issue / kein RENEWED_DOMAINS → über LINEAGE matchen
if [ -n "$HOST" ] && [ "$HOST" = "$host" ]; then
copy_cert "${ACME_BASE}/${host}" "${dir}" && did_any=1
fi
}
# Gezieltes Kopieren
maybe_copy_for_host "$UI_HOST" "/etc/ssl/ui"
maybe_copy_for_host "$WEBMAIL_HOST" "/etc/ssl/webmail"
maybe_copy_for_host "$MAIL_HOSTNAME" "/etc/ssl/mail"
# Fallback (Erstlauf): kopiere vorhandene Lineages
if [ "$did_any" -eq 0 ]; then
[ -n "$UI_HOST" ] && [ -d "${ACME_BASE}/${UI_HOST}" ] && copy_cert "${ACME_BASE}/${UI_HOST}" "/etc/ssl/ui"
[ -n "$WEBMAIL_HOST" ] && [ -d "${ACME_BASE}/${WEBMAIL_HOST}" ] && copy_cert "${ACME_BASE}/${WEBMAIL_HOST}" "/etc/ssl/webmail"
[ -n "$MAIL_HOSTNAME" ] && [ -d "${ACME_BASE}/${MAIL_HOSTNAME}" ] && copy_cert "${ACME_BASE}/${MAIL_HOSTNAME}" "/etc/ssl/mail"
fi
# TLSA-Refresh (tolerant falls App noch nicht ready)
if command -v php >/dev/null 2>&1 && [ -f /var/www/mailwolt/artisan ]; then
(cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
fi
# Services neu laden
if [ -n "$HOST" ]; then
if [ -n "$MAIL_HOSTNAME" ] && [ "$HOST" = "$MAIL_HOSTNAME" ]; then
reload_services mail
else
reload_services ui
fi
else
reload_services ui
fi
exit 0
WRAP
chmod +x /usr/local/sbin/mailwolt-deploy.sh
# -------------------------------------------------------------------
# 3) Certbot deploy-hook, der den Wrapper aufruft
# -------------------------------------------------------------------
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh <<'HOOK'
#!/bin/sh
exec /usr/local/sbin/mailwolt-deploy.sh
HOOK
chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh
log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
## Persistente Installer-Variablen (werden vom Wrapper gelesen)
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#BASE_DOMAIN=${BASE_DOMAIN}
#LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
#SYSMAIL_SUB="${SYSMAIL_SUB}"
#SYSMAIL_DOMAIN="${SYSMAIL_DOMAIN}"
#DKIM_ENABLE="${DKIM_ENABLE}"
#DKIM_SELECTOR="${DKIM_SELECTOR}"
#DKIM_GENERATE="${DKIM_GENERATE}"
#APP_ENV=${APP_ENV:-production}
#EOF
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper, den Certbot bei Issue/Renew aufruft
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
## Installer-Variablen laden
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
## --- Kopieren statt Symlinks (damit Laravel lesen kann) ---------------------
#copy_cert() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
#
# [[ -s "$cert" && -s "$key" ]] || return 0
#
# install -d -m 0755 "$target_dir"
#
# # Vorhandene Symlinks entfernen, sonst kopierst du in die LE-Datei hinein
# [ -L "${target_dir}/fullchain.pem" ] && rm -f "${target_dir}/fullchain.pem"
# [ -L "${target_dir}/privkey.pem" ] && rm -f "${target_dir}/privkey.pem"
#
# # Echte Dateien ablegen
# install -m 0644 "$cert" "${target_dir}/fullchain.pem"
# install -m 0600 "$key" "${target_dir}/privkey.pem"
#
# echo "[+] Copied ${target_dir}/fullchain.pem und privkey.pem ← ${le_base}"
#}
#
## Nur Domains bearbeiten, die in diesem Lauf betroffen sind.
## Bei manchen Distros ist RENEWED_DOMAINS auf Erst-issue leer -> Fallback nutzen.
#RDOMS=" ${RENEWED_DOMAINS:-} "
#did_any=0
#
#maybe_copy_for() {
# local host="$1" dir="$2"
# [[ -z "$host" ]] && return 0
# if [[ "$RDOMS" == *" ${host} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${host}" "${dir}"
# did_any=1
# fi
#}
#
## 1) Normalfall: nur die vom Certbot gemeldeten Hosts kopieren
#maybe_copy_for "$UI_HOST" "/etc/ssl/ui"
#maybe_copy_for "$WEBMAIL_HOST" "/etc/ssl/webmail"
#maybe_copy_for "$MAIL_HOSTNAME" "/etc/ssl/mail"
#
## 2) Fallback: Beim Erstlauf/Edge-Cases alles kopieren, was bereits existiert
#if [[ "$did_any" -eq 0 ]]; then
# [[ -n "$UI_HOST" && -d "/etc/letsencrypt/live/${UI_HOST}" ]] && copy_cert "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
# [[ -n "$WEBMAIL_HOST" && -d "/etc/letsencrypt/live/${WEBMAIL_HOST}" ]] && copy_cert "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
# [[ -n "$MAIL_HOSTNAME" && -d "/etc/letsencrypt/live/${MAIL_HOSTNAME}"]] && copy_cert "/etc/letsencrypt/live/${MAIL_HOSTNAME}"/etc/ssl/mail
#fi
#
## Optional: TLSA via Laravel (tolerant, falls App noch nicht gebaut)
#if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ] && [ -f /var/www/mailwolt/artisan ]; then
# (cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
#fi
#
## Nginx nur neu laden, wenn aktiv
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot-Deploy-Hook: ruft den Wrapper bei jeder erfolgreichen Ausstellung/Renew auf
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
## Persistente Installer-Variablen (werden vom Wrapper gelesen)
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#BASE_DOMAIN=${BASE_DOMAIN}
#LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
#APP_ENV=${APP_ENV:-production}
#EOF
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper, den Certbot bei Issue/Renew aufruft
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
## Installer-Variablen laden
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
## --- Kopieren statt Symlinks (damit Laravel lesen kann) ---------------------
#copy_cert() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
#
# [[ -s "$cert" && -s "$key" ]] || return 0
#
# # Zielordner sicherstellen
# install -d -m 0755 "$target_dir"
#
# # Falls vorher Symlinks existieren → entfernen, sonst würde "install" das Ziel des Links überschreiben
# [ -L "${target_dir}/fullchain.pem" ] && rm -f "${target_dir}/fullchain.pem"
# [ -L "${target_dir}/privkey.pem" ] && rm -f "${target_dir}/privkey.pem"
#
# # KOPIEREN mit sauberen Rechten (Chain world-readable, Key nur root)
# install -m 0644 "$cert" "${target_dir}/fullchain.pem"
# install -m 0600 "$key" "${target_dir}/privkey.pem"
#
# echo "[+] Copied ${target_dir}/fullchain.pem und privkey.pem ← ${le_base}"
#}
#
## Nur für Domains arbeiten, die in diesem Lauf betroffen sind
#RDOMS=" ${RENEWED_DOMAINS:-} "
#
## UI
#if [[ -n "$UI_HOST" && "$RDOMS" == *" ${UI_HOST} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
#fi
## Webmail
#if [[ -n "$WEBMAIL_HOST" && "$RDOMS" == *" ${WEBMAIL_HOST} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
#fi
## MX
#if [[ -n "$MAIL_HOSTNAME" && "$RDOMS" == *" ${MAIL_HOSTNAME} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${MAIL_HOSTNAME}" "/etc/ssl/mail"
#fi
#
## Optional: TLSA via Laravel (still tolerant, falls App noch nicht gebaut)
#if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ] && [ -f /var/www/mailwolt/artisan ]; then
# (cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
#fi
#
## Nginx nur neu laden, wenn aktiv
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot-Deploy-Hook: ruft den Wrapper bei jeder erfolgreichen Ausstellung/Renew auf
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#BASE_DOMAIN=${BASE_DOMAIN}
#LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
#APP_ENV=${APP_ENV:-production}
#EOF
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper, den Certbot bei Issue/Renew aufruft
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
## Installer-Variablen laden (UI_HOST, WEBMAIL_HOST, MAIL_HOSTNAME, optional LE_EMAIL etc.)
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
#link_if() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
# [[ -s "$cert" && -s "$key" ]] || return 0
# install -d -m 0755 "$target_dir"
# ln -sf "$cert" "${target_dir}/fullchain.pem"
# ln -sf "$key" "${target_dir}/privkey.pem"
# chmod 644 "${target_dir}/fullchain.pem" 2>/dev/null || true
# chmod 600 "${target_dir}/privkey.pem" 2>/dev/null || true
# echo "[+] Linked ${target_dir} -> ${le_base}"
#}
#
## Nur für Domains arbeiten, die im aktuellen Lauf erneuert/ausgestellt wurden
#RDOMS=" ${RENEWED_DOMAINS:-} "
#
## UI
#if [[ -n "$UI_HOST" && "$RDOMS" == *" ${UI_HOST} "* ]]; then
# link_if "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
#fi
## Webmail
#if [[ -n "$WEBMAIL_HOST" && "$RDOMS" == *" ${WEBMAIL_HOST} "* ]]; then
# link_if "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
#fi
## MX
#if [[ -n "$MAIL_HOSTNAME" && "$RDOMS" == *" ${MAIL_HOSTNAME} "* ]]; then
# link_if "/etc/letsencrypt/live/${MAIL_HOSTNAME}" "/etc/ssl/mail"
#fi
#
## Optional: TLSA via Laravel, falls App schon vorhanden (sonst still überspringen)
#if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ]; then
# (cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
#fi
#
## Nginx nur neu laden, wenn aktiv
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot-Deploy-Hooks einrichten (ruft nur den Wrapper auf)
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper-Skript, das Symlinks setzt und Nginx reloaded
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
#link_if() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
# [[ -s "$cert" && -s "$key" ]] || return 0
# install -d -m 0755 "$target_dir"
# ln -sf "$cert" "${target_dir}/fullchain.pem"
# ln -sf "$key" "${target_dir}/privkey.pem"
# chmod 644 "${target_dir}/fullchain.pem" 2>/dev/null || true
# chmod 600 "${target_dir}/privkey.pem" 2>/dev/null || true
# echo "[+] Linked ${target_dir} -> ${le_base}"
#}
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
#[[ -n "$UI_HOST" ]] && link_if "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
#[[ -n "$WEBMAIL_HOST" ]] && link_if "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
#[[ -n "$MAIL_HOSTNAME" ]] && link_if "/etc/letsencrypt/live/${MAIL_HOSTNAME}" "/etc/ssl/mail"
#
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot Deploy-Hook-Verzeichnis + Symlink für Renewals
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
#
###!/usr/bin/env bash
##set -euo pipefail
##source ./lib.sh
##
### ────────────────────────────────────────────────────────────────────────────
### 21-le-deploy-hook.sh
### • legt /etc/mailwolt/installer.env an (falls fehlt)
### • erzeugt Deploy-Hooks:
### - 50-mailwolt-symlinks.sh → verlinkt LE-Zerts nach /etc/ssl/{ui,webmail,mail}
### - 60-mailwolt-tlsa.sh → aktualisiert TLSA (3 1 1) für MX bei jedem Renew
### • KEIN Reload von Postfix/Dovecot (kommt später im Installer)
### ────────────────────────────────────────────────────────────────────────────
##
### 0) Hostnamen persistent speichern (für spätere Deploys)
##install -d -m 0755 /etc/mailwolt
##if [[ ! -f /etc/mailwolt/installer.env ]]; then
## cat >/etc/mailwolt/installer.env <<EOF
##UI_HOST=${UI_HOST}
##WEBMAIL_HOST=${WEBMAIL_HOST}
##MAIL_HOSTNAME=${MAIL_HOSTNAME}
##EOF
## echo "[+] /etc/mailwolt/installer.env erstellt."
##fi
##
### 1) Deploy-Hooks-Verzeichnis anlegen
##install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
##
### ────────────────────────────────────────────────────────────────────────────
### 2) 50-mailwolt-symlinks.sh
### ────────────────────────────────────────────────────────────────────────────
##cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<HOOK
###!/usr/bin/env bash
##set -euo pipefail
##
##UI_LE="/etc/letsencrypt/live/${UI_HOST}"
##WEBMAIL_LE="/etc/letsencrypt/live/${WEBMAIL_HOST}"
##MX_LE="/etc/letsencrypt/live/${MAIL_HOSTNAME}"
##
##UI_SSL_DIR="/etc/ssl/ui"
##WEBMAIL_SSL_DIR="/etc/ssl/webmail"
##MAIL_SSL_DIR="/etc/ssl/mail"
##
### Zielverzeichnisse anlegen (einmalig)
##install -d -m 0755 "\$UI_SSL_DIR" "\$WEBMAIL_SSL_DIR" "\$MAIL_SSL_DIR"
##
##link_if() {
## local le_base="\$1" target_dir="\$2"
## local cert="\${le_base}/fullchain.pem"
## local key="\${le_base}/privkey.pem"
## [[ -s "\$cert" && -s "\$key" ]] || return 0
## ln -sf "\$cert" "\${target_dir}/fullchain.pem"
## ln -sf "\$key" "\${target_dir}/privkey.pem"
## chmod 644 "\${target_dir}/fullchain.pem" 2>/dev/null || true
## chmod 600 "\${target_dir}/privkey.pem" 2>/dev/null || true
## echo "[+] Linked \${target_dir} -> \${le_base}"
##}
##
### Verlinken (nur wenn Host konfiguriert)
##[[ -n "${UI_HOST}" ]] && link_if "\$UI_LE" "\$UI_SSL_DIR"
##[[ -n "${WEBMAIL_HOST}" ]] && link_if "\$WEBMAIL_LE" "\$WEBMAIL_SSL_DIR"
##[[ -n "${MAIL_HOSTNAME}" ]] && link_if "\$MX_LE" "\$MAIL_SSL_DIR"
##
### Nur reloaden, wenn Nginx aktiv ist (Installer startet ihn später erst)
##if systemctl is-active --quiet nginx; then
## systemctl reload nginx || true
##fi
##HOOK
##chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
##
### ────────────────────────────────────────────────────────────────────────────
### 3) 60-mailwolt-tlsa.sh
### → nutzt Laravel, falls vorhanden; sonst Fallback mit OpenSSL.
### → schreibt nur, wenn sich der Hash geändert hat (idempotent)
### ────────────────────────────────────────────────────────────────────────────
##cat >/etc/letsencrypt/renewal-hooks/deploy/60-mailwolt-tlsa.sh <<'HOOK'
###!/usr/bin/env bash
##set -euo pipefail
##
### installer.env lesen
##set +u
##[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
##set -u
##
##APP_ENV_VAL="${APP_ENV:-production}"
##BASE_DOMAIN_VAL="${BASE_DOMAIN:-example.com}"
##
##case "$APP_ENV_VAL" in
## local|dev|development) exit 0 ;;
##esac
##[ "$BASE_DOMAIN_VAL" = "example.com" ] && exit 0
##
##MX_HOST="${MAIL_HOSTNAME:-}"
##SERVICE="_25._tcp"
##DNS_DIR="/etc/mailwolt/dns"
##OUT_FILE="${DNS_DIR}/${MX_HOST}.tlsa.txt"
##
### Nur reagieren, wenn MX-Zertifikat betroffen war
##case " ${RENEWED_DOMAINS:-} " in
## *" ${MX_HOST} "*) ;;
## *) exit 0 ;;
##esac
##
##CERT="${RENEWED_LINEAGE}/fullchain.pem"
##[ -s "$CERT" ] || exit 0
##
### Wenn Laravel vorhanden ist → interner Command (DB + Datei idempotent)
##if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ]; then
## cd /var/www/mailwolt || exit 0
## php artisan dns:tlsa:refresh || true
## exit 0
##fi
##
### Fallback: nur Datei aktualisieren, wenn Hash sich ändert
##HASH="$(openssl x509 -in "$CERT" -noout -pubkey \
## | openssl pkey -pubin -outform DER \
## | openssl dgst -sha256 | sed 's/^.*= //')"
##NEW_LINE="${SERVICE}.${MX_HOST}. IN TLSA 3 1 1 ${HASH}"
##
##mkdir -p "$DNS_DIR"
##
##if [ -r "$OUT_FILE" ] && grep -q "IN TLSA" "$OUT_FILE"; then
## if grep -q "$HASH" "$OUT_FILE"; then
## echo "[TLSA] Unverändert kein Update nötig."
## exit 0
## fi
##fi
##
##echo "$NEW_LINE" > "$OUT_FILE"
##echo "[TLSA] Aktualisiert: $NEW_LINE"
##HOOK
##chmod +x /etc/letsencrypt/renewal-hooks/deploy/60-mailwolt-tlsa.sh
##
### ────────────────────────────────────────────────────────────────────────────
##echo "[✓] Deploy-Hooks installiert."

View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Installiere DKIM-Helper …"
install -d -m 0755 /usr/local/sbin
cat >/usr/local/sbin/mailwolt-install-dkim <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="$1" # z.B. sysmail.toastra.com
SELECTOR="${2:-mwl1}"
[[ -n "$DOMAIN" ]] || { echo "Usage: $0 <domain> [selector]"; exit 2; }
KEYDIR="/etc/opendkim/keys/${DOMAIN}"
PRIV="${KEYDIR}/${SELECTOR}.private"
TXT="${KEYDIR}/${SELECTOR}.txt"
install -d -m 0750 -o opendkim -g opendkim "$KEYDIR"
if [[ ! -s "$PRIV" ]]; then
opendkim-genkey -b 2048 -s "$SELECTOR" -d "$DOMAIN" -D "$KEYDIR"
chown opendkim:opendkim "$PRIV"
chmod 600 "$PRIV"
fi
grep -q "^${SELECTOR}\._domainkey\.${DOMAIN} " /etc/opendkim/KeyTable 2>/dev/null \
|| echo "${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:${PRIV}" >> /etc/opendkim/KeyTable
grep -q "^\*@${DOMAIN} " /etc/opendkim/SigningTable 2>/dev/null \
|| echo "*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}" >> /etc/opendkim/SigningTable
install -d -m 0755 /etc/mailwolt/dns
[[ -s "$TXT" ]] && cp -f "$TXT" "/etc/mailwolt/dns/dkim-${DOMAIN}.txt" || true
systemctl restart opendkim
EOF
log "[✓] DKIM-Helper installiert: /usr/local/sbin/mailwolt-install-dkim"

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "MariaDB vorbereiten …"
systemctl enable --now mariadb
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL

View File

@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
MAIL_SSL_DIR="/etc/ssl/mail"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
log "Postfix konfigurieren …"
# --- TLS-Dateirechte (falls du sie in /etc/mailwolt/ssl spiegelst) -----------
if [[ -e "${MAIL_KEY}" ]]; then
chgrp -R postfix /etc/mailwolt/ssl || true
chmod 750 /etc/mailwolt/ssl || true
chmod 640 /etc/mailwolt/ssl/key.pem /etc/mailwolt/ssl/cert.pem || true
fi
# --- Basiskonfiguration -------------------------------------------------------
/usr/sbin/postconf -e "myhostname = ${MAIL_HOSTNAME}"
/usr/sbin/postconf -e "myorigin = \$myhostname"
/usr/sbin/postconf -e "mydestination = "
/usr/sbin/postconf -e "inet_interfaces = all"
/usr/sbin/postconf -e "inet_protocols = all"
/usr/sbin/postconf -e "smtpd_banner = \$myhostname ESMTP"
# --- TLS ----------------------------------------------------------------------
/usr/sbin/postconf -e "smtpd_tls_cert_file = ${MAIL_CERT}"
/usr/sbin/postconf -e "smtpd_tls_key_file = ${MAIL_KEY}"
/usr/sbin/postconf -e "smtpd_tls_security_level = may"
/usr/sbin/postconf -e "smtpd_use_tls = yes"
/usr/sbin/postconf -e "smtpd_tls_received_header = yes"
/usr/sbin/postconf -e "smtpd_tls_protocols = !SSLv2,!SSLv3"
/usr/sbin/postconf -e "smtpd_tls_mandatory_protocols = !SSLv2,!SSLv3"
/usr/sbin/postconf -e "smtpd_tls_loglevel = 1"
/usr/sbin/postconf -e "smtp_tls_security_level = may"
/usr/sbin/postconf -e "smtp_tls_loglevel = 1"
DH_FILE="/etc/ssl/private/dhparam.pem"
if [[ ! -s "$DH_FILE" ]]; then
log "Generiere 2048-Bit DH-Parameter …"
openssl dhparam -out "$DH_FILE" 2048
chmod 600 "$DH_FILE"
chown root:root "$DH_FILE"
fi
/usr/sbin/postconf -e "smtpd_tls_dh1024_param_file = ${DH_FILE}"
/usr/sbin/postconf -e "smtpd_tls_dh1024_param_file = ${DH_FILE}"
/usr/sbin/postconf -e "smtpd_tls_eecdh_grade = strong"
/usr/sbin/postconf -e "tls_preempt_cipherlist = yes"
# Nur moderne TLS-Versionen (auch für ausgehendes SMTP)
# (überschreibt die älteren Zeilen oben)
/usr/sbin/postconf -e "smtpd_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1"
/usr/sbin/postconf -e "smtpd_tls_mandatory_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1"
/usr/sbin/postconf -e "smtp_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1"
# Hohe Cipher, alte raus
/usr/sbin/postconf -e "smtpd_tls_ciphers = high"
/usr/sbin/postconf -e "smtp_tls_ciphers = high"
/usr/sbin/postconf -e "smtpd_tls_exclude_ciphers = aNULL,eNULL,MD5,RC4,DES,3DES"
/usr/sbin/postconf -e "smtp_tls_exclude_ciphers = aNULL,eNULL,MD5,RC4,DES,3DES"
# --- SMTP Sicherheit ----------------------------------------------------------
/usr/sbin/postconf -e "disable_vrfy_command = yes"
/usr/sbin/postconf -e "smtpd_helo_required = yes"
# --- Milter -------------------------------------------------------------------
/usr/sbin/postconf -e "milter_default_action = accept"
/usr/sbin/postconf -e "milter_protocol = 6"
/usr/sbin/postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
/usr/sbin/postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
# --- SASL Auth via Dovecot ----------------------------------------------------
/usr/sbin/postconf -e "smtpd_sasl_type = dovecot"
/usr/sbin/postconf -e "smtpd_sasl_path = private/auth"
/usr/sbin/postconf -e "smtpd_sasl_auth_enable = yes"
/usr/sbin/postconf -e "smtpd_sasl_security_options = noanonymous"
# --- Recipient & Relay Restriction --------------------------------------------
/usr/sbin/postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
/usr/sbin/postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
# --- Listener / Master.cf Definition ------------------------------------------
/usr/sbin/postconf -M "smtp/inet=smtp inet n - n - - smtpd -o smtpd_peername_lookup=no -o smtpd_timeout=30s"
/usr/sbin/postconf -M "submission/inet=submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_peername_lookup=no -o smtpd_tls_security_level=encrypt -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
/usr/sbin/postconf -M "smtps/inet=smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_peername_lookup=no -o smtpd_tls_wrappermode=yes -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
# postscreen ggf. deaktivieren
sed -i 's/^[[:space:]]*smtp[[:space:]]\+inet[[:space:]]\+.*postscreen/# &/' /etc/postfix/master.cf || true
# --- SQL Maps (Verzeichnis zuerst!) -------------------------------------------
install -d -o root -g postfix -m 750 /etc/postfix/sql
# Domains
cat > /etc/postfix/sql/mysql-virtual-domains.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT 1 FROM domains WHERE domain = '%s' AND is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-domains.cf
chmod 640 /etc/postfix/sql/mysql-virtual-domains.cf
# Mailboxen
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT 1 FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%s' AND u.is_active = 1 AND u.can_login = 1 AND u.password_hash IS NOT NULL AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
# Aliase
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT COALESCE(mu.email, r.email) AS destination FROM mail_aliases a JOIN domains d ON d.id = a.domain_id JOIN mail_alias_recipients r ON r.alias_id = a.id LEFT JOIN mail_users mu ON mu.id = r.mail_user_id WHERE d.domain = SUBSTRING_INDEX('%s','@',-1) AND a.local = SUBSTRING_INDEX('%s','@', 1) AND a.is_active = 1 AND d.is_active = 1 AND (mu.email IS NOT NULL OR r.email IS NOT NULL) ORDER BY r.position ASC;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
# Aktivieren
/usr/sbin/postconf -e "virtual_mailbox_domains = proxy:mysql:/etc/postfix/sql/mysql-virtual-domains.cf"
/usr/sbin/postconf -e "virtual_mailbox_maps = proxy:mysql:/etc/postfix/sql/mysql-virtual-mailbox-maps.cf"
/usr/sbin/postconf -e "virtual_alias_maps = proxy:mysql:/etc/postfix/sql/mysql-virtual-alias-maps.cf"
/usr/sbin/postconf -e "virtual_transport = lmtp:unix:private/dovecot-lmtp"
# --- Dienst aktivieren & neu laden --------------------------------------------
#systemctl enable postfix >/dev/null 2>&1 || true

View File

@ -0,0 +1,247 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
MAIL_SSL_DIR="/etc/ssl/mail"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
log "Dovecot konfigurieren …"
# ──────────────────────────────────────────────────────────────────────────────
# 1) vmail-Benutzer/Gruppe & Mailspool vorbereiten (DYNAMIC UID!)
# ──────────────────────────────────────────────────────────────────────────────
# Sicherstellen, dass die Gruppe 'mail' existiert (auf Debian/Ubuntu idR vorhanden)
getent group mail >/dev/null || groupadd -g 8 mail || true
# vmail anlegen, wenn er fehlt. Bevorzugt UID 109, falls frei sonst automatisch.
if ! getent passwd vmail >/dev/null; then
if ! getent passwd 109 >/dev/null; then
useradd -u 109 -g mail -d /var/mail -M -s /usr/sbin/nologin vmail
else
useradd -g mail -d /var/mail -M -s /usr/sbin/nologin vmail
fi
fi
# Tatsächliche vmail-UID ermitteln (wird unten in die Dovecot-Config geschrieben)
VMAIL_UID="$(id -u vmail)"
# Mailspool-Basis
install -d -m 0770 -o vmail -g mail /var/mail/vhosts
# ──────────────────────────────────────────────────────────────────────────────
# 2) Dovecot Grundgerüst
# ──────────────────────────────────────────────────────────────────────────────
# Hauptdatei
install -d -m 0755 /etc/dovecot/conf.d
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
# Mail-Location & Namespace + UID-Grenzen
cat > /etc/dovecot/conf.d/10-mail.conf <<CONF
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox {
inbox = yes
}
mail_privileged_group = mail
mail_access_groups = mail
first_valid_uid = ${VMAIL_UID}
last_valid_uid = ${VMAIL_UID}
CONF
cat > /etc/dovecot/conf.d/15-mailboxes.conf <<'CONF'
namespace inbox {
inbox = yes
mailbox Drafts {
special_use = \Drafts
auto = subscribe
}
mailbox Junk {
special_use = \Junk
auto = subscribe
}
mailbox Trash {
special_use = \Trash
auto = subscribe
}
mailbox Sent {
special_use = \Sent
auto = subscribe
}
mailbox Archive {
special_use = \Archive
auto = create
}
}
CONF
# Auth
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
auth_cache_size = 10M
auth_cache_ttl = 1 hour
CONF
# SQL-Anbindung (Passwörter aus App-DB)
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
password_query = SELECT u.email AS user, u.password_hash AS password FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%u' AND u.is_active = 1 AND u.can_login = 1 AND u.password_hash IS NOT NULL AND d.is_active = 1 LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext
# Auth-SQL → userdb static auf vmail:mail (Home unter /var/mail/vhosts/%d/%n)
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=mail home=/var/mail/vhosts/%d/%n
}
CONF
chown root:dovecot /etc/dovecot/conf.d/auth-sql.conf.ext
chmod 640 /etc/dovecot/conf.d/auth-sql.conf.ext
# ──────────────────────────────────────────────────────────────────────────────
# 3) IMAP Optimierung (iOS/IDLE-freundlich)
# ──────────────────────────────────────────────────────────────────────────────
cat > /etc/dovecot/conf.d/20-imap.conf <<'CONF'
# IMAP-spezifische Einstellungen
imap_idle_notify_interval = 2 mins
imap_hibernate_timeout = 0
protocol imap {
mail_max_userip_connections = 20
imap_logout_format = in=%i out=%o deleted=%{deleted} expunged=%{expunged}
}
CONF
# ──────────────────────────────────────────────────────────────────────────────
# 4) Master Services (LMTP, AUTH, IMAP, POP3, STATS)
# ──────────────────────────────────────────────────────────────────────────────
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
unix_listener auth-userdb {
mode = 0660
user = vmail
group = mail
}
process_limit = 1
}
service imap-login {
inet_listener imap {
port = 143
}
inet_listener imaps {
port = 993
ssl = yes
}
process_limit = 128
process_min_avail = 10
service_count = 0
vsz_limit = 512M
}
service pop3-login {
inet_listener pop3 {
port = 110
}
inet_listener pop3s {
port = 995
ssl = yes
}
process_limit = 50
service_count = 0
}
CONF
# --- Dovecot: doveadm-server für App-Zugriff ---
cat >/etc/dovecot/conf.d/99-mailwolt-perms.conf <<'CONF'
service auth {
unix_listener auth-userdb {
mode = 0660
user = vmail
group = mail
}
}
service stats {
unix_listener stats-reader {
mode = 0660
user = vmail
group = mail
}
unix_listener stats-writer {
mode = 0660
user = vmail
group = mail
}
}
CONF
# ──────────────────────────────────────────────────────────────────────────────
# 5) SSL-Konfiguration (ohne DH-Param-Erzeugung)
# ──────────────────────────────────────────────────────────────────────────────
DOVECOT_SSL_CONF="/etc/dovecot/conf.d/10-ssl.conf"
touch "$DOVECOT_SSL_CONF"
grep -q '^ssl\s*=' "$DOVECOT_SSL_CONF" 2>/dev/null || echo "ssl = required" >> "$DOVECOT_SSL_CONF"
if grep -q '^\s*ssl_cert\s*=' "$DOVECOT_SSL_CONF"; then
sed -i "s|^\s*ssl_cert\s*=.*|ssl_cert = <${MAIL_CERT}|" "$DOVECOT_SSL_CONF"
else
echo "ssl_cert = <${MAIL_CERT}" >> "$DOVECOT_SSL_CONF"
fi
if grep -q '^\s*ssl_key\s*=' "$DOVECOT_SSL_CONF"; then
sed -i "s|^\s*ssl_key\s*=.*|ssl_key = <${MAIL_KEY}|" "$DOVECOT_SSL_CONF"
else
echo "ssl_key = <${MAIL_KEY}" >> "$DOVECOT_SSL_CONF"
fi
grep -q '^ssl_min_protocol' "$DOVECOT_SSL_CONF" || echo "ssl_min_protocol = TLSv1.2" >> "$DOVECOT_SSL_CONF"
grep -q '^ssl_prefer_server_ciphers' "$DOVECOT_SSL_CONF" || echo "ssl_prefer_server_ciphers = yes" >> "$DOVECOT_SSL_CONF"
grep -q '^ssl_dh' "$DOVECOT_SSL_CONF" || echo "ssl_dh = </etc/ssl/private/dhparam.pem" >> "$DOVECOT_SSL_CONF"
# ──────────────────────────────────────────────────────────────────────────────
# 6) Verzeichnisse & Rechte prüfen
# ──────────────────────────────────────────────────────────────────────────────
mkdir -p /var/spool/postfix/private
chown root:root /var/spool/postfix
chmod 0755 /var/spool/postfix
chown postfix:postfix /var/spool/postfix/private
chmod 0755 /var/spool/postfix/private
# ──────────────────────────────────────────────────────────────────────────────
# 7) Abschluss
# ──────────────────────────────────────────────────────────────────────────────
log "Dovecot-Konfiguration abgeschlossen."

View File

@ -0,0 +1,319 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Rspamd + OpenDKIM einrichten …"
# ──────────────────────────────────────────────────────────────
# ENV laden
# ──────────────────────────────────────────────────────────────
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
SYSMAIL_DOMAIN="${SYSMAIL_DOMAIN:-sysmail.${BASE_DOMAIN}}" # z.B. sysmail.example.com
DKIM_ENABLE="${DKIM_ENABLE:-1}" # 1=OpenDKIM aktiv
DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}" # z.B. mwl1
DKIM_GENERATE="${DKIM_GENERATE:-0}" # 1=Key generieren, falls fehlt
RSPAMD_CONTROLLER_PASSWORD="${RSPAMD_CONTROLLER_PASSWORD:-admin}"
# ──────────────────────────────────────────────────────────────
# Rspamd (Controller + Milter)
# ──────────────────────────────────────────────────────────────
install -d -m 0750 /etc/rspamd/local.d
if command -v rspamadm >/dev/null 2>&1; then
RSPAMD_HASH="$(rspamadm pw -p "${RSPAMD_CONTROLLER_PASSWORD}")"
else
RSPAMD_HASH="${RSPAMD_CONTROLLER_PASSWORD}"
fi
cat >/etc/rspamd/local.d/worker-controller.inc <<CONF
worker "controller" {
bind_socket = "127.0.0.1:11334";
password = "${RSPAMD_HASH}";
}
CONF
cat >/etc/rspamd/local.d/statistic.conf <<CONF
classifier "bayes" {
backend = "redis";
autolearn = true;
autolearn_threshold = 6.0;
ham_symbols = ["BAYES_HAM"];
spam_symbols = ["BAYES_SPAM"];
min_learns = 10;
store_tokens = true;
per_user = false;
}
CONF
cat >/etc/rspamd/local.d/worker-proxy.inc <<'CONF'
worker "proxy" {
bind_socket = "127.0.0.1:11332";
milter = yes;
timeout = 120s;
upstream "scan" {
default = yes;
self_scan = yes;
servers = "127.0.0.1:11333";
}
}
CONF
cat >/etc/rspamd/local.d/worker-normal.inc <<'CONF'
worker "normal" {
bind_socket = "127.0.0.1:11333";
}
CONF
cat >/etc/rspamd/local.d/milter_headers.conf <<'CONF'
use = ["authentication-results"];
header = "Authentication-Results";
CONF
cat >/etc/rspamd/local.d/options.inc <<'CONF'
dns {
servers = ["9.9.9.9:53", "1.1.1.1:53"];
timeout = 5s;
retransmits = 2;
}
CONF
# ──────────────────────────────────────────────────────────────
# Rspamd Redis-Konfiguration
# ──────────────────────────────────────────────────────────────
log "Rspamd Redis konfigurieren …"
: "${REDIS_PASS:=}"
cat >/etc/rspamd/local.d/redis.conf <<CONF
servers = "127.0.0.1:6379";
${REDIS_PASS:+password = "${REDIS_PASS}";}
db = 0;
CONF
# Eigentümer und Rechte setzen
chown root:_rspamd /etc/rspamd/local.d /etc/rspamd/local.d/redis.conf
chmod 750 /etc/rspamd/local.d
chmod 640 /etc/rspamd/local.d/redis.conf
# Testweise prüfen, ob Redis erreichbar ist (nicht kritisch)
if command -v redis-cli >/dev/null 2>&1; then
if [[ -n "${REDIS_PASS}" ]]; then
if redis-cli -h 127.0.0.1 -p 6379 -a "${REDIS_PASS}" ping >/dev/null 2>&1; then
log "[✓] Redis erreichbar und Passwort akzeptiert."
else
log "[!] Warnung: Redis antwortet nicht oder Passwort falsch."
fi
else
if redis-cli -h 127.0.0.1 -p 6379 ping >/dev/null 2>&1; then
log "[✓] Redis erreichbar (ohne Passwort)."
else
log "[!] Warnung: Redis antwortet nicht."
fi
fi
fi
systemctl enable --now rspamd || true
# ──────────────────────────────────────────────────────────────
# OpenDKIM nur wenn DKIM_ENABLE=1
# ──────────────────────────────────────────────────────────────
if [[ "${DKIM_ENABLE}" != "1" ]]; then
log "DKIM_ENABLE=0 → OpenDKIM wird übersprungen."
/usr/sbin/postconf -e "milter_default_action = accept"
/usr/sbin/postconf -e "milter_protocol = 6"
/usr/sbin/postconf -e "smtpd_milters = inet:127.0.0.1:11332"
/usr/sbin/postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
exit 0
fi
install -d -m 0755 /etc/opendkim
install -d -m 0750 /etc/opendkim/keys
chown -R opendkim:opendkim /etc/opendkim
chmod 750 /etc/opendkim/keys
# TrustedHosts
cat >/etc/opendkim/TrustedHosts <<'CONF'
127.0.0.1
::1
localhost
CONF
chown opendkim:opendkim /etc/opendkim/TrustedHosts
chmod 640 /etc/opendkim/TrustedHosts
# ── Key-Verzeichnis für SYSMAIL_DOMAIN vorbereiten ───────────────────────────
KEY_DIR="/etc/opendkim/keys/${SYSMAIL_DOMAIN}"
KEY_PRIV="${KEY_DIR}/${DKIM_SELECTOR}.private"
KEY_DNSTXT="${KEY_DIR}/${DKIM_SELECTOR}.txt"
install -d -m 0750 -o opendkim -g opendkim "${KEY_DIR}"
# ── Key optional generieren (nur wenn gewünscht) ─────────────────────────────
if [[ ! -s "${KEY_PRIV}" && "${DKIM_GENERATE}" = "1" ]]; then
if command -v opendkim-genkey >/dev/null 2>&1; then
opendkim-genkey -b 2048 -s "${DKIM_SELECTOR}" -d "${SYSMAIL_DOMAIN}" -D "${KEY_DIR}"
chown opendkim:opendkim "${KEY_DIR}/${DKIM_SELECTOR}.private" || true
chmod 600 "${KEY_DIR}/${DKIM_SELECTOR}.private" || true
else
echo "[!] opendkim-genkey fehlt kann DKIM-Key nicht generieren."
fi
fi
# ── Key-/SigningTable nur anlegen, nicht leeren ───────────────────────────────
touch /etc/opendkim/KeyTable /etc/opendkim/SigningTable
chown opendkim:opendkim /etc/opendkim/KeyTable /etc/opendkim/SigningTable
chmod 640 /etc/opendkim/KeyTable /etc/opendkim/SigningTable
if [[ -s "${KEY_PRIV}" && "${BASE_DOMAIN}" != "example.com" ]]; then
LINE_KT="${DKIM_SELECTOR}._domainkey.${SYSMAIL_DOMAIN} ${SYSMAIL_DOMAIN}:${DKIM_SELECTOR}:${KEY_PRIV}"
LINE_ST="*@${SYSMAIL_DOMAIN} ${DKIM_SELECTOR}._domainkey.${SYSMAIL_DOMAIN}"
grep -Fqx "$LINE_KT" /etc/opendkim/KeyTable || echo "$LINE_KT" >> /etc/opendkim/KeyTable
grep -Fqx "$LINE_ST" /etc/opendkim/SigningTable || echo "$LINE_ST" >> /etc/opendkim/SigningTable
else
echo "[i] Kein Private Key unter ${KEY_PRIV} App-Helper trägt später ein."
fi
# ── Hauptkonfiguration ───────────────────────────────────────────────────────
cat >/etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
PidFile /run/opendkim/opendkim.pid
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
UserID opendkim:opendkim
AutoRestart yes
AutoRestartRate 10/1h
Background yes
DNSTimeout 5
SignatureAlgorithm rsa-sha256
SyslogSuccess yes
CONF
# ── systemd Drop-in: /run/opendkim sicherstellen ─────────────────────────────
install -d -m 0755 /etc/systemd/system/opendkim.service.d
cat >/etc/systemd/system/opendkim.service.d/override.conf <<'EOF'
[Service]
RuntimeDirectory=opendkim
RuntimeDirectoryMode=0755
EOF
install -d -o opendkim -g opendkim -m 0755 /run/opendkim
# ──────────────────────────────────────────────────────────────
# Root-Helper: DKIM installieren / entfernen + sudoers-Regel
# ──────────────────────────────────────────────────────────────
install -d -m 0750 /usr/local/sbin
# --- mailwolt-install-dkim ------------------------------------
cat > /usr/local/sbin/mailwolt-install-dkim <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="$1"
SELECTOR="$2"
SRC_PRIV="$3"
SRC_TXT="${4:-}"
OKDIR="/etc/opendkim"
KEYDIR="${OKDIR}/keys/${DOMAIN}"
KEYPRI="${KEYDIR}/${SELECTOR}.private"
install -d -m 0750 -o opendkim -g opendkim "${KEYDIR}"
install -m 0600 -o opendkim -g opendkim "${SRC_PRIV}" "${KEYPRI}"
KT="${OKDIR}/KeyTable"
ST="${OKDIR}/SigningTable"
touch "$KT" "$ST"
chown opendkim:opendkim "$KT" "$ST"
chmod 0640 "$KT" "$ST"
LINE_KT="${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:${KEYPRI}"
LINE_ST="*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}"
grep -Fqx "$LINE_KT" "$KT" || echo "$LINE_KT" >> "$KT"
grep -Fqx "$LINE_ST" "$ST" || echo "$LINE_ST" >> "$ST"
if [[ -n "${SRC_TXT}" && -s "${SRC_TXT}" ]]; then
install -d -m 0755 /etc/mailwolt/dns
cp -f "${SRC_TXT}" "/etc/mailwolt/dns/dkim-${DOMAIN}.txt"
fi
systemctl is-active --quiet opendkim && systemctl reload opendkim || true
echo "OK"
EOSH
chmod 0750 /usr/local/sbin/mailwolt-install-dkim
chown root:root /usr/local/sbin/mailwolt-install-dkim
# --- 2) mailwolt-remove-dkim ----------------------------------
cat >/usr/local/sbin/mailwolt-remove-dkim <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="$1" # z.B. kunden.tld oder sysmail.example.com
SELECTOR="$2" # z.B. mwl1
OKDIR="/etc/opendkim"
KEYDIR="${OKDIR}/keys/${DOMAIN}"
KEYPRI="${KEYDIR}/${SELECTOR}.private"
KT="${OKDIR}/KeyTable"
ST="${OKDIR}/SigningTable"
# Key-Datei löschen (falls vorhanden)
[[ -f "${KEYPRI}" ]] && rm -f "${KEYPRI}"
# Zeilen aus KeyTable und SigningTable entfernen
if [[ -f "$KT" ]]; then
tmp="$(mktemp)"; grep -v -F "${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:" "$KT" >"$tmp" && mv "$tmp" "$KT"
chown opendkim:opendkim "$KT"; chmod 0640 "$KT"
fi
if [[ -f "$ST" ]]; then
tmp="$(mktemp)"; grep -v -F "*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}" "$ST" >"$tmp" && mv "$tmp" "$ST"
chown opendkim:opendkim "$ST"; chmod 0640 "$ST"
fi
# Verzeichnis ggf. aufräumen
rmdir "${KEYDIR}" 2>/dev/null || true
# Dienst neu laden, falls aktiv
if systemctl is-active --quiet opendkim; then
systemctl reload opendkim || true
fi
echo "OK"
EOSH
chown root:root /usr/local/sbin/mailwolt-remove-dkim
chmod 0750 /usr/local/sbin/mailwolt-remove-dkim
# ── Dienst + Postfix-Milter aktivieren ─────────────────────────
systemctl daemon-reload
systemctl enable opendkim || true
touch /run/mailwolt.need-apply-milters || true
chgrp _rspamd /etc/rspamd/local.d/*.inc /etc/rspamd/local.d/*.conf || true
chmod 0640 /etc/rspamd/local.d/*.inc /etc/rspamd/local.d/*.conf || true
#/usr/sbin/postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
#/usr/sbin/postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
log "[✓] Rspamd + OpenDKIM eingerichtet (läuft; signiert, sobald Keys vorhanden sind)."

View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "OpenDMARC installieren/konfigurieren …"
# Flags laden
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
# Paket sicherstellen
if ! dpkg -s opendmarc >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y opendmarc
fi
# Config-Verzeichnisse
install -d -m 0755 /etc/opendmarc
install -d -m 0755 /run/opendmarc
# IgnoreHosts
cat >/etc/opendmarc/ignore.hosts <<'EOF'
127.0.0.1
::1
localhost
EOF
chmod 0644 /etc/opendmarc/ignore.hosts
# Hauptkonfiguration
cat >/etc/opendmarc.conf <<'EOF'
AuthservID mailwolt
TrustedAuthservIDs mailwolt
IgnoreHosts /etc/opendmarc/ignore.hosts
Syslog true
SoftwareHeader true
Socket local:/run/opendmarc/opendmarc.sock
RejectFailures false
EOF
chmod 0644 /etc/opendmarc.conf
# systemd Drop-in für RuntimeDirectory (robust nach Reboot)
install -d -m 0755 /etc/systemd/system/opendmarc.service.d
cat >/etc/systemd/system/opendmarc.service.d/override.conf <<'EOF'
[Service]
RuntimeDirectory=opendmarc
RuntimeDirectoryMode=0755
EOF
systemctl daemon-reload
# Dienst nach Flag
if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
systemctl enable --now opendmarc
else
systemctl disable --now opendmarc || true
fi
# Postfix-Milter-Kette konsistent setzen (Rspamd + OpenDKIM + optional OpenDMARC)
touch /run/mailwolt.need-apply-milters || true
log "[✓] OpenDMARC (ENABLE=${OPENDMARC_ENABLE}) bereit."

View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "ClamAV (clamav-daemon) installieren/konfigurieren …"
# Flags laden
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
CLAMAV_ENABLE="${CLAMAV_ENABLE:-0}"
# Pakete
if ! dpkg -s clamav-daemon >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y clamav clamav-daemon
fi
# Signaturen aktualisieren (erst Freshclam starten)
systemctl stop clamav-freshclam 2>/dev/null || true
freshclam || true
systemctl start clamav-freshclam || true
# clamd LocalSocket setzen
sed -i 's|^#\?LocalSocket .*|LocalSocket /run/clamav/clamd.ctl|' /etc/clamav/clamd.conf || true
install -d -m 0755 /run/clamav
chown clamav:clamav /run/clamav
# Dienst nach Flag
if [[ "$CLAMAV_ENABLE" = "1" ]]; then
systemctl enable --now clamav-daemon
else
systemctl disable --now clamav-daemon || true
fi
# Rspamd-Integration (nur wenn aktiv)
AV_CONF="/etc/rspamd/local.d/antivirus.conf"
if [[ "$CLAMAV_ENABLE" = "1" ]]; then
cat >"$AV_CONF" <<'EOF'
clamav {
symbol = "CLAM_VIRUS";
type = "clamav";
servers = "/run/clamav/clamd.ctl";
scan_mime_parts = true;
scan_text_mime = true;
max_size = 50mb;
log_clean = false;
action = "reject";
}
EOF
chown root:_rspamd "$AV_CONF" || true
chmod 0640 "$AV_CONF" || true
systemctl reload rspamd || systemctl restart rspamd
else
rm -f "$AV_CONF" || true
systemctl reload rspamd || true
fi
log "[✓] ClamAV (ENABLE=${CLAMAV_ENABLE}) konfiguriert."

View File

@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Fail2Ban installieren/konfigurieren …"
# Flags laden
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
# Paket
if ! dpkg -s fail2ban >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y fail2ban sqlite3
fi
install -d -m 0755 /etc/fail2ban/jail.d
# ---------------------------------------------------------------
# Basis-Jails (praxisnah)
# ---------------------------------------------------------------
cat >/etc/fail2ban/jail.d/mailwolt.conf <<'EOF'
[sshd]
enabled = true
port = ssh
logpath = /var/log/auth.log
[postfix]
enabled = true
logpath = /var/log/mail.log
port = smtp,ssmtp,submission,465
[dovecot]
enabled = true
logpath = /var/log/mail.log
port = pop3,pop3s,imap,imaps,submission,465,587,993
[rspamd-controller]
enabled = true
port = 11334
filter = rspamd
logpath = /var/log/rspamd/rspamd.log
maxretry = 5
EOF
# einfacher Filter für Rspamd-Controller
if [ ! -f /etc/fail2ban/filter.d/rspamd.conf ]; then
cat >/etc/fail2ban/filter.d/rspamd.conf <<'EOF'
[Definition]
failregex = .*Authentication failed for user.* from <HOST>
ignoreregex =
EOF
fi
# ---------------------------------------------------------------
# Fail2Ban-Backend auf SQLite umstellen
# ---------------------------------------------------------------
log "SQLite-Backend aktivieren …"
cat >/etc/fail2ban/fail2ban.local <<'EOF'
[Definition]
loglevel = INFO
logtarget = /var/log/fail2ban.log
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 86400
EOF
# Datenbankverzeichnis sicherstellen
install -d -o fail2ban -g fail2ban -m 0750 /var/lib/fail2ban
# Falls DB nicht existiert, Dummy anlegen (wird vom Dienst erweitert)
if [ ! -f /var/lib/fail2ban/fail2ban.sqlite3 ]; then
sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 "VACUUM;"
fi
chown fail2ban:fail2ban /var/lib/fail2ban/fail2ban.sqlite3
chmod 0640 /var/lib/fail2ban/fail2ban.sqlite3
# ---------------------------------------------------------------
# sudoers für Web-UI
# ---------------------------------------------------------------
# Fail2Ban Blacklist-Jail
cat >/etc/fail2ban/jail.d/mailwolt-blacklist.local <<'EOF'
[mailwolt-blacklist]
enabled = true
filter = none
port = anyport
bantime = -1
findtime = 1
maxretry = 1
EOF
cat >/etc/fail2ban/filter.d/none.conf <<'EOF'
[Definition]
failregex =
ignoreregex =
EOF
chmod 0640 /etc/fail2ban/filter.d/none.conf
SUDOERS_F2B="/etc/sudoers.d/mailwolt-fail2ban"
cat > "${SUDOERS_F2B}" <<'EOF'
Defaults:www-data !requiretty
www-data ALL=(root) NOPASSWD: \
/usr/bin/fail2ban-client, \
/usr/bin/fail2ban-client ping, \
/usr/bin/fail2ban-client status, \
/usr/bin/fail2ban-client status *, \
/usr/bin/fail2ban-client get *, \
/usr/bin/fail2ban-client set * banip *, \
/usr/bin/fail2ban-client set * unbanip *, \
/usr/bin/fail2ban-client reload, \
/usr/bin/journalctl, \
/bin/journalctl, \
/usr/bin/zgrep, \
/bin/zgrep, \
/usr/bin/grep, \
/bin/grep, \
/usr/bin/tail, \
/bin/tail, \
/usr/bin/sqlite3, \
/usr/bin/tee /etc/fail2ban/jail.d/*
EOF
chown root:root "${SUDOERS_F2B}"
chmod 440 "${SUDOERS_F2B}"
if ! visudo -c -f "${SUDOERS_F2B}" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_F2B} entferne Datei."
rm -f "${SUDOERS_F2B}"
fi
# ---------------------------------------------------------------
# Dienst aktivieren/deaktivieren
# ---------------------------------------------------------------
if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
systemctl enable --now fail2ban
else
systemctl disable --now fail2ban || true
fi
log "[✓] Fail2Ban (ENABLE=${FAIL2BAN_ENABLE}) bereit."
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#log "Fail2Ban installieren/konfigurieren …"
#
## Flags laden
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#
## Paket
#if ! dpkg -s fail2ban >/dev/null 2>&1; then
# apt-get update -qq
# apt-get install -y fail2ban
#fi
#
#install -d -m 0755 /etc/fail2ban/jail.d
#
## Basis-Jails (praxisnah)
#cat >/etc/fail2ban/jail.d/mailwolt.conf <<'EOF'
#[DEFAULT]
#bantime = 1h
#findtime = 10m
#maxretry = 5
#backend = auto
#
#[sshd]
#enabled = true
#port = ssh
#logpath = /var/log/auth.log
#
#[postfix]
#enabled = true
#logpath = /var/log/mail.log
#port = smtp,ssmtp,submission,465
#
#[dovecot]
#enabled = true
#logpath = /var/log/mail.log
#port = pop3,pop3s,imap,imaps,submission,465,587,993
#
#[rspamd-controller]
#enabled = true
#port = 11334
#filter = rspamd
#logpath = /var/log/rspamd/rspamd.log
#maxretry = 5
#EOF
#
## einfacher Filter für Rspamd-Controller
#if [ ! -f /etc/fail2ban/filter.d/rspamd.conf ]; then
# cat >/etc/fail2ban/filter.d/rspamd.conf <<'EOF'
#[Definition]
#failregex = .*Authentication failed for user.* from <HOST>
#ignoreregex =
#EOF
#fi
#
#SUDOERS_F2B="/etc/sudoers.d/mailwolt-fail2ban"
#cat > "${SUDOERS_F2B}" <<'EOF'
#www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client status, /usr/bin/fail2ban-client status *
#EOF
#chown root:root "${SUDOERS_F2B}"
#chmod 440 "${SUDOERS_F2B}"
#
#if ! visudo -c -f "${SUDOERS_F2B}" >/dev/null 2>&1; then
# echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_F2B} entferne Datei."
# rm -f "${SUDOERS_F2B}"
#fi
#
#sudo tee /etc/sudoers.d/mailwolt-fail2ban >/dev/null <<'EOF'
#www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client status, /usr/bin/fail2ban-client status *
#EOF
#sudo visudo -cf /etc/sudoers.d/mailwolt-fail2ban
#
## Dienst nach Flag
#if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
# systemctl enable --now fail2ban
#else
# systemctl disable --now fail2ban || true
#fi
#
#log "[✓] Fail2Ban (ENABLE=${FAIL2BAN_ENABLE}) bereit."

View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# nur ausführen, wenn vorherige Schritte das Flag gesetzt haben
if [[ -f /run/mailwolt.need-apply-milters ]]; then
if command -v /usr/local/sbin/mailwolt-apply-milters >/dev/null 2>&1; then
log "Setze Postfix-Milter-Kette (Rspamd/OpenDKIM[/OpenDMARC]) …"
/usr/local/sbin/mailwolt-apply-milters || true
else
# Fallback (ident wie im Tool)
/usr/sbin/postconf -e "milter_default_action = accept"
/usr/sbin/postconf -e "milter_protocol = 6"
CHAIN="inet:127.0.0.1:11333, inet:127.0.0.1:8891"
systemctl is-active --quiet opendmarc && CHAIN="$CHAIN, inet:127.0.0.1:8893" || true
/usr/sbin/postconf -e "smtpd_milters = $CHAIN"
/usr/sbin/postconf -e "non_smtpd_milters = $CHAIN"
systemctl reload postfix || true
fi
rm -f /run/mailwolt.need-apply-milters || true
log "[✓] Milter-Kette angewandt."
else
log "Milter-Kette: kein Bedarf (Flag nicht gesetzt) überspringe."
fi

View File

@ -0,0 +1,465 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Nginx konfigurieren …"
# ── Flags/Umgebung (vom Bootstrap gesetzt; hier Fallbacks) ────────────────
DEV_MODE="${DEV_MODE:-0}" # 1 = DEV (Vite-Proxy aktiv), 0 = PROD
PROXY_MODE="${PROXY_MODE:-0}" # 1 = NPM/Proxy davor, Backend spricht nur HTTP:80
NPM_IP="${NPM_IP:-}" # z.B. 10.10.20.20
# Erwartet vom Bootstrap/Installer exportiert:
: "${UI_HOST:?UI_HOST fehlt}"
: "${WEBMAIL_HOST:?WEBMAIL_HOST fehlt}"
: "${APP_DIR:?APP_DIR fehlt}"
ACME_ROOT="/var/www/letsencrypt"
install -d -m 0755 "$ACME_ROOT"
# Default-Sites entfernen (verhindert doppelten default_server)
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
# HTTP/2-Unterstützung erkennen
NGINX_HTTP2_SUFFIX=""
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUFFIX=" http2"
fi
# PHP-FPM Socket/TCP finden → fastcgi_pass bauen
detect_php_fpm_sock(){
for v in 8.3 8.2 8.1 8.0 7.4; do
s="/run/php/php${v}-fpm.sock"
[[ -S "$s" ]] && { echo "unix:${s}"; return; }
done
[[ -S "/run/php/php-fpm.sock" ]] && { echo "unix:/run/php/php-fpm.sock"; return; }
echo "127.0.0.1:9000"
}
PHP_FPM_TARGET="$(detect_php_fpm_sock)"
if [[ "$PHP_FPM_TARGET" == unix:* ]]; then
FASTCGI_PASS="fastcgi_pass ${PHP_FPM_TARGET};"
else
FASTCGI_PASS="fastcgi_pass ${PHP_FPM_TARGET};"
fi
# ── Builder 1: HTTP-only (Proxy-Mode: TLS endet im NPM) ───────────────────
## $1=host, $2=outfile
#build_site_http_only(){
# local host="$1" outfile="$2"
#
# local def=""
# [[ "${DEV_MODE}" = "1" ]] && def=" default_server"
# [[ -z "${host}" || "${host}" = "_" ]] && host="_"
#
# cat > "$outfile" <<CONF
## --- ${host} : HTTP (kein Redirect, kein TLS; läuft hinter Reverse-Proxy) ---
#server {
# listen 80;
# listen [::]:80;
# server_name ${host};
#
# # ACME HTTP-01 (optional; meist übernimmt das der Proxy)
# location ^~ /.well-known/acme-challenge/ {
# root ${ACME_ROOT};
# allow all;
# }
#
# root ${APP_DIR}/public;
# index index.php index.html;
#
# access_log /var/log/nginx/${host}_access.log;
# error_log /var/log/nginx/${host}_error.log;
#
# client_max_body_size 25m;
#
# location / { try_files \$uri \$uri/ /index.php?\$query_string; }
#
# location ~ \.php\$ {
# include snippets/fastcgi-php.conf;
# ${FASTCGI_PASS}
# }
#
# location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
# location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
#
# # WebSocket: Laravel Reverb (Backend intern HTTP)
# location /ws/ {
# proxy_http_version 1.1;
# proxy_set_header Upgrade \$http_upgrade;
# proxy_set_header Connection "Upgrade";
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/;
# }
#
# # Reverb HTTP API
# location /apps/ {
# proxy_http_version 1.1;
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/apps/;
# }
#CONF
#
# if [[ "${DEV_MODE}" = "1" ]]; then
# cat >> "$outfile" <<'CONF'
# # DEV: Vite-Proxy (HMR)
# location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; }
# location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; }
# location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; }
#CONF
# fi
#
# echo "}" >> "$outfile"
#}
#build_site_http_only(){
# local host="$1" outfile="$2"
#
# # DEV: IP-Zugriff ohne Hostname → default_server + server_name _
# local def=""
# if [[ "${DEV_MODE}" = "1" ]]; then
# def=" default_server"
# host="_"
# fi
# [[ -z "${host}" || "${host}" = "_" ]] && host="_"
#
# cat > "$outfile" <<CONF
## --- ${host} : HTTP (kein Redirect, kein TLS; läuft hinter Reverse-Proxy/DEV) ---
#server {
# listen 80${def};
# listen [::]:80${def};
# server_name ${host};
#
# # ACME HTTP-01 (optional; meist übernimmt das der Proxy)
# location ^~ /.well-known/acme-challenge/ {
# root ${ACME_ROOT};
# allow all;
# }
#
# root ${APP_DIR}/public;
# index index.php index.html;
#
# access_log /var/log/nginx/${host/_/__}_access.log;
# error_log /var/log/nginx/${host/_/__}_error.log;
#
# client_max_body_size 25m;
#
# location / { try_files \$uri \$uri/ /index.php?\$query_string; }
#
# location ~ \.php\$ {
# include snippets/fastcgi-php.conf;
# ${FASTCGI_PASS}
# }
#
# location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
# location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
#
# # WebSocket: Laravel Reverb
# location /ws/ {
# proxy_http_version 1.1;
# proxy_set_header Upgrade \$http_upgrade;
# proxy_set_header Connection "Upgrade";
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/;
# }
#
# # Reverb HTTP API
# location /apps/ {
# proxy_http_version 1.1;
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/apps/;
# }
#CONF
#
# if [[ "${DEV_MODE}" = "1" ]]; then
# cat >> "$outfile" <<'CONF'
# # DEV: Vite-Proxy (HMR)
# location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; }
# location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; }
# location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; }
#CONF
# fi
#
# echo "}" >> "$outfile"
#}
# $1=host, $2=outfile, $3=default_flag (default|nodefault)
build_site_http_only(){
local host="$1" outfile="$2" def_flag="${3:-default}"
local def=""
if [[ "${DEV_MODE}" = "1" && "${def_flag}" = "default" ]]; then
def=" default_server"
fi
[[ -z "${host}" || "${host}" = "_" ]] && host="_"
cat > "$outfile" <<CONF
# --- ${host} : HTTP (kein Redirect, kein TLS; läuft hinter Reverse-Proxy/DEV) ---
server {
listen 80${def};
listen [::]:80${def};
server_name ${host};
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
allow all;
}
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${host/_/__}_access.log;
error_log /var/log/nginx/${host/_/__}_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
${FASTCGI_PASS}
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
# WebSocket: Laravel Reverb
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/;
}
# Reverb HTTP API
location /apps/ {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/apps/;
}
CONF
if [[ "${DEV_MODE}" = "1" ]]; then
cat >> "$outfile" <<'CONF'
# DEV: Vite-Proxy (HMR)
location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; }
location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; }
location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; }
CONF
fi
echo "}" >> "$outfile"
}
# ── Builder 2: 80→443 Redirect + 443/TLS (Live-Server) ────────────────────
# $1=host, $2=cert_dir (/etc/ssl/ui | /etc/ssl/webmail), $3=outfile
build_site_tls(){
local host="$1" cert_dir="$2" outfile="$3"
local cert="${cert_dir}/fullchain.pem"
local key="${cert_dir}/privkey.pem"
cat > "$outfile" <<CONF
# --- ${host} : HTTP (ACME + Redirect) ---
server {
listen 80;
listen [::]:80;
server_name ${host};
# ACME HTTP-01 auf Port 80
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
allow all;
}
return 301 https://\$host\$request_uri;
}
# --- ${host} : HTTPS ---
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX};
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name ${host};
ssl_certificate ${cert};
ssl_certificate_key ${key};
ssl_protocols TLSv1.2 TLSv1.3;
# WICHTIG: ACME auch auf 443, sonst 404 bei Redirects
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
allow all;
}
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${host}_ssl_access.log;
error_log /var/log/nginx/${host}_ssl_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
${FASTCGI_PASS}
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
# WebSocket: Laravel Reverb (Backend intern HTTP)
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/;
}
# Reverb HTTP API
location /apps/ {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/apps/;
}
CONF
if [[ "${DEV_MODE}" = "1" ]]; then
cat >> "$outfile" <<'CONF'
# DEV: Vite-Proxy
location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
CONF
fi
echo "}" >> "$outfile"
}
build_site_acme_only(){
local host="$1" outfile="$2"
cat > "$outfile" <<CONF
# --- ${host} : ACME-only (80 + 443), KEIN App-Root ---
server {
listen 80;
listen [::]:80;
server_name ${host};
# HTTP-01 Challenge exakt ausliefern
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
default_type "text/plain";
try_files \$uri =404;
}
# Alles andere → nach https
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX};
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name ${host};
ssl_certificate /etc/ssl/mail/fullchain.pem;
ssl_certificate_key /etc/ssl/mail/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Auch via https die Challenge bedienen (falls Redirects gefolgt werden)
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
default_type "text/plain";
try_files \$uri =404;
}
# Sonst nichts preisgeben
location / { return 444; }
}
CONF
}
# ── Sites erzeugen ─────────────────────────────────────────────────────────
MX_SITE="/etc/nginx/sites-available/mx-mailwolt.conf"
UI_SITE="/etc/nginx/sites-available/ui-mailwolt.conf"
WEBMAIL_SITE="/etc/nginx/sites-available/webmail-mailwolt.conf"
# UI & Webmail wie gehabt …
#if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
# build_site_http_only "$UI_HOST" "$UI_SITE"
# build_site_http_only "$WEBMAIL_HOST" "$WEBMAIL_SITE"
#else
# build_site_tls "$UI_HOST" "/etc/ssl/ui" "$UI_SITE"
# build_site_tls "$WEBMAIL_HOST" "/etc/ssl/webmail" "$WEBMAIL_SITE"
#fi
# UI & Webmail …
if [[ "${DEV_MODE}" = "1" ]]; then
# UI = Catch-All + default_server, Webmail = Catch-All ohne default
build_site_http_only "_" "$UI_SITE" "default"
build_site_http_only "_" "$WEBMAIL_SITE" "nodefault"
else
if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
build_site_http_only "$UI_HOST" "$UI_SITE"
build_site_http_only "$WEBMAIL_HOST" "$WEBMAIL_SITE"
else
build_site_tls "$UI_HOST" "/etc/ssl/ui" "$UI_SITE"
build_site_tls "$WEBMAIL_HOST" "/etc/ssl/webmail" "$WEBMAIL_SITE"
fi
fi
#if [[ "${DEV_MODE}" = "1" ]]; then
# # DEV: per IP erreichbar → Catch-All („_“) und HTTP-only
# build_site_http_only "_" "$UI_SITE"
# build_site_http_only "_" "$WEBMAIL_SITE"
#else
# if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
# build_site_http_only "$UI_HOST" "$UI_SITE"
# build_site_http_only "$WEBMAIL_HOST" "$WEBMAIL_SITE"
# else
# build_site_tls "$UI_HOST" "/etc/ssl/ui" "$UI_SITE"
# build_site_tls "$WEBMAIL_HOST" "/etc/ssl/webmail" "$WEBMAIL_SITE"
# fi
#fi
# MX: **immer** ACME-only (kein Laravel dahinter)
build_site_acme_only "${MAIL_HOSTNAME}" "$MX_SITE"
ln -sf "$UI_SITE" /etc/nginx/sites-enabled/ui-mailwolt.conf
ln -sf "$WEBMAIL_SITE" /etc/nginx/sites-enabled/webmail-mailwolt.conf
ln -sf "$MX_SITE" /etc/nginx/sites-enabled/mx-mailwolt.conf
# ── Real-IP nur, wenn Proxy davor ──────────────────────────────────────────
if [[ "${PROXY_MODE}" -eq 1 && -n "${NPM_IP}" ]]; then
cat > /etc/nginx/conf.d/realip.conf <<NGX
real_ip_header X-Forwarded-For;
set_real_ip_from ${NPM_IP};
real_ip_recursive on;
NGX
else
rm -f /etc/nginx/conf.d/realip.conf || true
fi
# ── Test & reload ──────────────────────────────────────────────────────────
if nginx -t; then
systemctl enable --now nginx >/dev/null 2>&1 || true
systemctl reload nginx || true
else
die "nginx -t fehlgeschlagen siehe /var/log/nginx/*.log"
fi

View File

@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
ACME_WEBROOT="/var/www/letsencrypt"
install -d -m 0755 "${ACME_WEBROOT}/.well-known/acme-challenge"
# Staging optional (verbraucht kein Live-Limit)
CERTBOT_EXTRA=()
LE_STAGING="${LE_STAGING:-0}"
[[ "$LE_STAGING" = "1" ]] && CERTBOT_EXTRA+=(--test-cert)
# Einheitliche LE-Mail (Fallback)
LE_MAIL="${LE_EMAIL:-admin@${BASE_DOMAIN}}"
resolve_ok() {
local host="$1"
local pats=()
[[ -n "${SERVER_PUBLIC_IPV4:-}" ]] && pats+=("${SERVER_PUBLIC_IPV4//./\\.}")
[[ -n "${SERVER_PUBLIC_IPV6:-}" ]] && pats+=("${SERVER_PUBLIC_IPV6//:/\\:}")
[[ ${#pats[@]} -eq 0 ]] && return 0
getent ahosts "$host" | awk '{print $1}' | sort -u \
| grep -Eq "^($(IFS='|'; echo "${pats[*]}"))$"
}
probe_http() {
local host="$1"
echo test > "${ACME_WEBROOT}/.well-known/acme-challenge/_probe"
curl -fsS --max-time 5 -4 "http://${host}/.well-known/acme-challenge/_probe" >/dev/null \
|| curl -fsS --max-time 5 -6 "http://${host}/.well-known/acme-challenge/_probe" >/dev/null
}
issue() {
local host="${1:-}"
[[ -z "$host" ]] && return 0
echo "[i] Versuche LE für ${host}"
if ! resolve_ok "$host"; then
echo "[!] DNS zeigt (noch) nicht hierher überspringe: ${host}"
return 0
fi
if ! probe_http "$host"; then
echo "[!] ACME-HTTP-Check für ${host} fehlgeschlagen (Port 80/IPv6/Firewall/Nginx prüfen)."
# wir versuchen trotzdem Certbot meldet sich, falls es scheitert
fi
EXTRA_ARGS=()
# Für MX den Key wiederverwenden → stabiler TLSA (3 1 1)
[[ "$host" == "${MAIL_HOSTNAME}" ]] && EXTRA_ARGS+=(--reuse-key)
# WICHTIG: Deploy-Wrapper anhängen, damit Symlinks/Nginx gesetzt werden
certbot certonly \
--agree-tos -m "${LE_MAIL}" --non-interactive \
--webroot -w "${ACME_WEBROOT}" -d "${host}" \
--deploy-hook /usr/local/sbin/mailwolt-deploy.sh \
"${EXTRA_ARGS[@]}" "${CERTBOT_EXTRA[@]}" || true
}
if [[ "${BASE_DOMAIN}" != "example.com" ]]; then
issue "${UI_HOST:-}"
issue "${WEBMAIL_HOST:-}"
issue "${MAIL_HOSTNAME:-}"
# Nginx nur neu laden, wenn aktiv
if systemctl is-active --quiet nginx; then
systemctl reload nginx || true
fi
else
echo "[i] BASE_DOMAIN=example.com LE wird übersprungen."
fi
# ──────────────────────────────────────────────────────────────────────────────
# FIX: Validierung & Reparatur des Mail-Zertifikats
# ──────────────────────────────────────────────────────────────────────────────
MAIL_SSL_DIR="/etc/ssl/mail"
install -d -m 0755 "$MAIL_SSL_DIR"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
HOST="${MAIL_HOSTNAME:-}"
LE_DIR=""
[[ -n "$HOST" ]] && LE_DIR="/etc/letsencrypt/live/${HOST}"
need_fix=0
# Ist der vorhandene Key gültig? (leer/nicht vorhanden/ungültig -> fix)
if [[ ! -s "$MAIL_KEY" ]] || ! openssl pkey -in "$MAIL_KEY" -noout >/dev/null 2>&1; then
need_fix=1
fi
# Wenn Fix nötig: aus Let's Encrypt Live kopieren
if [[ $need_fix -eq 1 ]]; then
echo "[!] Ungültiger oder fehlender Mail-Private-Key versuche Reparatur …"
if [[ -n "$LE_DIR" && -r "${LE_DIR}/privkey.pem" && -r "${LE_DIR}/fullchain.pem" ]]; then
cp -f "${LE_DIR}/privkey.pem" "$MAIL_KEY"
cp -f "${LE_DIR}/fullchain.pem" "$MAIL_CERT"
chown root:root "$MAIL_CERT" "$MAIL_KEY"
chmod 600 "$MAIL_KEY"
chmod 644 "$MAIL_CERT"
echo "[+] Zertifikate neu kopiert aus ${LE_DIR}."
# Reload NICHT sofort flaggen für 90-services
touch /run/mailwolt.need-dovecot-reload
else
echo "[!] Konnte ${LE_DIR}/privkey.pem oder fullchain.pem nicht lesen bitte prüfen."
fi
else
echo "[✓] Mail-Zertifikat & -Key sind gültig."
fi
# Optionaler Live-Check (nur wenn Host gesetzt)
if [[ -n "$HOST" ]]; then
if openssl s_client -connect "${HOST}:993" -servername "${HOST}" </dev/null 2>/dev/null \
| grep -q "Verify return code: 0"; then
echo "[✓] TLS-Handshake erfolgreich auf imaps://${HOST}:993."
else
echo "[!] TLS-Handshake auf imaps://${HOST}:993 fehlgeschlagen (Dovecot Reload folgt in 90-services, falls Flag gesetzt)."
fi
fi

View File

@ -0,0 +1,343 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# --- Helper: sicherer Frontend-Build als APP_USER ---------------------------
safe_frontend_build() {
echo "[i] Frontend build …"
# Verzeichnisse & Rechte vorbereiten (Gruppen-sticky & ACL)
install -d -m 2775 -o "$APP_USER" -g "$APP_GROUP" \
"${APP_DIR}/public/build" "${APP_DIR}/node_modules" "${APP_DIR}/.npm-cache"
chown -R "$APP_USER":"$APP_GROUP" "${APP_DIR}"
find "${APP_DIR}" -type d -exec chmod 2775 {} \;
find "${APP_DIR}" -type f -exec chmod 664 {} \;
setfacl -R -m g:"$APP_GROUP":rwX -m d:g:"$APP_GROUP":rwX "${APP_DIR}" || true
# Vite-/Build-Reste bereinigen (falls mal root dort gebaut hat)
rm -rf "${APP_DIR}/node_modules/.vite" "${APP_DIR}/public/build/"* 2>/dev/null || true
# npm auf projektlokales Cache konfigurieren
sudo -u "$APP_USER" -H bash -lc "cat > ~/.npmrc <<'RC'
fund=false
audit=false
prefer-offline=true
cache=${APP_DIR}/.npm-cache
RC"
# Node ggf. installieren
if ! command -v node >/dev/null 2>&1; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
fi
# Dependencies + Build (als App-User)
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && (npm ci --no-audit --no-fund || npm install --no-audit --no-fund) && npm run build"; then
return 0
fi
echo "[!] Build fehlgeschlagen Rechtefix + Clean + Retry …"
rm -rf "${APP_DIR}/node_modules/.vite" "${APP_DIR}/public/build/"* 2>/dev/null || true
chown -R "$APP_USER":"$APP_GROUP" "${APP_DIR}"
find "${APP_DIR}" -type d -exec chmod 2775 {} \;
find "${APP_DIR}" -type f -exec chmod 664 {} \;
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build"
}
relink_and_reload() {
if [[ -d /etc/letsencrypt/renewal-hooks/deploy ]]; then
run-parts /etc/letsencrypt/renewal-hooks/deploy || true
fi
if systemctl is-active --quiet nginx; then
systemctl reload nginx || true
fi
}
log "App bereitstellen …"
mkdir -p "$(dirname "$APP_DIR")"
chown -R "$APP_USER":"$APP_GROUP" "$(dirname "$APP_DIR")"
# Repo holen oder Laravel anlegen passe GIT_REPO/GIT_BRANCH bei Bedarf an
GIT_REPO="${GIT_REPO:-https://git.nexlab.at/boban/mailwolt.git}"
GIT_BRANCH="${GIT_BRANCH:-main}"
if [[ "${GIT_REPO}" == "https://example.com/your-repo-placeholder.git" ]]; then
[[ -d "$APP_DIR" && -n "$(ls -A "$APP_DIR" 2>/dev/null || true)" ]] || \
sudo -u "$APP_USER" -H bash -lc "cd /var/www && composer create-project laravel/laravel ${APP_USER} --no-interaction"
else
if [[ ! -d "${APP_DIR}/.git" ]]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git reset --hard origin/${GIT_BRANCH}"
fi
[[ -f "${APP_DIR}/composer.json" ]] && sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist"
fi
ENV_FILE="${APP_DIR}/.env"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
grep -q '^APP_KEY=' "$ENV_FILE" || echo "APP_KEY=" >> "$ENV_FILE"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force || true"
# --- App-URL/Hosts ----------------------------------------------------------
#SERVER_PUBLIC_IPV4="${SERVER_PUBLIC_IPV4:-}"
#if [[ -z "$SERVER_PUBLIC_IPV4" ]] && command -v curl >/dev/null 2>&1; then
# SERVER_PUBLIC_IPV4="$(curl -fsS --max-time 2 https://ifconfig.me 2>/dev/null || true)"
# [[ "$SERVER_PUBLIC_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || SERVER_PUBLIC_IPV4=""
#fi
#[[ -n "$SERVER_PUBLIC_IPV4" ]] || SERVER_PUBLIC_IPV4="$(detect_ip)"
#
#UI_CERT="/etc/ssl/ui/fullchain.pem"
#UI_KEY="/etc/ssl/ui/privkey.pem"
#
#if [[ -n "${UI_HOST:-}" ]]; then
# APP_HOST_VAL="$UI_HOST"
# APP_URL_VAL="https://${UI_HOST}"
#else
# APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
# SCHEME="http"
# [[ -s "$UI_CERT" && -s "$UI_KEY" ]] && SCHEME="https"
# APP_URL_VAL="${SCHEME}://${SERVER_PUBLIC_IPV4}"
#fi
SERVER_PUBLIC_IPV4="${SERVER_PUBLIC_IPV4:-}"
if [[ -z "$SERVER_PUBLIC_IPV4" ]] && command -v curl >/dev/null 2>&1; then
SERVER_PUBLIC_IPV4="$(curl -fsS --max-time 2 https://ifconfig.me 2>/dev/null || true)"
[[ "$SERVER_PUBLIC_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || SERVER_PUBLIC_IPV4=""
fi
[[ -n "$SERVER_PUBLIC_IPV4" ]] || SERVER_PUBLIC_IPV4="$(detect_ip)"
UI_CERT="/etc/ssl/ui/fullchain.pem"
UI_KEY="/etc/ssl/ui/privkey.pem"
# DEV-Modus: immer IP als Host, http (kein example.com / keine Fake-Domain)
if [[ "${DEV_MODE:-0}" = "1" || "${APP_ENV:-production}" = "local" ]]; then
APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
APP_URL_VAL="http://${APP_HOST_VAL}"
else
# PROD/normal: wenn UI_HOST gesetzt → benutzen, sonst IP
if [[ -n "${UI_HOST:-}" ]]; then
APP_HOST_VAL="$UI_HOST"
APP_URL_VAL="https://${UI_HOST}"
else
APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
SCHEME="http"
[[ -s "$UI_CERT" && -s "$UI_KEY" ]] && SCHEME="https"
APP_URL_VAL="${SCHEME}://${APP_HOST_VAL}"
fi
fi
SECURE=$([[ "${APP_ENV}" = "production" ]] && echo true || echo false)
# --- .env schreiben ---------------------------------------------------------
upsert_env APP_URL "${APP_URL_VAL}"
if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
TP_LIST="127.0.0.1,::1"
[[ -n "${NPM_IP:-}" ]] && TP_LIST="${TP_LIST},${NPM_IP}"
upsert_env TRUSTED_PROXIES "$TP_LIST"
upsert_env TRUSTED_HEADERS "x-forwarded-all"
else
upsert_env TRUSTED_PROXIES ""
upsert_env TRUSTED_HEADERS "x-forwarded-all"
fi
upsert_env APP_HOST "${APP_HOST_VAL}"
upsert_env APP_NAME "${APP_NAME}"
upsert_env APP_ENV "${APP_ENV:-production}"
upsert_env APP_DEBUG "${APP_DEBUG:-false}"
upsert_env APP_TIMEZONE "${APP_TZ:-UTC}"
upsert_env APP_LOCALE "${APP_LOCALE:-de}"
upsert_env APP_FALLBACK_LOCALE "en"
upsert_env SERVER_PUBLIC_IPV4 "${SERVER_PUBLIC_IPV4}"
upsert_env SERVER_PUBLIC_IPV6 "${SERVER_PUBLIC_IPV6:-}"
upsert_env SYSMAIL_SUB "${SYSMAIL_SUB}"
upsert_env SYSMAIL_DOMAIN "${SYSMAIL_DOMAIN}"
upsert_env DKIM_ENABLE "${DKIM_ENABLE}"
upsert_env DKIM_SELECTOR "${DKIM_SELECTOR}"
upsert_env DKIM_GENERATE "${DKIM_GENERATE}"
upsert_env BASE_DOMAIN "${BASE_DOMAIN}"
upsert_env UI_SUB "${UI_SUB}"
upsert_env WEBMAIL_SUB "${WEBMAIL_SUB}"
upsert_env MTA_SUB "${MTA_SUB}"
upsert_env LE_EMAIL "${LE_EMAIL:-admin@${BASE_DOMAIN}}"
upsert_env DB_CONNECTION "mysql"
upsert_env DB_HOST "127.0.0.1"
upsert_env DB_PORT "3306"
upsert_env DB_DATABASE "${DB_NAME}"
upsert_env DB_USERNAME "${DB_USER}"
upsert_env DB_PASSWORD "${DB_PASS}"
upsert_env CACHE_SETTINGS_STORE "redis"
upsert_env CACHE_STORE "redis"
upsert_env CACHE_DRIVER "redis"
upsert_env CACHE_PREFIX "${APP_USER_PREFIX}_cache:"
upsert_env SESSION_DRIVER "redis"
upsert_env SESSION_SECURE_COOKIE "${SECURE}" # DEV=false, PROD=true
upsert_env SESSION_SAMESITE "lax"
upsert_env REDIS_CLIENT "phpredis"
upsert_env REDIS_HOST "127.0.0.1"
upsert_env REDIS_PORT "6379"
upsert_env REDIS_PASSWORD "${REDIS_PASS}"
upsert_env REDIS_DB "0"
upsert_env REDIS_CACHE_DB "1"
upsert_env REDIS_CACHE_CONNECTION "cache"
upsert_env REDIS_CACHE_LOCK_CONNECTION "default"
upsert_env BROADCAST_DRIVER "reverb"
upsert_env QUEUE_CONNECTION "redis"
upsert_env LOG_CHANNEL "daily"
upsert_env REVERB_APP_ID "${APP_USER_PREFIX}"
grep -q '^REVERB_APP_KEY=' "$ENV_FILE" || upsert_env REVERB_APP_KEY "${APP_USER_PREFIX}_$(openssl rand -hex 16)"
grep -q '^REVERB_APP_SECRET=' "$ENV_FILE" || upsert_env REVERB_APP_SECRET "${APP_USER_PREFIX}_$(openssl rand -hex 32)"
upsert_env REVERB_HOST "\${APP_HOST}"
upsert_env REVERB_PORT "443"
upsert_env REVERB_SCHEME "https"
upsert_env REVERB_PATH "/ws"
upsert_env REVERB_SCALING_ENABLED "true"
upsert_env REVERB_SCALING_CHANNEL "reverb"
upsert_env VITE_REVERB_APP_KEY "\${REVERB_APP_KEY}"
upsert_env VITE_REVERB_HOST "\${REVERB_HOST}"
upsert_env VITE_REVERB_PORT "\${REVERB_PORT}"
upsert_env VITE_REVERB_SCHEME "\${REVERB_SCHEME}"
upsert_env VITE_REVERB_PATH "\${REVERB_PATH}"
upsert_env REVERB_SERVER_APP_KEY "\${REVERB_APP_KEY}"
upsert_env REVERB_SERVER_HOST "127.0.0.1"
upsert_env REVERB_SERVER_PORT "8080"
upsert_env REVERB_SERVER_PATH ""
upsert_env REVERB_SERVER_SCHEME "http"
# --- DEV Block (optional) ---------------------------------------------------
DEV_MODE="${DEV_MODE:-0}"
if [[ "$DEV_MODE" = "1" ]]; then
sed -i '/^# --- MailWolt DEV/,/^# --- \/MailWolt DEV/d' "${ENV_FILE}"
cat >> "${ENV_FILE}" <<CONF
# --- MailWolt DEV ---
VITE_DEV_HOST=127.0.0.1
VITE_DEV_PORT=5173
VITE_HMR_PROTOCOL=wss
VITE_HMR_CLIENT_PORT=443
VITE_HMR_HOST=${SERVER_PUBLIC_IPV4}
VITE_DEV_ORIGIN=$(grep '^APP_URL=' "${ENV_FILE}" | cut -d= -f2-)
# --- /MailWolt DEV ---
CONF
fi
# --- Zertifikate in /etc/ssl/* bereitstellen, bevor Laravel irgendwas liest --
relink_and_reload
# --- RECHTE FIXEN: storage & bootstrap/cache (www-data + mailwolt) ----------
log "Setze korrekte Rechte für Laravel-Verzeichnisse …"
cd "${APP_DIR}"
chgrp -R www-data storage bootstrap/cache || true
find storage bootstrap/cache -type d -exec chmod 2775 {} \; || true
find storage bootstrap/cache -type f -exec chmod 0664 {} \; || true
setfacl -R -m u:www-data:rwx,u:${APP_USER}:rwx storage bootstrap/cache || true
setfacl -dR -m u:www-data:rwx,u:${APP_USER}:rwx storage bootstrap/cache || true
log "[✓] Schreibrechte für Laravel korrigiert."
# --- DKIM: Verzeichnisse & Basisrechte --------------------------------------
install -d -m 2775 -o "$APP_USER" -g www-data "$APP_DIR/storage/app/private"
install -d -m 2775 -o "$APP_USER" -g www-data "$APP_DIR/storage/app/private/dkim"
setfacl -R -m u:${APP_USER}:rwx,u:www-data:rwx "$APP_DIR/storage/app/private" || true
setfacl -dR -m u:${APP_USER}:rwx,u:www-data:rwx "$APP_DIR/storage/app/private" || true
# --- OpenDKIM: keys & DNS-Verzeichnis --------------------------------------
install -d -m 0750 -o opendkim -g opendkim /etc/opendkim
install -d -m 0750 -o opendkim -g opendkim /etc/opendkim/keys
install -d -m 0755 -o root -g root /etc/mailwolt
install -d -m 0755 -o root -g root /etc/mailwolt/dns
# --- Caches leeren, Migrationen ausführen -----------------------------------
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan migrate --force"
# --- Seeder (legt Domains/DKIM etc. an) -------------------------------------
if [[ "${BASE_DOMAIN}" != "example.com" ]]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan db:seed --class=SystemDomainSeeder --force"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan db:seed --class=SystemBackupSeeder --force"
fi
# --- DKIM für SYSMAIL_DOMAIN via App erzeugen & per Helper einhängen --------
DKIM_ENABLE="${DKIM_ENABLE:-1}"
DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
SYSMAIL_DOMAIN="${SYSMAIL_DOMAIN:-sysmail.${BASE_DOMAIN}}"
if [[ "${DKIM_ENABLE}" = "1" && -n "${SYSMAIL_DOMAIN}" ]]; then
log "Erzeuge/aktualisiere DKIM für ${SYSMAIL_DOMAIN} (Selector: ${DKIM_SELECTOR}) …"
# 1) In der App generieren (als mailwolt), und Pfad + TXT zurückgeben
OUT="$(sudo -u "${APP_USER}" -H bash -lc "
set -e
cd '${APP_DIR}'
php -r '
require \"vendor/autoload.php\";
\$app=require \"bootstrap/app.php\";
\$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
\$d = App\\Models\\Domain::firstOrCreate([\"domain\"=>\"${SYSMAIL_DOMAIN}\"],[\"is_active\"=>1,\"is_system\"=>1]);
\$r = app(App\\Services\\DkimService::class)->generateForDomain(\$d, 2048, \"${DKIM_SELECTOR}\");
echo \$r[\"priv_path\"], \"\\n\";
echo \$r[\"dns_txt\"], \"\\n\";
'
")"
PRIV_PATH="$(printf '%s\n' "$OUT" | sed -n '1p')"
DNS_TXT="$(printf '%s\n' "$OUT" | sed -n '2,$p')"
if [[ -z "$PRIV_PATH" || ! -s "$PRIV_PATH" ]]; then
echo "[!] DKIM priv_path fehlt oder Datei leer: $PRIV_PATH" >&2
exit 1
fi
TMP_TXT="$(mktemp /tmp/dkim_txt_XXXXXX.txt)"
printf '%s' "$DNS_TXT" >"$TMP_TXT"
# 2) Root-Helper ausführen (hängt Key ein, pflegt Key/SigningTable, kopiert TXT)
if [[ -x /usr/local/sbin/mailwolt-install-dkim ]]; then
/usr/local/sbin/mailwolt-install-dkim "${SYSMAIL_DOMAIN}" "${DKIM_SELECTOR}" "${PRIV_PATH}" "${TMP_TXT}"
else
echo "[!] Helper /usr/local/sbin/mailwolt-install-dkim fehlt oder ist nicht ausführbar." >&2
fi
rm -f "$TMP_TXT" || true
# 3) OpenDKIM neu laden
touch /run/mailwolt.need-opendkim-reload || true
else
log "DKIM übersprungen (DKIM_ENABLE=${DKIM_ENABLE}, SYSMAIL_DOMAIN='${SYSMAIL_DOMAIN}')."
fi
# --- TLSA aus App heraus (idempotent; läuft, wenn Zert lesbar ist) ----------
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan dns:tlsa:refresh || true"
# --- Build Frontend (nur wenn nötig) ----------------------------------------
if [[ -f "${APP_DIR}/package.json" && ! -f "${APP_DIR}/public/build/manifest.json" ]]; then
safe_frontend_build
fi
# --- Abschluss: Caches + Rechte + Reloads -----------------------------------
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache && php artisan optimize:clear"
# Konsistente Rechte/ACL für das gesamte App-Verzeichnis
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 2775 {} \;
find "$APP_DIR" -type f -exec chmod 664 {} \;
setfacl -R -m g:"$APP_GROUP":rwX -m d:g:"$APP_GROUP":rwX "$APP_DIR" || true
# Laravel-Write-Dirs sicherstellen (mit setgid & ACL)
install -d -m 2775 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR/storage" "$APP_DIR/bootstrap/cache"
chgrp -R www-data "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" || true
find "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" -type d -exec chmod 2775 {} \; || true
find "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" -type f -exec chmod 0664 {} \; || true
setfacl -R -m u:www-data:rwx,u:${APP_USER}:rwx "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" || true
setfacl -dR -m u:www-data:rwx,u:${APP_USER}:rwx "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" || true

View File

@ -0,0 +1,264 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Update-Wrapper & Sudoers …"
WRAPPER="/usr/local/sbin/mailwolt-update"
LOGFILE="/var/log/mailwolt-update.log"
STATEDIR="/var/lib/mailwolt/update"
SUDOERS="/etc/sudoers.d/mailwolt-update"
VERSION_FILE="/var/lib/mailwolt/version"
SUDOERS_SERVICES="/etc/sudoers.d/mailwolt-services"
SUDOERS_ARTISAN="/etc/sudoers.d/mailwolt-artisan"
# Kandidaten: wo liegt update.sh?
CANDIDATES=(
/opt/mailwolt-installer/scripts/update.sh
/mailwolt-installer/scripts/update.sh
/usr/local/lib/mailwolt/update.sh
)
# State/Log vorbereiten
install -d -m 0755 "$(dirname "$LOGFILE")"
install -d -m 0755 "$STATEDIR"
: > "$LOGFILE" || true
chmod 0644 "$LOGFILE"
# Wrapper erzeugen
cat > "$WRAPPER" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
LOG="/var/log/mailwolt-update.log"
STATE_DIR="/var/lib/mailwolt/update"
APP_DIR="/var/www/mailwolt"
WEB_USER="www-data"
CANDIDATES=(
/opt/mailwolt-installer/scripts/update.sh
/mailwolt-installer/scripts/update.sh
/usr/local/lib/mailwolt/update.sh
)
install -d -m 0755 "$(dirname "$LOG")" "$STATE_DIR" /var/lib/mailwolt
: > "$LOG" || true
chmod 0644 "$LOG"
echo "running" > "$STATE_DIR/state"
{
echo "===== $(date -Is) :: Update gestartet ====="
# --- Update-Script finden --------------------------------------------------
SCRIPT=""
for p in "${CANDIDATES[@]}"; do
if [[ -x "$p" ]]; then SCRIPT="$p"; break; fi
if [[ -f "$p" && -r "$p" ]]; then SCRIPT="$p"; break; fi
done
if [[ -z "$SCRIPT" ]]; then
echo "[!] update.sh nicht gefunden (versucht: ${CANDIDATES[*]})"
rc=127
else
echo "[i] benutze: $SCRIPT"
if [[ "$(id -u)" -ne 0 ]]; then
echo "[!] Bitte als root ausführen"
rc=1
else
if [[ -x "$SCRIPT" ]]; then
ALLOW_DIRTY=1 "$SCRIPT"
else
ALLOW_DIRTY=1 bash "$SCRIPT"
fi
rc=$?
fi
fi
echo "===== $(date -Is) :: Update-Script beendet (rc=$rc) ====="
# --- Nach dem Update: Assets neu bauen & Laravel optimieren ---------------
if [ -d "$APP_DIR" ]; then
cd "$APP_DIR" || exit 1
echo "[i] Führe Composer aus (falls vorhanden) ..."
if [ -f composer.json ]; then
sudo -u "$WEB_USER" composer install --no-dev --prefer-dist --no-interaction -q || true
fi
echo "[i] Baue Frontend-Assets neu ..."
if command -v npm >/dev/null 2>&1 && [ -f package.json ]; then
sudo -u "$WEB_USER" npm ci --silent || true
sudo -u "$WEB_USER" npm run build --silent || true
fi
echo "[i] Führe Migrationen & Cache-Optimierungen durch ..."
sudo -u "$WEB_USER" php artisan migrate --force || true
sudo -u "$WEB_USER" php artisan config:cache || true
sudo -u "$WEB_USER" php artisan optimize:clear || true
sudo -u "$WEB_USER" php artisan route:cache || true
sudo -u "$WEB_USER" php artisan view:cache || true
echo "[i] Hebe Wartungsmodus auf ..."
sudo -u "$WEB_USER" php artisan up >/dev/null 2>&1 || true
fi
# --- Version aktualisieren -------------------------------------------------
echo "[i] Aktualisiere Version ..."
if command -v git >/dev/null 2>&1; then
SRC="/var/www/mailwolt"
if [ ! -d "$SRC/.git" ]; then
SRC="/opt/mailwolt-installer"
fi
git config --global --add safe.directory "$SRC" || true
if [ -f "$SRC/.git/shallow" ]; then
git -C "$SRC" fetch --unshallow --quiet || true
fi
git -C "$SRC" fetch --tags --quiet origin || true
raw="$(git -C "$SRC" describe --tags --always --dirty 2>/dev/null || echo "unknown")"
norm="$(printf '%s' "$raw" | sed -E 's/^[vV]//; s/-.*$//')"
printf '%s\n' "$raw" > /var/lib/mailwolt/version_raw
printf '%s\n' "$norm" > /var/lib/mailwolt/version
chmod 0644 /var/lib/mailwolt/version_raw /var/lib/mailwolt/version
echo "[i] Version aktualisiert: raw=$raw norm=$norm (Quelle: $SRC)"
else
echo "unknown" > /var/lib/mailwolt/version_raw
echo "0.0.0" > /var/lib/mailwolt/version
chmod 0644 /var/lib/mailwolt/version_raw /var/lib/mailwolt/version
fi
# --- Services neu starten --------------------------------------------------
echo "[i] Starte MailWolt-Dienste neu ..."
sudo -u "$WEB_USER" php artisan mailwolt:restart-services || true
# --- Abschluss -------------------------------------------------------------
printf '%s\n' "$rc" > "$STATE_DIR/rc"
echo "done" > "$STATE_DIR/state"
echo "===== $(date -Is) :: Update beendet ====="
exit "$rc"
} | tee -a "$LOG"
EOF
chmod 0755 "$WRAPPER"
chown root:root "$WRAPPER"
# Sudoers: www-data (Laravel) & mailwolt dürfen den Wrapper laufen lassen
cat > "$SUDOERS" <<'EOF'
Defaults!/usr/local/sbin/mailwolt-update !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
EOF
chown root:root "$SUDOERS"
chmod 440 "$SUDOERS"
if ! visudo -c -f "$SUDOERS" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $SUDOERS entferne Datei."
rm -f "$SUDOERS"
fi
cat > "$SUDOERS_SERVICES" <<'EOF'
Defaults!/usr/bin/systemctl !requiretty
Cmnd_Alias MW_SERVICES = \
/usr/bin/systemctl reload nginx.service, \
/usr/bin/systemctl try-reload-or-restart nginx.service, \
/usr/bin/systemctl try-reload-or-restart postfix.service, \
/usr/bin/systemctl try-reload-or-restart dovecot.service, \
/usr/bin/systemctl try-reload-or-restart rspamd.service, \
/usr/bin/systemctl try-reload-or-restart opendkim.service, \
/usr/bin/systemctl try-reload-or-restart opendmarc.service, \
/usr/bin/systemctl try-reload-or-restart clamav-daemon.service, \
/usr/bin/systemctl try-reload-or-restart redis-server.service
www-data ALL=(root) NOPASSWD: MW_SERVICES
EOF
chmod 440 "$SUDOERS_SERVICES"
chown root:root "$SUDOERS_SERVICES"
# Prüfen, ob Syntax gültig ist
if ! visudo -c -f "$SUDOERS_SERVICES" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $SUDOERS_SERVICES entferne Datei."
rm -f "$SUDOERS_SERVICES"
else
echo "[✓] Sudoers für Dienststeuerung angelegt: $SUDOERS_SERVICES"
fi
# Version-File initial anlegen, falls nicht existiert
if [[ ! -f "$VERSION_FILE" ]]; then
echo "unknown" > "$VERSION_FILE"
chmod 0644 "$VERSION_FILE"
fi
cat > "$SUDOERS_ARTISAN" <<'EOF'
# mailwolt darf artisan im App-Verzeichnis als www-data ausführen (ohne Passwort)
mailwolt ALL=(www-data) NOPASSWD: /usr/bin/php /var/www/mailwolt/artisan *
EOF
chown root:root "$SUDOERS_ARTISAN"
chmod 440 "$SUDOERS_ARTISAN"
if ! visudo -c -f "$SUDOERS_ARTISAN" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $SUDOERS_ARTISAN entferne Datei."
rm -f "$SUDOERS_ARTISAN"
else
echo "[✓] Sudoers für Artisan-Kommandos angelegt: $SUDOERS_ARTISAN"
fi
log "[✓] Update-Wrapper bereit: $WRAPPER"
log "[✓] Version wird unter $VERSION_FILE gespeichert"
# ─── Installer-Wrapper ────────────────────────────────────────────────────────
INSTALL_WRAPPER="/usr/local/sbin/mailwolt-install"
INSTALL_SUDOERS="/etc/sudoers.d/mailwolt-install"
INSTALL_STATE_DIR="/var/lib/mailwolt/install"
INSTALL_LOG="/var/log/mailwolt-install.log"
# State/Log vorbereiten
install -d -m 0755 "$INSTALL_STATE_DIR"
: > "$INSTALL_LOG" || true
chmod 0644 "$INSTALL_LOG"
# Installer-Wrapper aus scripts/install-wrapper.sh kopieren
INSTALL_SRC=""
for candidate in \
"$(dirname "$0")/install-wrapper.sh" \
/opt/mailwolt-installer/scripts/install-wrapper.sh \
/var/www/mailwolt/mailwolt-installer/scripts/install-wrapper.sh; do
[[ -f "$candidate" ]] && INSTALL_SRC="$candidate" && break
done
if [[ -n "$INSTALL_SRC" ]]; then
cp "$INSTALL_SRC" "$INSTALL_WRAPPER"
chmod 0755 "$INSTALL_WRAPPER"
chown root:root "$INSTALL_WRAPPER"
echo "[✓] Installer-Wrapper angelegt: $INSTALL_WRAPPER"
else
echo "[!] install-wrapper.sh nicht gefunden Installer-Wrapper wird übersprungen."
fi
# Sudoers: www-data & mailwolt dürfen den Installer-Wrapper laufen lassen
cat > "$INSTALL_SUDOERS" <<'SUDOEOF'
Defaults!/usr/local/sbin/mailwolt-install !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install *
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install *
SUDOEOF
chown root:root "$INSTALL_SUDOERS"
chmod 440 "$INSTALL_SUDOERS"
if ! visudo -c -f "$INSTALL_SUDOERS" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $INSTALL_SUDOERS entferne Datei."
rm -f "$INSTALL_SUDOERS"
else
echo "[✓] Sudoers für Installer angelegt: $INSTALL_SUDOERS"
fi

View File

@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "systemd Units (Reverb / Scheduler / Queue / Mail) …"
cat > /etc/systemd/system/${APP_USER}-ws.service <<EOF
[Unit]
Description=${APP_NAME} WebSocket Backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=NODE_ENV=production WS_PORT=8080
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-ws.log
StandardError=append:/var/log/${APP_USER}-ws.log
KillSignal=SIGINT
TimeoutStopSec=15
UMask=0002
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/${APP_USER}-schedule.service <<EOF
[Unit]
Description=${APP_NAME} Laravel Scheduler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan schedule:work
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-schedule.log
StandardError=append:/var/log/${APP_USER}-schedule.log
KillSignal=SIGINT
TimeoutStopSec=15
UMask=0002
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/${APP_USER}-queue.service <<EOF
[Unit]
Description=${APP_NAME} Queue Worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan queue:work --queue=default,notify --tries=1
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-queue.log
StandardError=append:/var/log/${APP_USER}-queue.log
KillSignal=SIGINT
TimeoutStopSec=15
UMask=0002
[Install]
WantedBy=multi-user.target
EOF
chown root:root /etc/systemd/system/${APP_USER}-*.service
chmod 644 /etc/systemd/system/${APP_USER}-*.service
touch /var/log/${APP_USER}-ws.log /var/log/${APP_USER}-schedule.log /var/log/${APP_USER}-queue.log
chown ${APP_USER}:${APP_GROUP} /var/log/${APP_USER}-*.log
chmod 664 /var/log/${APP_USER}-*.log
systemctl daemon-reload
# App-Dienste
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan list --no-ansi | grep -qE '(^| )reverb:start( |$)'"; then
systemctl enable --now ${APP_USER}-ws
else
systemctl disable --now ${APP_USER}-ws >/dev/null 2>&1 || true
fi
systemctl enable --now ${APP_USER}-schedule
systemctl enable --now ${APP_USER}-queue
# Mail-Dienste starten
systemctl enable --now rspamd opendkim postfix dovecot || true
# PHP-FPM: Unit erkennen, enable + (re)load
enable_and_touch_php_fpm() {
for u in php8.3-fpm php8.2-fpm php8.1-fpm php8.0-fpm php7.4-fpm php-fpm; do
if systemctl list-unit-files | grep -q "^${u}\.service"; then
systemctl enable --now "$u" || true
systemctl reload "$u" || systemctl restart "$u" || true
echo "[i] PHP-FPM unit: $u"
return 0
fi
done
echo "[!] Keine passende php-fpm Unit gefunden."
}
enable_and_touch_php_fpm
# Falls in 80-app.sh DKIM installiert wurde: jetzt einmal reloaden
if [[ -e /run/mailwolt.need-opendkim-reload ]]; then
systemctl reload opendkim || true
rm -f /run/mailwolt.need-opendkim-reload || true
fi
# Falls Zert-Fix markiert ist: Dovecot neu laden
if [[ -e /run/mailwolt.need-dovecot-reload ]]; then
systemctl reload dovecot || true
rm -f /run/mailwolt.need-dovecot-reload || true
fi
# Falls DB-Migration schon durch: einmal reload
db_ready(){ mysql -u"${DB_USER}" -p"${DB_PASS}" -h 127.0.0.1 -D "${DB_NAME}" -e "SHOW TABLES LIKE 'migrations'\G" >/dev/null 2>&1; }
if db_ready; then
systemctl reload postfix || true
fi
# Mini-Portcheck (hilft beim Installer-Output)
echo "Listening (25/465/587):"
ss -ltnp | awk '$4 ~ /:(25|465|587)$/ {print " " $0}'

View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Sudoers: npm-Build ohne Passwort für user 'mailwolt' …"
# 1) npm-Binary ermitteln (normal: /usr/bin/npm)
NPM_BIN="$(command -v npm || true)"
if [[ -z "$NPM_BIN" ]]; then
warn "npm wurde nicht gefunden sudoers wird vorbereitet, aber ohne Validierung. Stelle sicher, dass Node/npm installiert ist."
# Fallback die meisten Distros legen hier an
NPM_BIN="/usr/bin/npm"
fi
SUDOERS_FILE="/etc/sudoers.d/mailwolt-npm"
# 2) Sudoers-Datei schreiben
cat > "$SUDOERS_FILE" <<EOF
Defaults!${NPM_BIN} !requiretty
mailwolt ALL=(root) NOPASSWD: ${NPM_BIN}
EOF
chown root:root "$SUDOERS_FILE"
chmod 440 "$SUDOERS_FILE"
# 3) Validieren
if visudo -c -f "$SUDOERS_FILE" >/dev/null 2>&1; then
log "[✓] sudoers OK: ${SUDOERS_FILE} erlaubt 'mailwolt' → ${NPM_BIN} ohne Passwort."
else
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_FILE} entferne Datei."
rm -f "$SUDOERS_FILE"
fi

View File

@ -0,0 +1,284 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Backup/Restore Tools, Config & Timer (installer.env) …"
# ─────────────────────────────────────────────────────────────
# 1) installer.env laden (ENV > installer.env > Defaults)
# ─────────────────────────────────────────────────────────────
if [[ -f /etc/mailwolt/installer.env ]]; then
# automatisch exportieren, damit ${VAR} später überall wirkt
set -a
# shellcheck disable=SC1091
source /etc/mailwolt/installer.env
set +a
else
log "[i] /etc/mailwolt/installer.env nicht gefunden nutze Defaults."
fi
# ─────────────────────────────────────────────────────────────
# 2) Pfade & Defaults (werden durch ENV/installer.env überschrieben)
# ─────────────────────────────────────────────────────────────
CONF_DIR="/etc/mailwolt"
CONF_FILE="${CONF_DIR}/backup.conf"
BIN_DIR="/usr/local/sbin"
UNIT_DIR="/etc/systemd/system"
APP_DIR="${APP_DIR:-/var/www/mailwolt}"
# DB-Parameter aus installer.env (bzw. ENV) oder Fallbacks
DB_HOST="${DB_HOST:-127.0.0.1}"
DB_NAME="${DB_NAME:-mailwolt}"
DB_USER="${DB_USER:-mailwolt}"
DB_PASS="${DB_PASS:-}"
# Backup-Settings aus installer.env (bzw. ENV)
BACKUP_DIR="${BACKUP_DIR:-/var/backups/mailwolt}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
BACKUP_USE_ZSTD="${BACKUP_USE_ZSTD:-1}"
BACKUP_ENABLED="${BACKUP_ENABLED:-0}" # 0|1
BACKUP_INTERVAL="${BACKUP_INTERVAL:-daily}" # daily|weekly|monthly
install -d -m 0755 "$CONF_DIR" "$BACKUP_DIR"
SUDOERS_BACKUP_FILE="/etc/sudoers.d/mailwolt-backup"
# 2) Sudoers-Datei schreiben
cat > "${SUDOERS_BACKUP_FILE} " <<EOF
Defaults!/usr/local/sbin/mailwolt-backup !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup
EOF
chown root:root "${SUDOERS_BACKUP_FILE}"
chmod 440 "${SUDOERS_BACKUP_FILE}"
if ! visudo -c -f "${SUDOERS_BACKUP_FILE}" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_BACKUP_FILE} entferne Datei."
rm -f "${SUDOERS_BACKUP_FILE}"
fi
# ─────────────────────────────────────────────────────────────
# 3) /etc/mailwolt/backup.conf (von UI/APP überschreibbar)
# ─────────────────────────────────────────────────────────────
cat > "$CONF_FILE" <<EOF
# MailWolt Backup Konfiguration (UI kann überschreiben)
APP_DIR="$APP_DIR"
BACKUP_DIR="$BACKUP_DIR"
RETENTION_DAYS="$BACKUP_RETENTION_DAYS"
USE_ZSTD="$BACKUP_USE_ZSTD"
# DB-Parameter
MYSQL_DB="$DB_NAME"
MYSQL_USER="$DB_USER"
MYSQL_PASS="$DB_PASS"
MYSQL_HOST="$DB_HOST"
MYSQL_PORT="3306"
EOF
chmod 0644 "$CONF_FILE"
log "[✓] config geschrieben: $CONF_FILE"
# ─────────────────────────────────────────────────────────────
# 4) /usr/local/sbin/mailwolt-backup (schreibt backup.status)
# ─────────────────────────────────────────────────────────────
cat > "${BIN_DIR}/mailwolt-backup" <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
log(){ echo "[$(date -Is)] $*"; }
# Konfiguration laden (ENV > Datei)
CONF="/etc/mailwolt/backup.conf"
[[ -f "$CONF" ]] && # shellcheck disable=SC1090
source "$CONF"
APP_DIR="${APP_DIR:-/var/www/mailwolt}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/mailwolt}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
USE_ZSTD="${USE_ZSTD:-1}"
MYSQL_DB="${MYSQL_DB:-mailwolt}"
MYSQL_USER="${MYSQL_USER:-mailwolt}"
MYSQL_PASS="${MYSQL_PASS:-}"
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
STATE_DIR="/var/lib/mailwolt"
STATUS_FILE="${STATE_DIR}/backup.status"
install -d -m 0755 "$STATE_DIR" "$BACKUP_DIR"
START_TS="$(date +%s)"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
TMP="$(mktemp -d /tmp/mwbackup.XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
fail(){
local msg="${1:-backup failed}"
local now="$(date -Is)"
{
echo "time=${now}"
echo "size=0"
echo "dur=$(( $(date +%s) - START_TS ))s"
echo "ok=0"
echo "error=${msg}"
} > "$STATUS_FILE"
echo "[$now] ${msg}" >&2
exit 1
}
trap 'fail "unexpected error (exit $?)"' ERR
OUT="${BACKUP_DIR}/mailwolt-${TS}.tar"
log "⇒ starte Backup in $OUT"
# 1) DB
log " • mysqldump …"
MYSQL_PWD="$MYSQL_PASS" mysqldump \
-h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" \
--single-transaction --routines --events --triggers \
"$MYSQL_DB" > "$TMP/mysql.sql"
# 2) Maildir
log " • Maildir …"
tar -C / -cf "$TMP/mail.tar" var/mail/vhosts 2>/dev/null || true
# 3) App (ohne heavy dirs)
log " • App …"
tar -C / -cf "$TMP/app.tar" \
--exclude='var/www/mailwolt/vendor' \
--exclude='var/www/mailwolt/node_modules' \
--exclude='var/www/mailwolt/public/build' \
var/www/mailwolt
# 4) Configs
log " • Configs …"
mkdir -p "$TMP/files"
cp -a /etc/mailwolt "$TMP/files/" 2>/dev/null || true
cp -a /etc/postfix "$TMP/files/" 2>/dev/null || true
cp -a /etc/dovecot "$TMP/files/" 2>/dev/null || true
cp -a /etc/opendkim "$TMP/files/" 2>/dev/null || true
cp -a /etc/opendmarc "$TMP/files/" 2>/dev/null || true
cp -a /etc/rspamd "$TMP/files/" 2>/dev/null || true
cp -a /etc/ssl/ui "$TMP/files/" 2>/dev/null || true
tar -C "$TMP" -cf "$TMP/files.tar" files
# 5) Paket
log " • Archiviere …"
tar -C "$TMP" -cf "$OUT" mysql.sql mail.tar app.tar files.tar
# 6) Komprimieren (optional)
if [[ "${USE_ZSTD:-1}" = "1" ]] && command -v zstd >/dev/null 2>&1; then
log " • komprimiere (zstd) …"
zstd -f --rm -19 "$OUT"
OUT="${OUT}.zst"
fi
# 7) Retention
if [[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]]; then
log " • Retention: lösche älter als ${RETENTION_DAYS} Tage …"
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -name 'mailwolt-*' -delete || true
fi
# 8) Statusfile fürs UI
SIZE_BYTES="$(stat -c '%s' "$OUT" 2>/dev/null || echo 0)"
{
echo "time=$(date -Is)"
echo "size=${SIZE_BYTES}"
echo "dur=$(( $(date +%s) - START_TS ))s"
echo "ok=1"
echo "file=${OUT}"
} > "$STATUS_FILE"
chmod 0644 "$STATUS_FILE" 2>/dev/null || true
log "[✓] Backup fertig: $OUT"
EOSH
chmod 0755 "${BIN_DIR}/mailwolt-backup"
# ─────────────────────────────────────────────────────────────
# 5) /usr/local/sbin/mailwolt-restore
# ─────────────────────────────────────────────────────────────
cat > "${BIN_DIR}/mailwolt-restore" <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
log(){ echo "[$(date -Is)] $*"; }
ARCHIVE="${1:-}"
[[ -n "$ARCHIVE" ]] || { echo "Usage: mailwolt-restore <backup.tar[.zst]>"; exit 1; }
[[ -f "$ARCHIVE" ]] || { echo "Backup nicht gefunden: $ARCHIVE"; exit 1; }
TMP="$(mktemp -d /tmp/mwrestore.XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
case "$ARCHIVE" in
*.zst) zstd -d -c "$ARCHIVE" > "$TMP/backup.tar" ;;
*) cp -a "$ARCHIVE" "$TMP/backup.tar" ;;
esac
log "⇒ entpacke …"
tar -C "$TMP" -xf "$TMP/backup.tar"
# Reihenfolge: DB → App → Mail → Config
if [[ -f "$TMP/mysql.sql" ]]; then
log " • MySQL wiederherstellen …"
mysql < "$TMP/mysql.sql"
fi
if [[ -f "$TMP/app.tar" ]]; then
log " • App → /var/www/mailwolt …"
tar -C / -xf "$TMP/app.tar"
fi
if [[ -f "$TMP/mail.tar" ]]; then
log " • Maildir → /var/mail/vhosts …"
tar -C / -xf "$TMP/mail.tar"
fi
if [[ -f "$TMP/files.tar" ]]; then
log " • Configs → /etc/* …"
tar -C / -xf "$TMP/files.tar"
fi
log "[✓] Restore abgeschlossen."
EOSH
chmod 0755 "${BIN_DIR}/mailwolt-restore"
log "[✓] Tools installiert: ${BIN_DIR}/mailwolt-backup, mailwolt-restore"
# ─────────────────────────────────────────────────────────────
# 6) systemd Service + Timer (Timer default via installer.env)
# ─────────────────────────────────────────────────────────────
cat > "${UNIT_DIR}/mailwolt-backup.service" <<'EOSVC'
[Unit]
Description=MailWolt Backup
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/mailwolt-backup
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOSVC
cat > "${UNIT_DIR}/mailwolt-backup.timer" <<EOTIM
[Unit]
Description=MailWolt Backup Timer
[Timer]
OnCalendar=${BACKUP_ONCALENDAR:-*-*-* 03:00:00}
Persistent=true
[Install]
WantedBy=timers.target
EOTIM
systemctl daemon-reload
if [[ "${BACKUP_ENABLED}" = "1" ]]; then
log "Aktiviere Backup-Timer (${BACKUP_ONCALENDAR}) …"
systemctl enable --now mailwolt-backup.timer
else
log "Timer bleibt deaktiviert (BACKUP_ENABLED=0)."
systemctl disable --now mailwolt-backup.timer >/dev/null 2>&1 || true
fi
log "[✓] Backup-Setup abgeschlossen."

View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Monit konfigurieren …"
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcp ssl then restart
if failed port 587 type tcp then restart
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
check process mariadb with pidfile /run/mysqld/mysqld.pid
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed port 3306 type tcp then restart
check process redis-server with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed port 6379 type tcp then restart
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
EOF
chmod 600 /etc/monit/monitrc
monit -t && systemctl enable --now monit
monit reload || true
log "[✓] Monit konfiguriert und gestartet"
# ── mailwolt-update ins System kopieren ─────────────────────────────
install -m 0750 -o root -g root scripts/update.sh /usr/local/sbin/mailwolt-update
log "[✓] mailwolt-update installiert → ausführbar via 'sudo mailwolt-update'"

View File

@ -0,0 +1,491 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "WoltGuard (Monit + Self-Heal) einrichten …"
# ─────────────────────────────────────────────────────────────
# Env nur nachladen, wenn Flags nicht bereits exportiert sind
# ─────────────────────────────────────────────────────────────
INSTALLER_ENV="/etc/mailwolt/installer.env"
: "${CLAMAV_ENABLE:=}" ; : "${OPENDMARC_ENABLE:=}" ; : "${FAIL2BAN_ENABLE:=}"
if [[ -z "${CLAMAV_ENABLE}${OPENDMARC_ENABLE}${FAIL2BAN_ENABLE}" && -r "$INSTALLER_ENV" ]]; then
# shellcheck disable=SC1090
. "$INSTALLER_ENV"
fi
CLAMAV_ENABLE="${CLAMAV_ENABLE:-0}"
OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-0}"
FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
# ─────────────────────────────────────────────────────────────
# Monit installieren & aktivieren
# ─────────────────────────────────────────────────────────────
command -v monit >/dev/null || { apt-get update -qq; apt-get install -y monit; }
systemctl enable --now monit
# ─────────────────────────────────────────────────────────────
# Helper-Skripte (laufen später eigenständig → Env selbst laden)
# ─────────────────────────────────────────────────────────────
install -d -m 0755 /usr/local/sbin
# Redis-Ping (nimmt REDIS_PASSWORD aus installer.env oder .env)
cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
# Defaults
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
REDIS_PASS="${REDIS_PASS:-}" # Legacy
# Installer-Env (falls vorhanden)
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV" || true
# Falls .env existiert: Werte ergänzen, die noch leer sind
if [[ -r "$APP_ENV" ]]; then
[[ -z "${REDIS_HOST}" ]] && REDIS_HOST="$(grep -m1 -E '^REDIS_HOST=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PORT}" ]] && REDIS_PORT="$(grep -m1 -E '^REDIS_PORT=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PASSWORD}" ]] && REDIS_PASSWORD="$(grep -m1 -E '^REDIS_PASSWORD=' "$APP_ENV" | cut -d= -f2- || true)"
fi
# Legacy-Fallback: wenn PASSWORD leer, aber PASS gesetzt → übernehmen
[[ -z "${REDIS_PASSWORD}" && -n "${REDIS_PASS}" ]] && REDIS_PASSWORD="$REDIS_PASS"
# Quotes strippen
strip(){ printf '%s' "$1" | sed -E 's/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/'; }
REDIS_HOST="$(strip "${REDIS_HOST:-}")"
REDIS_PORT="$(strip "${REDIS_PORT:-}")"
REDIS_PASSWORD="$(strip "${REDIS_PASSWORD:-}")"
# redis-cli muss vorhanden sein
command -v redis-cli >/dev/null 2>&1 || exit 1
BASE=(timeout 2 redis-cli --no-auth-warning --raw -h "$REDIS_HOST" -p "$REDIS_PORT")
if [[ -n "$REDIS_PASSWORD" ]]; then
CMD=("${BASE[@]}" -a "$REDIS_PASSWORD" ping)
else
CMD=("${BASE[@]}" ping)
fi
# Erfolgreich nur bei exakt "PONG"
[[ "$("${CMD[@]}" 2>/dev/null || true)" == "PONG" ]]
EOSH
chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
# Rspamd-Heal (setzt Laufzeitverzeichnis, leert alte Socke, restarts rspamd)
cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV"
if [[ -z "${REDIS_PASSWORD}" && -r "$APP_ENV" ]]; then
REDIS_PASSWORD="$(grep -E '^REDIS_PASSWORD=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
fi
# Rspamd Runtime fixen
install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
[[ -S /var/lib/rspamd/rspamd.sock ]] && rm -f /var/lib/rspamd/rspamd.sock || true
# Neustart
systemctl restart rspamd
# Mini-Healthcheck
sleep 2
ss -tln | grep -q ':11334' || echo "[WARN] Rspamd Controller Port 11334 nicht sichtbar"
exit 0
EOSH
chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
# ─────────────────────────────────────────────────────────────
# WoltGuard Wrapper + Unit
# ─────────────────────────────────────────────────────────────
cat >/usr/local/bin/woltguard <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-status}" in
start) systemctl enable --now monit ;;
stop) systemctl stop monit ;;
status) monit summary || systemctl status monit || true ;;
heal) monit reload || true; sleep 1; monit restart all || true ;;
monitor) monit monitor all || true ;;
unmonitor) monit unmonitor all || true ;;
*) echo "Usage: woltguard {start|stop|status|heal|monitor|unmonitor}"; exit 2;;
esac
EOSH
chmod 0755 /usr/local/bin/woltguard
cat >/etc/systemd/system/woltguard.service <<'EOF'
[Unit]
Description=WoltGuard Self-Healing Monitor for MailWolt
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/woltguard start
ExecStop=/usr/local/bin/woltguard stop
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now woltguard
# ─────────────────────────────────────────────────────────────
# Monit Basis + includes
# ─────────────────────────────────────────────────────────────
sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
grep -q 'include /etc/monit/conf.d/*' /etc/monit/monitrc || echo 'include /etc/monit/conf.d/*' >>/etc/monit/monitrc
install -d -m 0755 /etc/monit/conf.d
# ─────────────────────────────────────────────────────────────
# Monit Checks
# ─────────────────────────────────────────────────────────────
# 10 Redis zuerst (abhängig für rspamd)
cat >/etc/monit/conf.d/10-redis.conf <<'EOF'
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed host 127.0.0.1 port 6379 for 2 cycles then restart
if 5 restarts within 5 cycles then alert
check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
EOF
# 20 Rspamd (hängt von Redis ab), robust über process-matching
cat >/etc/monit/conf.d/20-rspamd.conf <<'EOF'
check process rspamd matching "/usr/bin/rspamd"
start program = "/bin/systemctl start rspamd"
stop program = "/bin/systemctl stop rspamd"
depends on redis
if failed host 127.0.0.1 port 11333 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if failed host 127.0.0.1 port 11334 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if 5 restarts within 5 cycles then alert
EOF
# 30 Maildienste
cat >/etc/monit/conf.d/30-postfix.conf <<'EOF'
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcpssl then restart
if failed port 587 type tcp then restart
if 5 restarts within 5 cycles then alert
EOF
cat >/etc/monit/conf.d/30-dovecot.conf <<'EOF'
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 993 type tcpssl for 2 cycles then restart
if failed port 24 protocol lmtp for 2 cycles then restart
if 5 restarts within 5 cycles then alert
EOF
# 40 Web/PHP
cat >/etc/monit/conf.d/40-nginx.conf <<'EOF'
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcpssl then restart
if 5 restarts within 5 cycles then alert
EOF
# 50 DKIM/DMARC
cat >/etc/monit/conf.d/50-opendkim.conf <<'EOF'
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then alert
EOF
# optional: OpenDMARC
if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
cat >/etc/monit/conf.d/55-opendmarc.conf <<'EOF'
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 5 cycles then alert
EOF
else
rm -f /etc/monit/conf.d/55-opendmarc.conf || true
fi
# 60 optional: ClamAV
if [[ "$CLAMAV_ENABLE" = "1" ]]; then
cat >/etc/monit/conf.d/60-clamav.conf <<'EOF'
check process clamd with pidfile /run/clamav/clamd.pid
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 5 cycles then timeout
EOF
else
rm -f /etc/monit/conf.d/60-clamav.conf || true
fi
# 70 Fail2Ban (optional, standardmäßig aktiv)
if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
cat >/etc/monit/conf.d/70-fail2ban.conf <<'EOF'
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 5 cycles then alert
EOF
else
rm -f /etc/monit/conf.d/70-fail2ban.conf || true
fi
# ─────────────────────────────────────────────────────────────
# Monit neu laden
# ─────────────────────────────────────────────────────────────
monit -t
systemctl reload monit || systemctl restart monit
systemctl status monit --no-pager || true
log "[✓] WoltGuard aktiv."
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#log "WoltGuard (Monit + Self-Heal) einrichten …"
#
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#CLAMAV_ENABLE="${CLAMAV_ENABLE:-0}"
#OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-0}"
#FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#
## Pakete sicherstellen
#command -v monit >/dev/null || { apt-get update -qq; apt-get install -y monit; }
#systemctl enable --now monit
#
## Helper-Skripte
#install -d -m 0755 /usr/local/sbin
#cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#PASS=""
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env || true
#if command -v redis-cli >/dev/null 2>&1; then
# [[ -n "${REDIS_PASS:-}" ]] \
# && redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASS" ping | grep -q PONG \
# || redis-cli -h 127.0.0.1 -p 6379 ping | grep -q PONG
#else
# exit 1
#fi
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
#
#cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#
#REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
#REDIS_PORT="${REDIS_PORT:-6379}"
#REDIS_PASSWORD="${REDIS_PASSWORD:-}"
#
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#APP_ENV="/var/www/mailwolt/.env"
#REDIS_CLI="$(command -v redis-cli || true)"
#SYSTEMCTL="$(command -v systemctl || true)"
#RSPAMD_SERVICE="rspamd"
#
#if [ -r "$INSTALLER_ENV" ]; then . "$INSTALLER_ENV"; fi
#if [ -z "${REDIS_PASSWORD}" ] && [ -r "$APP_ENV" ]; then
# REDIS_PASSWORD="$(grep -E '^REDIS_PASSWORD=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
#fi
#
#if [ -n "$REDIS_CLI" ]; then
# echo "[INFO] Prüfe Redis Verbindung..."
# if [ -n "${REDIS_PASSWORD}" ]; then
# if ! "$REDIS_CLI" -h "$REDIS_HOST" -p "$REDIS_PORT" -a "$REDIS_PASSWORD" ping | grep -q '^PONG$'; then
# echo "[WARN] Redis antwortet nicht oder Passwort falsch!"
# else
# echo "[OK] Redis antwortet (auth ok)."
# fi
# else
# if ! "$REDIS_CLI" -h "$REDIS_HOST" -p "$REDIS_PORT" ping | grep -q '^PONG$'; then
# echo "[WARN] Redis antwortet nicht (ohne Passwort)."
# else
# echo "[OK] Redis antwortet (kein Passwort)."
# fi
# fi
#else
# echo "[WARN] redis-cli nicht gefunden überspringe Test."
#fi
#
#echo "[INFO] Prüfe Rspamd Socket & Verzeichnis..."
#install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
#[ -S /var/lib/rspamd/rspamd.sock ] && rm -f /var/lib/rspamd/rspamd.sock || true
#
#echo "[INFO] Starte Rspamd neu..."
#if [ -n "$SYSTEMCTL" ]; then
# "$SYSTEMCTL" restart "$RSPAMD_SERVICE"
# echo "[OK] Rspamd erfolgreich neu gestartet."
#else
# echo "[ERROR] systemctl nicht gefunden kein Neustart möglich."
# exit 1
#fi
#
#echo "[INFO] Healthcheck (Port 11334)..."
#sleep 3
#if ss -tln | grep -q ':11334'; then
# echo "[OK] Rspamd Controller läuft auf Port 11334."
#else
# echo "[WARN] Rspamd Controller Port 11334 nicht erreichbar."
#fi
#
#echo "[DONE] Mailwolt Rspamd-Heal abgeschlossen."
#exit 0
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
#
## WoltGuard Wrapper + Unit
#cat >/usr/local/bin/woltguard <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#case "${1:-status}" in
# start) systemctl enable --now monit ;;
# stop) systemctl stop monit ;;
# status) monit summary || systemctl status monit || true ;;
# heal) monit reload || true; sleep 1; monit restart all || true ;;
# monitor) monit monitor all || true ;;
# unmonitor) monit unmonitor all || true ;;
# *) echo "Usage: woltguard {start|stop|status|heal|monitor|unmonitor}"; exit 2;;
#esac
#EOSH
#chmod 0755 /usr/local/bin/woltguard
#
#cat >/etc/systemd/system/woltguard.service <<'EOF'
#[Unit]
#Description=WoltGuard Self-Healing Monitor for MailWolt
#After=network.target
#[Service]
#Type=oneshot
#ExecStart=/usr/local/bin/woltguard start
#ExecStop=/usr/local/bin/woltguard stop
#RemainAfterExit=yes
#[Install]
#WantedBy=multi-user.target
#EOF
#systemctl daemon-reload
#systemctl enable --now woltguard
#
## Monit Basis + include
#sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
#grep -q 'include /etc/monit/conf.d/*' /etc/monit/monitrc || echo 'include /etc/monit/conf.d/*' >>/etc/monit/monitrc
#install -d -m 0755 /etc/monit/conf.d
#
## Checks
#cat >/etc/monit/conf.d/postfix.conf <<'EOF'
#check process postfix with pidfile /var/spool/postfix/pid/master.pid
# start program = "/bin/systemctl start postfix"
# stop program = "/bin/systemctl stop postfix"
# if failed port 25 protocol smtp then restart
# if failed port 465 type tcpssl then restart
# if failed port 587 type tcp then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/dovecot.conf <<'EOF'
#check process dovecot with pidfile /run/dovecot/master.pid
# start program = "/bin/systemctl start dovecot"
# stop program = "/bin/systemctl stop dovecot"
# if failed port 993 type tcpssl for 2 cycles then restart
# if failed port 24 protocol lmtp for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/nginx.conf <<'EOF'
#check process nginx with pidfile /run/nginx.pid
# start program = "/bin/systemctl start nginx"
# stop program = "/bin/systemctl stop nginx"
# if failed port 80 type tcp then restart
# if failed port 443 type tcpssl then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/redis.conf <<'EOF'
#check process redis with pidfile /run/redis/redis-server.pid
# start program = "/bin/systemctl start redis-server"
# stop program = "/bin/systemctl stop redis-server"
# if failed host 127.0.0.1 port 6379 for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#
#check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
# if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
#EOF
#
#cat >/etc/monit/conf.d/rspamd.conf <<'EOF'
#check process rspamd with pidfile /run/rspamd/rspamd.pid
# start program = "/bin/systemctl start rspamd"
# stop program = "/bin/systemctl stop rspamd"
# if failed port 11333 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if failed port 11334 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/opendkim.conf <<'EOF'
#check process opendkim with pidfile /run/opendkim/opendkim.pid
# start program = "/bin/systemctl start opendkim"
# stop program = "/bin/systemctl stop opendkim"
# if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## optional: OpenDMARC
#if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
# cat >/etc/monit/conf.d/opendmarc.conf <<'EOF'
#check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
# start program = "/bin/systemctl start opendmarc"
# stop program = "/bin/systemctl stop opendmarc"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/conf.d/opendmarc.conf || true
#fi
#
## optional: ClamAV
#if [[ "$CLAMAV_ENABLE" = "1" ]]; then
# cat >/etc/monit/conf.d/clamav.conf <<'EOF'
#check process clamd with pidfile /run/clamav/clamd.pid
# start program = "/bin/systemctl start clamav-daemon"
# stop program = "/bin/systemctl stop clamav-daemon"
# if failed unixsocket /run/clamav/clamd.ctl then restart
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/conf.d/clamav.conf || true
#fi
#
## optional: Fail2Ban
#if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
# cat >/etc/monit/conf.d/fail2ban.conf <<'EOF'
#check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
# start program = "/bin/systemctl start fail2ban"
# stop program = "/bin/systemctl stop fail2ban"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/conf.d/fail2ban.conf || true
#fi
#
#monit -t
#systemctl reload monit || systemctl restart monit
#systemctl status monit --no-pager || true
#log "[✓] WoltGuard aktiv."

View File

@ -0,0 +1,439 @@
#!/usr/bin/env bash
set -euo pipefail
# Flags laden (falls vorhanden)
INSTALLER_ENV="/etc/mailwolt/installer.env"
: "${CLAMAV_ENABLE:=}"; : "${OPENDMARC_ENABLE:=}"; : "${FAIL2BAN_ENABLE:=}"; : "${MONIT_HTTP:=}"
if [[ -z "${CLAMAV_ENABLE}${OPENDMARC_ENABLE}${FAIL2BAN_ENABLE}" && -r "$INSTALLER_ENV" ]]; then
. "$INSTALLER_ENV"
fi
CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
MONIT_HTTP="${MONIT_HTTP:-1}"
# ── Monit so konfigurieren, dass NUR monitrc.d/* geladen wird ────────────────
install -d -m 0755 /etc/monit/monitrc.d
install -d -m 0755 /etc/monit/conf.d # passiver Ablageort (NICHT includiert)
# Poll-Intervall (30s)
sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
# alle alten include-Zeilen raus und monitrc.d setzen
sed -i 's|^#\?\s*include .*$||g' /etc/monit/monitrc
grep -q '^include /etc/monit/monitrc.d/\*' /etc/monit/monitrc \
|| echo 'include /etc/monit/monitrc.d/*' >> /etc/monit/monitrc
# Optional: HTTP-UI nur einschalten, wenn explizit gewünscht
if [[ "$MONIT_HTTP" = "1" ]]; then
grep -q '^set httpd port 2812' /etc/monit/monitrc || cat >>/etc/monit/monitrc <<'HTTP'
set httpd port 2812 and
use address localhost
allow localhost
HTTP
fi
# KEIN Löschen mehr der Dateien wir verschieben je nach Status
# (vorher stand hier rm -rf /etc/monit/monitrc.d/* und rm -f /etc/monit/conf.d/*.conf)
# ── Helper-Skripte ──────────────────────────────────────────────────────────
install -d -m 0755 /usr/local/sbin
# Redis-Ping (Password: REDIS_PASSWORD aus installer.env oder .env)
cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
REDIS_PASS="${REDIS_PASS:-}"
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV" || true
if [[ -r "$APP_ENV" ]]; then
[[ -z "${REDIS_HOST}" ]] && REDIS_HOST="$(grep -m1 '^REDIS_HOST=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PORT}" ]] && REDIS_PORT="$(grep -m1 '^REDIS_PORT=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PASSWORD}" ]] && REDIS_PASSWORD="$(grep -m1 '^REDIS_PASSWORD=' "$APP_ENV" | cut -d= -f2- || true)"
fi
[[ -z "${REDIS_PASSWORD}" && -n "${REDIS_PASS}" ]] && REDIS_PASSWORD="$REDIS_PASS"
strip(){ printf '%s' "$1" | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'; }
REDIS_HOST="$(strip "${REDIS_HOST:-}")"
REDIS_PORT="$(strip "${REDIS_PORT:-}")"
REDIS_PASSWORD="$(strip "${REDIS_PASSWORD:-}")"
command -v redis-cli >/dev/null 2>&1 || exit 1
BASE=(timeout 2 redis-cli --no-auth-warning --raw -h "$REDIS_HOST" -p "$REDIS_PORT")
[[ -n "$REDIS_PASSWORD" ]] && CMD=("${BASE[@]}" -a "$REDIS_PASSWORD" ping) || CMD=("${BASE[@]}" ping)
[[ "$("${CMD[@]}" 2>/dev/null || true)" == "PONG" ]]
EOSH
chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
# Rspamd-Heal (Socke aufräumen, restart, Mini-Port-Check)
cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASS="${REDIS_PASS:-}"
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV"
if [[ -z "${REDIS_PASS}" && -r "$APP_ENV" ]]; then
REDIS_PASS="$(grep -E '^REDIS_PASS=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
fi
# Rspamd Runtime fixen
install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
[[ -S /var/lib/rspamd/rspamd.sock ]] && rm -f /var/lib/rspamd/rspamd.sock || true
echo "$(date '+%F %T') heal run" >> /var/log/rspamd-heal.log
# Neustart
systemctl restart rspamd
# Mini-Healthcheck
sleep 2
ss -tln | grep -q ':11334' || echo "[WARN] Rspamd Controller Port 11334 nicht sichtbar"
exit 0
EOSH
chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
# ── Monit-Checks (nummeriert) fixe Dienste immer aktiv ────────────────────
# 10 Redis
cat >/etc/monit/monitrc.d/10-redis.conf <<'EOF'
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed host 127.0.0.1 port 6379 for 2 cycles then restart
if 5 restarts within 5 cycles then alert
check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
EOF
# 20 Rspamd (robust via process-matching + Heal)
cat >/etc/monit/monitrc.d/20-rspamd.conf <<'EOF'
check process rspamd matching "rspamd: main process"
start program = "/bin/systemctl start rspamd" with timeout 120 seconds
stop program = "/bin/systemctl stop rspamd"
depends on redis
if failed host 127.0.0.1 port 11333 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if failed host 127.0.0.1 port 11334 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if does not exist for 2 cycles then restart
if 5 restarts within 10 cycles then unmonitor
EOF
# 30 Postfix
cat >/etc/monit/monitrc.d/30-postfix.conf <<'EOF'
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed host 127.0.0.1 port 25 type tcp with timeout 15 seconds for 3 cycles then restart
if failed host 127.0.0.1 port 465 type tcpssl with timeout 10 seconds then restart
if failed host 127.0.0.1 port 587 type tcp with timeout 10 seconds then restart
if 5 restarts within 5 cycles then alert
EOF
# 30 Dovecot (IMAPS; LMTP oft Unix-Socket → kein TCP-Fehlalarm)
cat >/etc/monit/monitrc.d/30-dovecot.conf <<'EOF'
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 993 type tcpssl for 3 cycles then restart
if 5 restarts within 10 cycles then alert
EOF
# 40 Nginx
cat >/etc/monit/monitrc.d/40-nginx.conf <<'EOF'
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcpssl then restart
if 5 restarts within 5 cycles then alert
EOF
# 50 OpenDKIM
cat >/etc/monit/monitrc.d/50-opendkim.conf <<'EOF'
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then alert
EOF
move_monit_conf() {
local name="$1" # z.B. 55-opendmarc
local enabled="$2" # "0" oder "1"
local src="/etc/monit/conf.d/${name}.conf"
local dst="/etc/monit/monitrc.d/${name}.conf"
mkdir -p /etc/monit/conf.d /etc/monit/monitrc.d
# Falls Datei nirgends existiert → in conf.d anlegen (lesbare Quelle)
if [[ ! -f "$src" && ! -f "$dst" ]]; then
cat >"$src" <<'EOF_PAYLOAD'
__PAYLOAD__
EOF_PAYLOAD
fi
if [[ "$enabled" = "1" ]]; then
# Aktiv: in monitrc.d haben
if [[ -f "$src" && ! -f "$dst" ]]; then
mv -f "$src" "$dst"
fi
else
# Inaktiv: in conf.d haben
if [[ -f "$dst" && ! -f "$src" ]]; then
mv -f "$dst" "$src"
fi
fi
}
move_monit_conf "55-opendmarc" "${OPENDMARC_ENABLE:-0}" <<'EOF'
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 5 cycles then alert
EOF
move_monit_conf "60-clamav" "${CLAMAV_ENABLE:-0}" <<'EOF'
check process clamd matching "clamd"
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 10 cycles then unmonitor
EOF
move_monit_conf "70-fail2ban" "${FAIL2BAN_ENABLE:-0}" <<'EOF'
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 5 cycles then alert
EOF
# ── Monit neu laden ─────────────────────────────────────────────────────────
monit -t
systemctl reload monit || systemctl restart monit
# Optionaler Sichttest (CLI funktioniert auch ohne HTTP-UI)
# sleep 2
# monit summary || true
##!/usr/bin/env bash
#set -euo pipefail
#
## Flags laden (falls vorhanden)
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#: "${CLAMAV_ENABLE:=}"; : "${OPENDMARC_ENABLE:=}"; : "${FAIL2BAN_ENABLE:=}"; : "${MONIT_HTTP:=}"
#if [[ -z "${CLAMAV_ENABLE}${OPENDMARC_ENABLE}${FAIL2BAN_ENABLE}" && -r "$INSTALLER_ENV" ]]; then
# . "$INSTALLER_ENV"
#fi
#CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
#OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
#FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#MONIT_HTTP="${MONIT_HTTP:-1}"
#
## ── Monit so konfigurieren, dass NUR monitrc.d/* geladen wird ────────────────
#install -d -m 0755 /etc/monit/monitrc.d
## Poll-Intervall (30s)
#sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
## alle alten include-Zeilen raus und monitrc.d setzen
#sed -i 's|^#\?\s*include .*$||g' /etc/monit/monitrc
#grep -q '^include /etc/monit/monitrc.d/\*' /etc/monit/monitrc \
# || echo 'include /etc/monit/monitrc.d/*' >> /etc/monit/monitrc
#
## Optional: HTTP-UI nur einschalten, wenn explizit gewünscht
#if [[ "$MONIT_HTTP" = "1" ]]; then
# grep -q '^set httpd port 2812' /etc/monit/monitrc || cat >>/etc/monit/monitrc <<'HTTP'
#set httpd port 2812 and
# use address localhost
# allow localhost
#HTTP
#fi
#
#sudo mkdir -p /etc/monit/monitrc.d
#sudo rm -rf /etc/monit/monitrc.d/* 2>/dev/null || true
#sudo rm -f /etc/monit/conf.d/*.conf 2>/dev/null || true
#
## ── Helper-Skripte ──────────────────────────────────────────────────────────
#install -d -m 0755 /usr/local/sbin
#
## Redis-Ping (Password: REDIS_PASSWORD aus installer.env oder .env)
#cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#APP_ENV="/var/www/mailwolt/.env"
#REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
#REDIS_PORT="${REDIS_PORT:-6379}"
#REDIS_PASSWORD="${REDIS_PASSWORD:-}"
#REDIS_PASS="${REDIS_PASS:-}"
#
#[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV" || true
#if [[ -r "$APP_ENV" ]]; then
# [[ -z "${REDIS_HOST}" ]] && REDIS_HOST="$(grep -m1 '^REDIS_HOST=' "$APP_ENV" | cut -d= -f2- || true)"
# [[ -z "${REDIS_PORT}" ]] && REDIS_PORT="$(grep -m1 '^REDIS_PORT=' "$APP_ENV" | cut -d= -f2- || true)"
# [[ -z "${REDIS_PASSWORD}" ]] && REDIS_PASSWORD="$(grep -m1 '^REDIS_PASSWORD=' "$APP_ENV" | cut -d= -f2- || true)"
#fi
#[[ -z "${REDIS_PASSWORD}" && -n "${REDIS_PASS}" ]] && REDIS_PASSWORD="$REDIS_PASS"
#
#strip(){ printf '%s' "$1" | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'; }
#REDIS_HOST="$(strip "${REDIS_HOST:-}")"
#REDIS_PORT="$(strip "${REDIS_PORT:-}")"
#REDIS_PASSWORD="$(strip "${REDIS_PASSWORD:-}")"
#
#command -v redis-cli >/dev/null 2>&1 || exit 1
#BASE=(timeout 2 redis-cli --no-auth-warning --raw -h "$REDIS_HOST" -p "$REDIS_PORT")
#[[ -n "$REDIS_PASSWORD" ]] && CMD=("${BASE[@]}" -a "$REDIS_PASSWORD" ping) || CMD=("${BASE[@]}" ping)
#[[ "$("${CMD[@]}" 2>/dev/null || true)" == "PONG" ]]
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
#
## Rspamd-Heal (Socke aufräumen, restart, Mini-Port-Check)
#cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#APP_ENV="/var/www/mailwolt/.env"
#
#REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
#REDIS_PORT="${REDIS_PORT:-6379}"
#REDIS_PASS="${REDIS_PASS:-}"
#
#[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV"
#if [[ -z "${REDIS_PASS}" && -r "$APP_ENV" ]]; then
# REDIS_PASS="$(grep -E '^REDIS_PASS=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
#fi
#
## Rspamd Runtime fixen
#install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
#[[ -S /var/lib/rspamd/rspamd.sock ]] && rm -f /var/lib/rspamd/rspamd.sock || true
#
#echo "$(date '+%F %T') heal run" >> /var/log/rspamd-heal.log
#
## Neustart
#systemctl restart rspamd
#
## Mini-Healthcheck
#sleep 2
#ss -tln | grep -q ':11334' || echo "[WARN] Rspamd Controller Port 11334 nicht sichtbar"
#
#exit 0
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
#
## ── Monit-Checks (nummeriert) ───────────────────────────────────────────────
## 10 Redis
#cat >/etc/monit/monitrc.d/10-redis.conf <<'EOF'
#check process redis with pidfile /run/redis/redis-server.pid
# start program = "/bin/systemctl start redis-server"
# stop program = "/bin/systemctl stop redis-server"
# if failed host 127.0.0.1 port 6379 for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#
#check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
# if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
#EOF
#
## 20 Rspamd (robust via process-matching + Heal)
#cat >/etc/monit/monitrc.d/20-rspamd.conf <<'EOF'
#check process rspamd matching "rspamd: main process"
# start program = "/bin/systemctl start rspamd" with timeout 120 seconds
# stop program = "/bin/systemctl stop rspamd"
# depends on redis
# if failed host 127.0.0.1 port 11333 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if failed host 127.0.0.1 port 11334 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if does not exist for 2 cycles then restart
# if 5 restarts within 10 cycles then unmonitor
#EOF
#
## 30 Postfix
#cat >/etc/monit/monitrc.d/30-postfix.conf <<'EOF'
#check process postfix with pidfile /var/spool/postfix/pid/master.pid
# start program = "/bin/systemctl start postfix"
# stop program = "/bin/systemctl stop postfix"
# if failed host 127.0.0.1 port 25 type tcp with timeout 15 seconds for 3 cycles then restart
# if failed host 127.0.0.1 port 465 type tcpssl with timeout 10 seconds then restart
# if failed host 127.0.0.1 port 587 type tcp with timeout 10 seconds then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## 30 Dovecot (IMAPS; LMTP oft Unix-Socket → kein TCP-Fehlalarm)
#cat >/etc/monit/monitrc.d/30-dovecot.conf <<'EOF'
#check process dovecot with pidfile /run/dovecot/master.pid
# start program = "/bin/systemctl start dovecot"
# stop program = "/bin/systemctl stop dovecot"
# if failed port 993 type tcpssl for 3 cycles then restart
# if 5 restarts within 10 cycles then alert
#EOF
#
## 40 Nginx
#cat >/etc/monit/monitrc.d/40-nginx.conf <<'EOF'
#check process nginx with pidfile /run/nginx.pid
# start program = "/bin/systemctl start nginx"
# stop program = "/bin/systemctl stop nginx"
# if failed port 80 type tcp then restart
# if failed port 443 type tcpssl then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## 50 OpenDKIM
#cat >/etc/monit/monitrc.d/50-opendkim.conf <<'EOF'
#check process opendkim with pidfile /run/opendkim/opendkim.pid
# start program = "/bin/systemctl start opendkim"
# stop program = "/bin/systemctl stop opendkim"
# if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## 55 OpenDMARC (optional)
#if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
# cat >/etc/monit/monitrc.d/55-opendmarc.conf <<'EOF'
#check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
# start program = "/bin/systemctl start opendmarc"
# stop program = "/bin/systemctl stop opendmarc"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/monitrc.d/55-opendmarc.conf || true
#fi
#
## 60 ClamAV (über Socket)
#if [[ "$CLAMAV_ENABLE" = "1" ]]; then
# cat >/etc/monit/monitrc.d/60-clamav.conf <<'EOF'
#check process clamd matching "clamd"
# start program = "/bin/systemctl start clamav-daemon"
# stop program = "/bin/systemctl stop clamav-daemon"
# if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
# if 5 restarts within 10 cycles then unmonitor
#EOF
#else
# rm -f /etc/monit/monitrc.d/60-clamav.conf || true
#fi
#
## 70 Fail2Ban (optional)
#if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
# cat >/etc/monit/monitrc.d/70-fail2ban.conf <<'EOF'
#check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
# start program = "/bin/systemctl start fail2ban"
# stop program = "/bin/systemctl stop fail2ban"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/monitrc.d/70-fail2ban.conf || true
#fi
#
## ── Monit neu laden ─────────────────────────────────────────────────────────
#monit -t
#systemctl reload monit || systemctl restart monit
#
## Optionaler Sichttest (CLI funktioniert auch ohne HTTP-UI)
##sleep 2
##monit summary || true

View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "MOTD installieren …"
install -d /usr/local/bin
cat >/usr/local/bin/mw-motd <<'SH'
#!/usr/bin/env bash
# MOTD MailWolt
# bewusst KEIN "set -e" und KEIN pipefail; MOTD darf nie hart abbrechen
set -u
# ---------- Farben ----------
NC="\033[0m"; WH="\033[1;37m"; CY="\033[1;36m"; GY="\033[0;90m"
GR="\033[1;32m"; YE="\033[1;33m"; RD="\033[1;31m"
# ---------- Breite / Zentrierung ----------
W=110
term_cols=$(tput cols 2>/dev/null || echo $W)
[ "$term_cols" -gt "$W" ] && pad=$(( (term_cols - W)/2 )) || pad=0
sp(){ [ "$1" -gt 0 ] && printf "%${1}s" " " || true; }
center() { local s="$1"; local n=$(( (W - ${#s})/2 )); sp $((pad+n)); printf "%s\n" "$s"; }
rule(){ sp "$pad"; printf "%0.s=" $(seq 1 "$W"); printf "\n"; }
title(){ sp "$pad"; local t="$1"; local lf=$(( (W - ${#t} - 2)/2 )); local rf=$(( W - ${#t} - 2 - lf )); \
printf "%s" "$(printf '─%.0s' $(seq 1 $lf))"; printf " %s " "$t"; printf "%s\n" "$(printf '─%.0s' $(seq 1 $rf))"; }
kv(){ sp "$pad"; printf "%-12s: %s\n" "$1" "$2"; }
# ---------- Installer-/App-Variablen ----------
UI_HOST=""; WEBMAIL_HOST=""; MAIL_HOSTNAME=""
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env || true
# ---------- Systemdaten ----------
now="$(date '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || echo '-')"
upt="$(uptime -p 2>/dev/null || echo '-')"
cores="$(nproc 2>/dev/null || echo 1)"
load_raw="$(awk '{printf "%s / %s / %s",$1,$2,$3}' /proc/loadavg 2>/dev/null || echo '0.00 / 0.00 / 0.00')"
load1="$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo 0)"
# RAM/SWAP
mem_total="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
mem_avail="$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
mem_used=$(( mem_total - mem_avail ))
swap_total="$(awk '/SwapTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
swap_free="$(awk '/SwapFree/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
swap_used=$(( swap_total - swap_free ))
pct(){ local u="$1" t="$2"; [ "$t" -gt 0 ] || { echo 0; return; }; awk -v u="$u" -v t="$t" 'BEGIN{printf "%d",(u*100)/t}' ; }
ram_pct=$(pct "$mem_used" "$mem_total")
swap_pct=$(pct "$swap_used" "$swap_total")
# Disks
df_line(){ df -hP "$1" 2>/dev/null | awk 'NR==2{printf "%s / %s (%s)",$3,$2,$5}'; }
df_pct(){ df -P "$1" 2>/dev/null | awk 'NR==2{gsub("%","",$5);print $5+0}'; }
disk_root="$(df_line /)"; pct_root="$(df_pct /)"
disk_var="$(df_line /var 2>/dev/null)"; [ -n "$disk_var" ] || disk_var="-"
pct_var="$(df_pct /var 2>/dev/null)"; [ -n "$pct_var" ] || pct_var=0
# IPs (int/ext)
ipv4_int="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i!~/:/){print $i;exit}}')"
ipv6_int="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i~/:/){print $i;exit}}')"
ipv4_ext="$(curl -4fsS --max-time 1 https://ifconfig.me 2>/dev/null || true)"
ipv6_ext="$(curl -6fsS --max-time 1 https://ifconfig.me 2>/dev/null || true)"
# ---------- Status-Farben ----------
mark(){ # value thresholdY thresholdR
local v="$1" y="$2" r="$3"
if [ "$v" -ge "$r" ]; then printf "${RD}[HIGH]${NC}"
elif [ "$v" -ge "$y" ]; then printf "${YE}[WARN]${NC}"
else printf "${GR}[OK]${NC}"
fi
}
# Load/CPU-Schwellen (pro Core)
load_pct=$(awk -v l="$load1" -v c="$cores" 'BEGIN{if(c<1)c=1; printf "%d", (l/c)*100}')
m_load="$(mark "$load_pct" 70 100)"
m_ram="$(mark "$ram_pct" 75 90)"
m_swap="$(mark "$swap_pct" 10 50)"
m_root="$(mark "$pct_root" 75 90)"
m_var="$(mark "$pct_var" 75 90)"
# ---------- Header ----------
rule
center ""
center ":::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::"
center ":+:+:+ :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: "
center ":+: +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ "
center "+#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+ "
center "+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+ "
center "#+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+# "
center "### ### ### ### ########### ########## ### ### ######## ########## ### "
center ""
rule
# ---------- System ----------
kv "Date / Time" "${YE}${now}${NC}"
sp "$pad"; printf "%-12s: int %-40s ext %s\n" "IPv4" "${ipv4_int:--}" "${ipv4_ext:--}"
sp "$pad"; printf "%-12s: int %-40s ext %s\n" "IPv6" "${ipv6_int:--}" "${ipv6_ext:--}"
kv "Uptime" "$upt"
sp "$pad"; printf "%-12s: %s cores, load %s %b\n" "CPU" "$cores" "$load_raw" "$m_load"
sp "$pad"; printf "%-12s: %s MiB / %s MiB (%d%%) %b %-5s %s MiB / %s MiB (%d%%) %b\n" \
"RAM" "$mem_used" "$mem_total" "$ram_pct" "$m_ram" "SWAP:" "$swap_used" "$swap_total" "$swap_pct" "$m_swap"
sp "$pad"; printf "%-12s: / %s %b %-5s %s %b\n" \
"Disk" "$disk_root" "$m_root" "/var:" "$disk_var" "$m_var"
echo
# ---------- Domains ----------
title "Domains"
[ -n "${UI_HOST:-}" ] && kv "UI" "${UI_HOST}"
[ -n "${WEBMAIL_HOST:-}" ] && kv "Webmail" "${WEBMAIL_HOST}"
[ -n "${MAIL_HOSTNAME:-}" ]&& kv "MX" "${MAIL_HOSTNAME}"
echo
# ---------- Services (4 Spalten, bündig) ----------
title "Services"
svc_state(){ systemctl is-active --quiet "$1" && printf "${GR}[OK]${NC}" || printf "${RD}[FAIL]${NC}"; }
SVC=( nginx mariadb redis-server postfix dovecot rspamd opendkim opendmarc clamav-daemon fail2ban mailwolt-ws mailwolt-queue mailwolt-schedule )
i=0; line=""
for s in "${SVC[@]}"; do
st="$(svc_state "$s")"
seg="$(printf "%-18s %-7s" "$s" "$st")"
line="$line$seg"
i=$((i+1))
if [ $((i%4)) -eq 0 ]; then sp "$pad"; echo "$line"; line=""; else line="$line "; fi
done
[ -n "$line" ] && { sp "$pad"; echo "$line"; }
echo
exit 0
SH
chmod 755 /usr/local/bin/mw-motd
# update-motd Hook
if [[ -d /etc/update-motd.d ]]; then
cat >/etc/update-motd.d/10-mailwolt <<'SH'
#!/usr/bin/env bash
/usr/local/bin/mw-motd
SH
chmod +x /etc/update-motd.d/10-mailwolt
[[ -f /etc/update-motd.d/50-motd-news ]] && chmod -x /etc/update-motd.d/50-motd-news || true
else
# Fallback für Systeme ohne dynamic MOTD
cat >/etc/profile.d/10-mailwolt-motd.sh <<'SH'
case "$-" in *i*) /usr/local/bin/mw-motd ;; esac
SH
fi
: > /etc/motd 2>/dev/null || true
log "[✓] MOTD installiert."

View File

@ -0,0 +1,212 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# ─────────────────────────────────────────────────────────────
# MailWolt Abschluss / Summary (Dienste, Zertifikate, Smoke-Test)
# ─────────────────────────────────────────────────────────────
# Farben & Deko
NC="\033[0m"; BOLD="\033[1m"; DIM="\033[2m"
GREEN="\033[1;32m"; RED="\033[1;31m"; YELLOW="\033[1;33m"; CYAN="\033[1;36m"; GREY="\033[0;90m"
OKS="${GREEN}OK${NC}"; FAILS="${RED}FAIL${NC}"
bar(){ printf "${CYAN}%s${NC}\n" "──────────────────────────────────────────────────────────────────────────────"; }
ok(){ printf " [${OKS}]\n"; }
fail(){ printf " [${FAILS}]\n"; }
# Installer-Variablen laden (falls vorhanden)
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
# Defaults / Umgebung
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
UI_HOST="${UI_HOST:-}"
WEBMAIL_HOST="${WEBMAIL_HOST:-}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
APP_ENV="${APP_ENV:-production}"
PROXY_MODE="${PROXY_MODE:-}" # leer = nicht anzeigen; "1"=Proxy, "dev"=Dev, sonst "nein"
NPM_IP="${NPM_IP:-}"
LE_EMAIL="${LE_EMAIL:-admin@${BASE_DOMAIN}}"
ACME_WEBROOT="/var/www/letsencrypt"
# Zert-Pfade (werden via Hook nach /etc/ssl/* verlinkt)
UI_CERT="/etc/ssl/ui/fullchain.pem"
UI_KEY="/etc/ssl/ui/privkey.pem"
WEBMAIL_CERT="/etc/ssl/webmail/fullchain.pem"
MAIL_CERT="/etc/ssl/mail/fullchain.pem"
# IPs (aus lib.sh)
SERVER_PUBLIC_IPV4="${SERVER_PUBLIC_IPV4:-$(detect_ip)}"
SERVER_PUBLIC_IPV6="${SERVER_PUBLIC_IPV6:-$(detect_ipv6)}"
# URLs (https nur, wenn UI-Cert+Key vorhanden)
SCHEME="http"
[[ -s "$UI_CERT" && -s "$UI_KEY" ]] && SCHEME="https"
APP_URL="${SCHEME}://${UI_HOST:-$SERVER_PUBLIC_IPV4}"
WEBMAIL_URL="${SCHEME}://${WEBMAIL_HOST:-$SERVER_PUBLIC_IPV4}"
# Ziel eines Symlinks auflösen
real_target(){ readlink -f -- "$1" 2>/dev/null || true; }
# "LE" werten, wenn live/* ODER archive/* (auch fullchainN.pem) getroffen wird
is_le_path(){
local p="$1"
[[ "$p" == /etc/letsencrypt/live/*/fullchain.pem || "$p" == /etc/letsencrypt/archive/*/fullchain*.pem ]]
}
UI_CERT_TARGET="$(real_target "$UI_CERT")"
WEBMAIL_CERT_TARGET="$(real_target "$WEBMAIL_CERT")"
MAIL_CERT_TARGET="$(real_target "$MAIL_CERT")"
is_le_path() {
case "$1" in
/etc/letsencrypt/live/*) return 0 ;;
*) return 1 ;;
esac
}
# robust gegen set -u: immer ${var:-}
UI_LE="self-signed/none"
if [ -s "${UI_CERT:-}" ] && [ -n "${UI_CERT_TARGET:-}" ] && is_le_path "${UI_CERT_TARGET:-}"; then
UI_LE="LE"
fi
WEBMAIL_LE="self-signed/none"
if [ -s "${WEBMAIL_CERT:-}" ] && [ -n "${WEBMAIL_CERT_TARGET:-}" ] && is_le_path "${WEBMAIL_CERT_TARGET:-}"; then
WEBMAIL_LE="LE"
fi
MAIL_LE="self-signed/none"
if [ -s "${MAIL_CERT:-}" ] && [ -n "${MAIL_CERT_TARGET:-}" ] && is_le_path "${MAIL_CERT_TARGET:-}"; then
MAIL_LE="LE"
fi
echo
bar
printf " %s\n" "✔ MailWolt Bootstrap fertig"
bar
# Kopf-Infos
printf " %-14s %s\n" "Aufruf UI:" "${APP_URL}"
printf " %-14s %s\n" "Webmail:" "${WEBMAIL_URL}"
printf " %-14s %s\n" "App Root:" "${APP_DIR}"
printf " %-14s %s\n" "Mail-FQDN:" "${MAIL_HOSTNAME:-$SERVER_PUBLIC_IPV4}"
printf " %-14s %s\n" "BASE_DOMAIN:" "${BASE_DOMAIN}"
printf " %-14s %s\n" "LE-Email:" "${LE_EMAIL}"
printf " %-14s %s\n" "APP_ENV:" "${APP_ENV}"
# Proxy-Block nur anzeigen, wenn Variable gesetzt ist
if [[ -n "$PROXY_MODE" ]]; then
if [[ "$PROXY_MODE" == "1" ]]; then
printf " %-14s %s\n" "Proxy-Mode:" "ja (NPM: ${NPM_IP:-unbekannt})"
elif [[ "$PROXY_MODE" == "dev" ]]; then
printf " %-14s %s\n" "Proxy-Mode:" "Entwicklungsmodus"
else
printf " %-14s %s\n" "Proxy-Mode:" "nein"
fi
fi
printf " %-14s %s\n" "Server IPv6:" "${SERVER_PUBLIC_IPV6:-}"
printf " %-14s %s\n" "ACME Webroot:" "${ACME_WEBROOT}"
echo
printf " %-14s UI=%s, Webmail=%s, MX=%s\n" "Zertifikate:" "$UI_LE" "$WEBMAIL_LE" "$MAIL_LE"
echo
echo " Anmeldung: Keine vordefinierten Admin-Daten."
echo " Bitte zuerst registrieren (Erst-User wird Admin, danach"
echo " wird die Registrierung automatisch gesperrt)."
echo
# ── Dienste ────────────────────────────────────────────────────────────────
bar
echo " Services"
bar
OK_LIST=()
FAIL_LIST=()
svc(){
local unit="$1" label="${2:-$1}"
printf " • %-18s … " "$label"
if systemctl is-active --quiet "$unit"; then
ok
OK_LIST+=("$label")
else
fail
FAIL_LIST+=("$label")
fi
}
# Kern-Services
svc nginx
svc mariadb
svc redis-server
svc postfix
svc dovecot
# App-Worker (tolerant)
svc "${APP_USER}-ws" "mailwolt-ws" || true
svc "${APP_USER}-schedule" "mailwolt-schedule" || true
svc "${APP_USER}-queue" "mailwolt-queue" || true
echo
if ((${#OK_LIST[@]})); then
printf " ${GREEN}OK:${NC} %s\n" "$(IFS=', '; echo "${OK_LIST[*]}")"
fi
if ((${#FAIL_LIST[@]})); then
printf " ${RED}FAIL:${NC} %s\n" "$(IFS=', '; echo "${FAIL_LIST[*]}")"
echo " ${YELLOW}Hinweis:${NC} Details mit: journalctl -u <dienst> -b --no-pager"
fi
echo
# ── Smoke-Test ─────────────────────────────────────────────────────────────
bar
echo " Smoke-Test (SMTP/IMAP/POP3 mit/ohne TLS)"
bar
check_port(){
local tag="$1" cmd="$2" desc="$3"
printf " [%-3s] %-35s … " "$tag" "$desc"
if timeout 8s bash -lc "$cmd" >/dev/null 2>&1; then ok; else fail; fi
}
# kleines Delay nach Erststart
sleep 6 || true
# SMTP
check_port "25" 'printf "EHLO x\r\nQUIT\r\n" | nc -w 3 127.0.0.1 25' \
"SMTP (EHLO)"
check_port "465" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:465 -quiet -ign_eof' \
"SMTPS (TLS + EHLO)"
check_port "587" 'printf "EHLO x\r\nSTARTTLS\r\nQUIT\r\n" | openssl s_client -starttls smtp -connect 127.0.0.1:587 -quiet -ign_eof' \
"Submission (STARTTLS)"
# POP/IMAP
check_port "110" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 110' \
"POP3 (QUIT)"
check_port "995" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:995 -quiet -ign_eof' \
"POP3S (TLS + QUIT)"
check_port "143" 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | nc -w 3 127.0.0.1 143' \
"IMAP (CAPABILITY/LOGOUT)"
check_port "993" 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | openssl s_client -connect 127.0.0.1:993 -quiet -ign_eof' \
"IMAPS (TLS + CAPABILITY/LOGOUT)"
echo
# Hinweise nur ausgeben, wenn wirklich kein LE für UI/Webmail
if [[ "$UI_LE" != "LE" || "$WEBMAIL_LE" != "LE" ]]; then
echo -e " ${YELLOW}Hinweis:${NC} UI/Webmail verwenden noch kein Let's-Encrypt-Zertifikat."
echo -e " Prüfe Symlinks unter /etc/ssl/{ui,webmail} und den LE-Hook (21/75-Skripte)."
echo
fi
# Proxy-Info (optional)
if [[ "$PROXY_MODE" == "1" ]]; then
echo -e " ${GREY}Proxy-Hinweis:${NC} App erwartet TLS am Proxy (Backend ohne https-Redirects)."
echo
fi

View File

@ -0,0 +1,633 @@
#!/usr/bin/env bash
set -euo pipefail
# --- Farbschema für whiptail (libnewt) hohe Lesbarkeit (dunkler Input, schwarze Schrift) ---
export NEWT_COLORS='
root=,blue
border=black,lightgray
window=black,lightgray
textbox=black,lightgray
label=black,lightgray
entry=black,cyan
button=black,cyan
actlistbox=black,cyan
actsellistbox=black,cyan
'
# optionales Backtitle (erscheint oben)
export DIALOGOPTS="--backtitle MailWolt Setup"
# ──────────────────────────────────────────────────────────────
# MailWolt Interaktiver Bootstrap (whiptail + Fallback)
# ──────────────────────────────────────────────────────────────
DEV_MODE=0
PROXY_MODE=0
NPM_IP=""
while [[ $# -gt 0 ]]; do
case "$1" in
-dev) DEV_MODE=1 ;;
-proxy) PROXY_MODE=1; NPM_IP="${2:-}"; shift ;;
esac
shift
done
APP_ENV="${APP_ENV:-$([[ $DEV_MODE -eq 1 ]] && echo local || echo production)}"
APP_DEBUG="${APP_DEBUG:-$([[ $DEV_MODE -eq 1 ]] && echo true || echo false)}"
export DEV_MODE PROXY_MODE NPM_IP APP_ENV APP_DEBUG
DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
export DB_PASS REDIS_PASS
cd "$(dirname "$0")"
source ./lib.sh
require_root
header
# ── Defaults ──────────────────────────────────────────────────
APP_NAME="${APP_NAME:-MailWolt}"
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
UI_SUB="${UI_SUB:-ui}"
WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
MTA_SUB="${MTA_SUB:-mx}"
DB_NAME="${DB_NAME:-${APP_USER}}"
DB_USER="${DB_USER:-${APP_USER}}"
SERVER_PUBLIC_IPV4="$(detect_ip)"
SERVER_PUBLIC_IPV6="$(detect_ipv6)"
DEFAULT_TZ="$(detect_timezone)"
DEFAULT_LOCALE="$(guess_locale_from_tz "$DEFAULT_TZ")"
echo -e "${GREY}Erkannte IP (v4): ${SERVER_PUBLIC_IPV4} v6: ${SERVER_PUBLIC_IPV6:-}${NC}"
# ── Helpers ───────────────────────────────────────────────────
have_whiptail(){ command -v whiptail >/dev/null 2>&1; }
#valid_fqdn(){
# [[ "$1" =~ ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$ ]]
#}
# ── Host-Validierung & DEV-Erkennung ────────────────────────────────────────
valid_fqdn_prod(){ [[ "$1" =~ ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$ ]]; }
valid_host_dev(){
# erlaubt: single-label (ui, webmail), FQDNs, IPv4
[[ "$1" =~ ^([a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9-]+)*$ ]] || [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]
}
is_local_like(){
local h="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
[[ "$h" =~ \.local$ || "$h" =~ \.loc$ || "$h" =~ \.dev$ || "$h" =~ \.test$ || "$h" = "localhost" ]] && return 0
[[ "$h" =~ ^10\. || "$h" =~ ^192\.168\. || "$h" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. || "$h" =~ ^127\. ]] && return 0
return 1
}
normalize_host(){
# $1=input $2=default (nutzt DEV_MODE für die passende Prüflogik)
local inp="$1" def="$2"
if [[ "${DEV_MODE}" = "1" ]]; then
valid_host_dev "$inp" && { echo "$inp"; return; }
else
valid_fqdn_prod "$inp" && { echo "$inp"; return; }
fi
echo "$def"
}
ask_tty_domain(){
local label="$1" example="$2" def="$3" outvar="$4" inp
echo -e "${CYAN}${label}${NC}"
echo -e " z.B. ${YELLOW}${example}${NC}"
echo -e " Default: ${GREY}${def}${NC}"
read -r -p " Eingabe (Enter=Default): " inp || true
inp="${inp:-$def}"
if ! valid_fqdn "$inp"; then
echo -e "${YELLOW}[!] Ungültiger FQDN, nehme Default: ${def}${NC}"
inp="$def"
fi
eval "$outvar='$inp'"
}
# ── Interaktive Eingaben (whiptail oder Fallback) ─────────────
MTA_DEFAULT="${MTA_SUB}.${BASE_DOMAIN}"
UI_DEFAULT="${UI_SUB}.${BASE_DOMAIN}"
WEBMAIL_DEFAULT="${WEBMAIL_SUB}.${BASE_DOMAIN}"
CLAMAV_ENABLE=1
OPENDMARC_ENABLE=1
FAIL2BAN_ENABLE=1
if command -v whiptail >/dev/null 2>&1; then
TITLE="MailWolt Setup"
# Hinweise zu erlaubten DEV-Hosts
MSG_SUFFIX="\n\nHinweis: Im DEV-Modus sind auch single-label Hosts (z.B. ui, webmail), *.local/*.dev und IPs erlaubt."
_mta_in="$(whiptail --title "$TITLE" --inputbox "Mailserver-Host (MX)\nBeispiele: mx.domain.tld | mx.local | 10.0.0.10${MSG_SUFFIX}" 13 70 "$MTA_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
_ui_in="$(whiptail --title "$TITLE" --inputbox "UI / Admin-Panel Host\nBeispiele: ui.domain.tld | ui.local | 10.0.0.10${MSG_SUFFIX}" 13 70 "$UI_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
_wm_in="$(whiptail --title "$TITLE" --inputbox "Webmail Host\nBeispiele: webmail.domain.tld | web.local | 10.0.0.10${MSG_SUFFIX}" 13 70 "$WEBMAIL_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# ZUERST provisorisch prüfen, ob „lokal“ → DEV erzwingen
if is_local_like "$_mta_in" || is_local_like "$_ui_in" || is_local_like "$_wm_in"; then
DEV_MODE=1; APP_ENV="local"; APP_DEBUG="true"
fi
export DEV_MODE APP_ENV APP_DEBUG
# Jetzt mit passender Logik normalisieren
MTA_FQDN="$(normalize_host "$_mta_in" "$MTA_DEFAULT")"
UI_FQDN="$(normalize_host "$_ui_in" "$UI_DEFAULT")"
WEBMAIL_FQDN="$(normalize_host "$_wm_in" "$WEBMAIL_DEFAULT")"
CHOICES="$(whiptail --title "$TITLE" --checklist "Optionale Dienste aktivieren" 15 70 6 \
"ClamAV" "Virenscan (clamd/clamav-daemon)" ON \
"OpenDMARC" "DMARC-Auswertung" ON \
"Fail2Ban" "Brute-Force-Schutz" ON \
3>&1 1>&2 2>&3)" || true
CLAMAV_ENABLE=0; [[ "$CHOICES" == *"ClamAV"* ]] && CLAMAV_ENABLE=1
OPENDMARC_ENABLE=0; [[ "$CHOICES" == *"OpenDMARC"* ]] && OPENDMARC_ENABLE=1
FAIL2BAN_ENABLE=0; [[ "$CHOICES" == *"Fail2Ban"* ]] && FAIL2BAN_ENABLE=1
else
echo -e "${GREY}[i] whiptail nicht gefunden TTY-Fallback.${NC}\n"
read -r -p "Mailserver-Host (MX) [${MTA_DEFAULT}]: " _mta_in; _mta_in="${_mta_in:-$MTA_DEFAULT}"
read -r -p "UI / Admin-Panel Host [${UI_DEFAULT}]: " _ui_in; _ui_in="${_ui_in:-$UI_DEFAULT}"
read -r -p "Webmail Host [${WEBMAIL_DEFAULT}]: " _wm_in; _wm_in="${_wm_in:-$WEBMAIL_DEFAULT}"
if is_local_like "$_mta_in" || is_local_like "$_ui_in" || is_local_like "$_wm_in"; then
DEV_MODE=1; APP_ENV="local"; APP_DEBUG="true"
fi
export DEV_MODE APP_ENV APP_DEBUG
MTA_FQDN="$(normalize_host "$_mta_in" "$MTA_DEFAULT")"
UI_FQDN="$(normalize_host "$_ui_in" "$UI_DEFAULT")"
WEBMAIL_FQDN="$(normalize_host "$_wm_in" "$WEBMAIL_DEFAULT")"
read -r -p "ClamAV aktivieren? (1/0, Enter=1): " CLAMAV_ENABLE; CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
read -r -p "OpenDMARC aktivieren? (1/0, Enter=1): " OPENDMARC_ENABLE; OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
read -r -p "Fail2Ban aktivieren? (1/0, Enter=1): " FAIL2BAN_ENABLE; FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
fi
#if have_whiptail; then
# TITLE="MailWolt Setup"
#
# MTA_FQDN="$(whiptail --title "$TITLE" --inputbox "Mailserver-FQDN (MX)\nBeispiel: mx.domain.tld" 11 70 "$MTA_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# valid_fqdn "$MTA_FQDN" || MTA_FQDN="$MTA_DEFAULT"
#
# UI_FQDN="$(whiptail --title "$TITLE" --inputbox "UI / Admin-Panel FQDN\nBeispiel: ui.domain.tld" 11 70 "$UI_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# valid_fqdn "$UI_FQDN" || UI_FQDN="$UI_DEFAULT"
#
# WEBMAIL_FQDN="$(whiptail --title "$TITLE" --inputbox "Webmail FQDN\nBeispiel: webmail.domain.tld" 11 70 "$WEBMAIL_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# valid_fqdn "$WEBMAIL_FQDN" || WEBMAIL_FQDN="$WEBMAIL_DEFAULT"
#
# CHOICES="$(whiptail --title "$TITLE" --checklist "Optionale Dienste aktivieren" 15 70 6 \
# "ClamAV" "Virenscan (clamd/clamav-daemon)" ON \
# "OpenDMARC" "DMARC-Auswertung" ON \
# "Fail2Ban" "Brute-Force-Schutz" ON \
# 3>&1 1>&2 2>&3)" || true
#
# CLAMAV_ENABLE=0; [[ "$CHOICES" == *"ClamAV"* ]] && CLAMAV_ENABLE=1
# OPENDMARC_ENABLE=0; [[ "$CHOICES" == *"OpenDMARC"* ]] && OPENDMARC_ENABLE=1
# FAIL2BAN_ENABLE=0; [[ "$CHOICES" == *"Fail2Ban"* ]] && FAIL2BAN_ENABLE=1
#
# whiptail --title "$TITLE" --msgbox "Zusammenfassung:
#
#MX : $MTA_FQDN
#UI : $UI_FQDN
#Webmail : $WEBMAIL_FQDN
#
#ClamAV : $([[ $CLAMAV_ENABLE -eq 1 ]] && echo Aktiv || echo Deaktiv)
#OpenDMARC : $([[ $OPENDMARC_ENABLE -eq 1 ]] && echo Aktiv || echo Deaktiv)
#Fail2Ban : $([[ $FAIL2BAN_ENABLE -eq 1 ]] && echo Aktiv || echo Deaktiv)
#" 16 70
#
#else
# echo -e "${GREY}[i] whiptail nicht gefunden nutze TTY-Prompts.${NC}\n"
# ask_tty_domain "Mailserver-FQDN (MX)" "mx.domain.tld" "$MTA_DEFAULT" MTA_FQDN
# ask_tty_domain "UI / Admin-Panel FQDN" "ui.domain.tld" "$UI_DEFAULT" UI_FQDN
# ask_tty_domain "Webmail FQDN" "webmail.domain.tld" "$WEBMAIL_DEFAULT" WEBMAIL_FQDN
#
# read -r -p "ClamAV aktivieren? (1/0, Enter=1): " CLAMAV_ENABLE; CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
# read -r -p "OpenDMARC aktivieren? (1/0, Enter=1): " OPENDMARC_ENABLE; OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
# read -r -p "Fail2Ban aktivieren? (1/0, Enter=1): " FAIL2BAN_ENABLE; FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#fi
# ── Defaults/Kompatibilität ──────────────────────────────────
MTA_FQDN="${MTA_FQDN:-${MTA_DEFAULT}}"
UI_FQDN="${UI_FQDN:-${UI_DEFAULT}}"
WEBMAIL_FQDN="${WEBMAIL_FQDN:-${WEBMAIL_DEFAULT}}"
DKIM_ENABLE="${DKIM_ENABLE:-1}"
DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
DKIM_GENERATE="${DKIM_GENERATE:-1}"
# BASE_DOMAIN/Subs aus FQDNs ableiten
if [[ "$MTA_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then MTA_SUB="${BASH_REMATCH[1]}"; BASE_DOMAIN="${BASH_REMATCH[2]}"; fi
if [[ "$UI_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then UI_SUB="${BASH_REMATCH[1]}"; fi
if [[ "$WEBMAIL_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then WEBMAIL_SUB="${BASH_REMATCH[1]}"; fi
SYSMAIL_SUB="${SYSMAIL_SUB:-sysmail}"
SYSMAIL_DOMAIN="${SYSMAIL_SUB}.${BASE_DOMAIN}"
MAIL_HOSTNAME="${MTA_FQDN}"
UI_HOST="${UI_FQDN}"
WEBMAIL_HOST="${WEBMAIL_FQDN}"
APP_TZ="${APP_TZ:-$DEFAULT_TZ}"
APP_LOCALE="${APP_LOCALE:-$DEFAULT_LOCALE}"
# ── Export & persist ─────────────────────────────────────────
export APP_NAME APP_USER APP_GROUP APP_USER_PREFIX APP_DIR
export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB
export SYSMAIL_SUB SYSMAIL_DOMAIN DKIM_ENABLE DKIM_SELECTOR DKIM_GENERATE
export UI_HOST WEBMAIL_HOST MAIL_HOSTNAME
export DB_NAME DB_USER
export SERVER_PUBLIC_IPV4 SERVER_PUBLIC_IPV6 APP_TZ APP_LOCALE
export CLAMAV_ENABLE OPENDMARC_ENABLE FAIL2BAN_ENABLE
install -d -m 0755 /etc/mailwolt
cat >/etc/mailwolt/installer.env <<EOF
SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
APP_TZ=${APP_TZ}
APP_LOCALE=${APP_LOCALE}
BASE_DOMAIN=${BASE_DOMAIN}
MTA_SUB=${MTA_SUB}
UI_SUB=${UI_SUB}
WEBMAIL_SUB=${WEBMAIL_SUB}
MAIL_HOSTNAME=${MAIL_HOSTNAME}
UI_HOST=${UI_HOST}
WEBMAIL_HOST=${WEBMAIL_HOST}
LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
SYSMAIL_SUB=${SYSMAIL_SUB}
SYSMAIL_DOMAIN=${SYSMAIL_DOMAIN}
DKIM_ENABLE=${DKIM_ENABLE}
DKIM_SELECTOR=${DKIM_SELECTOR}
DKIM_GENERATE=${DKIM_GENERATE}
DB_HOST=127.0.0.1
DB_NAME=${DB_NAME}
DB_USER=${DB_USER}
DB_PASS=${DB_PASS}
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASS=${REDIS_PASS}
SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
APP_ENV=${APP_ENV}
CLAMAV_ENABLE=${CLAMAV_ENABLE}
OPENDMARC_ENABLE=${OPENDMARC_ENABLE}
FAIL2BAN_ENABLE=${FAIL2BAN_ENABLE}
BACKUP_ONCALENDAR="${BACKUP_ONCALENDAR:-*-*-* 03:00:00}"
BACKUP_ENABLED=0
BACKUP_INTERVAL=daily
BACKUP_RETENTION_DAYS=7
BACKUP_DIR=/var/backups/mailwolt
BACKUP_USE_ZSTD=1
EOF
chmod 600 /etc/mailwolt/installer.env
# ── Installer-Sequenz ────────────────────────────────────────
for STEP in \
10-provision \
20-ssl 21-le-deploy-hook 22-dkim-helper \
30-db 40-postfix 50-dovecot \
60-rspamd-opendkim 61-opendmarc 62-clamav 63-fail2ban 64-apply-milters \
70-nginx 75-le-issue 80-app 88-update-wrapper 90-services \
92-sudoers-npm 93-backup-tools 95-woltguard 98-motd 99-summary
do
log ">>> Running ${STEP}.sh"
bash "./${STEP}.sh"
done
##!/usr/bin/env bash
#set -euo pipefail
#
## --- Flags / Modi ---
#DEV_MODE=0
#PROXY_MODE=0
#NPM_IP=""
#
#while [[ $# -gt 0 ]]; do
# case "$1" in
# -dev) DEV_MODE=1 ;;
# -proxy) PROXY_MODE=1; NPM_IP="${2:-}"; shift ;;
# esac
# shift
#done
#
#APP_ENV="${APP_ENV:-$([[ $DEV_MODE -eq 1 ]] && echo local || echo production)}"
#APP_DEBUG="${APP_DEBUG:-$([[ $DEV_MODE -eq 1 ]] && echo true || echo false)}"
#export DEV_MODE PROXY_MODE NPM_IP APP_ENV APP_DEBUG
#
#DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
#REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
#
#export DB_PASS REDIS_PASS
#
#cd "$(dirname "$0")"
#source ./lib.sh
#require_root
#header
#
## ── Defaults ────────────────────────────────────────────────────────────────
#APP_NAME="${APP_NAME:-MailWolt}"
#APP_USER="${APP_USER:-mailwolt}"
#APP_GROUP="${APP_GROUP:-www-data}"
#APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
#APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
#
#BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
#UI_SUB="${UI_SUB:-ui}"
#WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
#MTA_SUB="${MTA_SUB:-mx}"
#
#DB_NAME="${DB_NAME:-${APP_USER}}"
#DB_USER="${DB_USER:-${APP_USER}}"
#
#SERVER_PUBLIC_IPV4="$(detect_ip)"
#SERVER_PUBLIC_IPV6="$(detect_ipv6)"
#DEFAULT_TZ="$(detect_timezone)"
#DEFAULT_LOCALE="$(guess_locale_from_tz "$DEFAULT_TZ")"
#
#echo -e "${GREY}Erkannte IP (v4): ${SERVER_PUBLIC_IPV4} v6: ${SERVER_PUBLIC_IPV6:-}${NC}"
#
## ── Schöne, farbige Abfragen ────────────────────────────────────────────────
#echo -e "${CYAN}"
#echo "──────────────────────────────────────────────"
#echo -e " 📧 MailWolt Setup Domain Konfiguration"
#echo "──────────────────────────────────────────────"
#echo -e "${NC}"
#
#MTA_DEFAULT="${MTA_SUB}.${BASE_DOMAIN}"
#UI_DEFAULT="${UI_SUB}.${BASE_DOMAIN}"
#WEBMAIL_DEFAULT="${WEBMAIL_SUB}.${BASE_DOMAIN}"
#
#ask_domain() {
# local __outvar="$1" label="$2" example="$3" defval="$4" input=""
# echo -e "${GREEN}[?]${NC} ${label}"
# echo -e " z.B. ${YELLOW}${example}${NC}"
# echo -e " Default: ${CYAN}${defval}${NC}"
# echo -ne " → Eingabe: ${CYAN}"
# read -r input
# echo -e "${NC}"
# if [[ -z "$input" ]]; then
# eval "$__outvar='$defval'"
# else
# eval "$__outvar='$input'"
# fi
#}
#
#ask_toggle() {
# local __outvar="$1" label="$2" defval="${3:-1}" input=""
# echo -ne "${GREEN}[?]${NC} ${label} (${CYAN}1${NC}=Ja / ${YELLOW}0${NC}=Nein) [Enter=${defval}]: "
# read -r input
# input="${input:-$defval}"
# case "$input" in
# 1|0) ;;
# *) echo -e "${YELLOW}Ungültig, nehme Default=${defval}.${NC}"; input="$defval" ;;
# esac
# eval "$__outvar='$input'"
#}
#
#ask_domain "MTA_FQDN" "Mailserver-FQDN (MX)" "mx.domain.tld" "$MTA_DEFAULT"
#ask_domain "UI_FQDN" "UI / Admin-Panel" "ui.domain.tld" "$UI_DEFAULT"
#ask_domain "WEBMAIL_FQDN" "Webmail-FQDN" "webmail.domain.tld" "$WEBMAIL_DEFAULT"
#
#echo -e "${CYAN}"
#echo "──────────────────────────────────────────────"
#echo -e " 🛡 Optionale Dienste"
#echo "──────────────────────────────────────────────"
#echo -e "${NC}"
#
#ask_toggle "CLAMAV_ENABLE" "ClamAV Virenscan aktivieren?" 1
#ask_toggle "OPENDMARC_ENABLE" "OpenDMARC auswerten?" 1
#ask_toggle "FAIL2BAN_ENABLE" "Fail2Ban aktivieren?" 1
#echo
#
## Defaults, wenn Enter gedrückt (Abwärtskompatibilität)
#MTA_FQDN="${MTA_FQDN:-${MTA_SUB}.${BASE_DOMAIN}}"
#UI_FQDN="${UI_FQDN:-${UI_SUB}.${BASE_DOMAIN}}"
#WEBMAIL_FQDN="${WEBMAIL_FQDN:-${WEBMAIL_SUB}.${BASE_DOMAIN}}"
#DKIM_ENABLE="${DKIM_ENABLE:-1}"
#DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
#DKIM_GENERATE="${DKIM_GENERATE:-1}"
#
## BASE_DOMAIN und Sub-Labels aus MTA/UI/WEBMAIL ableiten (robust)
#if [[ "$MTA_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
# MTA_SUB="${BASH_REMATCH[1]}"
# BASE_DOMAIN="${BASH_REMATCH[2]}"
#fi
#if [[ "$UI_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
# UI_SUB="${BASH_REMATCH[1]}"
#fi
#if [[ "$WEBMAIL_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
# WEBMAIL_SUB="${BASH_REMATCH[1]}"
#fi
#
#SYSMAIL_SUB="${SYSMAIL_SUB:-sysmail}"
#SYSMAIL_DOMAIN="${SYSMAIL_SUB}.${BASE_DOMAIN}"
## Kanonische Host-Variablen (NIE wieder zusammenbauen nimm die FQDNs)
#MAIL_HOSTNAME="${MTA_FQDN}"
#UI_HOST="${UI_FQDN}"
#WEBMAIL_HOST="${WEBMAIL_FQDN}"
#
## Zeitzone/Locale sinnvoll setzen
#APP_TZ="${APP_TZ:-$DEFAULT_TZ}"
#APP_LOCALE="${APP_LOCALE:-$DEFAULT_LOCALE}"
#
## ── Variablen exportieren ───────────────────────────────────────────────────
#export APP_NAME APP_USER APP_GROUP APP_USER_PREFIX APP_DIR
#export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB
#export SYSMAIL_SUB SYSMAIL_DOMAIN DKIM_ENABLE DKIM_SELECTOR DKIM_GENERATE
#export UI_HOST WEBMAIL_HOST MAIL_HOSTNAME
#export DB_NAME DB_USER
#export SERVER_PUBLIC_IPV4 SERVER_PUBLIC_IPV6 APP_TZ APP_LOCALE
#export CLAMAV_ENABLE OPENDMARC_ENABLE FAIL2BAN_ENABLE
#
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#BASE_DOMAIN=${BASE_DOMAIN}
#MTA_SUB=${MTA_SUB}
#UI_SUB=${UI_SUB}
#WEBMAIL_SUB=${WEBMAIL_SUB}
#
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#
#SYSMAIL_SUB=${SYSMAIL_SUB}
#SYSMAIL_DOMAIN=${SYSMAIL_DOMAIN}
#
#DKIM_ENABLE=${DKIM_ENABLE}
#DKIM_SELECTOR=${DKIM_SELECTOR}
#DKIM_GENERATE=${DKIM_GENERATE}
#
#DB_HOST=127.0.0.1
#DB_NAME=${DB_NAME}
#DB_USER=${DB_USER}
#DB_PASS=${DB_PASS}
#REDIS_PASS=${REDIS_PASS}
#
#SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
#SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
#APP_ENV=${APP_ENV}
#
#CLAMAV_ENABLE=${CLAMAV_ENABLE}
#OPENDMARC_ENABLE=${OPENDMARC_ENABLE}
#FAIL2BAN_ENABLE=${FAIL2BAN_ENABLE}
#EOF
#
#chmod 600 /etc/mailwolt/installer.env
#
## ── Sequenz ────────────────────────────────────────────────────────────────
#for STEP in 10-provision 20-ssl 21-le-deploy-hook 22-dkim-helper 30-db 40-postfix 50-dovecot 60-rspamd-opendkim 61-opendmarc 62-clamav 63-fail2ban 70-nginx 75-le-issue 80-app 90-services 95-woltguard 98-motd 99-summary
#do
# log ">>> Running ${STEP}.sh"
# bash "./${STEP}.sh"
#done
###!/usr/bin/env bash
##set -euo pipefail
##
### --- Flags / Modi ---
##DEV_MODE=0
##PROXY_MODE=0
##NPM_IP=""
##
##while [[ $# -gt 0 ]]; do
## case "$1" in
## -dev) DEV_MODE=1 ;;
## -proxy) PROXY_MODE=1; NPM_IP="${2:-}"; shift ;;
## esac
## shift
##done
##
##APP_ENV="${APP_ENV:-$([[ $DEV_MODE -eq 1 ]] && echo local || echo production)}"
##APP_DEBUG="${APP_DEBUG:-$([[ $DEV_MODE -eq 1 ]] && echo true || echo false)}"
##export DEV_MODE PROXY_MODE NPM_IP APP_ENV APP_DEBUG
##
##DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
##REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
##
##export DB_PASS REDIS_PASS
##
##cd "$(dirname "$0")"
##source ./lib.sh
##require_root
##header
##
### ── Defaults ────────────────────────────────────────────────────────────────
##APP_NAME="${APP_NAME:-MailWolt}"
##APP_USER="${APP_USER:-mailwolt}"
##APP_GROUP="${APP_GROUP:-www-data}"
##APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
##APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
##
##BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
##UI_SUB="${UI_SUB:-ui}"
##WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
##MTA_SUB="${MTA_SUB:-mx}"
##
##DB_NAME="${DB_NAME:-${APP_USER}}"
##DB_USER="${DB_USER:-${APP_USER}}"
##
##SERVER_PUBLIC_IPV4="$(detect_ip)"
##SERVER_PUBLIC_IPV6="$(detect_ipv6)"
##DEFAULT_TZ="$(detect_timezone)"
##DEFAULT_LOCALE="$(guess_locale_from_tz "$DEFAULT_TZ")"
##
##echo -e "${GREY}Erkannte IP (v4): ${SERVER_PUBLIC_IPV4} v6: ${SERVER_PUBLIC_IPV6:-}${NC}"
##
### ── FQDNs abfragen ───────────────────────────────────────────────────────────
##read -r -p "Mailserver FQDN (MX, z.B. mx.domain.tld) [Enter=${MTA_SUB}.${BASE_DOMAIN}]: " MTA_FQDN
##read -r -p "UI / Admin-Panel FQDN (z.B. ui.domain.tld) [Enter=${UI_SUB}.${BASE_DOMAIN}]: " UI_FQDN
##read -r -p "Webmail FQDN (z.B. webmail.domain.tld) [Enter=${WEBMAIL_SUB}.${BASE_DOMAIN}]: " WEBMAIL_FQDN
##
### Defaults, wenn Enter gedrückt
##MTA_FQDN="${MTA_FQDN:-${MTA_SUB}.${BASE_DOMAIN}}"
##UI_FQDN="${UI_FQDN:-${UI_SUB}.${BASE_DOMAIN}}"
##WEBMAIL_FQDN="${WEBMAIL_FQDN:-${WEBMAIL_SUB}.${BASE_DOMAIN}}"
##DKIM_ENABLE="${DKIM_ENABLE:-1}"
##DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
##DKIM_GENERATE="${DKIM_GENERATE:-1}"
##
### BASE_DOMAIN und Sub-Labels aus MTA/UI/WEBMAIL ableiten (robust)
##if [[ "$MTA_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
## MTA_SUB="${BASH_REMATCH[1]}"
## BASE_DOMAIN="${BASH_REMATCH[2]}"
##fi
##if [[ "$UI_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
## UI_SUB="${BASH_REMATCH[1]}"
##fi
##if [[ "$WEBMAIL_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
## WEBMAIL_SUB="${BASH_REMATCH[1]}"
##fi
##
##SYSMAIL_SUB="${SYSMAIL_SUB:-sysmail}"
##SYSMAIL_DOMAIN="${SYSMAIL_SUB}.${BASE_DOMAIN}"
### Kanonische Host-Variablen (NIE wieder zusammenbauen nimm die FQDNs)
##MAIL_HOSTNAME="${MTA_FQDN}"
##UI_HOST="${UI_FQDN}"
##WEBMAIL_HOST="${WEBMAIL_FQDN}"
##
### Zeitzone/Locale sinnvoll setzen (könntest du auch noch abfragen)
##APP_TZ="${APP_TZ:-$DEFAULT_TZ}"
##APP_LOCALE="${APP_LOCALE:-$DEFAULT_LOCALE}"
##
### ── Variablen exportieren ───────────────────────────────────────────────────
##export APP_NAME APP_USER APP_GROUP APP_USER_PREFIX APP_DIR
##export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB
##export SYSMAIL_SUB SYSMAIL_DOMAIN DKIM_ENABLE DKIM_SELECTOR DKIM_GENERATE
##export UI_HOST WEBMAIL_HOST MAIL_HOSTNAME
##export DB_NAME DB_USER
##export SERVER_PUBLIC_IPV4 SERVER_PUBLIC_IPV6 APP_TZ APP_LOCALE
##
##install -d -m 0755 /etc/mailwolt
##cat >/etc/mailwolt/installer.env <<EOF
##BASE_DOMAIN=${BASE_DOMAIN}
##MTA_SUB=${MTA_SUB}
##UI_SUB=${UI_SUB}
##WEBMAIL_SUB=${WEBMAIL_SUB}
##
##MAIL_HOSTNAME=${MAIL_HOSTNAME}
##UI_HOST=${UI_HOST}
##WEBMAIL_HOST=${WEBMAIL_HOST}
##
##SYSMAIL_SUB=${SYSMAIL_SUB}
##SYSMAIL_DOMAIN=${SYSMAIL_DOMAIN}
##
##DKIM_ENABLE=${DKIM_ENABLE}
##DKIM_SELECTOR=${DKIM_SELECTOR}
##DKIM_GENERATE=${DKIM_GENERATE}
##
##DB_HOST=127.0.0.1
##DB_NAME=${DB_NAME}
##DB_USER=${DB_USER}
##DB_PASS=${DB_PASS}
##REDIS_PASS=${REDIS_PASS}
##
##SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
##SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
##APP_ENV=${APP_ENV}
##
##CLAMAV_ENABLE=1
##OPENDMARC_ENABLE=1
##FAIL2BAN_ENABLE=1
##EOF
##
##chmod 600 /etc/mailwolt/installer.env
##
### ── Sequenz ────────────────────────────────────────────────────────────────
##for STEP in 10-provision 20-ssl 21-le-deploy-hook 22-dkim-helper 30-db 40-postfix 50-dovecot 60-rspamd-opendkim 61-opendmarc 62-clamav 63-fail2ban 70-nginx 75-le-issue 80-app 90-services 95-woltguard 98-motd 99-summary
##do
## log ">>> Running ${STEP}.sh"
## bash "./${STEP}.sh"
##done

View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Mailwolt Installer-Wrapper
# Deploy to: /usr/local/sbin/mailwolt-install
# Permissions: chmod 0755, chown root:root
set -euo pipefail
LOG="/var/log/mailwolt-install.log"
STATE_DIR="/var/lib/mailwolt/install"
INSTALLER_SCRIPT="/var/www/mailwolt/mailwolt-installer/install.sh"
APP_DIR="/var/www/mailwolt"
install -d -m 0755 "$STATE_DIR" /var/lib/mailwolt
: > "$LOG"
chmod 0644 "$LOG"
echo "running" > "$STATE_DIR/state"
: > "$STATE_DIR/rc"
{
echo "===== $(date -Is) :: Installation gestartet ====="
if [[ "$(id -u)" -ne 0 ]]; then
echo "[!] Muss als root laufen"
printf '1\n' > "$STATE_DIR/rc"
echo "done" > "$STATE_DIR/state"
exit 1
fi
# Komponente aus $1, falls übergeben (z.B. "nginx", "postfix", "dovecot", "all")
COMPONENT="${1:-all}"
echo "[i] Komponente: $COMPONENT"
RC=0
if [[ -f "$INSTALLER_SCRIPT" ]]; then
APP_DIR="$APP_DIR" COMPONENT="$COMPONENT" bash "$INSTALLER_SCRIPT" || RC=$?
else
echo "[!] installer script nicht gefunden: $INSTALLER_SCRIPT"
RC=127
fi
echo "===== $(date -Is) :: Installation beendet (rc=$RC) ====="
printf '%s\n' "$RC" > "$STATE_DIR/rc"
echo "done" > "$STATE_DIR/state"
exit "$RC"
} 2>&1 | tee -a "$LOG"

View File

@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
if [ -f /etc/mailwolt/installer.env ]; then
set -a
. /etc/mailwolt/installer.env
set +a
fi
# ── Styling ────────────────────────────────────────────────────────────────
GREEN="$(printf '\033[1;32m')"; YELLOW="$(printf '\033[1;33m')"
RED="$(printf '\033[1;31m')"; CYAN="$(printf '\033[1;36m')"
GREY="$(printf '\033[0;90m')"; NC="$(printf '\033[0m')"
BAR="──────────────────────────────────────────────────────────────────────────────"
log(){ echo -e "${GREEN}[+]${NC} $*"; }
warn(){ echo -e "${YELLOW}[!]${NC} $*"; }
err(){ echo -e "${RED}[x]${NC} $*"; }
die(){ err "$*"; exit 1; }
require_root(){ [[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausführen."; }
# --- Defaults, nur wenn noch nicht gesetzt ---------------------------------
: "${APP_USER:=mailwolt}"
: "${APP_GROUP:=www-data}"
: "${APP_DIR:=/var/www/${APP_USER}}"
: "${APP_NAME:=MailWolt}"
: "${BASE_DOMAIN:=example.com}"
: "${UI_SUB:=ui}"
: "${WEBMAIL_SUB:=webmail}"
: "${MTA_SUB:=mx}"
# DB / Redis (werden später durch .env überschrieben)
: "${DB_NAME:=${APP_USER}}"
: "${DB_USER:=${APP_USER}}"
: "${DB_PASS:=}"
: "${REDIS_PASS:=}"
# Stabile Zert-Pfade (UI/WEBMAIL/MX → symlinked via 20-ssl.sh)
: "${MAIL_SSL_DIR:=/etc/ssl/mail}"
: "${UI_SSL_DIR:=/etc/ssl/ui}"
: "${WEBMAIL_SSL_DIR:=/etc/ssl/webmail}"
: "${UI_CERT:=${UI_SSL_DIR}/fullchain.pem}"
: "${UI_KEY:=${UI_SSL_DIR}/privkey.pem}"
# Optional: E-Mail für LE
: "${LE_EMAIL:=admin@${BASE_DOMAIN}}"
load_env_file(){
local f="$1"
[[ -f "$f" ]] || return 0
while IFS='=' read -r k v; do
[[ "$k" =~ ^[A-Z0-9_]+$ ]] || continue
export "$k=$v"
done < <(grep -E '^[A-Z0-9_]+=' "$f")
}
header(){ echo -e "${CYAN}${BAR}${NC}
${CYAN} :::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::
${CYAN} +:+:+: :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
${CYAN} +:+ +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
${CYAN} +#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+
${CYAN} +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+
${CYAN} #+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+#
${CYAN} ### ### ### ### ########### ########## ### ### ######## ########## ###
${CYAN} ${CYAN}${BAR}${NC}\n"; }
#header(){ echo -e "${CYAN}${BAR}${NC}
#${CYAN} 888b d888 d8b 888 888 888 888 888
#${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888
#${CYAN} 88888b.d88888 888 888 d8b 888 888 888
#${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888
#${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888
#${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888
#${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b.
#${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888
#${CYAN}${BAR}${NC}\n"; }
detect_ip(){
local ip
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" || true
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${ip:-}" ]] || die "Konnte Server-IP nicht ermitteln."
echo "$ip"
}
detect_ipv4() {
local ext=""
if command -v curl >/dev/null 2>&1; then
ext="$(curl -fsS --max-time 2 https://ifconfig.me 2>/dev/null || true)"
[[ "$ext" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || ext=""
fi
echo "$ext"
}
detect_ipv6(){
local ip6
ip6="$(ip -6 addr show scope global 2>/dev/null | awk '/inet6/{print $2}' | cut -d/ -f1 | head -n1)" || true
[[ -n "${ip6:-}" ]] || ip6="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /:/){print $i; exit}}')" || true
echo "${ip6:-}"
}
detect_timezone(){
local tz
if command -v timedatectl >/dev/null 2>&1; then
tz="$(timedatectl show -p Timezone --value 2>/dev/null | tr -d '[:space:]')" || true
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
fi
[[ -r /etc/timezone ]] && { tz="$(sed -n '1p' /etc/timezone | tr -d '[:space:]')" || true; [[ "$tz" == */* ]] && { echo "$tz"; return; }; }
if [[ -L /etc/localtime ]]; then
tz="$(readlink -f /etc/localtime 2>/dev/null || true)"; tz="${tz#/usr/share/zoneinfo/}"
[[ "$tz" == */* ]] && { echo "$tz"; return; }
fi
if command -v curl >/dev/null 2>&1; then
tz="$(curl -fsSL --max-time 3 https://ipapi.co/timezone 2>/dev/null || true)"; [[ "$tz" == */* ]] && { echo "$tz"; return; }
fi
echo "UTC"
}
guess_locale_from_tz(){ case "${1:-UTC}" in
Europe/Berlin|Europe/Vienna|Europe/Zurich|Europe/Luxembourg|Europe/Brussels|Europe/Amsterdam) echo "de";;
*) echo "en";; esac; }
resolve_ok(){ local host="$1"; getent ahosts "$host" | awk '{print $1}' | sort -u | grep -q -F "${SERVER_PUBLIC_IPV4:-}" ; }
join_host(){ local sub="$1" base="$2"; [[ -z "$sub" ]] && echo "$base" || echo "$sub.$base"; }
# dns_preflight HOST [HOST2 ...]
# Prüft: A-Record → SERVER_PUBLIC_IPV4, MX (nur wenn HOST == MAIL_HOSTNAME), PTR.
# Gibt strukturierte Zeilen aus: OK|WARN|FAIL <host> <check> <detail>
# Rückgabe 0 = alles OK; 1 = mind. ein FAIL.
dns_preflight(){
local overall=0
local server_ip="${SERVER_PUBLIC_IPV4:-}"
_dns_line(){ local level="$1" host="$2" check="$3" detail="$4"
case "$level" in
OK) echo -e "${GREEN}[DNS OK ]${NC} ${host} ${GREY}${check}${NC}${detail}" ;;
WARN) echo -e "${YELLOW}[DNS WARN]${NC} ${host} ${GREY}${check}${NC}${detail}" ;;
FAIL) echo -e "${RED}[DNS FAIL]${NC} ${host} ${GREY}${check}${NC}${detail}"; overall=1 ;;
esac
}
for host in "$@"; do
[[ -z "$host" || "$host" == "example.com" ]] && continue
# A-Record
local a_ip
a_ip="$(dig +short A "$host" @1.1.1.1 2>/dev/null | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -n1)"
if [[ -z "$a_ip" ]]; then
_dns_line FAIL "$host" "A-Record" "kein Eintrag gefunden"
elif [[ -n "$server_ip" && "$a_ip" != "$server_ip" ]]; then
_dns_line FAIL "$host" "A-Record" "${a_ip}${server_ip} (Server-IP)"
else
_dns_line OK "$host" "A-Record" "${a_ip}"
fi
# MX (nur für MAIL_HOSTNAME)
if [[ "$host" == "${MAIL_HOSTNAME:-}" ]]; then
local mx
mx="$(dig +short MX "$host" @1.1.1.1 2>/dev/null | awk '{print $2}' | head -n1)"
if [[ -z "$mx" ]]; then
_dns_line WARN "$host" "MX-Record" "kein Eintrag ausgehende Mail ggf. eingeschränkt"
else
_dns_line OK "$host" "MX-Record" "${mx}"
fi
fi
# PTR (nur wenn IP bekannt)
if [[ -n "$server_ip" && -n "$a_ip" && "$a_ip" == "$server_ip" ]]; then
local ptr
ptr="$(dig +short -x "$server_ip" @1.1.1.1 2>/dev/null | head -n1 | sed 's/\.$//')"
if [[ -z "$ptr" ]]; then
_dns_line WARN "$host" "PTR-Record" "kein Reverse-DNS kann Spam-Score erhöhen"
elif [[ "$ptr" != "$host" && "$ptr" != "${MAIL_HOSTNAME:-}" ]]; then
_dns_line WARN "$host" "PTR-Record" "${ptr} (zeigt nicht auf ${host})"
else
_dns_line OK "$host" "PTR-Record" "${ptr}"
fi
fi
done
return $overall
}
upsert_env(){ # upsert in $ENV_FILE
local k="$1" v="$2" ek ev
ek="$(printf '%s' "$k" | sed -e 's/[.[\*^$(){}+?|/]/\\&/g')"
ev="$(printf '%s' "$v" | sed -e 's/[&/]/\\&/g')"
if grep -qE "^[#[:space:]]*${ek}=" "$ENV_FILE" 2>/dev/null; then
sed -Ei "s|^[#[:space:]]*${ek}=.*|${k}=${ev}|g" "$ENV_FILE"
else
printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE"
fi
}

View File

@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Phase 2: Go-Live — DNS-Check → LE-Zertifikate → Nginx → Postfix/Dovecot aktualisieren
# Muss als root ausgeführt werden, NACHDEM die DNS-Einträge gesetzt wurden.
# Liest Konfiguration aus /etc/mailwolt/installer.env (durch Phase 1 geschrieben).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
require_root
ENV_FILE="/etc/mailwolt/installer.env"
[[ -f "$ENV_FILE" ]] || die "Phase 1 noch nicht abgeschlossen ${ENV_FILE} fehlt."
set -a; . "$ENV_FILE"; set +a
# ── Pflichtfelder ─────────────────────────────────────────────────────────────
: "${BASE_DOMAIN:?BASE_DOMAIN fehlt in ${ENV_FILE}}"
: "${UI_HOST:?UI_HOST fehlt in ${ENV_FILE}}"
: "${WEBMAIL_HOST:?WEBMAIL_HOST fehlt in ${ENV_FILE}}"
: "${MAIL_HOSTNAME:?MAIL_HOSTNAME fehlt in ${ENV_FILE}}"
: "${SERVER_PUBLIC_IPV4:?SERVER_PUBLIC_IPV4 fehlt in ${ENV_FILE}}"
header
echo -e "${CYAN} Phase 2 Go-Live${NC}"
echo -e "${CYAN}${BAR}${NC}"
echo
# ── Schritt 1: DNS Preflight ──────────────────────────────────────────────────
log "DNS-Vorab-Check …"
SKIP_DNS="${SKIP_DNS:-0}"
dns_ok=0
if [[ "$SKIP_DNS" != "1" ]]; then
if dns_preflight "$UI_HOST" "$WEBMAIL_HOST" "$MAIL_HOSTNAME"; then
dns_ok=1
else
warn "Einige DNS-Einträge zeigen noch nicht auf diesen Server."
echo
echo -e " Möglichkeiten:"
echo -e " a) DNS reparieren und dieses Skript erneut ausführen."
echo -e " b) Trotzdem fortfahren: SKIP_DNS=1 bash phase2-go-live.sh"
echo
read -rp "Trotzdem fortfahren? [j/N] " _ans
[[ "${_ans,,}" == "j" ]] || die "Abgebrochen."
dns_ok=1
fi
else
warn "DNS-Check übersprungen (SKIP_DNS=1)."
dns_ok=1
fi
echo
# ── Schritt 2: Let's Encrypt Zertifikate ─────────────────────────────────────
log "Let's Encrypt Zertifikate ausstellen …"
bash "${SCRIPT_DIR}/75-le-issue.sh"
echo
# ── Schritt 3: Nginx-Konfiguration neu schreiben (TLS) ───────────────────────
log "Nginx-Konfiguration aktualisieren (TLS) …"
# Nginx-Builder aus 70-nginx.sh wiederverwenden
source "${SCRIPT_DIR}/70-nginx.sh" || true # sourcing setzt Variablen und führt aus
echo
# ── Schritt 4: Postfix hostname + TLS-Zertifikate aktualisieren ──────────────
log "Postfix: myhostname = ${MAIL_HOSTNAME}"
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "smtpd_tls_cert_file = /etc/ssl/mail/fullchain.pem"
postconf -e "smtpd_tls_key_file = /etc/ssl/mail/privkey.pem"
systemctl reload postfix || true
# ── Schritt 5: Dovecot TLS ───────────────────────────────────────────────────
log "Dovecot: TLS-Zertifikate aktualisieren …"
if [[ -f /etc/dovecot/conf.d/10-ssl.conf ]]; then
sed -i "s|^ssl_cert =.*|ssl_cert = </etc/ssl/mail/fullchain.pem|" /etc/dovecot/conf.d/10-ssl.conf || true
sed -i "s|^ssl_key =.*|ssl_key = </etc/ssl/mail/privkey.pem|" /etc/dovecot/conf.d/10-ssl.conf || true
systemctl reload dovecot || true
fi
# ── Schritt 6: App-URL in .env aktualisieren ──────────────────────────────────
APP_ENV_FILE="${APP_DIR}/.env"
if [[ -f "$APP_ENV_FILE" ]]; then
log "Laravel APP_URL aktualisieren → https://${UI_HOST}"
ENV_FILE="$APP_ENV_FILE"
upsert_env APP_URL "https://${UI_HOST}"
upsert_env APP_HOST "${UI_HOST}"
upsert_env SESSION_SECURE_COOKIE "true"
sudo -u "${APP_USER}" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache" || true
fi
# ── Fertig ────────────────────────────────────────────────────────────────────
echo
echo -e "${GREEN}${BAR}${NC}"
echo -e "${GREEN} ✔ Phase 2 abgeschlossen!${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo -e " UI: ${CYAN}https://${UI_HOST}${NC}"
echo -e " Webmail: ${CYAN}https://${WEBMAIL_HOST}${NC}"
echo -e " MX: ${GREY}${MAIL_HOSTNAME}${NC}"
echo

View File

@ -3,17 +3,13 @@ set -euo pipefail
# -------- Konfiguration -------------------------------------------------------
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_GROUP="${APP_GROUP:-www-data}" # Fallback; setz das in deiner Umgebung falls anders
APP_DIR="${APP_DIR:-/var/www/mailwolt}"
BRANCH="${BRANCH:-main}"
MODE="${UPDATE_MODE:-tags}"
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
BRANCH="${BRANCH:-main}" # nur relevant bei UPDATE_MODE=branch
MODE="${UPDATE_MODE:-tags}" # tags | branch
ALLOW_DIRTY="${ALLOW_DIRTY:-0}" # 1 = Dirty-Working-Tree zulassen
STATE_DIR="/var/lib/mailwolt/update"
LOCK_FILE="/var/run/mailwolt-update.lock"
LOG_FILE="/var/log/mailwolt-update.log"
# npm / CI Defaults
# npm / CI Defaults für weniger Lärm
export CI=1
export NPM_CONFIG_FUND=false
export NPM_CONFIG_AUDIT=false
@ -72,16 +68,12 @@ get_version(){
as_app "cd ${APP_DIR} && (git describe --tags --always 2>/dev/null || git rev-parse --short=7 HEAD)"
}
write_version_files(){
write_build_info(){
local ver="$1" rev="$2"
install -d -m 0755 /etc/mailwolt /var/lib/mailwolt || true
install -d -m 0755 /etc/mailwolt || true
printf "version=%s\nrev=%s\nupdated=%s\n" "$ver" "$rev" "$(date -Is)" > /etc/mailwolt/build.info || true
printf '%s\n' "${ver#v}" > /var/lib/mailwolt/version || true
printf '%s\n' "$ver" > /var/lib/mailwolt/version_raw || true
}
write_build_info(){ write_version_files "$@"; }
# --- Frontend build (quiet & robust) ------------------------------------------
frontend_build_quiet() {
local LOG="/var/log/mailwolt-frontend-build.log"
@ -132,35 +124,6 @@ frontend_build_quiet() {
[[ "$(id -u)" -eq 0 ]] || { echo "[!] Bitte als root ausführen"; exit 1; }
[[ -d "$APP_DIR/.git" ]] || { echo "[!] $APP_DIR scheint kein Git-Repo zu sein"; exit 1; }
# -------- Lock ----------------------------------------------------------------
mkdir -p "$STATE_DIR"
if [[ -f "$LOCK_FILE" ]]; then
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true)
if [[ -n "$LOCK_PID" ]] && kill -0 "$LOCK_PID" 2>/dev/null; then
echo "[!] Update läuft bereits (PID $LOCK_PID) — Abbruch."
exit 1
fi
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
# -------- State & Cleanup-Trap -----------------------------------------------
MAINTENANCE_ACTIVE=0
_cleanup() {
local rc=$?
[[ "$MAINTENANCE_ACTIVE" -eq 1 ]] && artisan_up
mkdir -p "$STATE_DIR"
echo "done" > "$STATE_DIR/state"
echo "$rc" > "$STATE_DIR/rc"
rm -f "$LOCK_FILE"
}
trap '_cleanup' EXIT INT TERM
# Update als laufend markieren
echo "running" > "$STATE_DIR/state"
rm -f "$STATE_DIR/rc"
: > "$LOG_FILE"
git_safe
git_dirty_check
@ -173,7 +136,7 @@ NEW_REV="$OLD_REV"
if [[ "$MODE" = "tags" ]]; then
# → Neueste Tags holen
as_app "git -C ${APP_DIR} fetch --quiet origin && git -C ${APP_DIR} fetch --tags --quiet origin || true"
LATEST_TAG="$(as_app "git -C ${APP_DIR} describe --tags --abbrev=0 \$(git -C ${APP_DIR} rev-list --tags --max-count=1 2>/dev/null) 2>/dev/null || echo ''")"
LATEST_TAG="$(as_app "git -C ${APP_DIR} tag --list | sort -V | tail -n1")"
if [[ -z "$LATEST_TAG" ]]; then
echo "[!] Keine Tags gefunden falle auf origin/${BRANCH} zurück"
as_app "git -C ${APP_DIR} checkout -q ${BRANCH} && git -C ${APP_DIR} pull --ff-only origin ${BRANCH}"
@ -246,6 +209,15 @@ if [[ $NEED_FRONTEND -eq 1 ]]; then
fi
# -------- Wartungsmodus: Migrations + Cache + PHP-FPM reload -----------------
# Trap stellt sicher dass artisan up auch bei Fehler läuft
MAINTENANCE_ACTIVE=0
cleanup_maintenance(){
if [[ "$MAINTENANCE_ACTIVE" -eq 1 ]]; then
artisan_up
fi
}
trap cleanup_maintenance EXIT INT TERM
if [[ $NEED_MIGRATIONS -eq 1 || $NEED_PHP_RESTART -eq 1 || $NEED_COMPOSER -eq 1 ]]; then
artisan_down
MAINTENANCE_ACTIVE=1
@ -282,8 +254,8 @@ if [[ "$MAINTENANCE_ACTIVE" -eq 1 ]]; then
MAINTENANCE_ACTIVE=0
fi
# -------- Version + Build-Info ablegen ----------------------------------------
# -------- Build-Info ablegen --------------------------------------------------
NEW_VER="$(get_version)"
write_version_files "$NEW_VER" "$NEW_REV"
write_build_info "$NEW_VER" "$NEW_REV"
echo "[✓] Update abgeschlossen: ${OLD_VER}${NEW_VER} (${OLD_REV:0:7}${NEW_REV:0:7})"
echo "[✓] Update abgeschlossen: ${OLD_REV:0:7}${NEW_REV:0:7} (Version: ${NEW_VER})"

View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
MSG=""
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
REMOTE="${REMOTE:-origin}"
usage(){ echo "Usage: $0 -m \"commit message\" [-b branch]"; exit 1; }
while getopts ":m:b:" opt; do
case "$opt" in
m) MSG="$OPTARG" ;;
b) BRANCH="$OPTARG" ;;
*) usage ;;
esac
done
shift $((OPTIND-1))
[[ -n "$MSG" ]] || usage
git rev-parse --git-dir >/dev/null 2>&1 || { echo "[x] kein Git-Repo"; exit 1; }
echo "[i] Add/Commit → $BRANCH"
git add -A
# Kein Commit, wenn nichts geändert ist
if ! git diff --cached --quiet; then
git commit -m "$MSG"
else
echo "[i] Nichts zu committen (Index leer) pushe nur."
fi
echo "[i] Push → $REMOTE/$BRANCH"
git push "$REMOTE" "$BRANCH"
echo "[✓] Done."

View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE="${REMOTE:-origin}"
BRANCH="${BRANCH:-main}"
ALLOW_DIRTY=0
VERSION=""
MSG=""
usage(){
echo "Usage: $0 <X.Y.Z> [-m \"release notes\"] [-b branch] [--allow-dirty]"
exit 1
}
# Args
[[ $# -ge 1 ]] || usage
VERSION="$1"; shift || true
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "[x] Ungültige Version: $VERSION (erwarte SemVer X.Y.Z)"; exit 1; }
while [[ $# -gt 0 ]]; do
case "$1" in
-m) MSG="$2"; shift 2 ;;
-b) BRANCH="$2"; shift 2 ;;
--allow-dirty) ALLOW_DIRTY=1; shift ;;
*) usage ;;
esac
done
git rev-parse --git-dir >/dev/null 2>&1 || { echo "[x] kein Git-Repo"; exit 1; }
# Clean tree?
if [[ $ALLOW_DIRTY -eq 0 ]]; then
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "[x] Arbeitsbaum nicht sauber. Committe oder nutze --allow-dirty."
exit 1
fi
fi
# Aktuellen Stand holen
git fetch --quiet "$REMOTE" --tags
# Branch check-out/sync
git checkout -q "$BRANCH"
git pull --ff-only "$REMOTE" "$BRANCH"
TAG="v${VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "[x] Tag ${TAG} existiert bereits."
exit 1
fi
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [[ -n "$LAST_TAG" ]]; then
CHANGELOG="$(git log --pretty=format:'- %s (%h)' "${LAST_TAG}..HEAD" || true)"
else
CHANGELOG="$(git log --pretty=format:'- %s (%h)' || true)"
fi
[[ -n "$CHANGELOG" ]] || CHANGELOG="- initial release content"
# VERSION bumpen
echo -n "$VERSION" > VERSION
git add VERSION
git commit -m "chore(release): v${VERSION}"
# Tag-Message bauen
if [[ -z "$MSG" ]]; then
read -r -d '' MSG <<EOF || true
MailWolt v${VERSION}
Changes since ${LAST_TAG:-repo start}:
${CHANGELOG}
EOF
fi
git tag -a "$TAG" -m "$MSG"
# Push commit + tag
git push "$REMOTE" "$BRANCH"
git push "$REMOTE" "$TAG"
echo "[✓] Release ${TAG} erstellt & gepusht."

View File

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "bash -c 'trap \"rm -f public/hot\" EXIT INT TERM; rm -f public/hot && vite'"
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",

View File

@ -5,7 +5,6 @@
@import "../fonts/Space/font.css";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/wire-elements/modal/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@ -100,19 +99,6 @@
.mw-divider {
@apply border-t border-white/10 my-4;
}
/* ── Header utility classes ── */
.btn-ghost {
@apply inline-flex items-center justify-center rounded-lg
text-white/60 hover:text-white hover:bg-white/5
transition-colors duration-150 cursor-pointer;
padding: 0.4rem;
}
.popup {
@apply rounded-xl border border-white/10 shadow-xl z-50
bg-[#0f172a]/95 backdrop-blur-xl text-white/80;
}
}
/* ============ BOOT-STATE: keine Sprünge ============ */
@ -1107,125 +1093,40 @@ select {
/* ── Main ── */
.mw-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
/*
Topbar Redesign
*/
/* ── Topbar ── */
.mw-topbar {
height: 54px;
padding: 0 20px;
border-bottom: 1px solid var(--mw-b1);
background: var(--mw-bg);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 8px;
height: 50px; padding: 0 22px; border-bottom: 1px solid var(--mw-b1);
background: var(--mw-bg); display: flex; align-items: center;
justify-content: space-between; flex-shrink: 0;
}
.mw-breadcrumb { display: flex; align-items: center; gap: 5px; font-size: 13px; color: var(--mw-t4); }
.mw-breadcrumb b { color: var(--mw-t2); font-weight: 500; }
.mw-topbar-right { display: flex; align-items: center; gap: 8px; }
/* ── Links ── */
.mw-tb-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
.mw-live { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--mw-t3); }
.mw-live-dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--mw-gr);
box-shadow: 0 0 5px rgba(34,197,94,.5); animation: mw-pulse 2s infinite;
}
@keyframes mw-pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.mw-hamburger {
display: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px; height: 32px;
border-radius: 8px;
background: none;
border: none;
color: var(--mw-t3);
cursor: pointer;
transition: background .15s, color .15s;
.mw-search {
display: flex; align-items: center; gap: 6px; background: var(--mw-bg3);
border: 1px solid var(--mw-b1); border-radius: 6px; padding: 5px 10px;
font-size: 12px; color: var(--mw-t4); cursor: pointer;
}
.mw-hamburger:hover { background: var(--mw-bg3); color: var(--mw-t1); }
.mw-tb-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
min-width: 0;
overflow: hidden;
}
.mw-tb-parent { color: var(--mw-t4); white-space: nowrap; flex-shrink: 0; }
.mw-tb-sep { color: var(--mw-b3); flex-shrink: 0; }
.mw-tb-current { color: var(--mw-t2); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Rechts ── */
.mw-tb-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Live-Dot */
.mw-tb-live {
display: flex; align-items: center; gap: 5px;
padding: 4px 9px;
border-radius: 99px;
background: rgba(34,197,94,.08);
border: 1px solid rgba(34,197,94,.18);
font-size: 11px; font-weight: 500; color: #4ade80;
}
.mw-tb-live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px rgba(34,197,94,.7);
animation: mw-pulse 2s infinite;
}
@keyframes mw-pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* Suchbutton */
.mw-tb-search {
display: flex; align-items: center; gap: 7px;
height: 32px; padding: 0 12px;
background: var(--mw-bg3);
border: 1px solid var(--mw-b2);
border-radius: 8px;
font-size: 12px; color: var(--mw-t4);
cursor: pointer;
transition: border-color .15s, color .15s;
white-space: nowrap;
}
.mw-tb-search:hover { border-color: var(--mw-b3); color: var(--mw-t2); }
.mw-tb-kbd {
display: inline-flex; align-items: center; justify-content: center;
padding: 2px 5px;
background: var(--mw-b1); border: 1px solid var(--mw-b2);
border-radius: 4px; font-size: 10px; color: var(--mw-t5);
font-family: ui-monospace, monospace;
}
/* Hostname-Badge */
.mw-tb-host {
display: flex; align-items: center; gap: 5px;
height: 32px; padding: 0 10px;
background: var(--mw-bg3); border: 1px solid var(--mw-b2);
border-radius: 8px;
.mw-search kbd { font-size: 9.5px; opacity: .5; margin-left: 2px; }
.mw-ip {
font-family: ui-monospace, monospace; font-size: 11px; color: var(--mw-t4);
white-space: nowrap;
background: var(--mw-bg3); border: 1px solid var(--mw-b1); border-radius: 5px; padding: 4px 9px;
}
/* + Domain Button */
.mw-tb-add {
display: flex; align-items: center; gap: 6px;
height: 32px; padding: 0 14px;
background: var(--mw-v);
border: none; border-radius: 8px;
font-size: 12.5px; font-weight: 600; color: #fff;
cursor: pointer; flex-shrink: 0;
box-shadow: 0 2px 12px rgba(124,58,237,.35);
transition: background .15s, box-shadow .15s;
.mw-btn-primary {
display: inline-flex; align-items: center; gap: 5px;
background: var(--mw-v); border: none; border-radius: 6px; padding: 6px 14px;
font-size: 12.5px; font-weight: 600; color: #fff; cursor: pointer;
box-shadow: 0 0 10px rgba(124,58,237,.35);
}
.mw-tb-add:hover { background: #6d28d9; box-shadow: 0 2px 16px rgba(124,58,237,.5); }
.mw-btn-primary:hover { background: #6d28d9; }
/* ── Content ── */
.mw-content { flex: 1; overflow-y: auto; padding: 20px 22px 30px; }
@ -1445,16 +1346,13 @@ select {
/* ── Responsive ── */
@media (max-width: 1024px) {
.mw-content { padding: 16px 18px 24px; }
.mw-topbar { padding: 0 16px; }
.mw-topbar { padding: 0 18px; }
}
@media (max-width: 900px) {
.mw-bottom-grid { grid-template-columns: 1fr; }
.mw-right-col { flex-direction: row; }
/* Hostname auf Tablet kompakter */
.mw-tb-host { font-size: 10.5px; padding: 0 8px; }
}
@media (max-width: 768px) {
/* Sidebar off-canvas */
.mw-sidebar {
position: fixed; left: 0; top: 0; z-index: 50;
transform: translateX(-100%); transition: transform .25s;
@ -1465,10 +1363,10 @@ select {
background: rgba(0,0,0,.55); z-index: 40;
}
.mw-sidebar-overlay.open { display: block; }
/* Hamburger einblenden */
.mw-hamburger { display: flex; }
/* Parent bleibt sichtbar (3 Buchstaben) */
/* Layout */
.mw-hamburger {
display: flex !important; background: none; border: none;
color: var(--mw-t3); cursor: pointer; padding: 4px; margin-right: 4px;
}
.mw-metric-grid { grid-template-columns: repeat(2, 1fr); }
.mw-status-grid { grid-template-columns: repeat(2, 1fr); }
.mw-shell { display: block; height: 100vh; }
@ -1478,176 +1376,7 @@ select {
@media (max-width: 480px) {
.mw-metric-grid { grid-template-columns: 1fr; }
.mw-status-grid { grid-template-columns: 1fr; }
.mw-topbar { padding: 0 12px; gap: 5px; }
.mw-tb-parent { display: none; }
.mw-tb-sep { display: none; }
.mw-tb-search-text { display: none; }
.mw-tb-kbd { display: none; }
.mw-tb-search { padding: 0 9px; }
.mw-tb-add-text { display: none; }
.mw-tb-add { padding: 0 9px; }
}
/* ── API Key Layout ── */
.mw-apikey-layout {
display: grid;
grid-template-columns: 1fr 340px;
gap: 14px;
align-items: start;
}
.mw-apikey-layout > * { min-width: 0; }
@media (max-width: 900px) {
.mw-apikey-layout { grid-template-columns: 1fr; }
}
/* ── Responsive list helpers ── */
@media (max-width: 640px) {
.mw-col-hide-sm { display: none !important; }
.mw-only-desktop { display: none !important; }
}
@media (min-width: 641px) {
.mw-only-mobile { display: none !important; }
}
/* ── API Key div-list ── */
.mw-kl-head,
.mw-kl-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 104px 80px 90px 40px;
gap: 8px;
padding: 8px 14px;
align-items: center;
min-width: 0;
}
.mw-kl-head {
font-size: 9.5px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--mw-t4);
border-bottom: 1px solid var(--mw-b1);
}
.mw-kl-row {
border-bottom: 1px solid var(--mw-b1);
transition: background .1s;
}
.mw-kl-row:last-child { border-bottom: none; }
.mw-kl-row:hover { background: rgba(255,255,255,.015); }
.mw-kl-row > * { min-width: 0; overflow: hidden; }
.mw-kl-date-inline { display: none; }
.mw-kl-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 900px) and (min-width: 641px) {
.mw-kl-head,
.mw-kl-row { grid-template-columns: minmax(0, 1fr) 120px 80px 40px; }
.mw-kl-date { display: none; }
.mw-kl-date-inline { display: inline; }
}
@media (max-width: 640px) {
.mw-kl-head { display: none; }
.mw-kl-row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 5px 8px;
padding: 10px 12px;
}
.mw-kl-name { grid-column: 1; grid-row: 1; }
.mw-kl-scopes { grid-column: 1; grid-row: 2; flex-wrap: wrap; overflow: visible; }
.mw-kl-modus { grid-column: 2; grid-row: 1; }
.mw-kl-date { display: none; }
.mw-kl-date-inline { display: inline; }
.mw-kl-actions { grid-column: 2; grid-row: 2; justify-self: end; }
}
/* ── Webhook div-list ── */
.mw-whl-head,
.mw-whl-row {
display: grid;
grid-template-columns: 1fr 124px 72px 54px 82px;
gap: 8px;
padding: 8px 14px;
align-items: center;
min-width: 0;
}
.mw-whl-head {
font-size: 9.5px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--mw-t4);
border-bottom: 1px solid var(--mw-b1);
}
.mw-whl-row {
border-bottom: 1px solid var(--mw-b1);
transition: background .1s;
}
.mw-whl-row:last-child { border-bottom: none; }
.mw-whl-row:hover { background: rgba(255,255,255,.015); }
.mw-whl-row > * { min-width: 0; }
.mw-whl-status-sm, .mw-whl-http-sm { display: none; }
@media (max-width: 640px) {
.mw-whl-head { display: none; }
.mw-whl-row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 5px 8px;
padding: 10px 12px;
}
.mw-whl-webhook { grid-column: 1; grid-row: 1; }
.mw-whl-events { grid-column: 1; grid-row: 2; }
.mw-whl-status { display: none; }
.mw-whl-http { display: none; }
.mw-whl-date { display: none; }
.mw-whl-actions {
grid-column: 2; grid-row: 1 / 3;
display: flex; flex-direction: column; gap: 4px; align-self: start;
}
.mw-whl-status-sm, .mw-whl-http-sm { display: inline; }
}
/* ── Webhook-Tabelle Layout ── */
.mw-wh-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 14px;
align-items: start;
}
.mw-wh-layout > * { min-width: 0; }
@media (max-width: 900px) {
.mw-wh-layout { grid-template-columns: 1fr; }
}
/* ── Sandbox Layout ── */
.mw-sandbox-client {
display: grid;
grid-template-columns: 300px 1fr;
gap: 0;
border: 1px solid var(--mw-b1);
border-radius: 10px;
overflow: hidden;
background: var(--mw-bg2);
min-height: 520px;
}
.mw-sandbox-list {
border-right: 1px solid var(--mw-b1);
overflow-y: auto;
max-height: 680px;
}
.mw-sandbox-detail {
overflow-y: auto;
max-height: 680px;
min-height: 400px;
}
.mw-postfix-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
padding: 16px 18px;
}
@media (max-width: 900px) {
.mw-sandbox-client { grid-template-columns: 1fr; }
.mw-sandbox-list { border-right: none; border-bottom: 1px solid var(--mw-b1); max-height: 260px; }
.mw-sandbox-detail { max-height: none; }
.mw-postfix-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.mw-sandbox-client { min-height: 0; }
.mw-postfix-grid { grid-template-columns: 1fr; }
.mw-ip { display: none; }
}
.mw-svc-status-off { color: var(--mw-rd) !important; opacity: 1; }
@ -2014,20 +1743,6 @@ textarea.mw-modal-input { height: auto; line-height: 1.55; padding: 8px 10px; }
}
/* Buttons */
.mw-btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 16px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer;
background: var(--mw-v); border: 1px solid var(--mw-v); color: #fff;
box-shadow: 0 2px 8px rgba(124,58,237,.3);
}
.mw-btn-primary:hover { background: #6d28d9; border-color: #6d28d9; }
.mw-btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer;
background: var(--mw-bg3); border: 1px solid var(--mw-b2); color: var(--mw-t2);
transition: background .15s, border-color .15s;
}
.mw-btn-secondary:hover { background: var(--mw-bg4); border-color: var(--mw-b3); color: var(--mw-t1); }
.mw-btn-cancel {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 7px; font-size: 13px; cursor: pointer;

View File

@ -1,17 +0,0 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
import '@tailwindplus/elements';
import "@phosphor-icons/web/duotone";
import "@phosphor-icons/web/light";
import "@phosphor-icons/web/regular";
import "@phosphor-icons/web/bold";
import $ from "jquery";
window.$ = $;
window.jQuery = $;
import './plugins/GlassToastra/toastra.glass.js';
import './plugins/GlassToastra/livewire-adapter';
import './utils/events.js';

View File

@ -1,13 +1,11 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
const reverbKey = import.meta.env.VITE_REVERB_APP_KEY;
if (reverbKey) {
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: reverbKey,
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
@ -15,6 +13,5 @@ if (reverbKey) {
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
}
export default window.Echo ?? null;
export default Echo;

View File

@ -1,17 +1,77 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden · Mailwolt</title>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body style="margin:0;background:var(--mw-bg);color:var(--mw-t1);font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;font-size:13.5px;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;align-items:center;justify-content:center;
background-image:radial-gradient(900px 600px at 15% 0%,rgba(99,102,241,.07),transparent),radial-gradient(700px 500px at 85% 100%,rgba(59,130,246,.05),transparent)">
{{-- resources/views/auth/login.blade.php --}}
@extends('layouts.blank')
@section('title', 'Login')
@section('content')
<div class="flex items-center justify-center p-6 w-full">
<livewire:auth.login-form />
</div>
@endsection
{{-- resources/views/auth/login.blade.php --}}
{{--@extends('layouts.app')--}}
@livewireScripts
</body>
</html>
{{--@section('title', 'Login')--}}
{{--@section('content')--}}
{{-- <div class="min-h-[86vh] grid place-items-center px-4--}}
{{-- bg-[radial-gradient(1200px_600px_at_10%_-10%,rgba(59,130,246,.08),transparent),--}}
{{-- radial-gradient(900px_500px_at_90%_0%,rgba(99,102,241,.06),transparent)]">--}}
{{-- <div class="nx-card w-full max-w-[520px]">--}}
{{-- --}}{{-- Header-Chip + Icon --}}
{{-- <div class="flex items-center justify-between mb-5">--}}
{{-- <span class="nx-chip">Erster Login</span>--}}
{{-- <i class="ph ph-lock-simple text-white/60"></i>--}}
{{-- </div>--}}
{{-- <p class="nx-subtle mb-7">--}}
{{-- Melde dich mit dem einmaligen Bootstrap-Konto an, um den Setup-Wizard zu starten.--}}
{{-- </p>--}}
{{-- --}}{{-- Fehler (optional) --}}
{{-- @if(session('error'))--}}
{{-- <div class="nx-alert mb-6">--}}
{{-- <i class="ph ph-warning-circle text-rose-300"></i>--}}
{{-- <div>--}}
{{-- <p class="font-medium">Anmeldung fehlgeschlagen</p>--}}
{{-- <p class="text-sm/5 text-rose-200/90">{{ session('error') }}</p>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- --}}{{-- Formular --}}
{{-- <form method="POST" action="{{ route('login') }}" class="Space-y-5">--}}
{{-- @csrf--}}
{{-- <label class="nx-label" for="email">E-Mail</label>--}}
{{-- <input id="email" name="email" type="email" autocomplete="username" autofocus--}}
{{-- class="nx-input" value="{{ old('email') }}"/>--}}
{{-- <label class="nx-label" for="password">Passwort</label>--}}
{{-- <div class="relative">--}}
{{-- <input id="password" name="password" type="password" autocomplete="current-password" class="nx-input pr-10"/>--}}
{{-- <button type="button" class="nx-eye" onclick="this.previousElementSibling.type = this.previousElementSibling.type==='password'?'text':'password'">--}}
{{-- <i class="ph ph-eye text-white/60"></i>--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- <div class="flex items-center justify-between pt-1">--}}
{{-- <label class="inline-flex items-center gap-2 text-sm text-white/70">--}}
{{-- <input type="checkbox" name="remember" class="nx-check"> Session merken--}}
{{-- </label>--}}
{{-- <a class="nx-link" href="#">Zugang zurücksetzen</a>--}}
{{-- </div>--}}
{{-- <button type="submit" class="nx-btn w-full">Anmelden</button>--}}
{{-- </form>--}}
{{-- --}}{{-- Optional: Provider-Buttons --}}
{{-- --}}{{-- <div class="nx-divider">oder verbinden via</div>--}}
{{-- <div class="grid grid-cols-2 gap-3 mt-4">--}}
{{-- <button class="nx-btn-ghost"><i class="ph ph-google-logo mr-2"></i> Google</button>--}}
{{-- <button class="nx-btn-ghost"><i class="ph ph-github-logo mr-2"></i> GitHub</button>--}}
{{-- </div> --}}
{{-- </div>--}}
{{-- </div>--}}
{{--@endsection--}}

View File

@ -1,110 +1,97 @@
{{--resources/views/components/partials/header.blade.php--}}
<div class="w-full border-b hr">
<div
class="#sticky top-0 w-full border-b hr #rounded-lg max-w-9xl mx-auto">
<header id="header" class="header w-full rounded-r-2xl rounded-l-none">
<nav class="flex h-[71px] items-center justify-between gap-3 px-4">
{{-- Links: Toggle + Seitentitel --}}
<div class="flex min-w-0 flex-1 items-center gap-3">
<button class="sidebar-toggle shrink-0 text-white/60 hover:text-white text-2xl">
<nav class="flex h-[71px] #h-[74px] items-center justify-between #p-3">
<div class="#flex-1 md:w-3/5 w-full">
<div class="relative flex items-center gap-5">
<button class="sidebar-toggle translate-0 right-5 block s#m:hidden text-white/60 hover:text-white text-2xl">
<i class="ph ph-list"></i>
</button>
<h1 class="truncate font-bold text-xl sm:text-2xl">@yield('header_title')</h1>
<div class="flex items-center gap-3">
<h1 class="font-bold text-2xl">@yield('header_title')</h1>
</div>
{{-- Rechts: Suche + Aktionen --}}
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
{{-- Suchbutton --}}
</div>
</div>
<div class="flex items-center justify-end gap-2 w-full max-w-96">
<button
id="openSearchPaletteBtn"
type="button"
onclick="Livewire.dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2.5 py-1.5 text-white/75 hover:text-white hover:border-white/20 sm:px-3"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5
text-white/75 hover:text-white hover:border-white/20"
title="Suche öffnen"
aria-label="Suche öffnen"
wire:click="$dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})"
>
<i class="ph ph-magnifying-glass text-base"></i>
<i class="ph ph-magnifying-glass text-[16px]"></i>
<span id="searchShortcutKbd"
class="hidden sm:inline-block rounded-md bg-white/5 border border-white/10 px-1.5 py-0.5 text-[11px] leading-none">
class="rounded-md bg-white/5 border border-white/10 px-1.5 py-0.5 text-[11px] leading-none">
<!-- wird per JS gesetzt -->
</span>
</button>
{{-- Domain erstellen --}}
<button
type="button"
onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})"
class="btn-ghost text-lg !p-2"
title="Domain erstellen"
>
<button onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
<i class="ph-duotone ph-globe-hemisphere-east"></i>
</button>
@foreach($header as $section)
<div class="#modal-button #relative #mr-2">
<div class="#p-1.5">
@if(!$section['image'])
{{-- Benachrichtigungen --}}
<div x-data="{ openMessages: false }" class="relative flex items-center">
<button
@click="openMessages = !openMessages"
class="btn-ghost text-base !p-2"
title="Benachrichtigungen"
>
<i class="ph-duotone ph-bell"></i>
</button>
<button @click="openMessages = !openMessages" class="btn-ghost text-base !p-2"><i class="ph-duotone ph-bell #mr-1"></i></button>
<div
x-show="openMessages"
@click.away="openMessages = false"
x-cloak
class="popup absolute top-[calc(100%+0.5rem)] right-0 w-56"
class="popup absolute top-16 right-0 w-48"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<div class="flex items-center justify-center h-40 text-sm text-white/50">
<div class="flex items-center justify-center h-40">
Keine Nachrichten
</div>
</div>
</div>
@else
{{-- Avatar-Menü --}}
<div x-data="{ openMenu: false }" class="relative flex items-center">
<button
@click="openMenu = !openMenu"
class="flex items-center gap-1.5 focus:outline-none"
>
<img
class="size-8 rounded-full object-cover shadow-2xl"
<button @click="openMenu = !openMenu"
class="flex items-center focus:outline-none">
<img class="size-8 rounded-full object-cover shadow-2xl"
src="https://i.pravatar.cc/100"
alt="Avatar"
/>
alt="Avatar"/>
<svg
class="size-4 text-white/40 transition-transform duration-200"
:class="{ 'rotate-180': openMenu }"
class="size-4 ml-2 text-gray-500 transform transition-transform duration-200"
:class="{'rotate-180': open}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="openMenu"
@click.away="openMenu = false"
x-cloak
class="popup absolute top-[calc(100%+0.5rem)] right-0 w-48"
class="popup absolute top-16 right-0 w-48"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
@foreach($section['sub'] as $sub)
<a href="{{ route($sub['route']) }}" class="block px-4 py-2 text-xs hover:bg-white/5">
<a href="{{ route($sub['route']) }}"
class="block px-4 py-2 text-xs #text-gray-700 #hover:bg-gray-100 #hover:text-black #hover:bg-gradient-to-r #hover:from-[rgba(var(--accent))] #hover:to-transparent">
<div class="flex items-center gap-3">
<i class="ph {{ $sub['icon'] ?? 'ph-circle' }} text-sm text-white/50"></i>
<span>Logo</span>
{{-- <span><x-dynamic-component :component="$sub['icon']"--}}
{{-- class="!size-4"/></span>--}}
<span>{{ $sub['title'] }}</span>
</div>
</a>
@ -112,8 +99,9 @@
</div>
</div>
@endif
</div>
</div>
@endforeach
</div>
</nav>
</header>

View File

@ -160,43 +160,23 @@
<div class="mw-main">
<header class="mw-topbar">
{{-- Links: Hamburger + Titel --}}
<div class="mw-tb-left">
<button class="mw-hamburger" onclick="document.getElementById('mw-sidebar').classList.add('open');document.getElementById('mw-overlay').classList.add('open');" aria-label="Menü">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<div class="mw-breadcrumb">
<button class="mw-hamburger" style="display:none" onclick="document.getElementById('mw-sidebar').classList.add('open');document.getElementById('mw-overlay').classList.add('open');">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 4h14M2 9h14M2 14h14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
<div class="mw-tb-title">
<span class="mw-tb-parent">@hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif</span>
<svg class="mw-tb-sep" width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M3 2l3.5 3L3 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<span class="mw-tb-current">@hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif</span>
@hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M3.5 2l4 3.5-4 3.5" stroke="var(--mw-b3)" stroke-width="1.3" stroke-linecap="round"/></svg>
<b>@hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif</b>
</div>
</div>
{{-- Rechts: Actions --}}
<div class="mw-tb-right">
{{-- Live-Status --}}
<div class="mw-tb-live">
<span class="mw-tb-live-dot"></span>
<span class="mw-tb-live-label">Live</span>
</div>
{{-- Suche --}}
<button class="mw-tb-search" onclick="Livewire.dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})">
<svg width="13" height="13" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.5" stroke="currentColor" stroke-width="1.3"/><path d="M7.8 7.8l2.2 2.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="mw-tb-search-text">Suche</span>
<kbd class="mw-tb-kbd">⌘K</kbd>
<div class="mw-topbar-right">
<div class="mw-live"><div class="mw-live-dot"></div>Live</div>
<button wire:click="$dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})" class="mw-search">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.8" stroke="currentColor" stroke-width="1.2"/><path d="M8 8l2.5 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Suche <kbd>⌘K</kbd>
</button>
{{-- Domain erstellen --}}
<button class="mw-tb-add" onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M5.5 1v9M1 5.5h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="mw-tb-add-text">Domain</span>
</button>
<div class="mw-ip">{{ gethostname() ?: '—' }}</div>
<button class="mw-btn-primary" onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">+ Domain</button>
</div>
</header>
<div class="mw-content">

View File

@ -40,7 +40,7 @@
</div>
@vite(['resources/js/app-webmail.js'])
@vite(['resources/js/app.js'])
@livewireScripts
</body>
</html>

View File

@ -132,7 +132,7 @@ function wmOpenSidebar() { document.getElementById('wm-sidebar').classList.add(
function wmCloseSidebar() { document.getElementById('wm-sidebar').classList.remove('open'); document.getElementById('wm-overlay').style.display='none'; }
</script>
@vite(['resources/js/app-webmail.js'])
@vite(['resources/js/app.js'])
@livewireScripts
</body>
</html>

View File

@ -1,115 +1,120 @@
<div style="width:100%;max-width:420px;padding:24px 16px">
<div class="w-full #min-h-[86vh] grid place-items-center
bg-[radial-gradient(1200px_600px_at_10%_-10%,rgba(59,130,246,.08),transparent),
radial-gradient(900px_500px_at_90%_0%,rgba(99,102,241,.06),transparent)]">
{{-- Logo --}}
<div style="display:flex;align-items:center;gap:10px;margin-bottom:32px;justify-content:center">
<div style="width:32px;height:32px;background:linear-gradient(135deg,#6366f1,#4f46e5);border-radius:8px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.4)">
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
<path d="M9 2L15.5 5.5v7L9 16 2.5 12.5v-7L9 2Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
</svg>
</div>
<div>
<div style="font-size:14px;font-weight:700;letter-spacing:.3px;color:var(--mw-t1)">MAILWOLT</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:-1px">Control Panel</div>
</div>
</div>
{{-- Setup-Done Banner --}}
{{-- Banner (dismissbar) --}}
@if($showBanner && $banner)
<div style="margin-bottom:14px;padding:12px 14px;border-radius:9px;border:1px solid rgba(34,197,94,.25);background:rgba(34,197,94,.07);display:flex;align-items:flex-start;gap:10px">
<svg width="15" height="15" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:1px;color:#4ade80"><path d="M2 7.5l3 3 7-7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div>
<div style="font-size:12.5px;font-weight:600;color:rgba(134,239,172,.95)">{{ $banner['title'] ?? 'Einrichtung abgeschlossen' }}</div>
@if(!empty($banner['message']))
<div style="font-size:11.5px;color:rgba(134,239,172,.7);margin-top:2px">{{ $banner['message'] }}</div>
@endif
<div class="max-w-[520px] mb-5 rounded-2xl border border-emerald-400/30 text-emerald-300 bg-emerald-500/10 p-4 #md:p-5 shadow backdrop-blur"
role="status" aria-live="polite">
<div class="flex items-start gap-3">
<div class="shrink-0 mt-0.5">
<i class="ph ph-check-circle text-emerald-100 text-xl"></i>
</div>
<div class="flex-1">
<div class="font-semibold text-emerald-100">
{{ $banner['title'] ?? 'Erfolgreich registriert' }}
</div>
<div class="mt-0.5 text-sm text-emerald-200">
{{ $banner['message'] ?? 'Dein Konto ist bereit. Melde dich jetzt an.' }}
</div>
</div>
</div>
</div>
@endif
{{-- Karte --}}
<div style="background:var(--mw-bg2);border:1px solid var(--mw-b2);border-radius:14px;padding:28px 24px">
<div style="margin-bottom:20px">
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Anmelden</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Melde dich mit deinem Account an</div>
<div class="nx-card w-full max-w-[520px]">
<div class="flex items-center justify-between mb-5">
<span class="nx-chip">Erster Login</span>
<i class="ph ph-lock-simple text-white/60"></i>
</div>
{{-- Fehler --}}
{{-- globale Fehlermeldung --}}
@if($error)
<div style="margin-bottom:16px;padding:10px 12px;border-radius:8px;border:1px solid rgba(239,68,68,.25);background:rgba(239,68,68,.07);display:flex;align-items:center;gap:8px">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#f87171"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M7 4.5v3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><circle cx="7" cy="9.5" r=".7" fill="currentColor"/></svg>
<span style="font-size:12px;color:#f87171">{{ $error }}</span>
<div class="nx-alert mb-6">
<i class="ph ph-warning-circle text-rose-300"></i>
<div>
<p class="font-medium">Anmeldung fehlgeschlagen</p>
<p class="text-sm/5 text-rose-200/90">{{ $error }}</p>
</div>
</div>
@endif
<form wire:submit.prevent="login" style="display:flex;flex-direction:column;gap:16px">
<form wire:submit.prevent="login" class="space-y-5">
<div>
<label class="mw-modal-label">E-Mail / Benutzername</label>
<input type="text"
<label class="nx-label" for="email">E-Mail / Benutzername</label>
<input id="name" type="name"
wire:model.defer="name"
autocomplete="username"
autofocus
class="mw-modal-input @error('name') border-red-500/50 @enderror"
placeholder="admin@example.com">
@error('name') <div class="mw-modal-error">{{ $message }}</div> @enderror
autocomplete="username" autofocus
class="nx-input @error('name') input-error @enderror">
@error('name')
<p class="field-error #mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label class="mw-modal-label">Passwort</label>
<div style="position:relative">
<input id="lf-password"
type="password"
<label class="nx-label" for="password">Passwort</label>
<div class="relative">
<input id="password" type="{{ $show ? 'text' : 'password' }}"
wire:model.defer="password"
autocomplete="current-password"
class="mw-modal-input @error('password') border-red-500/50 @enderror"
style="padding-right:38px"
placeholder="••••••••••">
<button type="button"
onclick="(function(btn){var i=document.getElementById('lf-password'),s=btn.querySelector('.ico-show'),h=btn.querySelector('.ico-hide');i.type=i.type==='password'?'text':'password';s.style.display=i.type==='text'?'none':'block';h.style.display=i.type==='text'?'block':'none';})(this)"
style="position:absolute;right:0;top:0;height:100%;width:36px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;cursor:pointer;color:var(--mw-t4)">
<svg class="ico-show" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="ico-hide" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
class="nx-input pr-10 @error('password') input-error @enderror">
<button type="button" class="nx-eye"
onclick="togglePassword('password', this)">
<i class="ph ph-eye text-white/60"></i>
</button>
</div>
@error('password') <div class="mw-modal-error">{{ $message }}</div> @enderror
@error('password')
<p class="field-error mt-1">{{ $message }}</p>
@enderror
</div>
<div style="display:flex;align-items:center;justify-content:space-between">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" wire:model.live="remember" style="display:none" id="lf-remember">
<span onclick="document.getElementById('lf-remember').click()"
style="width:16px;height:16px;border-radius:4px;border:1.5px solid var(--mw-b2);background:var(--mw-bg3);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s;{{ $remember ? 'background:rgba(99,102,241,.2);border-color:rgba(99,102,241,.6)' : '' }}">
@if($remember)
<svg width="9" height="9" viewBox="0 0 9 9" fill="none"><path d="M1.5 4.5l2 2 4-4" stroke="#a5b4fc" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
@endif
<div>
<div class="flex items-center justify-between pt-1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model.live="remember" class="peer hidden">
<span
class="w-5 h-5 flex items-center justify-center rounded border border-white/30 bg-white/5 peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400 transition">
<i class="ph ph-check text-emerald-400 text-xs hidden peer-checked:inline nx-check"></i>
</span>
<span style="font-size:12px;color:var(--mw-t4)">Merken</span>
<span class="text-sm text-white/70">Merken</span>
</label>
</div>
</div>
<button type="submit"
wire:loading.attr="disabled"
class="mbx-btn-primary"
style="width:100%;justify-content:center;font-size:13px;padding:9px 16px">
<span wire:loading.remove wire:target="login">Anmelden</span>
<span wire:loading wire:target="login" style="display:none">
<span style="display:inline-flex;align-items:center;gap:6px">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="animation:spin .7s linear infinite;flex-shrink:0">
<path d="M10 6A4 4 0 1 1 6 2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<button type="submit" class="nx-btn w-full" wire:loading.attr="disabled">
<span wire:loading.remove>Anmelden</span>
<span wire:loading class="inline-flex items-center gap-2">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
wird geprüft…
</span>
</span>
</button>
</form>
</div>
@if(\App\Models\Setting::signupAllowed())
<div style="margin-top:16px;text-align:center;font-size:12px;color:var(--mw-t4)">
Noch keinen Account?
<a wire:navigate href="{{ route('signup') }}" style="color:#a5b4fc;text-decoration:none">Zur Registrierung</a>
<div class="mt-6 text-sm text-white/70">
Noch keinen Account? <a wire:navigate href="{{ route('signup') }}" class="nx-link">Zur Registiereung</a>
</div>
@endif
</div>
<script>
function togglePassword(id, btn) {
const input = document.getElementById(id);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('ph-eye');
icon.classList.add('ph-eye-slash');
} else {
input.type = 'password';
icon.classList.remove('ph-eye-slash');
icon.classList.add('ph-eye');
}
}
</script>

View File

@ -15,7 +15,6 @@
</div>
{{-- ═══ Schritt-Indikator ═══ --}}
@if($step < 5)
<div style="display:flex;align-items:center;gap:0;margin-bottom:28px">
@foreach([1 => 'System', 2 => 'Domains', 3 => 'Admin', 4 => 'Fertig'] as $n => $label)
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:5px">
@ -38,7 +37,6 @@
@endif
@endforeach
</div>
@endif
{{-- ═══ Karte ═══ --}}
<div style="background:var(--mw-bg2);border:1px solid var(--mw-b2);border-radius:14px;padding:28px 24px">
@ -49,6 +47,7 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">System-Einstellungen</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Grundkonfiguration für deine Mailwolt-Instanz</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<label class="mw-modal-label">Instanzname</label>
@ -79,10 +78,12 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Domains</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Domains müssen bereits auf diesen Server zeigen</div>
</div>
<div style="display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:8px;background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.2);margin-bottom:16px">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:1px;color:#f59e0b"><path d="M7 1.5L12.5 11H1.5L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M7 5.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="10" r=".7" fill="currentColor"/></svg>
<span style="font-size:11.5px;color:rgba(251,191,36,.85);line-height:1.5">DNS-Einträge zuerst setzen, dann hier eintragen. Kein <code style="font-size:10.5px">http://</code> am Anfang.</span>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<label class="mw-modal-label">UI Domain <span style="color:var(--mw-t4);font-weight:400">(Control Panel)</span></label>
@ -90,7 +91,7 @@
@error('ui_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Mailserver Domain <span style="color:var(--mw-t4);font-weight:400">(MX / SMTP / IMAP)</span></label>
<label class="mw-modal-label">Mailserver Domain <span style="color:var(--mw-t4);font-weight:400">(MX / SMTP)</span></label>
<input type="text" wire:model="mail_domain" class="mw-modal-input" placeholder="mx.example.com">
@error('mail_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
@ -107,6 +108,7 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Admin-Account</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Dieser Account hat vollen Zugriff auf das Control Panel</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<label class="mw-modal-label">Name</label>
@ -119,7 +121,7 @@
@error('admin_email') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Passwort <span style="color:var(--mw-t4);font-weight:400">(min. 6 Zeichen)</span></label>
<label class="mw-modal-label">Passwort <span style="color:var(--mw-t4);font-weight:400">(min. 10 Zeichen)</span></label>
<input type="password" wire:model="admin_password" class="mw-modal-input">
@error('admin_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
@ -135,6 +137,7 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Alles bereit</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Überprüfe die Einstellungen und schließe die Einrichtung ab</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:4px">
@foreach([
['label' => 'Instanz', 'value' => $instance_name],
@ -150,124 +153,11 @@
</div>
@endforeach
</div>
<div style="margin-top:16px;padding:12px 14px;background:var(--mw-bg3);border-radius:8px;border:1px solid var(--mw-b2)">
<label class="mw-modal-check">
<input type="checkbox" wire:model="skipSsl">
<span class="mw-modal-check-label">SSL jetzt überspringen später in den Einstellungen einrichten</span>
</label>
@if($skipSsl)
<div style="margin-top:8px;font-size:11.5px;color:#fbbf24;padding-left:23px">Nginx wird ohne SSL konfiguriert. Im Dashboard erscheint ein Hinweis bis SSL eingerichtet ist.</div>
@endif
</div>
{{-- ── Schritt 5: Domain-Setup ── --}}
@elseif($step === 5)
<div wire:poll.2s="pollSetup"></div>
@php
$anyFailed = collect($domainStatus)->contains(fn($s) => in_array($s, ['error','nodns','noipv6']));
$allDone = $setupDone;
@endphp
<div style="margin-bottom:20px">
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Domains werden eingerichtet</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">SSL-Zertifikate werden beantragt und Nginx wird konfiguriert</div>
</div>
@php
$labels = ['ui' => $ui_domain, 'mail' => $mail_domain, 'webmail' => $webmail_domain];
$statusConfig = [
'pending' => [
'icon' => '…', 'color' => 'var(--mw-t5)', 'bg' => 'var(--mw-bg3)',
'label' => 'Warte …', 'hint' => null,
],
'running' => [
'icon' => '↻', 'color' => '#7dd3fc', 'bg' => 'rgba(14,165,233,.08)',
'label' => 'Wird registriert …', 'spin' => true, 'hint' => null,
],
'done' => [
'icon' => '✓', 'color' => 'rgba(34,197,94,.9)', 'bg' => 'rgba(34,197,94,.07)',
'label' => 'SSL-Zertifikat ausgestellt', 'hint' => null,
],
'nodns' => [
'icon' => '!', 'color' => '#fbbf24', 'bg' => 'rgba(251,191,36,.07)',
'label' => 'Kein DNS-Eintrag gefunden',
'hint' => 'Der A-Record dieser Domain zeigt nicht auf diesen Server oder ist noch nicht propagiert. DNS-Einstellungen prüfen und danach Retry klicken.',
'hints_extra' => ['DNS A-Record auf Server-IP setzen', 'DNS-Propagierung abwarten (bis zu 24h)', 'Mit dig +short A domain.com prüfen'],
],
'noipv6' => [
'icon' => '!', 'color' => '#fbbf24', 'bg' => 'rgba(251,191,36,.07)',
'label' => 'IPv6 nicht konfiguriert',
'hint' => 'Die Domain hat einen AAAA-Record, aber dieser Server hat kein aktives IPv6. Let\'s Encrypt prüft alle DNS-Records.',
'hints_extra' => ['IPv6 am Server aktivieren', 'ODER: AAAA-Record aus dem DNS entfernen'],
],
'error' => [
'icon' => '✗', 'color' => '#f87171', 'bg' => 'rgba(239,68,68,.07)',
'label' => 'SSL-Zertifikat fehlgeschlagen',
'hint' => 'Let\'s Encrypt konnte die Domain nicht verifizieren. Self-signed Zertifikat wird verwendet.',
'hints_extra' => ['Port 80 muss von außen erreichbar sein', 'Firewall-Regeln prüfen (ufw allow 80)', 'AAAA-Record ohne IPv6 am Server entfernen', 'http://domain/.well-known/acme-challenge/ im Browser testen'],
],
'skip' => [
'icon' => '', 'color' => 'var(--mw-t4)', 'bg' => 'var(--mw-bg3)',
'label' => 'SSL übersprungen', 'hint' => null,
],
];
@endphp
<div style="display:flex;flex-direction:column;gap:8px">
@foreach(['ui' => 'UI Domain', 'mail' => 'Mail Domain', 'webmail' => 'Webmail Domain'] as $key => $typeLabel)
@php
$st = $domainStatus[$key] ?? 'pending';
$cfg = $statusConfig[$st] ?? $statusConfig['pending'];
@endphp
<div style="border-radius:8px;border:1px solid var(--mw-b2);overflow:hidden">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;background:{{ $cfg['bg'] }}">
<div style="width:28px;height:28px;border-radius:50%;background:{{ $cfg['bg'] }};border:1px solid {{ $cfg['color'] }};display:flex;align-items:center;justify-content:center;font-size:13px;color:{{ $cfg['color'] }};flex-shrink:0;{{ isset($cfg['spin']) ? 'animation:spin .9s linear infinite' : '' }}">
{{ $cfg['icon'] }}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:11px;color:var(--mw-t4)">{{ $typeLabel }}</div>
<div style="font-size:12.5px;color:var(--mw-t1);font-family:monospace;margin-top:1px">{{ $labels[$key] }}</div>
</div>
<span style="font-size:11.5px;color:{{ $cfg['color'] }};white-space:nowrap">{{ $cfg['label'] }}</span>
</div>
@if(!empty($cfg['hint']))
<div style="padding:10px 14px;background:rgba(0,0,0,.15);border-top:1px solid var(--mw-b2)">
<div style="font-size:11.5px;color:var(--mw-t3);line-height:1.5;margin-bottom:{{ !empty($cfg['hints_extra']) ? '8px' : '0' }}">
{{ $cfg['hint'] }}
</div>
@if(!empty($cfg['hints_extra']))
<ul style="margin:0;padding-left:14px;display:flex;flex-direction:column;gap:3px">
@foreach($cfg['hints_extra'] as $hint)
<li style="font-size:11px;color:var(--mw-t4);line-height:1.4">{{ $hint }}</li>
@endforeach
</ul>
@endif
</div>
@endif
</div>
@endforeach
</div>
@if($allDone && $anyFailed)
<div style="margin-top:16px;padding:12px 14px;background:rgba(239,68,68,.05);border:1px solid rgba(239,68,68,.2);border-radius:8px">
<div style="font-size:12px;color:var(--mw-t3);margin-bottom:10px">
Einige Domains konnten nicht vollständig eingerichtet werden. Du kannst es erneut versuchen oder mit Self-signed Zertifikat fortfahren.
</div>
<button wire:click="retryDomains" wire:loading.attr="disabled" style="display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:7px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid rgba(99,102,241,.4);background:rgba(99,102,241,.12);color:#a5b4fc">
<svg wire:loading.remove wire:target="retryDomains" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6A4 4 0 1 1 8.5 2.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M8.5 1v2h2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg wire:loading wire:target="retryDomains" width="12" height="12" viewBox="0 0 12 12" fill="none" style="animation:spin .7s linear infinite"><path d="M10 6A4 4 0 1 1 6 2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Erneut versuchen
</button>
</div>
@endif
@endif
</div>
{{-- ═══ Navigation ═══ --}}
@if($step < 5)
<div style="display:flex;align-items:center;margin-top:20px;gap:10px">
@if($step > 1)
<button wire:click="back" class="mbx-btn-mute" style="font-size:12.5px">
@ -290,15 +180,5 @@
</button>
@endif
</div>
@elseif($step === 5 && $setupDone)
<div style="display:flex;justify-content:flex-end;margin-top:20px">
{{-- Kein wire:click plain Link damit kein Livewire-POST nötig ist.
nginx leitet /login nach SSL-Switch automatisch auf HTTPS weiter. --}}
<a href="/login" class="mbx-btn-primary" style="font-size:12.5px;width:fit-content;text-decoration:none;display:inline-flex;align-items:center;gap:6px">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6.5l2.5 2.5 5.5-5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{{ collect($domainStatus)->contains(fn($s) => in_array($s, ['error','nodns','noipv6'])) ? 'Trotzdem zum Login' : 'Zum Login' }}
</a>
</div>
@endif
</div>

View File

@ -3,20 +3,6 @@
<div wire:poll.30s>
{{-- SSL-Banner --}}
@if(!$sslConfigured)
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;margin-bottom:16px;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.25);border-radius:10px">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0;color:#fbbf24"><path d="M8 1.5L14.5 13H1.5L8 1.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M8 6v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="8" cy="11.5" r=".8" fill="currentColor"/></svg>
<div style="flex:1">
<span style="font-size:13px;font-weight:600;color:#fbbf24">SSL noch nicht eingerichtet</span>
<span style="font-size:12px;color:rgba(251,191,36,.7);margin-left:8px">Domains laufen ohne HTTPS Zertifikate in den Einstellungen beantragen.</span>
</div>
<a href="{{ route('ui.system.settings') }}" style="font-size:12px;padding:5px 12px;border-radius:6px;background:rgba(251,191,36,.15);border:1px solid rgba(251,191,36,.3);color:#fbbf24;text-decoration:none;white-space:nowrap">
Jetzt einrichten
</a>
</div>
@endif
{{-- Hero Banner --}}
<div class="mw-hero">
<div class="mw-hero-icon">
@ -213,25 +199,8 @@
</div>
<span class="mw-sc-badge mw-badge-mute">{{ $alertCount }} offen</span>
</div>
<div class="mw-sc-body">
@if($sandboxAlerts->isEmpty())
<div style="font-style:italic;color:var(--mw-t4);font-size:12px;">Keine Warnungen.</div>
@else
<div style="display:flex;flex-direction:column;gap:5px;">
@foreach($sandboxAlerts as $sr)
<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;background:rgba(234,179,8,.07);border:1px solid rgba(234,179,8,.2);border-radius:6px;">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" style="flex-shrink:0;color:#fbbf24"><path d="M5.5 1L10.5 10H.5L5.5 1Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M5.5 4.5v2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span style="font-size:11.5px;color:#fbbf24;font-weight:500;">
@if($sr->type === 'global') Sandbox global aktiv
@elseif($sr->type === 'domain') Sandbox aktiv: {{ $sr->target }}
@else Sandbox aktiv: {{ $sr->target }}
@endif
</span>
<a href="{{ route('ui.system.sandbox') }}" style="margin-left:auto;font-size:10.5px;color:rgba(251,191,36,.6);text-decoration:none;white-space:nowrap;">verwalten </a>
</div>
@endforeach
</div>
@endif
<div class="mw-sc-body" style="font-style:italic;color:var(--mw-t4);">
{{ $alertCount === 0 ? 'Keine Warnungen.' : ($alertCount . ' aktive Warnungen.') }}
</div>
</div>

View File

@ -88,7 +88,7 @@ $_wmUrl = ($_wmSub && $_wmBase)
<td class="mbx-td">
<div class="mbx-actions">
<a href="{{ $_wmUrl }}" target="_blank" class="mbx-act-btn" title="Webmail öffnen">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d=".5 5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
</a>
<button wire:click="openDns({{ $systemDomain->id }})" class="mbx-act-btn" title="DNS-Assistent">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 9.5C5 8 6 7 7 7s2 1 2.5 2.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/><circle cx="7" cy="4.5" r=".7" fill="currentColor"/></svg>
@ -138,15 +138,6 @@ $_wmUrl = ($_wmSub && $_wmBase)
<td class="mbx-td">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
@if(\App\Models\SandboxRoute::isActiveForDomain($d->domain))
<div class="dom-icon" style="color:#fbbf24;background:rgba(234,179,8,.1);border-color:rgba(234,179,8,.3);flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M7 2L13 12H1L7 2Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M7 6v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="7" cy="10.5" r=".6" fill="currentColor"/>
</svg>
</div>
@else
<div class="dom-icon">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/>
@ -154,7 +145,6 @@ $_wmUrl = ($_wmSub && $_wmBase)
<path d="M1.5 5.5h11M1.5 8.5h11" stroke="currentColor" stroke-width="1.2"/>
</svg>
</div>
@endif
<div style="min-width:0">
<div class="dom-name">{{ $d->domain }}</div>
@if(!empty($d->description))

View File

@ -2,8 +2,7 @@
<x-slot:breadcrumb>API Keys</x-slot:breadcrumb>
<div x-data="{activeGroup: 'mailboxes'}"
x-on:token-created.window="$wire.$refresh()"
x-on:token-deleted.window="$wire.$refresh()">
x-on:token-created.window="$dispatch('openModal', {component: 'ui.system.modal.api-key-show-modal', arguments: {plainText: $event.detail.plainText}})">
<div class="mbx-page-header">
<div class="mbx-page-title">
@ -24,7 +23,7 @@
</div>
</div>
<div class="mw-apikey-layout">
<div style="display:grid;grid-template-columns:1fr 340px;gap:14px;align-items:start">
{{-- ═══ Left: Keys + Endpoint-Docs ═══ --}}
<div class="mbx-sections">
@ -47,68 +46,62 @@
<div style="font-size:11.5px;color:var(--mw-t4)">Erstelle deinen ersten Key um externe Anwendungen zu verbinden.</div>
</div>
@else
{{-- Header --}}
<div class="mw-kl-head">
<span>Name</span>
<span>Scopes</span>
<span>Modus</span>
<span>Erstellt</span>
<span></span>
</div>
{{-- Rows --}}
<div class="mbx-table-wrap">
<table class="mbx-table">
<thead>
<tr>
<th class="mbx-th">Name</th>
<th class="mbx-th">Scopes</th>
<th class="mbx-th" style="width:86px">Modus</th>
<th class="mbx-th" style="width:120px">Zuletzt genutzt</th>
<th class="mbx-th" style="width:110px">Erstellt</th>
<th class="mbx-th mbx-th-right" style="width:50px"></th>
</tr>
</thead>
<tbody>
@foreach($tokens as $token)
@php
$abilities = $token->abilities;
$visible = array_slice($abilities, 0, 2);
$rest = count($abilities) - 2;
@endphp
<div class="mw-kl-row">
{{-- Name --}}
<div class="mw-kl-name" style="display:flex;align-items:center;gap:8px;overflow:hidden">
<tr class="mbx-tr">
<td class="mbx-td">
<div style="display:flex;align-items:center;gap:8px">
<div style="width:26px;height:26px;border-radius:7px;background:var(--mw-bg4);border:1px solid var(--mw-b2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<path d="M6 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" stroke="var(--mw-t3)" stroke-width="1.3"/>
<path d="M9.5 8.5L14 13" stroke="var(--mw-t3)" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<span style="font-size:12.5px;font-weight:500;color:var(--mw-t1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1">{{ $token->name }}</span>
<span style="font-size:12.5px;font-weight:500;color:var(--mw-t1)">{{ $token->name }}</span>
</div>
{{-- Scopes --}}
<div class="mw-kl-scopes" style="display:flex;flex-wrap:nowrap;gap:3px;align-items:center;overflow:hidden">
@foreach($visible as $scope)
@php $short = collect(explode(':', $scope))->map(fn($p) => $p[0])->join(':'); @endphp
<span title="{{ $scope }}" style="font-family:monospace;font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4)">{{ $short }}</span>
</td>
<td class="mbx-td">
<div style="display:flex;flex-wrap:wrap;gap:3px">
@foreach($token->abilities as $scope)
<span style="font-family:monospace;font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4)">{{ $scope }}</span>
@endforeach
@if($rest > 0)
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.api-key-scopes-modal',arguments:{tokenId:{{ $token->id }}}})"
style="font-family:monospace;font-size:9.5px;padding:1px 6px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-v2);cursor:pointer;white-space:nowrap;flex-shrink:0">
+{{ $rest }}
</button>
@endif
<span class="mw-kl-date-inline" style="font-size:10px;color:var(--mw-t5);margin-left:2px">· {{ $token->created_at->format('d.m.Y') }}</span>
</div>
{{-- Modus --}}
<div class="mw-kl-modus">
</td>
<td class="mbx-td">
@if($token->sandbox)
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.25);color:#f59e0b;font-weight:500;white-space:nowrap">Sandbox</span>
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.25);color:#f59e0b;font-weight:500">Sandbox</span>
@else
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.2);color:#34d399;font-weight:500;white-space:nowrap">Live</span>
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.2);color:#34d399;font-weight:500">Live</span>
@endif
</div>
{{-- Date (desktop only) --}}
<div class="mw-kl-date" style="font-size:11px;color:var(--mw-t4)">{{ $token->created_at->format('d.m.Y') }}</div>
{{-- Actions --}}
<div class="mw-kl-actions">
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.api-key-delete-modal',arguments:{tokenId:{{ $token->id }}}})"
</td>
<td class="mbx-td mbx-td-muted" style="font-size:11px">{{ $token->last_used_at?->diffForHumans() ?? '—' }}</td>
<td class="mbx-td mbx-td-muted" style="font-size:11px">{{ $token->created_at->format('d.m.Y') }}</td>
<td class="mbx-td">
<div class="mbx-actions">
<button wire:click="deleteToken({{ $token->id }})"
wire:confirm="API Key '{{ $token->name }}' wirklich löschen?"
class="mbx-act-btn mbx-act-danger" title="Löschen">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@ -300,5 +293,4 @@ Content-Type: application/json</pre>
</div>
</div>

View File

@ -64,7 +64,7 @@
@php
$componentIcons = [
'nginx' => '<path d="M7 2L2 5v4l5 3 5-3V5L7 2Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M7 2v11M2 5l10 6M12 5L2 11" stroke="currentColor" stroke-width="1.2"/>',
'postfix' => '<rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4.5L13.5 5" stroke="currentColor" stroke-width="1.2"/>',
'postfix' => '<rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d=".5 5l6.5 4.5L13.5 5" stroke="currentColor" stroke-width="1.2"/>',
'dovecot' => '<path d="M7 1.5C7 1.5 2 4 2 8.5a5 5 0 0 0 10 0C12 4 7 1.5 7 1.5Z" stroke="currentColor" stroke-width="1.2"/>',
'rspamd' => '<path d="M7 1.5L12.5 4v4.8c0 2.8-2.3 4.8-5.5 5.2C3.8 13.6 1.5 11.6 1.5 8.8V4L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>',
'fail2ban' => '<rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',

View File

@ -18,37 +18,33 @@
@error('name') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div style="margin-top:14px">
<div style="margin-top:16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<label class="mw-modal-label" style="margin:0">Berechtigungen</label>
<label class="mw-modal-label" style="margin:0">Berechtigungen (Scopes)</label>
<button type="button" wire:click="toggleAll"
style="background:none;border:none;font-size:11px;color:var(--mw-v);cursor:pointer;padding:0">
{{ count($selected) === count($scopes) ? 'Alle abwählen' : 'Alle wählen' }}
</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px">
<div style="display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px">
@foreach($scopes as $key => $label)
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;padding:5px 8px;border-radius:5px;transition:background .1s"
onmouseover="this.style.background='var(--mw-bg3)'"
onmouseout="this.style.background='transparent'">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" wire:model="selected" value="{{ $key }}"
style="width:13px;height:13px;flex-shrink:0;accent-color:var(--mw-v)">
<div>
<div style="font-size:11px;color:var(--mw-t2);font-family:monospace;line-height:1.3">{{ $key }}</div>
<div style="font-size:10px;color:var(--mw-t4);line-height:1.3">{{ $label }}</div>
</div>
style="width:13px;height:13px;accent-color:var(--mw-v)">
<span style="font-size:12px;color:var(--mw-t2);font-family:monospace">{{ $key }}</span>
<span style="font-size:11px;color:var(--mw-t4)"> {{ $label }}</span>
</label>
@endforeach
</div>
@error('selected') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div style="margin-top:12px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:10px 12px;background:rgba(251,191,36,.05);border:1px solid rgba(251,191,36,.18);border-radius:8px">
<input type="checkbox" wire:model="sandbox" style="width:13px;height:13px;flex-shrink:0;accent-color:#f59e0b">
<div style="margin-top:14px;padding:12px;background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.2);border-radius:8px">
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" wire:model="sandbox" style="width:13px;height:13px;margin-top:1px;accent-color:#f59e0b">
<div>
<div style="font-size:12px;color:var(--mw-t1);font-weight:500">Sandbox-Modus</div>
<div style="font-size:10.5px;color:var(--mw-t4);margin-top:1px">Schreiboperationen werden simuliert keine Änderungen in der DB.</div>
<div style="font-size:12.5px;color:var(--mw-t1);font-weight:500">Sandbox-Modus</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:2px">Schreiboperationen werden simuliert keine Änderungen in der Datenbank. Ideal für Tests und Entwicklung.</div>
</div>
</label>
</div>

View File

@ -1,30 +0,0 @@
<div class="mw-modal-frame">
<div class="mw-modal-head">
<div style="display:flex;align-items:center;gap:7px">
<span class="mbx-badge-mute">API Key</span>
<span style="font-size:12px;color:var(--mw-t3)"> Löschen</span>
</div>
<button wire:click="$dispatch('closeModal')" class="mw-modal-close">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1 1l11 11M12 1L1 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
<div class="mw-modal-body">
<div style="padding:12px 14px;border-radius:8px;border:1px solid rgba(239,68,68,.25);background:rgba(239,68,68,.06)">
<div style="font-size:12.5px;font-weight:500;color:var(--mw-t1);font-family:monospace">{{ $tokenName }}</div>
<div style="font-size:11px;color:rgba(252,165,165,.8);margin-top:4px">
Dieser API Key wird dauerhaft gelöscht. Anwendungen die ihn nutzen verlieren sofort den Zugriff.
</div>
</div>
</div>
<div class="mw-modal-foot">
<button wire:click="$dispatch('closeModal')" class="mw-btn-cancel">Abbrechen</button>
<button wire:click="delete" class="mw-btn-del">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Löschen
</button>
</div>
</div>

View File

@ -1,29 +0,0 @@
<div class="mw-modal-frame">
<div class="mw-modal-head">
<div style="display:flex;align-items:center;gap:7px">
<span class="mbx-badge-mute">{{ $tokenName }}</span>
<span style="font-size:12px;color:var(--mw-t3)"> Scopes</span>
</div>
<button wire:click="$dispatch('closeModal')" class="mw-modal-close">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1 1l11 11M12 1L1 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
<div class="mw-modal-body">
<div style="display:flex;flex-direction:column;gap:5px">
@foreach($scopes as $scope)
@php $short = collect(explode(':', $scope))->map(fn($p) => $p[0])->join(':'); @endphp
<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
<code style="font-family:monospace;font-size:11px;padding:1px 7px;background:var(--mw-bg3);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);flex-shrink:0">{{ $short }}</code>
<span style="font-size:11.5px;color:var(--mw-t3)">{{ $scope }}</span>
</div>
@endforeach
</div>
</div>
<div class="mw-modal-foot">
<button wire:click="$dispatch('closeModal')" class="mw-btn-cancel">Schließen</button>
</div>
</div>

View File

@ -5,7 +5,7 @@
<span class="mbx-badge-ok">Key erstellt</span>
<span style="font-size:12px;color:var(--mw-t3)"> einmalig anzeigen</span>
</div>
<button wire:click="dismiss" class="mw-modal-close">
<button wire:click="$dispatch('closeModal')" class="mw-modal-close">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1 1l11 11M12 1L1 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
@ -37,7 +37,7 @@
</div>
<div class="mw-modal-foot">
<button wire:click="dismiss" class="mw-btn-primary">Fertig</button>
<button wire:click="$dispatch('closeModal')" class="mbx-btn-primary">Fertig</button>
</div>
</div>

View File

@ -30,16 +30,11 @@
</div>
</div>
{{-- Sandbox-Routing Regeln --}}
<livewire:ui.system.sandbox-routes />
<div style="height:1px;background:var(--mw-b1);margin-bottom:14px"></div>
{{-- Mail-Client Layout --}}
<div class="mw-sandbox-client">
<div style="display:grid;grid-template-columns:320px 1fr;gap:0;border:1px solid var(--mw-b1);border-radius:10px;overflow:hidden;background:var(--mw-bg2);min-height:520px">
{{-- ═══ Left: Mail-Liste ═══ --}}
<div class="mw-sandbox-list">
<div style="border-right:1px solid var(--mw-b1);overflow-y:auto;max-height:680px">
@if($mails->isEmpty())
<div style="padding:48px 16px;text-align:center">
@ -88,7 +83,7 @@
</div>
{{-- ═══ Right: Mail-Detail ═══ --}}
<div class="mw-sandbox-detail">
<div style="overflow-y:auto;max-height:680px">
@if(!$selected)
<div style="display:flex;align-items:center;justify-content:center;height:100%;min-height:400px;flex-direction:column;gap:12px">
@ -194,7 +189,7 @@
<span style="font-size:11px;color:var(--mw-t4);margin-left:6px"> Sandbox-Transport aktivieren</span>
</div>
</div>
<div class="mw-postfix-grid">
<div style="padding:16px 18px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px">
<div>
<div style="font-size:11px;color:var(--mw-t4);margin-bottom:6px;font-weight:500">1. master.cf Transport anlegen</div>

View File

@ -1,113 +0,0 @@
<div style="margin-bottom:14px">
{{-- ── Header ── --}}
<div class="mbx-domain-head" style="margin-bottom:10px">
<div class="mbx-domain-info">
<span style="font-size:10.5px;padding:2px 8px;border-radius:4px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:#fbbf24;font-weight:600;">
Sandbox-Routing
</span>
<span style="font-size:11px;color:var(--mw-t4);margin-left:6px"> Transport-Regeln für Postfix</span>
</div>
<button wire:click="syncPostfix" class="mw-btn-secondary"
style="display:flex;align-items:center;gap:6px;font-size:12px;padding:5px 12px;white-space:nowrap">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M12.5 7A5.5 5.5 0 1 1 7 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<path d="M7 .5l2.5 1.5L7 3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Postfix-Sync
</button>
</div>
{{-- ── Sync-Message ── --}}
@if($syncMessage)
<div style="margin-bottom:10px;padding:7px 12px;border-radius:6px;font-size:11.5px;display:flex;align-items:center;gap:7px;
{{ $syncOk
? 'background:rgba(34,197,94,.07);border:1px solid rgba(34,197,94,.25);color:var(--mw-gr)'
: 'background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.25);color:#f87171' }}">
@if($syncOk)
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M1.5 5.5l3 3 5-5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
@else
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M1.5 1.5l8 8M9.5 1.5l-8 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
@endif
{{ $syncMessage }}
</div>
@endif
{{-- ── Add-Form ── --}}
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
<select wire:model.live="newType"
style="font-size:12px;padding:5px 8px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2);color:var(--mw-t2);cursor:pointer;outline:none;height:30px;flex-shrink:0">
<option value="global">global</option>
<option value="domain">domain</option>
<option value="address">address</option>
</select>
@if($newType !== 'global')
<input wire:model="newTarget" type="text"
placeholder="{{ $newType === 'domain' ? 'example.com' : 'user@example.com' }}"
style="font-size:12px;padding:5px 10px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2);color:var(--mw-t1);outline:none;width:220px;height:30px;flex-shrink:0"
wire:keydown.enter="addRoute">
@endif
<button wire:click="addRoute" class="mw-btn-primary" style="font-size:12px;padding:5px 14px;height:30px;white-space:nowrap;flex-shrink:0">
+ Hinzufügen
</button>
</div>
{{-- ── Route List ── --}}
@if($this->routes->isEmpty())
<div style="padding:20px 16px;text-align:center;color:var(--mw-t4);font-size:12px;background:var(--mw-bg3);border:1px solid var(--mw-b1);border-radius:8px;">
Keine Sandbox-Routen konfiguriert.
</div>
@else
<div style="border:1px solid var(--mw-b1);border-radius:8px;overflow:hidden">
@foreach($this->routes as $route)
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;
{{ !$loop->last ? 'border-bottom:1px solid var(--mw-b1);' : '' }}
background:{{ $route->is_active ? 'var(--mw-bg3)' : 'var(--mw-bg4)' }}">
{{-- Type badge --}}
@if($route->type === 'global')
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.25);color:#f87171;font-weight:600;flex-shrink:0">global</span>
@elseif($route->type === 'domain')
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(124,58,237,.12);border:1px solid rgba(124,58,237,.25);color:var(--mw-v2);font-weight:600;flex-shrink:0">domain</span>
@else
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(59,130,246,.12);border:1px solid rgba(59,130,246,.25);color:#60a5fa;font-weight:600;flex-shrink:0">address</span>
@endif
{{-- Target --}}
<span style="font-size:12px;color:{{ $route->is_active ? 'var(--mw-t1)' : 'var(--mw-t4)' }};font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $route->target ?? '* (alle)' }}
</span>
{{-- Active indicator --}}
@if($route->is_active)
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.2);color:var(--mw-gr);font-weight:600;flex-shrink:0">aktiv</span>
@else
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:var(--mw-bg4);border:1px solid var(--mw-b2);color:var(--mw-t4);font-weight:600;flex-shrink:0">inaktiv</span>
@endif
{{-- Toggle button --}}
<button wire:click="toggleRoute({{ $route->id }})" class="mbx-act-btn"
title="{{ $route->is_active ? 'Deaktivieren' : 'Aktivieren' }}"
style="flex-shrink:0">
@if($route->is_active)
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><rect x="1" y="4" width="12" height="6" rx="3" stroke="currentColor" stroke-width="1.2"/><circle cx="10" cy="7" r="2" fill="currentColor"/></svg>
@else
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><rect x="1" y="4" width="12" height="6" rx="3" stroke="currentColor" stroke-width="1.2"/><circle cx="4" cy="7" r="2" fill="currentColor" opacity=".4"/></svg>
@endif
</button>
{{-- Delete button --}}
<button wire:click="removeRoute({{ $route->id }})"
wire:confirm="Route '{{ $route->target ?? 'global' }}' wirklich löschen?"
class="mbx-act-btn mbx-act-danger" title="Löschen" style="flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none">
<path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -23,7 +23,7 @@
</div>
</div>
<div class="mw-wh-layout">
<div style="display:grid;grid-template-columns:1fr 300px;gap:14px;align-items:start">
{{-- ═══ Left ═══ --}}
<div class="mbx-sections">
@ -45,68 +45,45 @@
<div style="font-size:11.5px;color:var(--mw-t4)">Verbinde externe Systeme sie werden bei Events automatisch benachrichtigt.</div>
</div>
@else
{{-- Header --}}
<div class="mw-whl-head">
<span>Webhook</span>
<span>Events</span>
<span>Status</span>
<span>HTTP</span>
<span>Aktionen</span>
</div>
{{-- Rows --}}
<div class="mbx-table-wrap">
<table class="mbx-table">
<thead>
<tr>
<th class="mbx-th">Webhook</th>
<th class="mbx-th">Events</th>
<th class="mbx-th" style="width:80px">Status</th>
<th class="mbx-th" style="width:90px">HTTP</th>
<th class="mbx-th" style="width:130px">Zuletzt ausgelöst</th>
<th class="mbx-th mbx-th-right" style="width:80px">Aktionen</th>
</tr>
</thead>
<tbody>
@foreach($webhooks as $wh)
@php
$evVisible = array_slice($wh->events, 0, 2);
$evRest = count($wh->events) - 2;
@endphp
<div class="mw-whl-row">
{{-- Webhook name + url --}}
<div class="mw-whl-webhook" style="display:flex;align-items:center;gap:8px;min-width:0">
<tr class="mbx-tr">
<td class="mbx-td">
<div style="display:flex;align-items:center;gap:8px">
<div style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:{{ $wh->is_active ? '#34d399' : 'var(--mw-t4)' }};box-shadow:{{ $wh->is_active ? '0 0 6px rgba(52,211,153,.5)' : 'none' }}"></div>
<div style="min-width:0">
<div style="font-size:12.5px;font-weight:500;color:var(--mw-t1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $wh->name }}
<span class="mw-whl-status-sm">
@if($wh->is_active)
<span class="mbx-badge-ok" style="font-size:9px;margin-left:4px">Aktiv</span>
@else
<span class="mbx-badge-warn" style="font-size:9px;margin-left:4px">Pausiert</span>
@endif
</span>
<span class="mw-whl-http-sm" style="font-family:monospace;font-size:9.5px;margin-left:4px">
@if($wh->last_status !== null)
@if($wh->last_status >= 200 && $wh->last_status < 300)
<span style="color:#34d399;font-weight:600">{{ $wh->last_status }}</span>
@else
<span style="color:#f87171;font-weight:600">{{ $wh->last_status === 0 ? 'T/O' : $wh->last_status }}</span>
@endif
@endif
</span>
</div>
<div style="font-family:monospace;font-size:10px;color:var(--mw-t5);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $wh->url }}</div>
<div>
<div style="font-size:12.5px;font-weight:500;color:var(--mw-t1)">{{ $wh->name }}</div>
<div style="font-family:monospace;font-size:10.5px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px">{{ $wh->url }}</div>
</div>
</div>
{{-- Events --}}
<div class="mw-whl-events" style="display:flex;flex-wrap:wrap;gap:3px;align-items:center">
@foreach($evVisible as $ev)
<span style="font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);font-family:monospace;white-space:nowrap">{{ $ev }}</span>
</td>
<td class="mbx-td">
<div style="display:flex;flex-wrap:wrap;gap:3px">
@foreach($wh->events as $ev)
<span style="font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);font-family:monospace">{{ $ev }}</span>
@endforeach
@if($evRest > 0)
<span style="font-size:9.5px;padding:1px 6px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);white-space:nowrap">+{{ $evRest }}</span>
@endif
</div>
{{-- Status --}}
<div class="mw-whl-status">
</td>
<td class="mbx-td">
@if($wh->is_active)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Pausiert</span>
@endif
</div>
{{-- HTTP --}}
<div class="mw-whl-http">
</td>
<td class="mbx-td">
@if($wh->last_status === null)
<span style="font-size:11px;color:var(--mw-t4)"></span>
@elseif($wh->last_status >= 200 && $wh->last_status < 300)
@ -116,9 +93,11 @@
@else
<span style="font-family:monospace;font-size:11px;color:#f87171;font-weight:600">{{ $wh->last_status }}</span>
@endif
</div>
{{-- Actions --}}
<div class="mw-whl-actions">
</td>
<td class="mbx-td mbx-td-muted" style="font-size:11px">
{{ $wh->last_triggered_at?->diffForHumans() ?? '—' }}
</td>
<td class="mbx-td">
<div class="mbx-actions">
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.webhook-edit-modal',arguments:{webhookId:{{ $wh->id }}}})"
class="mbx-act-btn" title="Bearbeiten">
@ -137,10 +116,12 @@
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>

View File

@ -28,29 +28,12 @@
<span class="hidden"></span>
{{-- <div x-show="show && showActiveComponent"--}}
{{-- x-bind:class="modalWidth"--}}
{{-- id="modal-container"--}}
{{-- x-trap.noscroll.inert="show && showActiveComponent"--}}
{{-- aria-modal="true"--}}
{{-- class="mw-modal-box inline-block w-full sm:max-w-md relative z-10">--}}
<span class="hidden"></span>
<div
x-show="show && showActiveComponent"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
<div x-show="show && showActiveComponent"
x-bind:class="modalWidth"
class="mw-modal-box relative inline-block w-full align-bottom rounded-lg text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:w-full"
id="modal-container"
x-trap.inert="show && showActiveComponent"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
class="mw-modal-box inline-block w-full sm:max-w-md relative z-10">
<div class="mw-modal-inner">
@forelse($components as $id => $component)

View File

@ -13,14 +13,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
try {
$setupDone = \App\Models\Setting::get('setup_completed', '0') === '1';
} catch (\Throwable) {
$setupDone = false;
}
if (!$setupDone) {
return redirect()->route('setup');
}
return Auth::check()
? redirect()->route('ui.dashboard')
: redirect()->route('login');
@ -116,5 +108,7 @@ Route::middleware('guest.only')->group(function () {
Route::get('/signup', [SignUpController::class, 'show'])->middleware('signup.open')->name('signup');
});
Route::middleware('auth')->group(function () {
Route::get('/setup', \App\Livewire\Setup\Wizard::class)->name('setup');
});

View File

@ -13,7 +13,7 @@ export default ({mode}) => {
return defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/app-webmail.js'],
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
tailwindcss(),
@ -21,7 +21,6 @@ export default ({mode}) => {
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
hmr: {
host: 'ui.dev.mail.nexlab.at',