Rechtebechebung für User mit Sudorechte

main
boban 2025-10-19 22:38:46 +02:00
parent 28129cb989
commit f8934b7a9a
9 changed files with 203 additions and 120 deletions

View File

@ -3,16 +3,33 @@
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class LoginForm extends Component
{
public ?array $banner = [];
public bool $showBanner = false;
public string $name = '';
public string $password = '';
public ?string $error = null;
public bool $show = false;
public function mount(): void
{
// Flash nur EINMAL ziehen
$flash = session()->pull('login_banner');
if ($flash) {
$this->banner = $flash;
$this->showBanner = true;
}
}
public function dismissBanner(): void
{
$this->showBanner = false;
}
public function login()
{
@ -28,7 +45,7 @@ class LoginForm extends Component
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
request()->session()->regenerate();
return redirect()->intended(route('setup.wizard')) ;
return redirect()->intended(route('ui.dashboard'));
}
$this->error = 'Ungültige Zugangsdaten.';

View File

@ -58,6 +58,13 @@ class SignupForm extends Component
// nach erstem User: Signup deaktivieren
if ($isFirstUser) {
Setting::set('system.signup_enabled', 0); // Redis + DB
return redirect()
->route('login')
->with('login_banner', [
'type' => 'success',
'title' => 'Registrierung abgeschlossen',
'message' => 'Dein Konto wurde erfolgreich erstellt. Du kannst dich jetzt anmelden.',
]);
}
});
});

View File

@ -20,6 +20,8 @@ class DomainDnsModal extends ModalComponent
public array $static = [];
/** @var array<int,array<string,string|int|null>> */
public array $dynamic = [];
/** @var array<int,array<string,string|int|null>> */
public array $optional = [];
public static function modalMaxWidth(): string
{
@ -37,6 +39,7 @@ class DomainDnsModal extends ModalComponent
'TXT' => 'bg-violet-500/20 text-violet-300',
'SRV' => 'bg-rose-500/20 text-rose-300',
'TLSA' => 'bg-red-500/20 text-red-300',
'OPTIONAL' => 'bg-gray-500/20 text-gray-300',
];
$d = Domain::findOrFail($domainId);
@ -59,7 +62,6 @@ class DomainDnsModal extends ModalComponent
// --- Statische Infrastruktur (für alle Domains gleich) ---
$this->static = [
['type' => 'MX', 'name' => $base, 'value' => "10 {$mailServerFqdn}."],
['type' => 'A', 'name' => $mailServerFqdn, 'value' => $ipv4],
['type' => 'PTR', 'name' => $this->ptrFromIPv4($ipv4), 'value' => $tlsa->host],
];
@ -78,7 +80,7 @@ class DomainDnsModal extends ModalComponent
}
// --- Domain-spezifisch ---
$spf = "v=spf1 a mx ip4:{$ipv4} ip6:{$ipv6} ~all";
$spf = "v=spf1 ip4:{$ipv4} ip6:{$ipv6} mx -all";
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
$dkim = DB::table('dkim_keys')
@ -90,19 +92,24 @@ class DomainDnsModal extends ModalComponent
: ($dkim->public_key_txt ?? 'v=DKIM1; k=rsa; p=');
$this->dynamic = [
['type' => 'MX', 'name' => $this->domainName, 'value' => "10 {$mailServerFqdn}."],
['type' => 'CNAME', 'name' => "autoconfig.$this->domainName", 'value' => "$mailServerFqdn."],
['type' => 'CNAME', 'name' => "autodiscover.$this->domainName", 'value' => "$mailServerFqdn."],
// SRV Records für Autodiscover und Maildienste
['type' => 'SRV', 'name' => "_autodiscover._tcp.$this->domainName", 'value' => "0 0 443 {$mailServerFqdn}."],
['type' => 'SRV', 'name' => "_imaps._tcp.$this->domainName", 'value' => "0 0 993 {$mailServerFqdn}."],
['type' => 'SRV', 'name' => "_pop3s._tcp.$this->domainName", 'value' => "0 0 995 {$mailServerFqdn}."],
['type' => 'SRV', 'name' => "_submission._tcp.$this->domainName", 'value' => "0 0 587 {$mailServerFqdn}."],
// TXT Records
['type' => 'TXT', 'name' => $this->domainName, 'value' => $spf, 'helpLabel' => 'SPF Record Syntax', 'helpUrl' => 'http://www.open-spf.org/SPF_Record_Syntax/'],
['type' => 'TXT', 'name' => "_dmarc.{$this->domainName}", 'value' => $dmarc, 'helpLabel' => 'DMARC Assistant', 'helpUrl' => 'https://www.kitterman.com/dmarc/assistant.html'],
['type' => 'TXT', 'name' => $dkimHost, 'value' => $dkimTxt, 'helpLabel' => 'DKIM Inspector', 'helpUrl' => 'https://dkimvalidator.com/'],
];
$this->optional = [
// SRV Records für Autodiscover und Maildienste
['type' => 'SRV', 'name' => "_autodiscover._tcp.$this->domainName", 'value' => "0 0 443 {$mailServerFqdn}."],
['type' => 'SRV', 'name' => "_imaps._tcp.$this->domainName", 'value' => "0 0 993 {$mailServerFqdn}."],
['type' => 'SRV', 'name' => "_pop3s._tcp.$this->domainName", 'value' => "0 0 995 {$mailServerFqdn}."],
['type' => 'SRV', 'name' => "_submission._tcp.$this->domainName", 'value' => "0 0 587 {$mailServerFqdn}."],
];
}

View File

@ -2,11 +2,8 @@
namespace App\Observers;
use App\Jobs\InstallDkimKey;
use App\Jobs\RemoveDkimKey;
use App\Models\DkimKey;
use App\Models\Domain;
use App\Services\DkimService;
use Illuminate\Support\Facades\Log;
class DomainObserver
{
@ -16,76 +13,54 @@ class DomainObserver
*/
public function created(Domain $domain): void
{
if ($domain->is_server) {
return;
}
if ($domain->is_server) return;
$selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$bits = (int) config('mailpool.defaults.dkim_bits', 2048);
$res = app(\App\Services\DkimService::class)
->generateForDomain($domain, $bits, $selector);
// Service erledigt: Key generieren, DB (upsert) pflegen, Helper ausführen, OpenDKIM reloaden
app(\App\Services\DkimService::class)->generateForDomain($domain, $bits, $selector);
$dk = \App\Models\DkimKey::create([
'domain_id' => $domain->id,
'selector' => $res['selector'],
'private_key_pem' => $res['private_pem'],
'public_key_txt' => preg_replace('/^v=DKIM1; k=rsa; p=/', '', $res['dns_txt']),
'is_active' => true,
]);
// Helper aufrufen (Pfad aus $res['priv_path']!)
dispatch(new \App\Jobs\InstallDkimKey(
domainId: $domain->id,
dkimKeyId: $dk->id,
privPath: $res['priv_path'],
dnsTxtContent: $res['dns_txt'],
));
// DNS-Records gleich anlegen/aktualisieren
app(\App\Services\DnsRecordService::class)->provision(
$domain,
$dk->selector,
"v=DKIM1; k=rsa; p={$dk->public_key_txt}",
[
'spf_tail' => \App\Models\Setting::get('mailpool.spf_tail', '~all'),
'spf_extra' => \App\Models\Setting::get('mailpool.spf_extra', []),
'dmarc_policy' => \App\Models\Setting::get('mailpool.dmarc_policy', 'none'),
'rua' => \App\Models\Setting::get('mailpool.rua', null),
]
);
// DNS-Records: aktiven Key aus DB lesen und provisionieren
$active = $domain->dkimKeys()->where('is_active', true)->latest()->first();
if ($active) {
app(\App\Services\DnsRecordService::class)->provision(
$domain,
$active->selector,
"v=DKIM1; k=rsa; p={$active->public_key_txt}",
[
'spf_tail' => \App\Models\Setting::get('mailpool.spf_tail', '~all'),
'spf_extra' => \App\Models\Setting::get('mailpool.spf_extra', []),
'dmarc_policy' => \App\Models\Setting::get('mailpool.dmarc_policy', 'none'),
'rua' => \App\Models\Setting::get('mailpool.rua', null),
]
);
}
}
// public function created(Domain $domain): void
// {
// // Standardwerte aus Config oder .env
// $selector = config('mailwolt.dkim.selector', 'mwl1');
// $bits = (int) config('mailwolt.dkim.bits', 2048);
// if ($domain->is_server) {
// return;
// }
//
// // Keypair erzeugen
// $res = app(DkimService::class)->generateForDomain(
// domainId: $domain,
// bits: $bits,
// selector: $selector
// $selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
// $bits = (int) config('mailpool.defaults.dkim_bits', 2048);
//
// $res = app(\App\Services\DkimService::class)
// ->generateForDomain($domain, $bits, $selector);
//
// // DNS-Records gleich anlegen/aktualisieren
// app(\App\Services\DnsRecordService::class)->provision(
// $domain,
// $dk->selector,
// "v=DKIM1; k=rsa; p={$dk->public_key_txt}",
// [
// 'spf_tail' => \App\Models\Setting::get('mailpool.spf_tail', '~all'),
// 'spf_extra' => \App\Models\Setting::get('mailpool.spf_extra', []),
// 'dmarc_policy' => \App\Models\Setting::get('mailpool.dmarc_policy', 'none'),
// 'rua' => \App\Models\Setting::get('mailpool.rua', null),
// ]
// );
//
// // In dkim_keys speichern
// $dk = DkimKey::create([
// 'domain_id' => $domain->id,
// 'selector' => $res['selector'],
// 'private_key_pem' => $res['private_pem'],
// 'public_key_txt' => preg_replace('/^v=DKIM1; k=rsa; p=/', '', $res['dns_txt']),
// 'is_active' => true,
// ]);
//
// // Helper-Job zum Installieren starten
// InstallDkimKey::dispatch(
// domainId: $domain->id,
// dkimKeyId: $dk->id,
// privPath: $res['priv_path'],
// dnsTxtContent: $res['dns_txt']
// )->afterCommit();
// }
/**
@ -93,12 +68,28 @@ class DomainObserver
*/
public function deleted(Domain $domain): void
{
// Falls SoftDeletes im Spiel, willst du evtl. forceDeleted spiegeln (s.u.)
foreach ($domain->dkimKeys()->get() as $dk) {
RemoveDkimKey::dispatch(
domainId: $domain->id,
selector: $dk->selector
)->afterCommit();
try {
/** @var \App\Services\DkimService $svc */
$svc = app(\App\Services\DkimService::class);
// Entferne DKIM aus OpenDKIM Config
$svc->removeForDomain($domain);
// Optionale lokale Dateien löschen
$path = storage_path("app/private/dkim/{$domain->domain}");
if (is_dir($path)) {
\Illuminate\Support\Facades\File::deleteDirectory($path);
}
// Reload OpenDKIM
\Illuminate\Support\Facades\Process::run(['sudo','-n','/usr/bin/systemctl','reload','opendkim']);
Log::info("Domain deleted + DKIM cleaned", ['domain' => $domain->domain]);
} catch (\Throwable $e) {
Log::error("Domain delete cleanup failed", [
'domain' => $domain->domain,
'error' => $e->getMessage(),
]);
}
}

View File

@ -22,7 +22,7 @@ class DkimService
$dirKey = $this->dirKeyFor($domain);
$selKey = preg_replace('/[^A-Za-z0-9._-]/', '_', substr($selector, 0, 32)); // schlicht & stabil
// Disk "local" zeigt bei dir auf storage/app/private (siehe Kommentar in deinem Code)
$disk = Storage::disk('local');
$baseRel = "dkim/{$dirKey}";
@ -159,36 +159,58 @@ class DkimService
return $san;
}
protected function safeKey($value, int $max = 64): string
public function removeForDomain(Domain|string $domain): void
{
if (is_object($value)) {
if (isset($value->id)) $value = $value->id;
elseif (method_exists($value, 'getKey')) $value = $value->getKey();
else $value = json_encode($value);
$domainName = $domain instanceof Domain ? $domain->domain : $domain;
$keyTable = '/etc/opendkim/KeyTable';
$signTable = '/etc/opendkim/SigningTable';
$keyDir = "/etc/opendkim/keys/{$domainName}";
// Tabellen bereinigen
foreach ([$keyTable, $signTable] as $file) {
if (is_file($file)) {
$lines = file($file, FILE_IGNORE_NEW_LINES);
$filtered = array_filter($lines, fn($l) => !str_contains($l, $domainName));
file_put_contents($file, implode(PHP_EOL, $filtered) . PHP_EOL);
}
}
$raw = (string) $value;
$san = preg_replace('/[^A-Za-z0-9._-]/', '_', $raw);
if ($san === '' ) $san = 'unknown';
if (strlen($san) > $max) {
$san = substr($san, 0, $max - 13) . '_' . substr(sha1($raw), 0, 12);
// Key-Verzeichnis löschen
if (is_dir($keyDir)) {
\Illuminate\Support\Facades\File::deleteDirectory($keyDir);
}
return $san;
}
protected static function extractPublicKeyBase64(string $pem): string
{
// Hole den Body zwischen den Headern (multiline, dotall)
if (!preg_match('/^-+BEGIN PUBLIC KEY-+\r?\n(.+?)\r?\n-+END PUBLIC KEY-+\s*$/ms', $pem, $m)) {
throw new \RuntimeException('DKIM: Ungültiges Public-Key-PEM (Header/Footers nicht gefunden).');
}
// Whitespace entfernen → reines Base64
$b64 = preg_replace('/\s+/', '', $m[1]);
if ($b64 === '' || base64_decode($b64, true) === false) {
throw new \RuntimeException('DKIM: Public Key Base64 ist leer/ungültig.');
}
return $b64;
}
// protected function safeKey($value, int $max = 64): string
// {
// if (is_object($value)) {
// if (isset($value->id)) $value = $value->id;
// elseif (method_exists($value, 'getKey')) $value = $value->getKey();
// else $value = json_encode($value);
// }
// $raw = (string) $value;
// $san = preg_replace('/[^A-Za-z0-9._-]/', '_', $raw);
// if ($san === '' ) $san = 'unknown';
// if (strlen($san) > $max) {
// $san = substr($san, 0, $max - 13) . '_' . substr(sha1($raw), 0, 12);
// }
// return $san;
// }
//
// protected static function extractPublicKeyBase64(string $pem): string
// {
// // Hole den Body zwischen den Headern (multiline, dotall)
// if (!preg_match('/^-+BEGIN PUBLIC KEY-+\r?\n(.+?)\r?\n-+END PUBLIC KEY-+\s*$/ms', $pem, $m)) {
// throw new \RuntimeException('DKIM: Ungültiges Public-Key-PEM (Header/Footers nicht gefunden).');
// }
//
// // Whitespace entfernen → reines Base64
// $b64 = preg_replace('/\s+/', '', $m[1]);
//
// if ($b64 === '' || base64_decode($b64, true) === false) {
// throw new \RuntimeException('DKIM: Public Key Base64 ist leer/ungültig.');
// }
//
// return $b64;
// }
}

View File

@ -1,17 +1,32 @@
<div class="w-full #min-h-[86vh] grid place-items-center px-4
<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)]">
{{-- Banner (dismissbar) --}}
@if($showBanner && $banner)
<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
<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>
<p class="nx-subtle mb-7">
Melde dich mit dem einmaligen Bootstrap-Konto an, um den Setup-Wizard zu starten.
</p>
{{-- globale Fehlermeldung --}}
@if($error)
<div class="nx-alert mb-6">

View File

@ -27,7 +27,7 @@
@if($systemDomain)
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-3">
<div class="grid grid-cols-1 #md:grid-cols-4 items-center gap-3">
<div class="grid grid-cols-1 md:grid-cols-4 items-center gap-3">
<div class=" flex items-center gap-1 text-white/90 font-medium truncate">
<span class="relative flex size-2.5 mx-1">
@if($systemDomain->is_active)

View File

@ -34,7 +34,7 @@
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200 {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
@ -52,6 +52,30 @@
</div>
</div>
@endforeach
@foreach ($optional as $r)
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
</div>
{{ $recordColors[$r['type']] }}
<div class="flex items-center gap-2 text-slate-300/70">
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if(!empty($r['helpUrl']))
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
<i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}
</a>
@endif
</div>
</div>
@endforeach
</div>
</section>

View File

@ -80,10 +80,10 @@ Route::get('/login', [LoginController::class, 'show'])->name('login');
Route::get('/signup', [\App\Http\Controllers\Auth\SignUpController::class, 'show' ])->middleware('signup.open')->name('signup');
Route::post('/logout', [\App\Http\Controllers\Auth\LoginController::class, 'logout'])->name('logout');
Route::middleware(['auth', 'ensure.setup'])->group(function () {
// Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/setup', [SetupWizard::class, 'show'])->name('setup.wizard');
});
//Route::middleware(['auth', 'ensure.setup'])->group(function () {
//// Route::get('/dashboard', Dashboard::class)->name('dashboard');
// Route::get('/setup', [SetupWizard::class, 'show'])->name('setup.wizard');
//});