338 lines
13 KiB
PHP
338 lines
13 KiB
PHP
<?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;
|
||
// }
|
||
}
|