mailwolt/app/Services/DkimService.php

338 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services;
use App\Models\DkimKey;
use App\Models\Domain;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class DkimService
{
/** Erzeugt Keypair & gibt den TXT-Record (ohne Host) zurück. */
public function generateForDomain(Domain $domain, int $bits = 2048, string $selector = null): array
{
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
// $dirKey = $this->safeKey($domain->domain);
// $selKey = $this->safeKey($selector, 32);
$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}";
// unsere Dateien (alle Varianten, damit sowohl App als auch OpenDKIM glücklich sind)
$privPemRel = "{$baseRel}/{$selKey}.pem"; // PKCS#1/PKCS#8 (App-Backup)
$pubPemRel = "{$baseRel}/{$selKey}.pub"; // Public PEM (App-Backup)
$privOKRel = "{$baseRel}/{$selKey}.private"; // <-- OpenDKIM erwartet *genau das*
$dnsTxtRel = "{$baseRel}/{$selKey}.txt"; // TXT-String "v=DKIM1; k=rsa; p=..."
// Absolute Pfade
$privPemAbs = method_exists($disk, 'path') ? $disk->path($privPemRel) : storage_path('app/private/'.$privPemRel);
$pubPemAbs = method_exists($disk, 'path') ? $disk->path($pubPemRel) : storage_path('app/private/'.$pubPemRel);
$privOKAbs = method_exists($disk, 'path') ? $disk->path($privOKRel) : storage_path('app/private/'.$privOKRel);
$dnsTxtAbs = method_exists($disk, 'path') ? $disk->path($dnsTxtRel) : storage_path('app/private/'.$dnsTxtRel);
// Wenn schon vollständig vorhanden → direkt zurück
if ($disk->exists($privOKRel) && $disk->exists($dnsTxtRel)) {
$privatePem = $disk->exists($privPemRel) ? $disk->get($privPemRel) : $disk->get($privOKRel);
$publicPem = $disk->exists($pubPemRel) ? $disk->get($pubPemRel) : null;
$dnsTxt = $disk->get($dnsTxtRel);
return [
'selector' => $selKey,
'priv_path' => $privOKAbs,
'pub_path' => $pubPemAbs,
'private_pem' => $privatePem,
'public_pem' => $publicPem,
'dns_name' => "{$selKey}._domainkey",
'dns_txt' => $dnsTxt,
'bits' => $bits,
];
}
// Neu generieren
$disk->makeDirectory($baseRel);
$res = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_RSA,
'private_key_bits' => $bits,
]);
if ($res === false) {
throw new RuntimeException('DKIM: openssl_pkey_new() fehlgeschlagen: ' . (openssl_error_string() ?: 'unbekannt'));
}
$privatePem = '';
if (!openssl_pkey_export($res, $privatePem)) {
throw new RuntimeException('DKIM: openssl_pkey_export() fehlgeschlagen: ' . (openssl_error_string() ?: 'unbekannt'));
}
$details = openssl_pkey_get_details($res);
if ($details === false || empty($details['key'])) {
throw new RuntimeException('DKIM: Public Key konnte nicht gelesen werden.');
}
$publicPem = $details['key'];
$publicBase64 = trim(preg_replace('/-----(BEGIN|END)\s+PUBLIC KEY-----|\s+/', '', $publicPem));
if (strlen($publicBase64) < 300) {
throw new RuntimeException('DKIM: Public Key zu kurz vermutlich Parsing-Fehler.');
}
$dnsTxt = "v=DKIM1; k=rsa; p={$publicBase64}";
// Dateien schreiben (App + OpenDKIM)
if (!$disk->put($privPemRel, $privatePem)) throw new RuntimeException("DKIM: Private-Key schreiben fehlgeschlagen: {$privPemRel}");
if (!$disk->put($pubPemRel, $publicPem)) throw new RuntimeException("DKIM: Public-Key schreiben fehlgeschlagen: {$pubPemRel}");
if (!$disk->put($privOKRel, $privatePem)) throw new RuntimeException("DKIM: OpenDKIM-Key schreiben fehlgeschlagen: {$privOKRel}");
if (!$disk->put($dnsTxtRel, $dnsTxt)) throw new RuntimeException("DKIM: DNS-TXT schreiben fehlgeschlagen: {$dnsTxtRel}");
// Dateirechte (best effort)
@chmod($privPemAbs, 0600);
@chmod($privOKAbs, 0600);
@chmod($pubPemAbs, 0640);
@chmod($dnsTxtAbs, 0644);
// DB pflegen
DkimKey::updateOrCreate(
['domain_id' => $domain->id, 'selector' => $selKey],
[
'private_key_pem' => $privatePem,
'public_key_txt' => $publicBase64,
'is_active' => true,
]
);
// OpenDKIM einhängen (wenn Helper existiert)
$helper = '/usr/local/sbin/mailwolt-install-dkim';
Log::debug('DKIM helper call', [
'as' => trim(Process::run(['whoami'])->output()),
'helper' => $helper,
'exists' => is_file($helper),
// KEINE privaten Keys loggen!
]);
$helper = '/usr/local/sbin/mailwolt-install-dkim';
$proc = Process::timeout(30)->run([
'sudo','-n', $helper,
$domain->domain, $selKey, $privOKAbs, $dnsTxtAbs
]);
Log::info('DKIM install exit', [
'cmd' => $helper,
'exit' => $proc->exitCode(),
'out' => $proc->output(),
'err' => $proc->errorOutput(),
]);
if (!$proc->successful()) {
// Optionale bessere Fehlermeldung
$err = $proc->errorOutput();
if (str_contains($err, 'command not found') || str_contains($err, 'No such file')) {
throw new \RuntimeException('Helper fehlt: '.$helper.' (Installer erneut ausführen?)');
}
if (str_contains($err, 'sudo') && str_contains($err, 'a password is required')) {
throw new \RuntimeException('sudo NOPASSWD fehlt für www-data → /etc/sudoers.d/mailwolt-dkim prüfen.');
}
throw new \RuntimeException("install-dkim failed: ".$err);
}
Process::run(['sudo','-n','/usr/bin/systemctl','reload','opendkim']);
// if (is_file($helper)) {
// $cmd = [
// 'sudo','-n', $helper,
// $domain->domain,
// $selKey,
// $privOKAbs,
// $dnsTxtAbs,
// ];
//
// $res = Process::timeout(30)->run($cmd);
//
// Log::info('DKIM install exit', [
// 'cmd' => implode(' ', $cmd),
// 'exit' => $res->exitCode(),
// 'out' => $res->output(),
// 'err' => $res->errorOutput(),
// ]);
//
// if ($res->failed()) {
// throw new RuntimeException('OpenDKIM-Install fehlgeschlagen: '.$res->errorOutput());
// }
//
// Process::run(['sudo','-n','/usr/bin/systemctl','reload','opendkim']);
// } else {
// Log::warning('DKIM helper not found', ['path' => $helper]);
// }
return [
'selector' => $selKey,
'priv_path' => $privOKAbs,
'pub_path' => $pubPemAbs,
'private_pem' => $privatePem,
'public_pem' => $publicPem,
'dns_name' => "{$selKey}._domainkey",
'dns_txt' => $dnsTxt,
'bits' => $bits,
];
}
protected function dirKeyFor(Domain $domain, int $max = 64): string
{
$raw = $domain->domain; // <<< WICHTIG: Domain-String, NICHT die ID!
$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;
}
// selector optional: wenn null → alle Selector der Domain löschen
public function removeForDomain(Domain|string $domain, ?string $selector = null): void
{
$name = $domain instanceof Domain ? $domain->domain : $domain;
// Selektoren ermitteln
if (is_null($selector)) {
$keys = $domain instanceof Domain
? $domain->dkimKeys()->pluck('selector')->all()
: \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
->pluck('selector')->all();
$keys = $keys ?: ['mwl1'];
} else {
$keys = [$selector];
}
foreach ($keys as $sel) {
$cmd = ['sudo','-n','/usr/local/sbin/mailwolt-remove-dkim',$name,$sel];
$res = \Illuminate\Support\Facades\Process::timeout(30)->run($cmd);
Log::info('DKIM remove exit', [
'domain' => $name,
'selector' => $sel,
'exit' => $res->exitCode(),
'out' => $res->output(),
'err' => $res->errorOutput(),
]);
if ($res->failed()) {
throw new \RuntimeException('OpenDKIM-Remove fehlgeschlagen: '.$res->errorOutput());
}
}
// Dienst neu laden (best effort)
\Illuminate\Support\Facades\Process::run(['sudo','-n','/bin/systemctl','reload','opendkim']);
}
// public function removeForDomain(Domain|string $domain, ?string $selector = null): void
// {
// $name = $domain instanceof Domain ? $domain->domain : $domain;
//
// if (is_null($selector)) {
// // alle Selector aus DB holen und nacheinander entfernen
// $keys = $domain instanceof Domain
// ? $domain->dkimKeys()->pluck('selector')->all()
// : \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
// ->pluck('selector')->all();
//
// $keys = $keys ?: ['mwl1']; // notfalls versuchen wir Standard
// } else {
// $keys = [$selector];
// }
//
// foreach ($keys as $sel) {
// Process::run(['sudo','-n','/usr/local/sbin/mailwolt-remove-dkim',$name,$sel]);
// }
//
// // Dienst neu laden (ohne Fehler abbrechen)
// Process::run(['sudo','-n','/bin/systemctl','reload','opendkim']);
//
// $res = Process::timeout(30)->run($cmd);
// Log::info('DKIM install exit', [
// 'exit' => $res->exitCode(),
// 'out' => $res->output(),
// 'err' => $res->errorOutput(),
// ]);
// }
// public function removeForDomain(Domain|string $domain, ?string $selector = null): void
// {
// $name = $domain instanceof \App\Models\Domain ? $domain->domain : $domain;
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
//
// // Root-Helper ausführen
// $p = Process::run([
// 'sudo','-n','/usr/local/sbin/mailwolt-remove-dkim',
// $name, $selector
// ]);
// if (!$p->successful()) {
// throw new \RuntimeException('mailwolt-remove-dkim failed: '.$p->errorOutput());
// }
//
// // OpenDKIM neu laden
// Process::run(['sudo','-n','/usr/bin/systemctl','reload','opendkim']);
// }
// public function removeForDomain(Domain|string $domain): void
// {
// $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);
// }
// }
//
// // Key-Verzeichnis löschen
// if (is_dir($keyDir)) {
// \Illuminate\Support\Facades\File::deleteDirectory($keyDir);
// }
// }
// 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;
// }
}