169 lines
6.0 KiB
PHP
169 lines
6.0 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use App\Models\Setting;
|
||
|
||
class ProbeRbl extends Command
|
||
{
|
||
protected $signature = 'rbl:probe {--force : Ignoriert Intervalle und prüft sofort}';
|
||
protected $description = 'Prüft öffentliche RBLs und speichert das Ergebnis in settings:health.rbl';
|
||
|
||
// Intervalle
|
||
private int $minIntervalDays = 7; // frühestens alle 7 Tage neu prüfen
|
||
private int $ttlDays = 14; // Ergebnis 14 Tage gültig
|
||
|
||
public function handle(): int
|
||
{
|
||
$now = now();
|
||
|
||
$existing = (array) Setting::get('health.rbl', []) ?: [];
|
||
$lastAt = isset($existing['checked_at']) ? \Illuminate\Support\Carbon::parse($existing['checked_at']) : null;
|
||
$nextDue = $lastAt ? $lastAt->copy()->addDays($this->minIntervalDays) : null;
|
||
|
||
if (!$this->option('force') && $nextDue && $now->lt($nextDue)) {
|
||
$this->info("Übersprungen: nächste Prüfung erst ab {$nextDue->toIso8601String()} (force mit --force).");
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
// IPs ermitteln (Installer-ENV bevorzugt)
|
||
[$ipv4, $ipv6] = $this->resolvePublicIpsFromInstallerEnv();
|
||
$ipv4 = $ipv4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
|
||
$ipv6 = $ipv6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
|
||
|
||
// Kandidat für RBL (nur IPv4)
|
||
$ip = $this->validIPv4($ipv4) ? $ipv4 : null;
|
||
if (!$ip) {
|
||
$file = trim((string) @file_get_contents('/etc/mailwolt/public_ip'));
|
||
if ($this->validIPv4($file)) $ip = $file;
|
||
}
|
||
if (!$ip) {
|
||
$curl = trim((string) @shell_exec('curl -fsS --max-time 2 ifconfig.me 2>/dev/null'));
|
||
if ($this->validIPv4($curl)) $ip = $curl;
|
||
}
|
||
if (!$ip) $ip = '0.0.0.0';
|
||
|
||
// Abfragen (DNS)
|
||
[$lists, $meta] = $this->queryRblLists($ip);
|
||
|
||
$payload = [
|
||
'ip' => $ip,
|
||
'ipv4' => $ipv4,
|
||
'ipv6' => $ipv6,
|
||
'hits' => count($lists),
|
||
'lists' => array_values($lists), // nur die tatsächlich gelisteten Zonen
|
||
'meta' => $meta, // {zone:{status, txt?}}
|
||
'checked_at' => $now->toIso8601String(),
|
||
'valid_until' => $now->copy()->addDays($this->ttlDays)->toIso8601String(),
|
||
'min_next' => $now->copy()->addDays($this->minIntervalDays)->toIso8601String(),
|
||
];
|
||
|
||
// Persistieren (DB) + in Redis spiegeln
|
||
Setting::set('health.rbl', $payload);
|
||
Cache::put('health.rbl', $payload, now()->addDays($this->ttlDays));
|
||
|
||
$this->info(sprintf(
|
||
'RBL: ip=%s hits=%d lists=[%s]',
|
||
$payload['ip'], $payload['hits'], implode(',', $payload['lists'])
|
||
));
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/* ---------- Helpers ---------- */
|
||
|
||
private function resolvePublicIpsFromInstallerEnv(): array
|
||
{
|
||
$file = '/etc/mailwolt/installer.env';
|
||
if (!is_readable($file)) return [null, null];
|
||
|
||
$ipv4 = $ipv6 = null;
|
||
foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||
if ($line === '' || $line[0] === '#') continue;
|
||
if (!str_contains($line, '=')) continue;
|
||
[$k, $v] = array_map('trim', explode('=', $line, 2));
|
||
$v = trim($v, " \t\n\r\0\x0B\"'");
|
||
if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
|
||
if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
|
||
}
|
||
return [ $ipv4, $ipv6 ];
|
||
}
|
||
|
||
private function validIPv4(?string $ip): bool
|
||
{
|
||
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
|
||
}
|
||
private function validIPv6(?string $ip): bool
|
||
{
|
||
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||
}
|
||
|
||
/**
|
||
* Gibt [listedZones, meta] zurück.
|
||
* meta[zone] = ['status'=>'listed|clean|blocked|nx', 'txt'=>?string]
|
||
*/
|
||
private function queryRblLists(string $ip): array
|
||
{
|
||
if (!$this->validIPv4($ip)) return [[], []];
|
||
|
||
$rev = implode('.', array_reverse(explode('.', $ip)));
|
||
|
||
// Kuratierte, erreichbare Zonen (ohne kaputte Subdomains)
|
||
$zones = [
|
||
// Spamhaus ZEN – rate-limitiert, liefert „blocked“ bei Open Resolver
|
||
'zen.spamhaus.org',
|
||
// PSBL
|
||
'psbl.surriel.com',
|
||
// UCEPROTECT Level 1
|
||
'dnsbl-1.uceprotect.net',
|
||
// s5h
|
||
'bl.s5h.net',
|
||
];
|
||
|
||
$listed = [];
|
||
$meta = [];
|
||
|
||
foreach ($zones as $zone) {
|
||
$q = "{$rev}.{$zone}.";
|
||
|
||
$txt = @dns_get_record($q, DNS_TXT) ?: [];
|
||
$a = @dns_get_record($q, DNS_A) ?: [];
|
||
|
||
// Spamhaus „blocked“ Heuristik
|
||
$blocked = false;
|
||
if ($zone === 'zen.spamhaus.org') {
|
||
foreach ($a as $rec) {
|
||
if (!empty($rec['ip']) && in_array($rec['ip'], ['127.255.255.254','127.255.255.255'], true)) {
|
||
$blocked = true; break;
|
||
}
|
||
}
|
||
if (!$blocked) {
|
||
foreach ($txt as $rec) {
|
||
$t = implode('', $rec['txt'] ?? []);
|
||
if (stripos($t, 'open resolver') !== false) { $blocked = true; break; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($blocked) {
|
||
$meta[$zone] = ['status' => 'blocked', 'txt' => 'Spamhaus blockt – nutze privaten Resolver'];
|
||
continue;
|
||
}
|
||
|
||
$hasA = !empty($a);
|
||
$hasTXT = !empty($txt);
|
||
if ($hasA || $hasTXT) {
|
||
$listed[] = $zone;
|
||
$meta[$zone] = ['status' => 'listed', 'txt' => $hasTXT ? ($txt[0]['txt'][0] ?? null) : null];
|
||
} else {
|
||
// NXDOMAIN / sauber
|
||
$meta[$zone] = ['status' => 'clean', 'txt' => null];
|
||
}
|
||
}
|
||
|
||
return [$listed, $meta];
|
||
}
|
||
}
|