Initial commit, DMARC checking support added
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
|
||||
|
||||
class CheckDmarcRecords extends Command
|
||||
{
|
||||
protected $signature = 'dmarc:check
|
||||
{--domain= : Check a specific domain}
|
||||
{--all : Check all domains}
|
||||
{--stats : Show DMARC statistics}';
|
||||
protected $description = 'Check DMARC records for domains';
|
||||
|
||||
public function handle(DmarcCheckService $service): void
|
||||
{
|
||||
if ($this->option('stats')) {
|
||||
$stats = $service->getStats();
|
||||
|
||||
$this->info("DMARC Statistics:");
|
||||
$this->line("Total domains checked: {$stats['total']}");
|
||||
$this->line("Valid DMARC records: {$stats['valid']}");
|
||||
$this->line("No DMARC record: {$stats['no_record']}");
|
||||
$this->line("Invalid records: {$stats['invalid']}");
|
||||
|
||||
if (!empty($stats['policies'])) {
|
||||
$this->line("\nPolicy Breakdown:");
|
||||
foreach ($stats['policies'] as $policy => $count) {
|
||||
$label = match($policy) {
|
||||
'none' => 'Monitor',
|
||||
'quarantine' => 'Quarantine',
|
||||
'reject' => 'Reject',
|
||||
default => $policy,
|
||||
};
|
||||
$this->line(" {$label}: {$count}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->option('domain')) {
|
||||
$domain = $this->option('domain');
|
||||
$this->info("Checking DMARC for: {$domain}");
|
||||
|
||||
$result = $service->checkDomain($domain);
|
||||
|
||||
if ($result && $result->valid) {
|
||||
$this->info("✓ DMARC record found!");
|
||||
$this->line("Policy: {$result->getPolicyLabel()}");
|
||||
$this->line("Record: {$result->record}");
|
||||
} else {
|
||||
$this->error("✗ No valid DMARC record found");
|
||||
if ($result) {
|
||||
$this->line("Error: {$result->error_message}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->option('all')) {
|
||||
$this->info('Checking DMARC for all domains...');
|
||||
$service->checkAllDomains();
|
||||
$this->info('Done!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: check domains that need updating
|
||||
$this->info('Checking domains that need DMARC updates...');
|
||||
$service->checkDomainsNeedingUpdate();
|
||||
$this->info('Done!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc;
|
||||
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcPolicy;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcAlignment;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcReporting;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcTesting;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Exceptions\InvalidDmarcRecordException;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Contracts\Support\Jsonable;
|
||||
use Illuminate\Support\Arr;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Responsible for building an object representation of a DMARC
|
||||
* compliant string value
|
||||
*
|
||||
* @link https://mxtoolbox.com/dmarc/details/dmarc-tags
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc7489
|
||||
*/
|
||||
class DmarcRecord implements Arrayable, Jsonable, JsonSerializable
|
||||
{
|
||||
protected ?string $version = null;
|
||||
protected ?DmarcPolicy $policy = null;
|
||||
protected ?DmarcPolicy $subdomainPolicy = null;
|
||||
protected ?string $rua = null;
|
||||
protected ?string $ruf = null;
|
||||
protected ?DmarcAlignment $adkim = null;
|
||||
protected ?DmarcAlignment $aspf = null;
|
||||
protected array $reporting = [];
|
||||
protected ?int $percentage = null;
|
||||
protected ?int $reportInterval = null;
|
||||
protected ?DmarcPolicy $nonExistentSubdomainPolicy = null;
|
||||
protected ?string $publicSuffixDomainPolicy = null;
|
||||
protected ?DmarcTesting $testingMode = null;
|
||||
|
||||
/**
|
||||
* Create a new DMARC record instance
|
||||
*/
|
||||
public function __construct(
|
||||
?string $version = 'DMARC1',
|
||||
DmarcPolicy|string|null $policy = DmarcPolicy::NONE,
|
||||
DmarcPolicy|string|null $subdomainPolicy = null,
|
||||
string|array|null $rua = null,
|
||||
string|array|null $ruf = null,
|
||||
DmarcAlignment|string|null $adkim = DmarcAlignment::RELAXED,
|
||||
DmarcAlignment|string|null $aspf = DmarcAlignment::RELAXED,
|
||||
array $reporting = [],
|
||||
?int $percentage = null,
|
||||
?int $reportInterval = null,
|
||||
DmarcPolicy|string|null $nonExistentSubdomainPolicy = null,
|
||||
?string $publicSuffixDomainPolicy = null,
|
||||
DmarcTesting|string|null $testingMode = null
|
||||
) {
|
||||
$this->version = $version;
|
||||
$this->setPolicy($policy);
|
||||
$this->setSubdomainPolicy($subdomainPolicy);
|
||||
$this->rua($rua);
|
||||
$this->ruf($ruf);
|
||||
$this->setAdkim($adkim);
|
||||
$this->setAspf($aspf);
|
||||
$this->reporting($reporting);
|
||||
$this->percentage($percentage);
|
||||
$this->reportInterval($reportInterval);
|
||||
$this->setNonExistentSubdomainPolicy($nonExistentSubdomainPolicy);
|
||||
$this->publicSuffixDomainPolicy($publicSuffixDomainPolicy);
|
||||
$this->setTestingMode($testingMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DMARC record from an array
|
||||
*/
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
return new static(
|
||||
version: Arr::get($data, 'version', 'DMARC1'),
|
||||
policy: Arr::get($data, 'policy'),
|
||||
subdomainPolicy: Arr::get($data, 'subdomain_policy'),
|
||||
rua: Arr::get($data, 'rua'),
|
||||
ruf: Arr::get($data, 'ruf'),
|
||||
adkim: Arr::get($data, 'adkim'),
|
||||
aspf: Arr::get($data, 'aspf'),
|
||||
reporting: Arr::get($data, 'reporting', []),
|
||||
percentage: Arr::get($data, 'percentage'),
|
||||
reportInterval: Arr::get($data, 'report_interval'),
|
||||
nonExistentSubdomainPolicy: Arr::get($data, 'np'),
|
||||
publicSuffixDomainPolicy: Arr::get($data, 'psd'),
|
||||
testingMode: Arr::get($data, 't'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from a DNS record string
|
||||
*/
|
||||
public static function fromString(string $record): static
|
||||
{
|
||||
$builder = new static;
|
||||
|
||||
$properties = [];
|
||||
|
||||
// Parse the DMARC record
|
||||
foreach (explode(';', $record) as $part) {
|
||||
$property = explode('=', trim($part));
|
||||
|
||||
if (count($property) !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = trim($property[0]);
|
||||
$value = trim($property[1]);
|
||||
|
||||
// Clean common issues:
|
||||
// 1. Remove trailing periods (common in DNS zone files)
|
||||
// 2. Remove surrounding quotes
|
||||
// 3. Trim whitespace
|
||||
$value = rtrim($value, '.');
|
||||
$value = trim($value, '"\'');
|
||||
$value = trim($value);
|
||||
|
||||
$properties[$key] = $value;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!isset($properties['v'])) {
|
||||
throw InvalidDmarcRecordException::missingKey('v', 'DMARC version is required');
|
||||
}
|
||||
|
||||
if (!isset($properties['p'])) {
|
||||
throw InvalidDmarcRecordException::missingKey('p', 'DMARC policy is required');
|
||||
}
|
||||
|
||||
// Parse each property
|
||||
foreach ($properties as $key => $value) {
|
||||
match ($key) {
|
||||
'v' => $builder->version($value),
|
||||
'p' => $builder->policy($value),
|
||||
'sp' => $builder->subdomainPolicy($value),
|
||||
'rua' => $builder->rua($value),
|
||||
'ruf' => $builder->ruf($value),
|
||||
'adkim' => $builder->adkim($value),
|
||||
'aspf' => $builder->aspf($value),
|
||||
'fo' => $builder->reportingFromString($value),
|
||||
'pct' => $builder->percentage((int) $value),
|
||||
'ri' => $builder->reportInterval((int) $value),
|
||||
'np' => $builder->nonExistentSubdomainPolicy($value),
|
||||
'psd' => $builder->publicSuffixDomainPolicy($value),
|
||||
't' => $builder->testingMode($value),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version
|
||||
*/
|
||||
public function version(?string $version): static
|
||||
{
|
||||
$this->version = $version;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set the policy
|
||||
*/
|
||||
public function policy(DmarcPolicy|string|null $policy): static
|
||||
{
|
||||
$this->setPolicy($policy);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set the subdomain policy
|
||||
*/
|
||||
public function subdomainPolicy(DmarcPolicy|string|null $policy): static
|
||||
{
|
||||
$this->setSubdomainPolicy($policy);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set RUA (aggregate report) URIs
|
||||
*/
|
||||
public function rua(string|array|null $mailto): static
|
||||
{
|
||||
$this->rua = $this->normalizeReportUris($mailto);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set RUF (forensic report) URIs
|
||||
*/
|
||||
public function ruf(string|array|null $mailto): static
|
||||
{
|
||||
$this->ruf = $this->normalizeReportUris($mailto);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize report URIs
|
||||
*/
|
||||
protected function normalizeReportUris(string|array|null $value): ?string
|
||||
{
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = is_array($value) ? $value : explode(',', $value);
|
||||
|
||||
$items = array_values(array_filter(
|
||||
array_map('trim', $items),
|
||||
fn (string $item): bool => $item !== ''
|
||||
));
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (!str_starts_with($item, 'mailto:')) {
|
||||
throw InvalidDmarcRecordException::invalidFormat(
|
||||
'mailto address should start with "mailto:"'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $items === [] ? null : implode(',', $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set ADKIM (DKIM alignment)
|
||||
*/
|
||||
public function adkim(DmarcAlignment|string|null $value): static
|
||||
{
|
||||
$this->setAdkim($value);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set ASPF (SPF alignment)
|
||||
*/
|
||||
public function aspf(DmarcAlignment|string|null $value): static
|
||||
{
|
||||
$this->setAspf($value);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set reporting options (fo)
|
||||
*/
|
||||
public function reporting(array $values = []): static
|
||||
{
|
||||
$values = array_values(array_unique($values));
|
||||
|
||||
$processedValues = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if ($value instanceof DmarcReporting) {
|
||||
$processedValues[] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to string and trim
|
||||
$value = trim((string)$value);
|
||||
|
||||
// Check if it's a short code (0, 1, d, s)
|
||||
if (in_array($value, ['0', '1', 'd', 's'], true)) {
|
||||
try {
|
||||
$processedValues[] = DmarcReporting::fromShortCode($value);
|
||||
continue;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Fall through to try from() method
|
||||
}
|
||||
}
|
||||
|
||||
// Try to create from the full value (all, any, dkim, spf)
|
||||
try {
|
||||
$processedValues[] = DmarcReporting::from($value);
|
||||
} catch (\ValueError $e) {
|
||||
throw InvalidDmarcRecordException::invalidValue(
|
||||
"Invalid reporting option: {$value}. Must be 0, 1, d, s, all, any, dkim, or spf"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mutually exclusive options (0 and 1 cannot be combined)
|
||||
$hasAll = in_array(DmarcReporting::ALL, $processedValues, true);
|
||||
$hasAny = in_array(DmarcReporting::ANY, $processedValues, true);
|
||||
|
||||
if ($hasAll && $hasAny) {
|
||||
throw InvalidDmarcRecordException::conflict(
|
||||
'Reporting options "all" (0) and "any" (1) are mutually exclusive.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->reporting = $processedValues;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse reporting options from string
|
||||
*/
|
||||
public function reportingFromString(string $value): static
|
||||
{
|
||||
// Remove whitespace and trim
|
||||
$value = trim($value);
|
||||
|
||||
// Clean the value - remove trailing periods and quotes
|
||||
$value = rtrim($value, '.');
|
||||
$value = trim($value, '"\'');
|
||||
$value = trim($value);
|
||||
|
||||
// Check if it's a colon-separated list (e.g., "0:1:d:s")
|
||||
if (str_contains($value, ':')) {
|
||||
$options = array_map(function($item) {
|
||||
$item = trim($item);
|
||||
$item = rtrim($item, '.');
|
||||
$item = trim($item, '"\'');
|
||||
return trim($item);
|
||||
}, explode(':', $value));
|
||||
} else {
|
||||
// Single value (e.g., "0", "1", "d", "s")
|
||||
$options = [trim($value)];
|
||||
}
|
||||
|
||||
// Filter out empty values
|
||||
$options = array_filter($options, fn($v) => $v !== '');
|
||||
|
||||
// Convert numeric values to their string representations
|
||||
$options = array_map(function($value) {
|
||||
if (is_numeric($value)) {
|
||||
return match((int)$value) {
|
||||
0 => '0',
|
||||
1 => '1',
|
||||
default => (string)$value,
|
||||
};
|
||||
}
|
||||
return $value;
|
||||
}, $options);
|
||||
|
||||
$this->reporting($options);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set percentage (pct)
|
||||
*/
|
||||
public function percentage(?int $percentage): static
|
||||
{
|
||||
if (!is_null($percentage) && ($percentage < 0 || $percentage > 100)) {
|
||||
throw InvalidDmarcRecordException::invalidValue(
|
||||
'Percentage must be between 0 and 100'
|
||||
);
|
||||
}
|
||||
$this->percentage = $percentage;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set reporting interval (ri)
|
||||
*/
|
||||
public function reportInterval(?int $interval): static
|
||||
{
|
||||
if (!is_null($interval) && $interval < 0) {
|
||||
throw InvalidDmarcRecordException::invalidValue(
|
||||
'Reporting interval must be a positive integer'
|
||||
);
|
||||
}
|
||||
$this->reportInterval = $interval;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set non-existent subdomain policy (np)
|
||||
*/
|
||||
public function nonExistentSubdomainPolicy(DmarcPolicy|string|null $policy): static
|
||||
{
|
||||
$this->setNonExistentSubdomainPolicy($policy);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set public suffix domain policy (psd)
|
||||
*/
|
||||
public function publicSuffixDomainPolicy(?string $policy): static
|
||||
{
|
||||
if (!is_null($policy) && !in_array($policy, ['y', 'n', 'u', null], true)) {
|
||||
throw InvalidDmarcRecordException::invalidValue(
|
||||
'PSD must be "y", "n", or "u"'
|
||||
);
|
||||
}
|
||||
$this->publicSuffixDomainPolicy = $policy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set testing mode (t)
|
||||
*/
|
||||
public function testingMode(DmarcTesting|string|null $testingMode): static
|
||||
{
|
||||
$this->setTestingMode($testingMode);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to a DNS record string
|
||||
*/
|
||||
public function toDnsRecord(): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if ($this->version) {
|
||||
$parts[] = "v={$this->version}";
|
||||
}
|
||||
|
||||
if ($this->policy) {
|
||||
$parts[] = "p={$this->policy->value}";
|
||||
}
|
||||
|
||||
if ($this->subdomainPolicy) {
|
||||
$parts[] = "sp={$this->subdomainPolicy->value}";
|
||||
}
|
||||
|
||||
if ($this->rua) {
|
||||
$parts[] = "rua={$this->rua}";
|
||||
}
|
||||
|
||||
if ($this->ruf) {
|
||||
$parts[] = "ruf={$this->ruf}";
|
||||
}
|
||||
|
||||
if ($this->adkim) {
|
||||
$parts[] = "adkim={$this->adkim->getShortCode()}";
|
||||
}
|
||||
|
||||
if ($this->aspf) {
|
||||
$parts[] = "aspf={$this->aspf->getShortCode()}";
|
||||
}
|
||||
|
||||
if (!empty($this->reporting)) {
|
||||
$foParts = array_map(
|
||||
fn(DmarcReporting $option) => $option->getShortCode(),
|
||||
$this->reporting
|
||||
);
|
||||
$parts[] = "fo=" . implode(':', $foParts);
|
||||
}
|
||||
|
||||
if (!is_null($this->percentage)) {
|
||||
$parts[] = "pct={$this->percentage}";
|
||||
}
|
||||
|
||||
if (!is_null($this->reportInterval)) {
|
||||
$parts[] = "ri={$this->reportInterval}";
|
||||
}
|
||||
|
||||
|
||||
if ($this->nonExistentSubdomainPolicy) {
|
||||
$parts[] = "np={$this->nonExistentSubdomainPolicy->value}";
|
||||
}
|
||||
|
||||
if ($this->publicSuffixDomainPolicy) {
|
||||
$parts[] = "psd={$this->publicSuffixDomainPolicy}";
|
||||
}
|
||||
|
||||
if ($this->testingMode) {
|
||||
$parts[] = "t={$this->testingMode->value}";
|
||||
}
|
||||
|
||||
return implode('; ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all validation rules for Filament forms
|
||||
*/
|
||||
public static function getValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'policy' => ['required', 'string', 'in:none,quarantine,reject'],
|
||||
'subdomain_policy' => ['nullable', 'string', 'in:none,quarantine,reject'],
|
||||
'rua' => ['nullable', 'array'],
|
||||
'rua.*' => ['string', 'starts_with:mailto:'],
|
||||
'ruf' => ['nullable', 'array'],
|
||||
'ruf.*' => ['string', 'starts_with:mailto:'],
|
||||
'adkim' => ['nullable', 'string', 'in:relaxed,strict'],
|
||||
'aspf' => ['nullable', 'string', 'in:relaxed,strict'],
|
||||
'reporting' => ['nullable', 'array'],
|
||||
'reporting.*' => ['string', 'in:all,any,dkim,spf'],
|
||||
'percentage' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'report_interval' => ['nullable', 'integer', 'min:0'],
|
||||
'np' => ['nullable', 'string', 'in:none,quarantine,reject'],
|
||||
'psd' => ['nullable', 'string', 'in:y,n,u'],
|
||||
't' => ['nullable', 'string', 'in:y,n'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->version,
|
||||
'policy' => $this->policy?->value,
|
||||
'subdomain_policy' => $this->subdomainPolicy?->value,
|
||||
'rua' => $this->rua ? explode(',', $this->rua) : null,
|
||||
'ruf' => $this->ruf ? explode(',', $this->ruf) : null,
|
||||
'adkim' => $this->adkim?->value,
|
||||
'aspf' => $this->aspf?->value,
|
||||
'reporting' => array_map(fn($opt) => $opt->value, $this->reporting),
|
||||
'percentage' => $this->percentage,
|
||||
'report_interval' => $this->reportInterval,
|
||||
'np' => $this->nonExistentSubdomainPolicy?->value,
|
||||
'psd' => $this->publicSuffixDomainPolicy,
|
||||
't' => $this->testingMode?->value,
|
||||
'dns_record' => $this->toDnsRecord(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON
|
||||
*/
|
||||
public function toJson($options = 0): string
|
||||
{
|
||||
return json_encode($this->toArray(), $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON serialize
|
||||
*/
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method for string conversion
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toDnsRecord();
|
||||
}
|
||||
|
||||
// Private setter methods with validation
|
||||
|
||||
private function setPolicy(DmarcPolicy|string|null $policy): void
|
||||
{
|
||||
if (is_null($policy)) {
|
||||
$this->policy = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->policy = $policy instanceof DmarcPolicy
|
||||
? $policy
|
||||
: DmarcPolicy::from($policy);
|
||||
}
|
||||
|
||||
private function setSubdomainPolicy(DmarcPolicy|string|null $policy): void
|
||||
{
|
||||
if (is_null($policy)) {
|
||||
$this->subdomainPolicy = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->subdomainPolicy = $policy instanceof DmarcPolicy
|
||||
? $policy
|
||||
: DmarcPolicy::from($policy);
|
||||
}
|
||||
|
||||
private function setAdkim(DmarcAlignment|string|null $value): void
|
||||
{
|
||||
if (is_null($value)) {
|
||||
$this->adkim = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value instanceof DmarcAlignment) {
|
||||
$this->adkim = $value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a short code (r or s)
|
||||
if (in_array($value, ['r', 's'], true)) {
|
||||
$this->adkim = DmarcAlignment::fromShortCode($value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to create from the string value directly
|
||||
try {
|
||||
$this->adkim = DmarcAlignment::from($value);
|
||||
} catch (\ValueError $e) {
|
||||
throw InvalidDmarcRecordException::invalidValue(
|
||||
"Invalid ADKIM value: '{$value}'. Must be 'relaxed', 'strict', 'r', or 's'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function setAspf(DmarcAlignment|string|null $value): void
|
||||
{
|
||||
if (is_null($value)) {
|
||||
$this->aspf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value instanceof DmarcAlignment) {
|
||||
$this->aspf = $value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a short code (r or s)
|
||||
if (in_array($value, ['r', 's'], true)) {
|
||||
$this->aspf = DmarcAlignment::fromShortCode($value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to create from the string value directly
|
||||
try {
|
||||
$this->aspf = DmarcAlignment::from($value);
|
||||
} catch (\ValueError $e) {
|
||||
throw InvalidDmarcRecordException::invalidValue(
|
||||
"Invalid ASPF value: '{$value}'. Must be 'relaxed', 'strict', 'r', or 's'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function setNonExistentSubdomainPolicy(DmarcPolicy|string|null $policy): void
|
||||
{
|
||||
if (is_null($policy)) {
|
||||
$this->nonExistentSubdomainPolicy = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->nonExistentSubdomainPolicy = $policy instanceof DmarcPolicy
|
||||
? $policy
|
||||
: DmarcPolicy::from($policy);
|
||||
}
|
||||
|
||||
private function setTestingMode(DmarcTesting|string|null $testingMode): void
|
||||
{
|
||||
if (is_null($testingMode)) {
|
||||
$this->testingMode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->testingMode = $testingMode instanceof DmarcTesting
|
||||
? $testingMode
|
||||
: DmarcTesting::from($testingMode);
|
||||
}
|
||||
|
||||
public function getVersion(): ?string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function getPolicy(): ?DmarcPolicy
|
||||
{
|
||||
return $this->policy;
|
||||
}
|
||||
|
||||
public function getSubdomainPolicy(): ?DmarcPolicy
|
||||
{
|
||||
return $this->subdomainPolicy;
|
||||
}
|
||||
|
||||
public function getRua(): ?string
|
||||
{
|
||||
return $this->rua;
|
||||
}
|
||||
|
||||
public function getRuf(): ?string
|
||||
{
|
||||
return $this->ruf;
|
||||
}
|
||||
|
||||
public function getAdkim(): ?DmarcAlignment
|
||||
{
|
||||
return $this->adkim;
|
||||
}
|
||||
|
||||
public function getAspf(): ?DmarcAlignment
|
||||
{
|
||||
return $this->aspf;
|
||||
}
|
||||
|
||||
public function getReporting(): array
|
||||
{
|
||||
return $this->reporting;
|
||||
}
|
||||
|
||||
public function getPercentage(): ?int
|
||||
{
|
||||
return $this->percentage;
|
||||
}
|
||||
|
||||
public function getReportInterval(): ?int
|
||||
{
|
||||
return $this->reportInterval;
|
||||
}
|
||||
|
||||
public function getNonExistentSubdomainPolicy(): ?DmarcPolicy
|
||||
{
|
||||
return $this->nonExistentSubdomainPolicy;
|
||||
}
|
||||
|
||||
public function getPublicSuffixDomainPolicy(): ?string
|
||||
{
|
||||
return $this->publicSuffixDomainPolicy;
|
||||
}
|
||||
|
||||
public function getTestingMode(): ?DmarcTesting
|
||||
{
|
||||
return $this->testingMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
|
||||
|
||||
enum DmarcAlignment: string
|
||||
{
|
||||
case RELAXED = 'relaxed';
|
||||
case STRICT = 'strict';
|
||||
|
||||
public function getShortCode(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::RELAXED => 'r',
|
||||
self::STRICT => 's',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::RELAXED => 'Relaxed alignment (subdomains allowed)',
|
||||
self::STRICT => 'Strict alignment (exact match required)',
|
||||
};
|
||||
}
|
||||
|
||||
public static function fromShortCode(string $code): self
|
||||
{
|
||||
return match($code) {
|
||||
'r' => self::RELAXED,
|
||||
's' => self::STRICT,
|
||||
default => throw new \InvalidArgumentException("Invalid alignment code: {$code}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
|
||||
|
||||
enum DmarcPolicy: string
|
||||
{
|
||||
case NONE = 'none';
|
||||
case QUARANTINE = 'quarantine';
|
||||
case REJECT = 'reject';
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::NONE => 'No action, monitor only',
|
||||
self::QUARANTINE => 'Mark as spam/quarantine',
|
||||
self::REJECT => 'Reject the email outright',
|
||||
};
|
||||
}
|
||||
|
||||
public function getSeverity(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::NONE => 0,
|
||||
self::QUARANTINE => 1,
|
||||
self::REJECT => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
|
||||
|
||||
enum DmarcReporting: string
|
||||
{
|
||||
case ALL = 'all';
|
||||
case ANY = 'any';
|
||||
case DKIM = 'dkim';
|
||||
case SPF = 'spf';
|
||||
|
||||
public function getShortCode(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::ALL => '0',
|
||||
self::ANY => '1',
|
||||
self::DKIM => 'd',
|
||||
self::SPF => 's',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::ALL => 'Report all failures',
|
||||
self::ANY => 'Report if either DKIM or SPF fails',
|
||||
self::DKIM => 'Report only DKIM failures',
|
||||
self::SPF => 'Report only SPF failures',
|
||||
};
|
||||
}
|
||||
|
||||
public static function fromShortCode(string $code): self
|
||||
{
|
||||
return match($code) {
|
||||
'0' => self::ALL,
|
||||
'1' => self::ANY,
|
||||
'd' => self::DKIM,
|
||||
's' => self::SPF,
|
||||
default => throw new \InvalidArgumentException("Invalid reporting option code: {$code}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
|
||||
|
||||
enum DmarcTesting: string
|
||||
{
|
||||
case YES = 'y';
|
||||
case NO = 'n';
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::YES => 'Testing mode (don\'t apply policy)',
|
||||
self::NO => 'Normal mode (apply policy)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class InvalidDmarcRecordException extends Exception
|
||||
{
|
||||
public static function invalidValue(string $message): self
|
||||
{
|
||||
return new self("Invalid DMARC value: {$message}");
|
||||
}
|
||||
|
||||
public static function missingKey(string $key, string $message): self
|
||||
{
|
||||
return new self("Missing required key '{$key}': {$message}");
|
||||
}
|
||||
|
||||
public static function invalidFormat(string $message): self
|
||||
{
|
||||
return new self("Invalid DMARC format: {$message}");
|
||||
}
|
||||
|
||||
public static function conflict(string $message): self
|
||||
{
|
||||
return new self("DMARC configuration conflict: {$message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Dmarc\Services;
|
||||
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\DmarcRecord;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Exceptions\InvalidDmarcRecordException;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DmarcCheckService
|
||||
{
|
||||
/**
|
||||
* Check a single domain's DMARC record
|
||||
*/
|
||||
public function checkDomain(string $domain, ?int $domainId = null): ?DmarcCheck
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$attempt = 1;
|
||||
$maxAttempts = 3;
|
||||
$lastError = null;
|
||||
|
||||
Log::info('DMARC check started', [
|
||||
'domain' => $domain,
|
||||
'domain_id' => $domainId,
|
||||
'timestamp' => now()->toDateTimeString()
|
||||
]);
|
||||
|
||||
while ($attempt <= $maxAttempts) {
|
||||
$attemptStart = microtime(true);
|
||||
|
||||
try {
|
||||
Log::debug('DMARC check attempt', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'max_attempts' => $maxAttempts
|
||||
]);
|
||||
|
||||
// Add delay between attempts (except first)
|
||||
if ($attempt > 1) {
|
||||
$delay = 1;
|
||||
Log::debug('Adding delay before retry', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'delay_seconds' => $delay
|
||||
]);
|
||||
sleep($delay);
|
||||
}
|
||||
|
||||
// Increase DNS timeout for remote domains
|
||||
$originalTimeout = ini_get('dns.timeout');
|
||||
$originalRetry = ini_get('dns.retry');
|
||||
|
||||
Log::debug('DNS settings before change', [
|
||||
'domain' => $domain,
|
||||
'original_timeout' => $originalTimeout,
|
||||
'original_retry' => $originalRetry
|
||||
]);
|
||||
|
||||
ini_set('dns.timeout', '10');
|
||||
ini_set('dns.retry', '5');
|
||||
|
||||
Log::debug('DNS settings after change', [
|
||||
'domain' => $domain,
|
||||
'new_timeout' => ini_get('dns.timeout'),
|
||||
'new_retry' => ini_get('dns.retry')
|
||||
]);
|
||||
|
||||
// Query DNS for DMARC record
|
||||
$dnsStart = microtime(true);
|
||||
$records = @dns_get_record("_dmarc.{$domain}", DNS_TXT);
|
||||
$dnsDuration = microtime(true) - $dnsStart;
|
||||
|
||||
// Restore original settings
|
||||
if ($originalTimeout !== false) {
|
||||
ini_set('dns.timeout', $originalTimeout);
|
||||
}
|
||||
if ($originalRetry !== false) {
|
||||
ini_set('dns.retry', $originalRetry);
|
||||
}
|
||||
|
||||
Log::debug('DNS query completed', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'duration_ms' => round($dnsDuration * 1000, 2),
|
||||
'success' => $records !== false,
|
||||
'record_count' => $records ? count($records) : 0,
|
||||
'records_found' => $records ? array_column($records, 'txt') : []
|
||||
]);
|
||||
|
||||
// If DNS query failed or returned no records
|
||||
if ($records === false) {
|
||||
$lastError = 'DNS query returned false (error)';
|
||||
Log::warning('DNS query returned false', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'duration_ms' => round($dnsDuration * 1000, 2)
|
||||
]);
|
||||
$attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($records)) {
|
||||
$lastError = 'No DMARC record found (empty response)';
|
||||
Log::warning('DNS query returned empty', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'duration_ms' => round($dnsDuration * 1000, 2)
|
||||
]);
|
||||
$attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the DMARC record (starts with v=DMARC1)
|
||||
$dmarcRecord = null;
|
||||
foreach ($records as $record) {
|
||||
$txt = $record['txt'] ?? '';
|
||||
if (str_starts_with(trim($txt), 'v=DMARC1')) {
|
||||
$dmarcRecord = $txt;
|
||||
Log::debug('Found DMARC record', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'record' => $dmarcRecord
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dmarcRecord) {
|
||||
$lastError = 'No valid DMARC record found (v=DMARC1 missing)';
|
||||
Log::warning('No DMARC record with v=DMARC1 found', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'records' => array_column($records, 'txt')
|
||||
]);
|
||||
$attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the DMARC record
|
||||
Log::debug('Parsing DMARC record', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'record' => $dmarcRecord
|
||||
]);
|
||||
|
||||
try {
|
||||
$parsed = DmarcRecord::fromString($dmarcRecord);
|
||||
} catch (InvalidDmarcRecordException $e) {
|
||||
$lastError = 'Invalid DMARC record: ' . $e->getMessage();
|
||||
Log::warning('Invalid DMARC record', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'error' => $e->getMessage(),
|
||||
'record' => $dmarcRecord
|
||||
]);
|
||||
$attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
|
||||
Log::info('DMARC check successful', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'total_duration_ms' => round($totalDuration * 1000, 2),
|
||||
'dns_duration_ms' => round($dnsDuration * 1000, 2),
|
||||
'policy' => $parsed->getPolicy()?->value,
|
||||
'record' => $dmarcRecord
|
||||
]);
|
||||
|
||||
return $this->saveSuccessfulCheck($domain, $domainId, $dmarcRecord, $parsed);
|
||||
|
||||
} catch (InvalidDmarcRecordException $e) {
|
||||
$lastError = 'Invalid DMARC record: ' . $e->getMessage();
|
||||
Log::warning('DMARC check attempt failed (InvalidDmarcRecordException)', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
$attempt++;
|
||||
continue;
|
||||
} catch (\Exception $e) {
|
||||
$lastError = 'Error checking DMARC: ' . $e->getMessage();
|
||||
Log::warning('DMARC check attempt failed (Exception)', [
|
||||
'domain' => $domain,
|
||||
'attempt' => $attempt,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
$attempt++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts failed
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
|
||||
Log::error('DMARC check failed after all attempts', [
|
||||
'domain' => $domain,
|
||||
'attempts' => $attempt - 1,
|
||||
'total_duration_ms' => round($totalDuration * 1000, 2),
|
||||
'last_error' => $lastError,
|
||||
'timestamp' => now()->toDateTimeString()
|
||||
]);
|
||||
|
||||
return $this->saveFailedCheck($domain, $domainId, $lastError ?? 'Unknown error after ' . ($attempt - 1) . ' attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all domains from the domains table
|
||||
*/
|
||||
public function checkAllDomains(): void
|
||||
{
|
||||
Log::info('Starting DMARC check for all domains');
|
||||
|
||||
// Get domains from the domains table
|
||||
$domains = \VEximweb\Core\Data\Models\Domain::where('enabled', true)
|
||||
->select('domain_id', 'domain')
|
||||
->get();
|
||||
|
||||
Log::info('Found domains to check', [
|
||||
'total' => $domains->count()
|
||||
]);
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
Log::debug('Checking domain', [
|
||||
'domain' => $domain->domain,
|
||||
'domain_id' => $domain->domain_id
|
||||
]);
|
||||
$this->checkDomain($domain->domain, $domain->domain_id);
|
||||
}
|
||||
|
||||
Log::info('Finished DMARC check for all domains');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check domains that need checking (next_check_at <= now)
|
||||
*/
|
||||
public function checkDomainsNeedingUpdate(): void
|
||||
{
|
||||
Log::info('Starting DMARC check for domains needing update');
|
||||
|
||||
// First, get all domains from the domains table
|
||||
$domains = \VEximweb\Core\Data\Models\Domain::where('enabled', true)
|
||||
->select('domain_id', 'domain')
|
||||
->get();
|
||||
|
||||
$checked = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
// Check if this domain needs updating
|
||||
$cache = DmarcCheck::where('domain', $domain->domain)->first();
|
||||
|
||||
if (!$cache || ($cache->next_check_at && $cache->next_check_at <= now())) {
|
||||
Log::debug('Domain needs update', [
|
||||
'domain' => $domain->domain,
|
||||
'last_check' => $cache?->last_checked_at,
|
||||
'next_check' => $cache?->next_check_at
|
||||
]);
|
||||
$this->checkDomain($domain->domain, $domain->domain_id);
|
||||
$checked++;
|
||||
} else {
|
||||
Log::debug('Domain skipped (not due for update)', [
|
||||
'domain' => $domain->domain,
|
||||
'next_check' => $cache->next_check_at
|
||||
]);
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('Finished DMARC check for domains needing update', [
|
||||
'checked' => $checked,
|
||||
'skipped' => $skipped,
|
||||
'total' => $domains->count()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a successful check
|
||||
*/
|
||||
protected function saveSuccessfulCheck(string $domain, ?int $domainId, string $record, DmarcRecord $parsed): DmarcCheck
|
||||
{
|
||||
Log::debug('Saving successful check', [
|
||||
'domain' => $domain,
|
||||
'policy' => $parsed->getPolicy()?->value
|
||||
]);
|
||||
|
||||
$result = DmarcCheck::updateOrCreate(
|
||||
['domain' => $domain],
|
||||
[
|
||||
'domain_id' => $domainId,
|
||||
'record' => $record,
|
||||
'policy' => $parsed->getPolicy()?->value,
|
||||
'subdomain_policy' => $parsed->getSubdomainPolicy()?->value,
|
||||
'adkim' => $parsed->getAdkim()?->value,
|
||||
'aspf' => $parsed->getAspf()?->value,
|
||||
'rua' => $parsed->getRua() ? explode(',', $parsed->getRua()) : null,
|
||||
'ruf' => $parsed->getRuf() ? explode(',', $parsed->getRuf()) : null,
|
||||
'reporting' => $parsed->getReporting() ? array_map(fn($r) => $r->value, $parsed->getReporting()) : null,
|
||||
'percentage' => $parsed->getPercentage(),
|
||||
'report_interval' => $parsed->getReportInterval(),
|
||||
'np' => $parsed->getNonExistentSubdomainPolicy()?->value,
|
||||
'psd' => $parsed->getPublicSuffixDomainPolicy(),
|
||||
't' => $parsed->getTestingMode()?->value,
|
||||
'valid' => true,
|
||||
'error_message' => null,
|
||||
'last_checked_at' => now(),
|
||||
'next_check_at' => now()->addHours(24),
|
||||
]
|
||||
);
|
||||
|
||||
Log::debug('Saved successful check', [
|
||||
'domain' => $domain,
|
||||
'id' => $result->id,
|
||||
'valid' => $result->valid
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a failed check
|
||||
*/
|
||||
protected function saveFailedCheck(string $domain, ?int $domainId, string $error): DmarcCheck
|
||||
{
|
||||
Log::debug('Saving failed check', [
|
||||
'domain' => $domain,
|
||||
'error' => $error
|
||||
]);
|
||||
|
||||
$result = DmarcCheck::updateOrCreate(
|
||||
['domain' => $domain],
|
||||
[
|
||||
'domain_id' => $domainId,
|
||||
'valid' => false,
|
||||
'error_message' => $error,
|
||||
'last_checked_at' => now(),
|
||||
'next_check_at' => now()->addHours(6),
|
||||
]
|
||||
);
|
||||
|
||||
Log::debug('Saved failed check', [
|
||||
'domain' => $domain,
|
||||
'id' => $result->id,
|
||||
'valid' => $result->valid,
|
||||
'error' => $result->error_message
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a domain
|
||||
*/
|
||||
public function clearCache(string $domain): void
|
||||
{
|
||||
Log::info('Clearing DMARC cache', ['domain' => $domain]);
|
||||
DmarcCheck::where('domain', $domain)->delete();
|
||||
Log::debug('DMARC cache cleared', ['domain' => $domain]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached DMARC record for a domain
|
||||
*/
|
||||
public function getCached(string $domain): ?DmarcCheck
|
||||
{
|
||||
Log::debug('Getting cached DMARC record', ['domain' => $domain]);
|
||||
|
||||
$cache = DmarcCheck::where('domain', $domain)->first();
|
||||
|
||||
if (!$cache) {
|
||||
Log::debug('No cached record found, checking domain', ['domain' => $domain]);
|
||||
return $this->checkDomain($domain);
|
||||
}
|
||||
|
||||
if ($cache->next_check_at && $cache->next_check_at <= now()) {
|
||||
Log::debug('Cached record expired, rechecking', [
|
||||
'domain' => $domain,
|
||||
'next_check' => $cache->next_check_at,
|
||||
'now' => now()
|
||||
]);
|
||||
return $this->checkDomain($domain);
|
||||
}
|
||||
|
||||
Log::debug('Returning cached record', [
|
||||
'domain' => $domain,
|
||||
'valid' => $cache->valid,
|
||||
'policy' => $cache->policy
|
||||
]);
|
||||
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about DMARC records
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
Log::debug('Getting DMARC statistics');
|
||||
|
||||
$total = DmarcCheck::count();
|
||||
$valid = DmarcCheck::where('valid', true)->count();
|
||||
$invalid = DmarcCheck::where('valid', false)->count();
|
||||
|
||||
$policies = DmarcCheck::where('valid', true)
|
||||
->select('policy', \DB::raw('count(*) as count'))
|
||||
->groupBy('policy')
|
||||
->pluck('count', 'policy')
|
||||
->toArray();
|
||||
|
||||
$noRecord = DmarcCheck::where('valid', false)
|
||||
->where('error_message', 'No DMARC record found')
|
||||
->count();
|
||||
|
||||
$stats = [
|
||||
'total' => $total,
|
||||
'valid' => $valid,
|
||||
'invalid' => $invalid,
|
||||
'policies' => $policies,
|
||||
'no_record' => $noRecord,
|
||||
];
|
||||
|
||||
Log::debug('DMARC statistics', $stats);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Slider;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\DmarcRecord;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcPolicy;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcAlignment;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcReporting;
|
||||
use VEximweb\Core\Data\Models\Domain;
|
||||
|
||||
class GenerateDmarc extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DmarcResource::class;
|
||||
|
||||
public Domain $domain;
|
||||
public ?DmarcCheck $existingDmarc = null;
|
||||
|
||||
public function mount($record = null): void
|
||||
{
|
||||
if ($record) {
|
||||
$this->domain = Domain::find($record);
|
||||
$this->existingDmarc = DmarcCheck::where('domain', $this->domain->domain)->first();
|
||||
}
|
||||
|
||||
parent::mount();
|
||||
|
||||
if ($this->existingDmarc) {
|
||||
$defaults = [
|
||||
'policy' => $this->existingDmarc->policy ?? 'none',
|
||||
'subdomain_policy' => $this->existingDmarc->subdomain_policy ?? null,
|
||||
'adkim' => $this->existingDmarc->adkim ?? 'relaxed',
|
||||
'aspf' => $this->existingDmarc->aspf ?? 'relaxed',
|
||||
'reporting' => json_decode($this->existingDmarc->reporting ?? '["all"]', true),
|
||||
'percentage' => $this->existingDmarc->percentage ?? 100,
|
||||
't' => $this->existingDmarc->t ?? 'n',
|
||||
];
|
||||
|
||||
if ($this->existingDmarc->rua) {
|
||||
$rua = json_decode($this->existingDmarc->rua, true);
|
||||
if (is_array($rua)) {
|
||||
$defaults['rua'] = collect($rua)->map(function ($item) {
|
||||
return ['email' => str_replace('mailto:', '', $item)];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->existingDmarc->ruf) {
|
||||
$ruf = json_decode($this->existingDmarc->ruf, true);
|
||||
if (is_array($ruf)) {
|
||||
$defaults['ruf'] = collect($ruf)->map(function ($item) {
|
||||
return ['email' => str_replace('mailto:', '', $item)];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
$this->form->fill($defaults);
|
||||
}
|
||||
}
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
$service = app(DmarcCheckService::class);
|
||||
$addresses = $service->getDefaultReportingAddresses($this->domain->domain ?? config('app.domain'));
|
||||
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('policy')
|
||||
->label('Policy')
|
||||
->options([
|
||||
'none' => 'None (Monitor only)',
|
||||
'quarantine' => 'Quarantine (Mark as spam)',
|
||||
'reject' => 'Reject (Block delivery)',
|
||||
])
|
||||
->required()
|
||||
->default('none')
|
||||
->live(),
|
||||
|
||||
Select::make('subdomain_policy')
|
||||
->label('Subdomain Policy')
|
||||
->options([
|
||||
'' => 'Inherit from main policy',
|
||||
'none' => 'None (Monitor only)',
|
||||
'quarantine' => 'Quarantine (Mark as spam)',
|
||||
'reject' => 'Reject (Block delivery)',
|
||||
])
|
||||
->nullable(),
|
||||
|
||||
Repeater::make('rua')
|
||||
->label('Aggregate Reports (RUA)')
|
||||
->schema([
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->prefix('mailto:')
|
||||
->required(),
|
||||
])
|
||||
->default(function () use ($addresses) {
|
||||
if ($this->existingDmarc && $this->existingDmarc->rua) {
|
||||
$rua = json_decode($this->existingDmarc->rua, true);
|
||||
if (is_array($rua)) {
|
||||
return collect($rua)->map(function ($item) {
|
||||
return ['email' => str_replace('mailto:', '', $item)];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
return [['email' => str_replace('mailto:', '', $addresses['rua'])]];
|
||||
})
|
||||
->addable()
|
||||
->deletable()
|
||||
->reorderable()
|
||||
->columnSpanFull(),
|
||||
|
||||
Repeater::make('ruf')
|
||||
->label('Forensic Reports (RUF)')
|
||||
->schema([
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->prefix('mailto:')
|
||||
->required(),
|
||||
])
|
||||
->default(function () use ($addresses) {
|
||||
if ($this->existingDmarc && $this->existingDmarc->ruf) {
|
||||
$ruf = json_decode($this->existingDmarc->ruf, true);
|
||||
if (is_array($ruf)) {
|
||||
return collect($ruf)->map(function ($item) {
|
||||
return ['email' => str_replace('mailto:', '', $item)];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
return [['email' => str_replace('mailto:', '', $addresses['ruf'])]];
|
||||
})
|
||||
->addable()
|
||||
->deletable()
|
||||
->reorderable()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('adkim')
|
||||
->label('DKIM Alignment')
|
||||
->options([
|
||||
'relaxed' => 'Relaxed (subdomains allowed)',
|
||||
'strict' => 'Strict (exact match)',
|
||||
])
|
||||
->default('relaxed'),
|
||||
|
||||
Select::make('aspf')
|
||||
->label('SPF Alignment')
|
||||
->options([
|
||||
'relaxed' => 'Relaxed (subdomains allowed)',
|
||||
'strict' => 'Strict (exact match)',
|
||||
])
|
||||
->default('relaxed'),
|
||||
|
||||
Select::make('reporting')
|
||||
->label('Failure Reporting (FO)')
|
||||
->multiple()
|
||||
->options([
|
||||
'all' => 'All failures',
|
||||
'any' => 'Any failure',
|
||||
'dkim' => 'DKIM failures only',
|
||||
'spf' => 'SPF failures only',
|
||||
])
|
||||
->default(['all']),
|
||||
|
||||
Slider::make('percentage')
|
||||
->label('Enforcement Percentage')
|
||||
->min(0)
|
||||
->max(100)
|
||||
->default(100),
|
||||
|
||||
Select::make('t')
|
||||
->label('Testing Mode')
|
||||
->options([
|
||||
'n' => 'Off (Apply policy)',
|
||||
'y' => 'On (Don\'t apply policy)',
|
||||
])
|
||||
->default('n'),
|
||||
|
||||
Placeholder::make('generated_record_preview')
|
||||
->label('Preview')
|
||||
->content(function ($get) {
|
||||
try {
|
||||
$settings = $this->prepareSettings($get);
|
||||
$dmarcRecord = new DmarcRecord(
|
||||
policy: DmarcPolicy::tryFrom($settings['policy']),
|
||||
subdomainPolicy: $settings['subdomain_policy'] ? DmarcPolicy::tryFrom($settings['subdomain_policy']) : null,
|
||||
rua: $settings['rua'],
|
||||
ruf: $settings['ruf'],
|
||||
adkim: DmarcAlignment::tryFrom($settings['adkim']),
|
||||
aspf: DmarcAlignment::tryFrom($settings['aspf']),
|
||||
reporting: array_map(fn($r) => DmarcReporting::tryFrom($r), $settings['reporting']),
|
||||
percentage: (int)$settings['percentage'],
|
||||
t: $settings['t'],
|
||||
);
|
||||
|
||||
if ($this->domain) {
|
||||
return "Name: _dmarc.{$this->domain->domain}\n" .
|
||||
"Type: TXT\n" .
|
||||
"Value: v=DMARC1; " . $dmarcRecord->toDnsRecord();
|
||||
}
|
||||
return "Please select a domain to preview";
|
||||
} catch (\Exception $e) {
|
||||
return "Error generating preview: " . $e->getMessage();
|
||||
}
|
||||
})
|
||||
->extraAttributes(['class' => 'font-mono text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded'])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function prepareSettings(array $data): array
|
||||
{
|
||||
return [
|
||||
'policy' => $data['policy'] ?? 'none',
|
||||
'subdomain_policy' => $data['subdomain_policy'] ?? null,
|
||||
'rua' => collect($data['rua'] ?? [])
|
||||
->filter(fn($item) => !empty($item['email']))
|
||||
->map(fn($item) => 'mailto:' . $item['email'])
|
||||
->values()
|
||||
->toArray(),
|
||||
'ruf' => collect($data['ruf'] ?? [])
|
||||
->filter(fn($item) => !empty($item['email']))
|
||||
->map(fn($item) => 'mailto:' . $item['email'])
|
||||
->values()
|
||||
->toArray(),
|
||||
'adkim' => $data['adkim'] ?? 'relaxed',
|
||||
'aspf' => $data['aspf'] ?? 'relaxed',
|
||||
'reporting' => $data['reporting'] ?? ['all'],
|
||||
'percentage' => (int)($data['percentage'] ?? 100),
|
||||
't' => $data['t'] ?? 'n',
|
||||
];
|
||||
}
|
||||
|
||||
protected function handleRecordCreation(array $data): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
$settings = $this->prepareSettings($data);
|
||||
|
||||
$dmarcRecord = new DmarcRecord(
|
||||
policy: DmarcPolicy::tryFrom($settings['policy']),
|
||||
subdomainPolicy: $settings['subdomain_policy'] ? DmarcPolicy::tryFrom($settings['subdomain_policy']) : null,
|
||||
rua: $settings['rua'],
|
||||
ruf: $settings['ruf'],
|
||||
adkim: DmarcAlignment::tryFrom($settings['adkim']),
|
||||
aspf: DmarcAlignment::tryFrom($settings['aspf']),
|
||||
reporting: array_map(fn($r) => DmarcReporting::tryFrom($r), $settings['reporting']),
|
||||
percentage: (int)$settings['percentage'],
|
||||
t: $settings['t'],
|
||||
);
|
||||
|
||||
$dnsRecord = 'v=DMARC1; ' . $dmarcRecord->toDnsRecord();
|
||||
|
||||
return DmarcCheck::updateOrCreate(
|
||||
['domain' => $this->domain->domain],
|
||||
[
|
||||
'domain_id' => $this->domain->domain_id,
|
||||
'record' => $dnsRecord,
|
||||
'policy' => $settings['policy'],
|
||||
'subdomain_policy' => $settings['subdomain_policy'],
|
||||
'adkim' => $settings['adkim'],
|
||||
'aspf' => $settings['aspf'],
|
||||
'rua' => json_encode($settings['rua']),
|
||||
'ruf' => json_encode($settings['ruf']),
|
||||
'reporting' => json_encode($settings['reporting']),
|
||||
'percentage' => (int)$settings['percentage'],
|
||||
't' => $settings['t'],
|
||||
'valid' => true,
|
||||
'error_message' => null,
|
||||
'last_checked_at' => now(),
|
||||
'next_check_at' => now()->addHours(24),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return "DMARC record generated for {$this->domain->domain}";
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return DmarcResource::getUrl('view', ['record' => $this->domain->getKey()]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('back')
|
||||
->label('Back to View')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->url(DmarcResource::getUrl('view', ['record' => $this->domain->getKey()])),
|
||||
|
||||
Action::make('back_to_list')
|
||||
->label('Back to List')
|
||||
->icon('heroicon-o-list-bullet')
|
||||
->url(DmarcResource::getUrl('index')),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
$action = $this->existingDmarc ? 'Update' : 'Generate';
|
||||
return "{$action} DMARC Record";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
|
||||
use VEximweb\Core\Data\Models\Domain;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListDmarc extends ListRecords
|
||||
{
|
||||
protected static string $resource = DmarcResource::class;
|
||||
|
||||
protected function getTableQuery(): Builder
|
||||
{
|
||||
return Domain::where('enabled', true);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query($this->getTableQuery())
|
||||
->columns([
|
||||
TextColumn::make('domain')
|
||||
->label('Domain')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->size('lg')
|
||||
->weight('bold'),
|
||||
|
||||
BadgeColumn::make('dmarc_status')
|
||||
->label('DMARC Status')
|
||||
->getStateUsing(function ($record) {
|
||||
$dmarc = DmarcCheck::where('domain', $record->domain)->first();
|
||||
if (!$dmarc) {
|
||||
return 'Not Checked';
|
||||
}
|
||||
if (!$dmarc->valid) {
|
||||
return 'No Record';
|
||||
}
|
||||
return $dmarc->getPolicyLabel();
|
||||
})
|
||||
->colors([
|
||||
'success' => 'Monitor',
|
||||
'warning' => 'Quarantine',
|
||||
'danger' => 'Reject',
|
||||
'gray' => ['Not Checked', 'No Record'],
|
||||
]),
|
||||
|
||||
BadgeColumn::make('dmarc_policy')
|
||||
->label('Policy')
|
||||
->getStateUsing(function ($record) {
|
||||
$dmarc = DmarcCheck::where('domain', $record->domain)->first();
|
||||
return $dmarc?->policy ?? '-';
|
||||
})
|
||||
->colors([
|
||||
'success' => 'none',
|
||||
'warning' => 'quarantine',
|
||||
'danger' => 'reject',
|
||||
'gray' => '-',
|
||||
]),
|
||||
|
||||
TextColumn::make('dmarc_last_checked')
|
||||
->label('Last Checked')
|
||||
->getStateUsing(function ($record) {
|
||||
$dmarc = DmarcCheck::where('domain', $record->domain)->first();
|
||||
return $dmarc?->last_checked_at;
|
||||
})
|
||||
->since()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('exim_users_count')
|
||||
->label('Accounts')
|
||||
->counts('eximUsers')
|
||||
->badge()
|
||||
->color('info'),
|
||||
])
|
||||
->filters([
|
||||
\Filament\Tables\Filters\SelectFilter::make('dmarc_status')
|
||||
->label('DMARC Status')
|
||||
->options([
|
||||
'has_record' => 'Has DMARC Record',
|
||||
'no_record' => 'No DMARC Record',
|
||||
'not_checked' => 'Not Checked',
|
||||
])
|
||||
->query(function ($query, $data) {
|
||||
if (empty($data['value'])) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match($data['value']) {
|
||||
'has_record' => $query->whereIn('domain',
|
||||
DmarcCheck::where('valid', true)->pluck('domain')->toArray()
|
||||
),
|
||||
'no_record' => $query->whereIn('domain',
|
||||
DmarcCheck::where('valid', false)->pluck('domain')->toArray()
|
||||
),
|
||||
'not_checked' => $query->whereNotIn('domain',
|
||||
DmarcCheck::pluck('domain')->toArray()
|
||||
),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
|
||||
\Filament\Tables\Filters\SelectFilter::make('policy')
|
||||
->label('Policy')
|
||||
->options([
|
||||
'none' => 'Monitor',
|
||||
'quarantine' => 'Quarantine',
|
||||
'reject' => 'Reject',
|
||||
])
|
||||
->query(function ($query, $data) {
|
||||
if (empty($data['value'])) {
|
||||
return $query;
|
||||
}
|
||||
$domains = DmarcCheck::where('policy', $data['value'])
|
||||
->where('valid', true)
|
||||
->pluck('domain')
|
||||
->toArray();
|
||||
return $query->whereIn('domain', $domains);
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
->label('View DMARC')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('info')
|
||||
->url(fn ($record): string => DmarcResource::getUrl('view', ['record' => $record])),
|
||||
|
||||
Action::make('generate')
|
||||
->label('Generate Record')
|
||||
->icon('heroicon-o-document-plus')
|
||||
->color('success')
|
||||
->url(fn ($record): string => DmarcResource::getUrl('generate', ['record' => $record])),
|
||||
|
||||
Action::make('check_dns')
|
||||
->label('Check DNS')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->action(function ($record) {
|
||||
$service = app(DmarcCheckService::class);
|
||||
$result = $service->checkDomain($record->domain, $record->domain_id);
|
||||
|
||||
if ($result && $result->valid) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('DMARC Record Found')
|
||||
->body("Policy: {$result->getPolicyLabel()}")
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('No DMARC Record Found')
|
||||
->body($result?->error_message ?? 'No DMARC record found in DNS')
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
Action::make('check_all_dmarc')
|
||||
->label('Check DMARC for Selected')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function ($records) {
|
||||
$service = app(DmarcCheckService::class);
|
||||
$valid = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$result = $service->checkDomain($record->domain, $record->domain_id);
|
||||
if ($result && $result->valid) {
|
||||
$valid++;
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('DMARC Check Complete')
|
||||
->body("Checked {$records->count()} domains, {$valid} have valid DMARC records")
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
|
||||
|
||||
class ViewDmarc extends ViewRecord
|
||||
{
|
||||
protected static string $resource = DmarcResource::class;
|
||||
|
||||
public ?DmarcCheck $dmarc = null;
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
// Get the DMARC record for this domain
|
||||
$this->dmarc = DmarcCheck::where('domain', $this->record->domain)->first();
|
||||
|
||||
// If no cache or invalid, check now
|
||||
if (!$this->dmarc || !$this->dmarc->valid) {
|
||||
$service = app(DmarcCheckService::class);
|
||||
$this->dmarc = $service->checkDomain($this->record->domain, $this->record->domain_id);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('check_dns')
|
||||
->label('Check DNS Again')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->action(function () {
|
||||
$service = app(DmarcCheckService::class);
|
||||
$this->dmarc = $service->checkDomain($this->record->domain, $this->record->domain_id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('DMARC Record Updated')
|
||||
->body($this->dmarc?->valid ? 'Record found and updated' : 'No valid record found')
|
||||
->send();
|
||||
|
||||
$this->refreshFormData();
|
||||
}),
|
||||
|
||||
Action::make('generate')
|
||||
->label('Generate New Record')
|
||||
->icon('heroicon-o-document-plus')
|
||||
->color('success')
|
||||
->url(DmarcResource::getUrl('generate', ['record' => $this->record])),
|
||||
|
||||
Action::make('back')
|
||||
->label('Back to List')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->url(DmarcResource::getUrl('index')),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'dmarc' => $this->dmarc,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return "DMARC Record for {$this->record->domain}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Providers;
|
||||
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
|
||||
|
||||
class DnsToolsPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->id('dns-tools')
|
||||
->path('dns-tools')
|
||||
->colors([
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->resources([
|
||||
DmarcResource::class,
|
||||
])
|
||||
->navigationGroups([
|
||||
'DNS Tools',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Notifications\Notification;
|
||||
use VEximweb\Core\Data\Models\Domain;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\DmarcRecord;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcPolicy;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcAlignment;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcReporting;
|
||||
use BackedEnum;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc\ListDmarc;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc\ViewDmarc;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc\GenerateDmarc;
|
||||
|
||||
class DmarcResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Domain::class;
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'DNS Tools';
|
||||
protected static ?string $navigationLabel = 'DMARC Management';
|
||||
protected static ?string $pluralLabel = 'DMARC Management';
|
||||
protected static ?int $navigationSort = 1;
|
||||
protected static ?string $slug = 'dnstools/dmarc';
|
||||
|
||||
public static function getModel(): string
|
||||
{
|
||||
return Domain::class;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListDmarc::route('/'),
|
||||
'view' => ViewDmarc::route('/{record}/view'),
|
||||
'generate' => GenerateDmarc::route('/{record}/generate'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Pages;
|
||||
|
||||
use VEximweb\Core\Domain\Filament\Resources\DomainResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListDomains extends ListRecords
|
||||
{
|
||||
protected static string $resource = DomainResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources;
|
||||
|
||||
//use VEximweb\Plugin\DnsTools\Filament\Resources\Dmarc\Pages\CreateDomain;
|
||||
//use VEximweb\Core\Domain\Filament\Resources\Pages\EditDomain;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\Pages\ListDomains;
|
||||
use VEximweb\Core\Domain\Filament\Resources\Schemas\DomainForm;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\Tables\DomainsTable;
|
||||
use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DnsToolsResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Domain::class;
|
||||
|
||||
protected static ?string $slug = 'dnstools/domains';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'domain';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'DNS Tools';
|
||||
|
||||
protected static ?string $navigationLabel = 'DNS Tools';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
// Get the base form
|
||||
$schema = DomainForm::configure($schema);
|
||||
|
||||
// Check if DNS Core is installed and try to get extensions
|
||||
if (class_exists(\VEximweb\Plugin\DnsCore\Services\DnsProviderDiscoveryService::class)) {
|
||||
try {
|
||||
$discoveryService = app(\VEximweb\Plugin\DnsCore\Services\DnsProviderDiscoveryService::class);
|
||||
|
||||
// Make sure the discovery service is booted
|
||||
if (method_exists($discoveryService, 'boot')) {
|
||||
$discoveryService->boot();
|
||||
}
|
||||
|
||||
$extensions = $discoveryService->getFormExtensions();
|
||||
|
||||
if (!empty($extensions)) {
|
||||
$extensionComponents = [];
|
||||
|
||||
foreach ($extensions as $extension) {
|
||||
if (isset($extension['components']) && is_callable($extension['components'])) {
|
||||
$components = $extension['components']();
|
||||
if (is_array($components)) {
|
||||
$extensionComponents = array_merge($extensionComponents, $components);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($extensionComponents)) {
|
||||
\Log::debug('Injecting DNS form extensions', [
|
||||
'component_count' => count($extensionComponents),
|
||||
'extension_count' => count($extensions),
|
||||
]);
|
||||
|
||||
$schema = $schema->components([
|
||||
...$schema->getComponents(),
|
||||
...$extensionComponents,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::debug('Could not load DNS form extensions: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function form_old(Schema $schema): Schema
|
||||
{
|
||||
return DomainForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return DomainsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
$user = auth()->user();
|
||||
|
||||
// If no user is logged in, return empty query
|
||||
if (!$user) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// System admins can see all domains
|
||||
if ($user->isSystemAdmin()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Domain admins only see domains they're assigned to
|
||||
if ($user->isDomainAdmin()) {
|
||||
return $query->whereHas('administrators', function ($q) use ($user) {
|
||||
$q->where('user_id', $user->id)
|
||||
->where('role', 'domain_admin');
|
||||
});
|
||||
}
|
||||
|
||||
// Domain users shouldn't see any domains
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeTooltip(): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
return 'Total number of domains in the system';
|
||||
}
|
||||
|
||||
if ($user->isDomainAdmin()) {
|
||||
return 'Total number of domains you administer';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide navigation item for domain-users
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Hide for domain-user
|
||||
if (!$user || $user->isDomainUser()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListDomains::route('/'),
|
||||
//'create' => CreateDomain::route('/create'),
|
||||
//'edit' => EditDomain::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['domain'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Pages;
|
||||
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListDomains extends ListRecords
|
||||
{
|
||||
protected static string $resource = DnsToolsResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Tables;
|
||||
|
||||
use VEximweb\Plugin\DnsTools\Models\SystemDomains;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Filament\Actions\Action;
|
||||
use VEximweb\Core\EximUser\Filament\Resources\EximUserResource;
|
||||
use VEximweb\Core\EximAlias\Filament\Resources\EximAliasResource;
|
||||
use VEximweb\Core\EximCatchAll\Filament\Resources\EximCatchAllResource;
|
||||
use VEximweb\Core\EximFail\Filament\Resources\EximFailResource;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Actions\ActionGroup;
|
||||
|
||||
class DomainsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordUrl(null)
|
||||
->columns([
|
||||
TextColumn::make('domain')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('enabled')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('dkim.enabled')
|
||||
->label('DKIM')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-key')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('gray')
|
||||
->tooltip(function ($record): string {
|
||||
if ($record->dkim && $record->dkim->enabled) {
|
||||
return "DKIM active (selector: {$record->dkim->selector})";
|
||||
} elseif ($record->dkim && !$record->dkim->enabled) {
|
||||
return "DKIM keys exist but are disabled";
|
||||
} else {
|
||||
return "No DKIM keys configured";
|
||||
}
|
||||
}),
|
||||
|
||||
IconColumn::make('dmarccheck.valid')
|
||||
->label('DMARC')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-shield-check')
|
||||
->falseIcon('heroicon-o-exclamation-triangle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->tooltip(function ($record): string {
|
||||
$dmarc = $record->dmarcCheck;
|
||||
|
||||
if (!$dmarc) {
|
||||
return 'DMARC not checked yet';
|
||||
}
|
||||
|
||||
if ($dmarc->valid) {
|
||||
return "DMARC valid (Policy: {$dmarc->getPolicyLabel()})";
|
||||
} else {
|
||||
return "DMARC invalid: {$dmarc->error_message}";
|
||||
}
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('enabled')
|
||||
->options([
|
||||
'1' => 'Enabled',
|
||||
'0' => 'Disabled',
|
||||
]),
|
||||
|
||||
SelectFilter::make('dkim_status')
|
||||
->label('DKIM Status')
|
||||
->options([
|
||||
'has_dkim' => 'Has DKIM (Active)',
|
||||
'has_disabled' => 'Has DKIM (Disabled)',
|
||||
'no_dkim' => 'No DKIM',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
if ($data['value'] === 'has_dkim') {
|
||||
return $query->whereHas('dkim', function ($q) {
|
||||
$q->where('enabled', true);
|
||||
});
|
||||
}
|
||||
if ($data['value'] === 'has_disabled') {
|
||||
return $query->whereHas('dkim', function ($q) {
|
||||
$q->where('enabled', false);
|
||||
});
|
||||
}
|
||||
if ($data['value'] === 'no_dkim') {
|
||||
return $query->whereDoesntHave('dkim');
|
||||
}
|
||||
return $query;
|
||||
}),
|
||||
|
||||
// DMARC Status Filter
|
||||
SelectFilter::make('dmarc_status')
|
||||
->label('DMARC Status')
|
||||
->options([
|
||||
'valid' => 'Valid DMARC',
|
||||
'invalid' => 'Invalid DMARC',
|
||||
'not_checked' => 'Not Checked',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
if ($data['value'] === 'valid') {
|
||||
return $query->whereHas('dmarcCheck', function ($q) {
|
||||
$q->where('valid', true);
|
||||
});
|
||||
}
|
||||
if ($data['value'] === 'invalid') {
|
||||
return $query->whereHas('dmarcCheck', function ($q) {
|
||||
$q->where('valid', false);
|
||||
});
|
||||
}
|
||||
if ($data['value'] === 'not_checked') {
|
||||
return $query->whereDoesntHave('dmarcCheck');
|
||||
}
|
||||
return $query;
|
||||
}),
|
||||
|
||||
// DMARC Policy Filter
|
||||
SelectFilter::make('dmarc_policy')
|
||||
->label('DMARC Policy')
|
||||
->options([
|
||||
'none' => 'Monitor (none)',
|
||||
'quarantine' => 'Quarantine',
|
||||
'reject' => 'Reject',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
if ($data['value']) {
|
||||
return $query->whereHas('dmarcCheck', function ($q) use ($data) {
|
||||
$q->where('policy', $data['value'])
|
||||
->where('valid', true);
|
||||
});
|
||||
}
|
||||
return $query;
|
||||
}),
|
||||
])
|
||||
|
||||
->recordActions([
|
||||
Action::make('checkDmarc')
|
||||
->icon(Heroicon::ArrowPath)
|
||||
->label('')
|
||||
->tooltip('Check DMARC record now')
|
||||
->color('info')
|
||||
->action(function ($record) {
|
||||
try {
|
||||
$service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class);
|
||||
$result = $service->checkDomain($record->domain, $record->domain_id);
|
||||
|
||||
if ($result && $result->valid) {
|
||||
Notification::make()
|
||||
->title('DMARC check completed')
|
||||
->body("Valid DMARC record found for {$record->domain}")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
$error = $result ? $result->error_message : 'Unknown error';
|
||||
Notification::make()
|
||||
->title('DMARC check completed')
|
||||
->body("No valid DMARC record for {$record->domain}: {$error}")
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('DMARC check failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
//EditAction::make(),
|
||||
ActionGroup::make([
|
||||
EditAction::make(),
|
||||
EditAction::make(),
|
||||
EditAction::make(),
|
||||
Action::make('viewDmarc')
|
||||
->label('DMARC Details')
|
||||
->modalHeading('DMARC Record Details')
|
||||
->modalSubheading(fn ($record) => $record->domain)
|
||||
|
||||
->modalContent(fn ($record) => view(
|
||||
'dns-tools::filament.modals.dmarc-details',
|
||||
[
|
||||
'domain' => $record->domain,
|
||||
'dmarc' => $record->dmarcCheck,
|
||||
'record' => $record,
|
||||
]
|
||||
))
|
||||
|
||||
->modalWidth('3xl')
|
||||
|
||||
->modalActions([
|
||||
Action::make('checkAgain')
|
||||
->label('Check Again')
|
||||
->action(function ($record) {
|
||||
$service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class);
|
||||
$service->checkDomain($record->domain, $record->domain_id);
|
||||
|
||||
Notification::make()
|
||||
->title('DMARC check completed')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('close')
|
||||
->label('Close')
|
||||
->close(),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
// Add bulk action to check DMARC for selected domains
|
||||
Action::make('checkSelectedDmarc')
|
||||
->label('Check DMARC for selected')
|
||||
->icon(Heroicon::ArrowPath)
|
||||
->color('info')
|
||||
->requiresConfirmation()
|
||||
->action(function ($records) {
|
||||
$service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class);
|
||||
$count = 0;
|
||||
$valid = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$result = $service->checkDomain($record->domain, $record->domain_id);
|
||||
$count++;
|
||||
if ($result && $result->valid) {
|
||||
$valid++;
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title("DMARC check completed for {$count} domains")
|
||||
->body("{$valid} domains have valid DMARC records")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Cached DMARC records for domains
|
||||
*/
|
||||
class DmarcCheck extends Model
|
||||
{
|
||||
protected $table = 'vw_dmarc_checks';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'domain',
|
||||
'domain_id', // Foreign key to domains.domain_id (just for reference)
|
||||
'record',
|
||||
'policy',
|
||||
'subdomain_policy',
|
||||
'adkim',
|
||||
'aspf',
|
||||
'rua',
|
||||
'ruf',
|
||||
'reporting',
|
||||
'percentage',
|
||||
'report_interval',
|
||||
'np',
|
||||
'psd',
|
||||
't',
|
||||
'valid',
|
||||
'error_message',
|
||||
'last_checked_at',
|
||||
'next_check_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid' => 'boolean',
|
||||
'last_checked_at' => 'datetime',
|
||||
'next_check_at' => 'datetime',
|
||||
'percentage' => 'integer',
|
||||
'report_interval' => 'integer',
|
||||
'rua' => 'array',
|
||||
'ruf' => 'array',
|
||||
'reporting' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the domain this DMARC check belongs to (optional relation)
|
||||
*/
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\VEximweb\Core\Data\Models\Domain::class, 'domain_id', 'domain_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this domain has a DMARC record
|
||||
*/
|
||||
public function hasRecord(): bool
|
||||
{
|
||||
return $this->valid && !empty($this->record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the DMARC record as a string
|
||||
*/
|
||||
public function getRecordString(): ?string
|
||||
{
|
||||
return $this->record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the policy with a nice label
|
||||
*/
|
||||
public function getPolicyLabel(): string
|
||||
{
|
||||
$labels = [
|
||||
'none' => 'Monitor',
|
||||
'quarantine' => 'Quarantine',
|
||||
'reject' => 'Reject',
|
||||
];
|
||||
|
||||
return $labels[$this->policy] ?? 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the policy with a color
|
||||
*/
|
||||
public function getPolicyColor(): string
|
||||
{
|
||||
return match($this->policy) {
|
||||
'none' => 'success',
|
||||
'quarantine' => 'warning',
|
||||
'reject' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get policy icon
|
||||
*/
|
||||
public function getPolicyIcon(): string
|
||||
{
|
||||
return match($this->policy) {
|
||||
'none' => 'heroicon-o-eye',
|
||||
'quarantine' => 'heroicon-o-shield-exclamation',
|
||||
'reject' => 'heroicon-o-x-circle',
|
||||
default => 'heroicon-o-question-mark-circle',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Models;
|
||||
|
||||
use VEximweb\Core\Data\Models\Domain as BaseDomain;
|
||||
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
|
||||
|
||||
class SystemDomains extends BaseDomain
|
||||
{
|
||||
/**
|
||||
* Get the DMARC check for this domain
|
||||
*/
|
||||
public function dmarcCheck()
|
||||
{
|
||||
return $this->hasOne(DmarcCheck::class, 'domain_id', 'domain_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domain has a valid DMARC record
|
||||
*/
|
||||
public function hasValidDmarc(): bool
|
||||
{
|
||||
$dmarc = $this->dmarcCheck()->first();
|
||||
return $dmarc && $dmarc->valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DMARC status
|
||||
*/
|
||||
public function getDmarcStatus(): string
|
||||
{
|
||||
$dmarc = $this->dmarcCheck()->first();
|
||||
if (!$dmarc) {
|
||||
return 'not_checked';
|
||||
}
|
||||
return $dmarc->valid ? 'valid' : 'invalid';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Providers;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource;
|
||||
|
||||
class DnsToolsPlugin implements Plugin
|
||||
{
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'dns-tools';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->resources([
|
||||
DnsToolsResource::class,
|
||||
]);
|
||||
|
||||
$widgetPath = __DIR__ . '/Filament/Widgets';
|
||||
if (is_dir($widgetPath)) {
|
||||
$widgetClasses = $this->discoverWidgets($widgetPath);
|
||||
$panel->widgets($widgetClasses);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
protected function discoverWidgets(string $path): array
|
||||
{
|
||||
$widgets = [];
|
||||
|
||||
/*
|
||||
$files = File::allFiles($path);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$class = 'VEximweb\\Plugin\\DMARC\\Filament\\Widgets\\' . $file->getFilenameWithoutExtension();
|
||||
if (class_exists($class)) {
|
||||
$widgets[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
return $widgets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use VEximweb\Plugin\DnsTools\Services\DnsToolsService;
|
||||
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
|
||||
use Filament\Panel;
|
||||
//use VEximweb\Plugin\DnsTools\Filament\Providers\DnsToolsPanelProvider;
|
||||
|
||||
class DnsToolsServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// Merge config
|
||||
/*
|
||||
$this->mergeConfigFrom(
|
||||
__DIR__ . '/../../config/dmarc.php',
|
||||
'dmarc'
|
||||
);
|
||||
*/
|
||||
|
||||
// Register DMARC record service
|
||||
$this->app->singleton('dmarc.record.service', function ($app) {
|
||||
// Try to get setting repository from container
|
||||
$settingRepository = null;
|
||||
try {
|
||||
if ($app->has('VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface')) {
|
||||
$settingRepository = $app->make('VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Setting repository not available, continue without it
|
||||
}
|
||||
|
||||
return new DmarcRecordService($settingRepository);
|
||||
});
|
||||
|
||||
// Register main service
|
||||
$this->app->singleton('dns-tools.service', function ($app) {
|
||||
return new DnsToolsService();
|
||||
});
|
||||
|
||||
Panel::configureUsing(function (Panel $panel) {
|
||||
$panel->plugin(DnsToolsPlugin::make());
|
||||
});
|
||||
|
||||
//$this->app->register(DnsToolsPanelProvider::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../config/dmarc.php' => config_path('dmarc.php'),
|
||||
], 'dmarc-config');
|
||||
|
||||
// Load migrations
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||
|
||||
// Load routes
|
||||
// $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php');
|
||||
|
||||
// Load views
|
||||
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'dns-tools');
|
||||
|
||||
// Load translations
|
||||
//$this->loadTranslationsFrom(__DIR__ . '/../../resources/lang', 'dns-tools');
|
||||
|
||||
|
||||
if (class_exists(\Filament\Panel::class)) {
|
||||
// The resource will be auto-discovered if you use:
|
||||
// $panel->discoverResources() in your panel provider
|
||||
// Or register it manually:
|
||||
\Filament\Facades\Filament::registerResources([
|
||||
\VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource::class,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
\VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DnsToolsService
|
||||
{
|
||||
/**
|
||||
* Check MX records for a domain
|
||||
*
|
||||
* @param string $domain
|
||||
* @return array|null Array of MX records or null if none found
|
||||
*/
|
||||
public function checkMxRecord(string $domain): ?array
|
||||
{
|
||||
try {
|
||||
$mxRecords = dns_get_record($domain, DNS_MX);
|
||||
|
||||
if (empty($mxRecords)) {
|
||||
Log::info("No MX records found for domain: {$domain}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
usort($mxRecords, function($a, $b) {
|
||||
return $a['pri'] <=> $b['pri'];
|
||||
});
|
||||
|
||||
return $mxRecords;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error checking MX records for {$domain}: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domain has valid MX records
|
||||
*/
|
||||
public function hasValidMxRecord(string $domain): bool
|
||||
{
|
||||
$records = $this->checkMxRecord($domain);
|
||||
return !empty($records);
|
||||
}
|
||||
|
||||
function getSPFRecord(string $domain) {
|
||||
$dnsRecords = dns_get_record($domain, DNS_TXT);
|
||||
|
||||
if (!$dnsRecords) {
|
||||
return "Failed to fetch DNS records.";
|
||||
}
|
||||
|
||||
foreach ($dnsRecords as $record) {
|
||||
if (isset($record['txt']) && stripos($record['txt'], 'v=spf1') === 0) {
|
||||
return $record['txt'];
|
||||
}
|
||||
}
|
||||
|
||||
return "No SPF record found.";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user