Compare commits
4 Commits
557d90adf5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f74f6f571 | |||
| 5859d1f17f | |||
| 1ef8f39c3b | |||
| e36b39d7c0 |
+34
@@ -0,0 +1,34 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
bootstrap/cache/
|
||||
composer.dev
|
||||
OLDBACKUPS
|
||||
composer.local.json
|
||||
build/
|
||||
test_curl
|
||||
+3
-1
@@ -20,7 +20,9 @@
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"filament/filament": "^5.0",
|
||||
"cbowofrivia/dmarc-record-builder": "^4.0"
|
||||
"cbowofrivia/dmarc-record-builder": "^4.0",
|
||||
"mlocati/spf-lib": "^3.3",
|
||||
"mlocati/ip-lib": "^1.22"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
Generated
+12667
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('vw_spf_checks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('domain')->unique()->index();
|
||||
$table->unsignedBigInteger('domain_id')->nullable()->index();
|
||||
$table->text('record')->nullable();
|
||||
$table->string('spf_version')->nullable()->default('v=spf1');
|
||||
$table->string('policy')->nullable()->index(); // -all, ~all, ?all, +all
|
||||
$table->integer('lookup_count')->default(0);
|
||||
$table->json('mechanisms')->nullable(); // Store all mechanisms as JSON
|
||||
$table->json('includes')->nullable(); // Include mechanisms
|
||||
$table->json('ip4')->nullable(); // IPv4 addresses
|
||||
$table->json('ip6')->nullable(); // IPv6 addresses
|
||||
$table->json('mx_domains')->nullable(); // MX mechanisms
|
||||
$table->json('a_domains')->nullable(); // A mechanisms
|
||||
$table->json('modifiers')->nullable(); // Redirect, Exp modifiers
|
||||
$table->boolean('has_ptr')->default(false);
|
||||
$table->boolean('has_exists')->default(false);
|
||||
$table->boolean('valid')->default(false)->index();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->json('validation_issues')->nullable(); // Store any validation issues
|
||||
$table->integer('mechanism_count')->default(0);
|
||||
$table->timestamp('last_checked_at')->nullable()->index();
|
||||
$table->timestamp('next_check_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
|
||||
// Add foreign key constraint if needed
|
||||
// $table->foreign('domain_id')->references('domain_id')->on('domains')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('vw_spf_checks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<x-filament-panels::page>
|
||||
<form wire:submit="generateSpf">
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
|
||||
<x-filament::modal id="spf-record-modal" width="2xl">
|
||||
<x-slot name="heading">
|
||||
Generated SPF Record
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
<pre class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm overflow-x-auto whitespace-pre-wrap break-all">{{ $generatedRecord }}</pre>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="navigator.clipboard.writeText(@js($generatedRecord))"
|
||||
class="fi-btn"
|
||||
>
|
||||
Copy to clipboard
|
||||
</button>
|
||||
</div>
|
||||
</x-filament::modal>
|
||||
<div wire:poll.5s>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use VEximweb\Plugin\DnsTools\Services\SpfRecordService;
|
||||
|
||||
class CheckSpfRecords extends Command
|
||||
{
|
||||
protected $signature = 'vw:spf-check
|
||||
{--domain= : Check a specific domain}
|
||||
{--all : Check all domains}
|
||||
{--stats : Show SPF statistics}';
|
||||
protected $description = 'Check SPF records for domains';
|
||||
|
||||
public function handle(SpfRecordService $service): void
|
||||
{
|
||||
if ($this->option('stats')) {
|
||||
$stats = $service->getStats();
|
||||
|
||||
$this->info("SPF Statistics:");
|
||||
$this->line("Total domains checked: {$stats['total']}");
|
||||
$this->line("Valid SPF records: {$stats['valid']}");
|
||||
$this->line("No SPF 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) {
|
||||
'-all' => 'Hard Fail',
|
||||
'~all' => 'Soft Fail',
|
||||
'?all' => 'Neutral',
|
||||
'+all' => 'Pass (DANGEROUS)',
|
||||
default => $policy,
|
||||
};
|
||||
$this->line(" {$label}: {$count}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($stats['lookup_distribution'])) {
|
||||
$this->line("\nLookup Count Distribution:");
|
||||
foreach ($stats['lookup_distribution'] as $count => $domains) {
|
||||
$this->line(" {$count} lookups: {$domains} domains");
|
||||
}
|
||||
}
|
||||
|
||||
if ($stats['high_lookup_count'] > 0) {
|
||||
$this->warn("\n⚠️ Domains with >10 lookups (may cause SPF failures):");
|
||||
foreach (array_slice($stats['high_lookups'], 0, 10) as $domain) {
|
||||
$this->line(" - {$domain}");
|
||||
}
|
||||
if ($stats['high_lookup_count'] > 10) {
|
||||
$this->line(" ... and " . ($stats['high_lookup_count'] - 10) . " more");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->option('domain')) {
|
||||
$domain = $this->option('domain');
|
||||
$this->info("Checking SPF for: {$domain}");
|
||||
|
||||
$result = $service->checkDomain($domain);
|
||||
|
||||
if ($result && $result->valid) {
|
||||
$this->info("✓ SPF record found!");
|
||||
$this->line("Policy: {$result->policy}");
|
||||
$this->line("Record: {$result->record}");
|
||||
$this->line("Lookups: {$result->lookup_count}");
|
||||
|
||||
if ($result->lookup_count > 10) {
|
||||
$this->warn("⚠️ Warning: {$result->lookup_count} DNS lookups exceeds recommended maximum of 10");
|
||||
}
|
||||
|
||||
// Show mechanisms
|
||||
if ($result->mechanisms) {
|
||||
$mechanisms = json_decode($result->mechanisms, true);
|
||||
$this->line("\nMechanisms:");
|
||||
foreach ($mechanisms as $mech) {
|
||||
$this->line(" - {$mech['value']}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->error("✗ No valid SPF record found");
|
||||
if ($result) {
|
||||
$this->line("Error: {$result->error_message}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->option('all')) {
|
||||
$this->info('Checking SPF for all domains...');
|
||||
$count = $service->checkAllDomains();
|
||||
$this->info("Done! Checked {$count} domains.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: check domains that need updating
|
||||
$this->info('Checking domains that need SPF updates...');
|
||||
$count = $service->checkDomainsNeedingUpdate();
|
||||
$this->info("Done! Checked {$count} domains.");
|
||||
}
|
||||
}
|
||||
@@ -231,15 +231,16 @@ class GenerateDmarcPage extends Page
|
||||
'content' => $this->generatedRecord
|
||||
]);
|
||||
|
||||
event(new \App\Events\DmarcKeyGenerated(
|
||||
zone: $domainName,
|
||||
name: '_dmarc',
|
||||
type: 'TXT',
|
||||
content: $this->generatedRecord,
|
||||
ttl: 3600,
|
||||
operation: 'create'
|
||||
));
|
||||
|
||||
if($data['update_dns']) {
|
||||
event(new \App\Events\DmarcKeyGenerated(
|
||||
zone: $domainName,
|
||||
name: '_dmarc',
|
||||
type: 'TXT',
|
||||
content: $this->generatedRecord,
|
||||
ttl: 3600,
|
||||
operation: 'create'
|
||||
));
|
||||
};
|
||||
$this->dispatch('open-modal', id: 'dmarc-record-modal');
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\Dmarc\Pages\GenerateDmarcPage;
|
||||
use VEximweb\Plugin\DnsTools\Filament\Resources\Spf\Pages\GenerateSpfPage;
|
||||
|
||||
class DnsToolsResource extends Resource
|
||||
{
|
||||
@@ -166,7 +167,8 @@ class DnsToolsResource extends Resource
|
||||
return [
|
||||
'index' => ListDomains::route('/'),
|
||||
//'dmarc' => GenerateDmarcPage::route('/dmarc'),
|
||||
'generate' => GenerateDmarcPage::route('/{domain}/generate-dmarc'),
|
||||
'generateDmarc' => GenerateDmarcPage::route('/{domain}/generate-dmarc'),
|
||||
'generateSpf' => GenerateSpfPage::route('/{domain}/generate-spf'),
|
||||
//'create' => CreateDomain::route('/create'),
|
||||
//'edit' => EditDomain::route('/{record}/edit'),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Spf\Pages;
|
||||
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Schemas\Schema;
|
||||
use BackedEnum;
|
||||
use UnitEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface;
|
||||
use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain;
|
||||
use VEximweb\Plugin\DnsTools\Services\SpfRecordService;
|
||||
|
||||
class GenerateSpfPage extends Page
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected string $view = 'dns-tools::filament.pages.generate-spf';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public Domain $domain;
|
||||
|
||||
public ?string $generatedRecord = null;
|
||||
|
||||
public ?int $lookupCount = 0;
|
||||
|
||||
public ?string $dnsName = '';
|
||||
|
||||
public ?array $validationIssues = [];
|
||||
|
||||
public static function getResource(): string
|
||||
{
|
||||
return \VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource::class;
|
||||
}
|
||||
|
||||
public static function getNavigationIcon(): string|BackedEnum|null
|
||||
{
|
||||
return 'heroicon-o-shield-check';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return 'Generate SPF';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Generate SPF Record';
|
||||
}
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return [
|
||||
'refreshNotifications' => 'refreshNotifications',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(Domain $domain): void
|
||||
{
|
||||
$this->domain = $domain;
|
||||
|
||||
// Get domain name
|
||||
$domainName = $domain->domain;
|
||||
|
||||
// Load existing settings if any
|
||||
$settings = app(SettingRepositoryInterface::class);
|
||||
$formData = $this->getValues($settings);
|
||||
|
||||
// Explicitly set the domain fields
|
||||
$formData['domain'] = $domainName;
|
||||
$formData['domain_id'] = $domain->domain_id;
|
||||
|
||||
// Fill the form
|
||||
$this->form->fill($formData);
|
||||
|
||||
// Set DNS name
|
||||
$this->dnsName = $domainName;
|
||||
|
||||
// Initialize validation issues
|
||||
$this->validationIssues = [];
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema($this->getFormSchema())
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the form schema as an array of components
|
||||
*/
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
$extra = [];
|
||||
|
||||
if (app()->bound('spfform.extenders')) {
|
||||
foreach (app('spfform.extenders')['components'] as $extender) {
|
||||
if (is_callable($extender)) {
|
||||
$result = $extender($this->domain);
|
||||
if (is_array($result)) {
|
||||
$extra = array_merge($extra, $result);
|
||||
}
|
||||
} else {
|
||||
$extra = array_merge($extra, $extender());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
Section::make('Domain Information')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Hidden::make('domain_id')
|
||||
->default($this->domain->domain_id ?? null),
|
||||
|
||||
TextInput::make('domain')
|
||||
->label('Domain')
|
||||
->default($this->domain->domain ?? '')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('The domain for which this SPF record is being generated'),
|
||||
]),
|
||||
|
||||
Section::make('Policy & Fail Settings')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('spf_fail_policy')
|
||||
->label('Fail Policy')
|
||||
->helperText('How receiving servers should handle SPF failures')
|
||||
->options([
|
||||
'-all' => 'Hard Fail (Recommended) - Reject emails that fail SPF',
|
||||
'~all' => 'Soft Fail - Accept but mark as suspicious',
|
||||
'?all' => 'Neutral - Do nothing',
|
||||
'+all' => 'Permit All (Not recommended - breaks SPF)',
|
||||
])
|
||||
->default('-all')
|
||||
->required(),
|
||||
|
||||
Select::make('ttl')
|
||||
->label('TTL (Time to Live)')
|
||||
->options([
|
||||
'300' => '300 (5 minutes - testing)',
|
||||
'3600' => '3600 (1 hour - standard)',
|
||||
'86400' => '86400 (24 hours - stable)',
|
||||
])
|
||||
->default('3600')
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Section::make('Sending Services (Include Mechanisms)')
|
||||
->schema([
|
||||
Repeater::make('spf_includes')
|
||||
->label('Email Sending Services')
|
||||
->helperText('Add the include mechanisms for your email providers (e.g., Google Workspace, SendGrid, Mailchimp)')
|
||||
->schema([
|
||||
TextInput::make('service')
|
||||
->label('Service Include')
|
||||
->placeholder('e.g., spf.google.com')
|
||||
->helperText('Enter the domain after "include:" (e.g., "spf.google.com" not "include:spf.google.com")')
|
||||
->required(),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->reorderable()
|
||||
->addActionLabel('Add Email Service'),
|
||||
]),
|
||||
|
||||
Section::make('IP Addresses')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Repeater::make('spf_ipv4')
|
||||
->label('IPv4 Addresses')
|
||||
->helperText('Add dedicated IPv4 addresses or CIDR blocks')
|
||||
->schema([
|
||||
TextInput::make('ipv4')
|
||||
->label('IPv4 Address')
|
||||
->placeholder('e.g., 192.0.2.0 or 192.0.2.0/24')
|
||||
->required()
|
||||
->rule('ipv4'),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->reorderable()
|
||||
->addActionLabel('Add IPv4 Address'),
|
||||
|
||||
Repeater::make('spf_ipv6')
|
||||
->label('IPv6 Addresses')
|
||||
->helperText('Add dedicated IPv6 addresses or CIDR blocks')
|
||||
->schema([
|
||||
TextInput::make('ipv6')
|
||||
->label('IPv6 Address')
|
||||
->placeholder('e.g., 2001:db8:: or 2001:db8::/32')
|
||||
->required()
|
||||
->rule('ipv6'),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->reorderable()
|
||||
->addActionLabel('Add IPv6 Address'),
|
||||
]),
|
||||
|
||||
Section::make('Mail Server Settings')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('spf_use_mx')
|
||||
->label('Use MX Records')
|
||||
->helperText('Allow all mail servers listed in your domain\'s MX records to send mail')
|
||||
->default(false),
|
||||
|
||||
TextInput::make('spf_mx_domain')
|
||||
->label('Custom MX Domain')
|
||||
->placeholder('Optional: mail.otherdomain.com')
|
||||
->helperText('Use MX records from another domain (e.g., "mx:mail.otherdomain.com")')
|
||||
->visible(fn ($get) => $get('spf_use_mx') === true),
|
||||
|
||||
Toggle::make('spf_use_a')
|
||||
->label('Use A Record')
|
||||
->helperText('Allow the IP found in your domain\'s A record to send mail')
|
||||
->default(false),
|
||||
|
||||
TextInput::make('spf_a_domain')
|
||||
->label('Custom A Domain')
|
||||
->placeholder('Optional: mail.otherdomain.com')
|
||||
->helperText('Use A record from another domain (e.g., "a:mail.otherdomain.com")')
|
||||
->visible(fn ($get) => $get('spf_use_a') === true),
|
||||
]),
|
||||
|
||||
Section::make('Advanced Settings')
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('spf_redirect')
|
||||
->label('Redirect')
|
||||
->placeholder('e.g., _spf.facebook.com')
|
||||
->helperText('Points the SPF query to another domain\'s SPF record entirely'),
|
||||
|
||||
TextInput::make('spf_exp')
|
||||
->label('Exp (Explanation)')
|
||||
->placeholder('e.g., %{i} is not authorized')
|
||||
->helperText('Sets an explanation text to show to receivers when an email fails (rarely used)'),
|
||||
|
||||
Select::make('spf_ptr')
|
||||
->label('PTR Mechanism')
|
||||
->options([
|
||||
'none' => 'Do not use PTR (Recommended)',
|
||||
'ptr' => 'Use PTR (Not recommended - deprecated)',
|
||||
])
|
||||
->default('none')
|
||||
->helperText('PTR is deprecated and causes massive DNS load. Use only if absolutely necessary.'),
|
||||
|
||||
TextInput::make('spf_exists')
|
||||
->label('Exists')
|
||||
->placeholder('e.g., exists.%{i}.example.com')
|
||||
->helperText('Complex DNS lookup query (only for power users)'),
|
||||
]),
|
||||
|
||||
...$extra,
|
||||
|
||||
Actions::make([
|
||||
Action::make('generate')
|
||||
->label('Generate SPF Record')
|
||||
->submit('generateSpf'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getValues(SettingRepositoryInterface $settings): array
|
||||
{
|
||||
return [
|
||||
'domain_id' => $this->domain->domain_id ?? null,
|
||||
'domain' => $this->domain->domain ?? '',
|
||||
'spf_fail_policy' => $settings->get('spf_fail_policy', '-all'),
|
||||
'ttl' => $settings->get('spf_ttl', '3600'),
|
||||
'spf_includes' => $settings->get('spf_includes', []),
|
||||
'spf_ipv4' => $settings->get('spf_ipv4', []),
|
||||
'spf_ipv6' => $settings->get('spf_ipv6', []),
|
||||
'spf_use_mx' => $settings->get('spf_use_mx', false),
|
||||
'spf_mx_domain' => $settings->get('spf_mx_domain', ''),
|
||||
'spf_use_a' => $settings->get('spf_use_a', false),
|
||||
'spf_a_domain' => $settings->get('spf_a_domain', ''),
|
||||
'spf_redirect' => $settings->get('spf_redirect', ''),
|
||||
'spf_exp' => $settings->get('spf_exp', ''),
|
||||
'spf_ptr' => $settings->get('spf_ptr', 'none'),
|
||||
'spf_exists' => $settings->get('spf_exists', ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function generateSpf(SpfRecordService $spf): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
\Log::debug('SPF Form data:' . json_encode($data));
|
||||
\Log::debug('Update dms state:' . $data['update_dns']);
|
||||
|
||||
// Generate the SPF record
|
||||
$result = $spf->generate($this->domain, $data);
|
||||
|
||||
$this->generatedRecord = $result['record'];
|
||||
$this->lookupCount = $result['lookups'] ?? 0;
|
||||
$this->dnsName = $this->domain->domain ?? '';
|
||||
$this->validationIssues = $result['issues'] ?? [];
|
||||
|
||||
// Log the generated record
|
||||
\Log::debug('SPF Event data:', [
|
||||
'zone' => $this->dnsName,
|
||||
'name' => '',
|
||||
'content' => $this->generatedRecord
|
||||
]);
|
||||
if($data['update_dns']) {
|
||||
|
||||
event(new \App\Events\SpfRecordGenerated(
|
||||
zone: $this->dnsName,
|
||||
name: '',
|
||||
type: 'TXT',
|
||||
content: $this->generatedRecord,
|
||||
ttl: (int) ($data['ttl'] ?? 3600),
|
||||
operation: 'create'
|
||||
));
|
||||
};
|
||||
|
||||
// Show the modal with the generated record
|
||||
$this->dispatch('open-modal', id: 'spf-record-modal');
|
||||
}
|
||||
|
||||
// Keep the static extend method for backward compatibility
|
||||
public static function extend(callable $components, callable $onSave): void
|
||||
{
|
||||
$existing = app()->bound('spfform.extenders')
|
||||
? app('spfform.extenders')
|
||||
: ['components' => [], 'hooks' => []];
|
||||
|
||||
$existing['components'][] = $components;
|
||||
$existing['hooks'][] = $onSave;
|
||||
|
||||
app()->instance('spfform.extenders', $existing);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,11 @@ class DomainsTable
|
||||
Action::make('generateDmarc')
|
||||
->label('Generate DMARC')
|
||||
->icon('heroicon-o-document-text')
|
||||
->url(fn ($record) => DnsToolsResource::getUrl('generate', ['domain' => $record])),
|
||||
->url(fn ($record) => DnsToolsResource::getUrl('generateDmarc', ['domain' => $record])),
|
||||
Action::make('generateSpf')
|
||||
->label('Generate SPF')
|
||||
->icon('heroicon-o-document-text')
|
||||
->url(fn ($record) => DnsToolsResource::getUrl('generateSpf', ['domain' => $record])),
|
||||
EditAction::make(),
|
||||
EditAction::make(),
|
||||
EditAction::make(),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SpfCheck extends Model
|
||||
{
|
||||
protected $table = 'vw_spf_checks';
|
||||
|
||||
protected $fillable = [
|
||||
'domain',
|
||||
'domain_id',
|
||||
'record',
|
||||
'spf_version',
|
||||
'policy',
|
||||
'lookup_count',
|
||||
'mechanisms',
|
||||
'includes',
|
||||
'ip4',
|
||||
'ip6',
|
||||
'mx_domains',
|
||||
'a_domains',
|
||||
'modifiers',
|
||||
'has_ptr',
|
||||
'has_exists',
|
||||
'valid',
|
||||
'error_message',
|
||||
'validation_issues',
|
||||
'mechanism_count',
|
||||
'last_checked_at',
|
||||
'next_check_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'mechanisms' => 'array',
|
||||
'includes' => 'array',
|
||||
'ip4' => 'array',
|
||||
'ip6' => 'array',
|
||||
'mx_domains' => 'array',
|
||||
'a_domains' => 'array',
|
||||
'modifiers' => 'array',
|
||||
'validation_issues' => 'array',
|
||||
'valid' => 'boolean',
|
||||
'has_ptr' => 'boolean',
|
||||
'has_exists' => 'boolean',
|
||||
'lookup_count' => 'integer',
|
||||
'mechanism_count' => 'integer',
|
||||
'last_checked_at' => 'datetime',
|
||||
'next_check_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the domain relationship
|
||||
*/
|
||||
public function domain()
|
||||
{
|
||||
return $this->belongsTo(SystemDomains::class, 'domain_id', 'domain_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable policy label
|
||||
*/
|
||||
public function getPolicyLabel(): string
|
||||
{
|
||||
return match($this->policy) {
|
||||
'-all' => 'Hard Fail (Reject)',
|
||||
'~all' => 'Soft Fail (Mark)',
|
||||
'?all' => 'Neutral',
|
||||
'+all' => 'Pass (DANGEROUS)',
|
||||
default => $this->policy ?? 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the SPF record has too many lookups
|
||||
*/
|
||||
public function hasTooManyLookups(): bool
|
||||
{
|
||||
return $this->lookup_count > 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get warning messages for the SPF record
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
if ($this->hasTooManyLookups()) {
|
||||
$warnings[] = "SPF record has {$this->lookup_count} DNS lookups (maximum recommended is 10)";
|
||||
}
|
||||
|
||||
if ($this->has_ptr) {
|
||||
$warnings[] = "PTR mechanism is deprecated and should be avoided";
|
||||
}
|
||||
|
||||
if ($this->policy === '+all') {
|
||||
$warnings[] = "WARNING: +all policy allows any server to send email from this domain";
|
||||
}
|
||||
|
||||
if ($this->policy === '?all') {
|
||||
$warnings[] = "?all policy provides no authentication (neutral)";
|
||||
}
|
||||
|
||||
if (!empty($this->validation_issues)) {
|
||||
$issues = is_array($this->validation_issues) ? $this->validation_issues : json_decode($this->validation_issues, true);
|
||||
if (is_array($issues)) {
|
||||
foreach ($issues as $issue) {
|
||||
if (isset($issue['level']) && $issue['level'] === 'warning') {
|
||||
$warnings[] = $issue['message'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error messages for the SPF record
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (!$this->valid) {
|
||||
$errors[] = $this->error_message ?? 'SPF record is invalid';
|
||||
}
|
||||
|
||||
if (!empty($this->validation_issues)) {
|
||||
$issues = is_array($this->validation_issues) ? $this->validation_issues : json_decode($this->validation_issues, true);
|
||||
if (is_array($issues)) {
|
||||
foreach ($issues as $issue) {
|
||||
if (isset($issue['level']) && $issue['level'] === 'error') {
|
||||
$errors[] = $issue['message'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include valid SPF records
|
||||
*/
|
||||
public function scopeValid($query)
|
||||
{
|
||||
return $query->where('valid', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include invalid SPF records
|
||||
*/
|
||||
public function scopeInvalid($query)
|
||||
{
|
||||
return $query->where('valid', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include records with no SPF record
|
||||
*/
|
||||
public function scopeNoRecord($query)
|
||||
{
|
||||
return $query->whereNull('record');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include records with high lookup counts
|
||||
*/
|
||||
public function scopeHighLookups($query, int $threshold = 10)
|
||||
{
|
||||
return $query->where('lookup_count', '>', $threshold);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,14 @@ class SystemDomains extends BaseDomain
|
||||
return $this->hasOne(DmarcCheck::class, 'domain_id', 'domain_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SPF check for this domain
|
||||
*/
|
||||
public function spfCheck()
|
||||
{
|
||||
return $this->hasOne(SpfCheck::class, 'domain_id', 'domain_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domain has a valid DMARC record
|
||||
*/
|
||||
@@ -24,6 +32,15 @@ class SystemDomains extends BaseDomain
|
||||
return $dmarc && $dmarc->valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domain has a valid SPF record
|
||||
*/
|
||||
public function hasValidSpf(): bool
|
||||
{
|
||||
$spf = $this->spfCheck()->first();
|
||||
return $spf && $spf->valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DMARC status
|
||||
*/
|
||||
@@ -35,4 +52,34 @@ class SystemDomains extends BaseDomain
|
||||
}
|
||||
return $dmarc->valid ? 'valid' : 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SPF status
|
||||
*/
|
||||
public function getSpfStatus(): string
|
||||
{
|
||||
$spf = $this->spfCheck()->first();
|
||||
if (!$spf) {
|
||||
return 'not_checked';
|
||||
}
|
||||
return $spf->valid ? 'valid' : 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DMARC policy if available
|
||||
*/
|
||||
public function getDmarcPolicy(): ?string
|
||||
{
|
||||
$dmarc = $this->dmarcCheck()->first();
|
||||
return $dmarc ? $dmarc->policy : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SPF policy if available
|
||||
*/
|
||||
public function getSpfPolicy(): ?string
|
||||
{
|
||||
$spf = $this->spfCheck()->first();
|
||||
return $spf ? $spf->policy : null;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ class DnsToolsServiceProvider extends ServiceProvider
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
\VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class,
|
||||
\VEximweb\Plugin\DnsTools\Console\Commands\CheckSpfRecords::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,928 @@
|
||||
<?php
|
||||
|
||||
namespace VEximweb\Plugin\DnsTools\Services;
|
||||
|
||||
use SPFLib\Record;
|
||||
use SPFLib\Term\Mechanism;
|
||||
use SPFLib\Term\Modifier;
|
||||
use SPFLib\SemanticValidator;
|
||||
use SPFLib\Decoder;
|
||||
use SPFLib\Exception;
|
||||
use SPFLib\Exception\InvalidTermException;
|
||||
use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain;
|
||||
use VEximweb\Plugin\DnsTools\Models\SpfCheck;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use IPLib\Factory;
|
||||
use IPLib\Address\IPv4;
|
||||
use IPLib\Address\IPv6;
|
||||
|
||||
class SpfRecordService
|
||||
{
|
||||
protected $decoder;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->decoder = new Decoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an SPF record from form data
|
||||
*
|
||||
* @param Domain $domain
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function generate(Domain $domain, array $data): array
|
||||
{
|
||||
try {
|
||||
// Initialize the SPF record with the domain
|
||||
$record = new Record($domain->domain);
|
||||
|
||||
// 1. Add Include mechanisms (sending services)
|
||||
if (!empty($data['spf_includes'])) {
|
||||
foreach ($data['spf_includes'] as $include) {
|
||||
if (!empty($include['service'])) {
|
||||
$record->addTerm(
|
||||
new Mechanism\IncludeMechanism(
|
||||
Mechanism::QUALIFIER_PASS,
|
||||
$include['service']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add IPv4 addresses
|
||||
if (!empty($data['spf_ipv4'])) {
|
||||
foreach ($data['spf_ipv4'] as $ipv4) {
|
||||
if (!empty($ipv4['ipv4'])) {
|
||||
$ip = $this->parseIpAddress($ipv4['ipv4']);
|
||||
if ($ip instanceof IPv4) {
|
||||
$record->addTerm(
|
||||
new Mechanism\Ip4Mechanism(
|
||||
Mechanism::QUALIFIER_PASS,
|
||||
$ip
|
||||
)
|
||||
);
|
||||
} else {
|
||||
Log::warning('Invalid IPv4 address: ' . $ipv4['ipv4']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add IPv6 addresses
|
||||
if (!empty($data['spf_ipv6'])) {
|
||||
foreach ($data['spf_ipv6'] as $ipv6) {
|
||||
if (!empty($ipv6['ipv6'])) {
|
||||
$ip = $this->parseIpAddress($ipv6['ipv6']);
|
||||
if ($ip instanceof IPv6) {
|
||||
$record->addTerm(
|
||||
new Mechanism\Ip6Mechanism(
|
||||
Mechanism::QUALIFIER_PASS,
|
||||
$ip
|
||||
)
|
||||
);
|
||||
} else {
|
||||
Log::warning('Invalid IPv6 address: ' . $ipv6['ipv6']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add MX mechanism
|
||||
if (!empty($data['spf_use_mx'])) {
|
||||
if (!empty($data['spf_mx_domain'])) {
|
||||
$record->addTerm(
|
||||
new Mechanism\MxMechanism(
|
||||
Mechanism::QUALIFIER_PASS,
|
||||
$data['spf_mx_domain']
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$record->addTerm(
|
||||
new Mechanism\MxMechanism(
|
||||
Mechanism::QUALIFIER_PASS
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Add A mechanism
|
||||
if (!empty($data['spf_use_a'])) {
|
||||
if (!empty($data['spf_a_domain'])) {
|
||||
$record->addTerm(
|
||||
new Mechanism\AMechanism(
|
||||
Mechanism::QUALIFIER_PASS,
|
||||
$data['spf_a_domain']
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$record->addTerm(
|
||||
new Mechanism\AMechanism(
|
||||
Mechanism::QUALIFIER_PASS
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Add Advanced mechanisms
|
||||
if (!empty($data['spf_ptr']) && $data['spf_ptr'] === 'ptr') {
|
||||
$record->addTerm(
|
||||
new Mechanism\PtrMechanism(
|
||||
Mechanism::QUALIFIER_PASS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['spf_exists'])) {
|
||||
$record->addTerm(
|
||||
new Mechanism\ExistsMechanism(
|
||||
Mechanism::QUALIFIER_PASS,
|
||||
$data['spf_exists']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Add Redirect modifier
|
||||
if (!empty($data['spf_redirect'])) {
|
||||
try {
|
||||
$record->addTerm(
|
||||
new Modifier\RedirectModifier($data['spf_redirect'])
|
||||
);
|
||||
} catch (InvalidTermException $e) {
|
||||
Log::warning('Invalid redirect modifier: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Add Exp modifier
|
||||
if (!empty($data['spf_exp'])) {
|
||||
try {
|
||||
$record->addTerm(
|
||||
new Modifier\ExpModifier($data['spf_exp'])
|
||||
);
|
||||
} catch (InvalidTermException $e) {
|
||||
Log::warning('Invalid exp modifier: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Add the fail policy (ALL mechanism - MUST be last)
|
||||
$qualifier = $this->getQualifier($data['spf_fail_policy'] ?? '-all');
|
||||
$record->addTerm(new Mechanism\AllMechanism($qualifier));
|
||||
|
||||
// Convert the record to string
|
||||
$spfString = (string) $record;
|
||||
|
||||
// Validate the record and get issues
|
||||
$issues = $this->validateRecord($record);
|
||||
$lookupCount = $this->countLookups($record);
|
||||
|
||||
if (!empty($issues)) {
|
||||
Log::warning('SPF record validation issues:', [
|
||||
'domain' => $domain->domain,
|
||||
'issues' => $issues
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'record' => $spfString,
|
||||
'lookups' => $lookupCount,
|
||||
'issues' => $issues,
|
||||
'is_valid' => empty($issues),
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to generate SPF record: ' . $e->getMessage(), [
|
||||
'domain' => $domain->domain,
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return [
|
||||
'record' => 'v=spf1 -all',
|
||||
'lookups' => 0,
|
||||
'issues' => ['Error: ' . $e->getMessage()],
|
||||
'is_valid' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an IP address string with proper CIDR handling
|
||||
*
|
||||
* @param string $ipString
|
||||
* @return IPv4|IPv6|null
|
||||
*/
|
||||
protected function parseIpAddress(string $ipString)
|
||||
{
|
||||
// Remove any whitespace
|
||||
$ipString = trim($ipString);
|
||||
|
||||
try {
|
||||
return Factory::parseAddressString($ipString);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Failed to parse IP address: ' . $ipString, ['error' => $e->getMessage()]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the qualifier for the ALL mechanism
|
||||
*
|
||||
* @param string $policy
|
||||
* @return string
|
||||
*/
|
||||
protected function getQualifier(string $policy): string
|
||||
{
|
||||
return match($policy) {
|
||||
'-all' => Mechanism::QUALIFIER_FAIL,
|
||||
'~all' => Mechanism::QUALIFIER_SOFTFAIL,
|
||||
'?all' => Mechanism::QUALIFIER_NEUTRAL,
|
||||
'+all' => Mechanism::QUALIFIER_PASS,
|
||||
default => Mechanism::QUALIFIER_FAIL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the SPF record and return any issues
|
||||
*
|
||||
* @param Record $record
|
||||
* @return array
|
||||
*/
|
||||
protected function validateRecord(Record $record): array
|
||||
{
|
||||
try {
|
||||
$validator = new SemanticValidator();
|
||||
$issues = $validator->validate($record);
|
||||
|
||||
$formattedIssues = [];
|
||||
foreach ($issues as $issue) {
|
||||
$formattedIssues[] = [
|
||||
'level' => $issue->getLevel(),
|
||||
'message' => $issue->getMessage(),
|
||||
'term' => $issue->getTerm() ? (string) $issue->getTerm() : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedIssues;
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
[
|
||||
'level' => 'error',
|
||||
'message' => 'Validation failed: ' . $e->getMessage(),
|
||||
'term' => null,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of DNS lookups in the SPF record
|
||||
*
|
||||
* @param Record $record
|
||||
* @return int
|
||||
*/
|
||||
protected function countLookups(Record $record): int
|
||||
{
|
||||
$lookupCount = 0;
|
||||
$terms = $record->getTerms();
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term instanceof Mechanism\IncludeMechanism ||
|
||||
$term instanceof Mechanism\MxMechanism ||
|
||||
$term instanceof Mechanism\AMechanism ||
|
||||
$term instanceof Mechanism\PtrMechanism ||
|
||||
$term instanceof Mechanism\ExistsMechanism) {
|
||||
$lookupCount++;
|
||||
}
|
||||
|
||||
if ($term instanceof Modifier\RedirectModifier) {
|
||||
$lookupCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $lookupCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of the SPF record
|
||||
*
|
||||
* @param string $recordString
|
||||
* @return array
|
||||
*/
|
||||
public function describe(string $recordString): array
|
||||
{
|
||||
try {
|
||||
$record = $this->decoder->getRecordFromTXT($recordString);
|
||||
|
||||
if ($record === null) {
|
||||
return ['error' => 'Not a valid SPF record'];
|
||||
}
|
||||
|
||||
$terms = $record->getTerms();
|
||||
|
||||
$description = [
|
||||
'version' => 'v=spf1',
|
||||
'mechanisms' => [],
|
||||
'modifiers' => [],
|
||||
'summary' => '',
|
||||
];
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$termString = (string) $term;
|
||||
$mechanismName = $term->getName();
|
||||
|
||||
if ($mechanismName === 'all') {
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'all',
|
||||
'value' => $termString,
|
||||
'description' => $this->getMechanismDescription($term),
|
||||
];
|
||||
} elseif ($mechanismName === 'include') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString);
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'include',
|
||||
'value' => $termString,
|
||||
'domain' => $domain,
|
||||
'description' => 'Include SPF records from ' . $domain,
|
||||
];
|
||||
} elseif ($mechanismName === 'ip4') {
|
||||
$ip = $this->extractDomainFromString($termString);
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'ip4',
|
||||
'value' => $termString,
|
||||
'ip' => $ip,
|
||||
'description' => 'Allow IP ' . $ip,
|
||||
];
|
||||
} elseif ($mechanismName === 'ip6') {
|
||||
$ip = $this->extractDomainFromString($termString);
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'ip6',
|
||||
'value' => $termString,
|
||||
'ip' => $ip,
|
||||
'description' => 'Allow IP ' . $ip,
|
||||
];
|
||||
} elseif ($mechanismName === 'mx') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString) ?: 'current domain';
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'mx',
|
||||
'value' => $termString,
|
||||
'domain' => $domain,
|
||||
'description' => 'Allow MX servers for ' . $domain,
|
||||
];
|
||||
} elseif ($mechanismName === 'a') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString) ?: 'current domain';
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'a',
|
||||
'value' => $termString,
|
||||
'domain' => $domain,
|
||||
'description' => 'Allow A record for ' . $domain,
|
||||
];
|
||||
} elseif ($mechanismName === 'ptr') {
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'ptr',
|
||||
'value' => $termString,
|
||||
'description' => 'PTR mechanism (deprecated)',
|
||||
];
|
||||
} elseif ($mechanismName === 'exists') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString);
|
||||
$description['mechanisms'][] = [
|
||||
'type' => 'exists',
|
||||
'value' => $termString,
|
||||
'domain' => $domain,
|
||||
'description' => 'Exists mechanism for ' . $domain,
|
||||
];
|
||||
} elseif ($mechanismName === 'redirect') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString);
|
||||
$description['modifiers'][] = [
|
||||
'type' => 'redirect',
|
||||
'value' => $termString,
|
||||
'domain' => $domain,
|
||||
'description' => 'Redirect to ' . $domain,
|
||||
];
|
||||
} elseif ($mechanismName === 'exp') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString);
|
||||
$description['modifiers'][] = [
|
||||
'type' => 'exp',
|
||||
'value' => $termString,
|
||||
'domain' => $domain,
|
||||
'description' => 'Explanation: ' . $domain,
|
||||
];
|
||||
} else {
|
||||
$description['mechanisms'][] = [
|
||||
'type' => $mechanismName,
|
||||
'value' => $termString,
|
||||
'description' => 'Unknown mechanism',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($description['mechanisms'] as $mech) {
|
||||
if ($mech['type'] !== 'all') {
|
||||
$parts[] = $mech['value'];
|
||||
}
|
||||
}
|
||||
$description['summary'] = implode(' ', $parts);
|
||||
|
||||
return $description;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'error' => 'Failed to parse SPF record: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a description for a mechanism
|
||||
*
|
||||
* @param Mechanism\AllMechanism $mechanism
|
||||
* @return string
|
||||
*/
|
||||
protected function getMechanismDescription(Mechanism\AllMechanism $mechanism): string
|
||||
{
|
||||
$qualifier = $mechanism->getQualifier();
|
||||
return match($qualifier) {
|
||||
Mechanism::QUALIFIER_FAIL => 'Reject all other senders (Hard Fail)',
|
||||
Mechanism::QUALIFIER_SOFTFAIL => 'Accept but mark as suspicious (Soft Fail)',
|
||||
Mechanism::QUALIFIER_NEUTRAL => 'Do nothing (Neutral)',
|
||||
Mechanism::QUALIFIER_PASS => 'Allow all senders (DANGEROUS)',
|
||||
default => 'Unknown qualifier',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a complex SPF record to its simplest form
|
||||
*
|
||||
* @param string $recordString
|
||||
* @return array
|
||||
*/
|
||||
public function flattenRecord(string $recordString): array
|
||||
{
|
||||
try {
|
||||
$record = $this->decoder->getRecordFromTXT($recordString);
|
||||
|
||||
if ($record === null) {
|
||||
return ['error' => 'Not a valid SPF record'];
|
||||
}
|
||||
|
||||
$terms = $record->getTerms();
|
||||
|
||||
$ip4List = [];
|
||||
$ip6List = [];
|
||||
$includes = [];
|
||||
$otherMechanisms = [];
|
||||
$allQualifier = null;
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$termString = (string) $term;
|
||||
$mechanismName = $term->getName();
|
||||
|
||||
if ($mechanismName === 'ip4') {
|
||||
$ip = $this->extractDomainFromString($termString);
|
||||
$ip4List[] = $ip;
|
||||
} elseif ($mechanismName === 'ip6') {
|
||||
$ip = $this->extractDomainFromString($termString);
|
||||
$ip6List[] = $ip;
|
||||
} elseif ($mechanismName === 'include') {
|
||||
$domain = $this->getDomainFromMechanism($term, $termString);
|
||||
$includes[] = $domain;
|
||||
} elseif ($mechanismName === 'all') {
|
||||
$allQualifier = $term->getQualifier();
|
||||
} else {
|
||||
$otherMechanisms[] = $termString;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ip4' => $ip4List,
|
||||
'ip6' => $ip6List,
|
||||
'includes' => $includes,
|
||||
'other' => $otherMechanisms,
|
||||
'all_qualifier' => $allQualifier,
|
||||
'lookup_reduction' => count($includes) > 0 ? 'Consider flattening includes' : 'Already optimal',
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'error' => 'Failed to flatten SPF record: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SPF CHECKING FUNCTIONALITY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check SPF record for a specific domain
|
||||
*
|
||||
* @param string $domain
|
||||
* @return SpfCheck|null
|
||||
*/
|
||||
public function checkDomain(string $domain): ?SpfCheck
|
||||
{
|
||||
try {
|
||||
// Check if we have a recent cached result
|
||||
$cacheKey = 'spf_check_' . md5($domain);
|
||||
if (Cache::has($cacheKey)) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
// Verify we got a valid object
|
||||
if ($cached instanceof SpfCheck) {
|
||||
return $cached;
|
||||
}
|
||||
// If not valid, remove from cache
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
// Perform DNS lookup
|
||||
$spfRecord = $this->getDnsSpfRecord($domain);
|
||||
|
||||
// Find or create the check record
|
||||
$check = SpfCheck::firstOrNew(['domain' => $domain]);
|
||||
|
||||
// Try to get domain_id if it exists
|
||||
$domainModel = Domain::where('domain', $domain)->first();
|
||||
if ($domainModel) {
|
||||
$check->domain_id = $domainModel->domain_id;
|
||||
}
|
||||
|
||||
if ($spfRecord) {
|
||||
// Parse and validate the SPF record
|
||||
$parsed = $this->parseSpfRecord($spfRecord, $domain);
|
||||
|
||||
$check->record = $spfRecord;
|
||||
$check->valid = $parsed['valid'];
|
||||
$check->policy = $parsed['policy'];
|
||||
$check->spf_version = $parsed['version'] ?? 'v=spf1';
|
||||
$check->lookup_count = $parsed['lookup_count'] ?? 0;
|
||||
$check->mechanisms = json_encode($parsed['mechanisms'] ?? []);
|
||||
$check->includes = json_encode($parsed['includes'] ?? []);
|
||||
$check->ip4 = json_encode($parsed['ip4'] ?? []);
|
||||
$check->ip6 = json_encode($parsed['ip6'] ?? []);
|
||||
$check->mx_domains = json_encode($parsed['mx_domains'] ?? []);
|
||||
$check->a_domains = json_encode($parsed['a_domains'] ?? []);
|
||||
$check->modifiers = json_encode($parsed['modifiers'] ?? []);
|
||||
$check->has_ptr = $parsed['has_ptr'] ?? false;
|
||||
$check->has_exists = $parsed['has_exists'] ?? false;
|
||||
$check->mechanism_count = $parsed['mechanism_count'] ?? 0;
|
||||
$check->validation_issues = json_encode($parsed['validation_issues'] ?? []);
|
||||
$check->error_message = null;
|
||||
} else {
|
||||
$check->valid = false;
|
||||
$check->error_message = 'No SPF record found for domain';
|
||||
$check->record = null;
|
||||
$check->validation_issues = null;
|
||||
}
|
||||
|
||||
$check->last_checked_at = now();
|
||||
$check->next_check_at = now()->addHours(24);
|
||||
$check->save();
|
||||
|
||||
// Reload the model to ensure we have a fresh instance
|
||||
$check = $check->fresh();
|
||||
|
||||
// Cache the result for 1 hour
|
||||
Cache::put($cacheKey, $check, 3600);
|
||||
|
||||
return $check;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SPF check failed for domain ' . $domain . ': ' . $e->getMessage());
|
||||
|
||||
try {
|
||||
$check = SpfCheck::firstOrNew(['domain' => $domain]);
|
||||
$check->valid = false;
|
||||
$check->error_message = 'Error checking SPF: ' . $e->getMessage();
|
||||
$check->last_checked_at = now();
|
||||
$check->save();
|
||||
return $check;
|
||||
} catch (\Exception $e2) {
|
||||
Log::error('Failed to save SPF error for domain ' . $domain . ': ' . $e2->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS SPF record for a domain
|
||||
*
|
||||
* @param string $domain
|
||||
* @return string|null
|
||||
*/
|
||||
protected function getDnsSpfRecord(string $domain): ?string
|
||||
{
|
||||
$records = dns_get_record($domain, DNS_TXT);
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (isset($record['txt']) && str_starts_with($record['txt'], 'v=spf1')) {
|
||||
return $record['txt'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate an SPF record using the Decoder
|
||||
*
|
||||
* @param string $spfString
|
||||
* @param string $domain
|
||||
* @return array
|
||||
*/
|
||||
protected function parseSpfRecord(string $spfString, string $domain): array
|
||||
{
|
||||
$result = [
|
||||
'valid' => false,
|
||||
'policy' => null,
|
||||
'version' => 'v=spf1',
|
||||
'lookup_count' => 0,
|
||||
'mechanisms' => [],
|
||||
'includes' => [],
|
||||
'ip4' => [],
|
||||
'ip6' => [],
|
||||
'mx_domains' => [],
|
||||
'a_domains' => [],
|
||||
'modifiers' => [],
|
||||
'has_ptr' => false,
|
||||
'has_exists' => false,
|
||||
'mechanism_count' => 0,
|
||||
'validation_issues' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
// Use the decoder to parse the SPF record
|
||||
$record = $this->decoder->getRecordFromTXT($spfString);
|
||||
|
||||
if ($record === null) {
|
||||
$result['validation_issues'][] = [
|
||||
'level' => 'error',
|
||||
'message' => 'Not a valid SPF record',
|
||||
'term' => null,
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$terms = $record->getTerms();
|
||||
|
||||
$lookupCount = 0;
|
||||
$mechanisms = [];
|
||||
$validationIssues = [];
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$termString = (string) $term;
|
||||
$result['mechanism_count']++;
|
||||
|
||||
// Get the mechanism name
|
||||
$mechanismName = $term->getName();
|
||||
|
||||
// Categorize different mechanism types using getName()
|
||||
if ($mechanismName === 'all') {
|
||||
$qualifier = $term->getQualifier();
|
||||
$result['policy'] = match($qualifier) {
|
||||
Mechanism::QUALIFIER_FAIL => '-all',
|
||||
Mechanism::QUALIFIER_SOFTFAIL => '~all',
|
||||
Mechanism::QUALIFIER_NEUTRAL => '?all',
|
||||
Mechanism::QUALIFIER_PASS => '+all',
|
||||
default => null,
|
||||
};
|
||||
$mechanisms[] = ['type' => 'all', 'value' => $termString];
|
||||
} elseif ($mechanismName === 'include') {
|
||||
$includeDomain = $this->getDomainFromMechanism($term, $termString);
|
||||
$result['includes'][] = $includeDomain;
|
||||
$lookupCount++;
|
||||
$mechanisms[] = ['type' => 'include', 'value' => $termString, 'domain' => $includeDomain];
|
||||
} elseif ($mechanismName === 'ip4') {
|
||||
$ipString = $this->extractDomainFromString($termString);
|
||||
$result['ip4'][] = $ipString;
|
||||
$mechanisms[] = ['type' => 'ip4', 'value' => $termString, 'ip' => $ipString];
|
||||
} elseif ($mechanismName === 'ip6') {
|
||||
$ipString = $this->extractDomainFromString($termString);
|
||||
$result['ip6'][] = $ipString;
|
||||
$mechanisms[] = ['type' => 'ip6', 'value' => $termString, 'ip' => $ipString];
|
||||
} elseif ($mechanismName === 'mx') {
|
||||
$mxDomain = $this->getDomainFromMechanism($term, $termString) ?: $domain;
|
||||
$result['mx_domains'][] = $mxDomain;
|
||||
$lookupCount++;
|
||||
$mechanisms[] = ['type' => 'mx', 'value' => $termString, 'domain' => $mxDomain];
|
||||
} elseif ($mechanismName === 'a') {
|
||||
$aDomain = $this->getDomainFromMechanism($term, $termString) ?: $domain;
|
||||
$result['a_domains'][] = $aDomain;
|
||||
$lookupCount++;
|
||||
$mechanisms[] = ['type' => 'a', 'value' => $termString, 'domain' => $aDomain];
|
||||
} elseif ($mechanismName === 'ptr') {
|
||||
$result['has_ptr'] = true;
|
||||
$lookupCount++;
|
||||
$mechanisms[] = ['type' => 'ptr', 'value' => $termString];
|
||||
} elseif ($mechanismName === 'exists') {
|
||||
$existsDomain = $this->getDomainFromMechanism($term, $termString);
|
||||
$result['has_exists'] = true;
|
||||
$lookupCount++;
|
||||
$mechanisms[] = ['type' => 'exists', 'value' => $termString, 'domain' => $existsDomain];
|
||||
} elseif ($mechanismName === 'redirect') {
|
||||
$redirectDomain = $this->getDomainFromMechanism($term, $termString);
|
||||
$result['modifiers'][] = ['type' => 'redirect', 'value' => $termString, 'domain' => $redirectDomain];
|
||||
$lookupCount++;
|
||||
} elseif ($mechanismName === 'exp') {
|
||||
$expDomain = $this->getDomainFromMechanism($term, $termString);
|
||||
$result['modifiers'][] = ['type' => 'exp', 'value' => $termString, 'domain' => $expDomain];
|
||||
} else {
|
||||
// Unknown mechanism type - add as is
|
||||
$mechanisms[] = ['type' => $mechanismName, 'value' => $termString];
|
||||
}
|
||||
}
|
||||
|
||||
$result['lookup_count'] = $lookupCount;
|
||||
$result['mechanisms'] = $mechanisms;
|
||||
|
||||
// Validate the record using SemanticValidator
|
||||
$validator = new SemanticValidator();
|
||||
$validationIssues = $validator->validate($record);
|
||||
|
||||
foreach ($validationIssues as $issue) {
|
||||
$result['validation_issues'][] = [
|
||||
'level' => $issue->getLevel(),
|
||||
'message' => $issue->getMessage(),
|
||||
'term' => $issue->getTerm() ? (string) $issue->getTerm() : null,
|
||||
];
|
||||
}
|
||||
|
||||
// Check if record is valid (has all mechanism and no critical issues)
|
||||
$result['valid'] = !empty($result['policy']) && empty(array_filter($validationIssues, function($issue) {
|
||||
return $issue->getLevel() === 'error' || $issue->getLevel() === 'fatal';
|
||||
}));
|
||||
|
||||
} catch (Exception $e) {
|
||||
$result['validation_issues'][] = [
|
||||
'level' => 'error',
|
||||
'message' => 'Failed to parse SPF record: ' . $e->getMessage(),
|
||||
'term' => null,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$result['validation_issues'][] = [
|
||||
'level' => 'error',
|
||||
'message' => 'Unexpected error: ' . $e->getMessage(),
|
||||
'term' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from SPF string
|
||||
*
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
protected function extractDomainFromString(string $string): string
|
||||
{
|
||||
// Remove the qualifier (+/-/~/?)
|
||||
$string = ltrim($string, '+~?-');
|
||||
|
||||
// Remove the mechanism name prefix if present (include:, a:, mx:, etc.)
|
||||
$parts = explode(':', $string, 2);
|
||||
if (count($parts) === 2) {
|
||||
// Check if the first part is a known mechanism name
|
||||
$knownMechanisms = ['include', 'a', 'mx', 'exists', 'ptr', 'ip4', 'ip6'];
|
||||
if (in_array($parts[0], $knownMechanisms)) {
|
||||
return trim($parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for equals separator (redirect=domain.com, exp=domain.com)
|
||||
if (strpos($string, '=') !== false) {
|
||||
$parts = explode('=', $string, 2);
|
||||
return trim($parts[1]);
|
||||
}
|
||||
|
||||
// If no separator, return the string as is
|
||||
return trim($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the domain from a mechanism using the proper methods
|
||||
*
|
||||
* @param Mechanism $mechanism
|
||||
* @param string $termString
|
||||
* @return string
|
||||
*/
|
||||
protected function getDomainFromMechanism(Mechanism $mechanism, string $termString): string
|
||||
{
|
||||
// Check if the mechanism implements TermWithDomainSpec
|
||||
if ($mechanism instanceof \SPFLib\Term\TermWithDomainSpec) {
|
||||
$domainSpec = $mechanism->getDomainSpec();
|
||||
if ($domainSpec !== null && !$domainSpec->isEmpty()) {
|
||||
return (string) $domainSpec;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract from string
|
||||
return $this->extractDomainFromString($termString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all domains
|
||||
*
|
||||
* @return int Number of domains checked
|
||||
*/
|
||||
public function checkAllDomains(): int
|
||||
{
|
||||
$domains = Domain::all();
|
||||
$count = 0;
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$this->checkDomain($domain->domain);
|
||||
$count++;
|
||||
|
||||
// Avoid rate limiting
|
||||
if ($count % 10 === 0) {
|
||||
usleep(100000); // 0.1 second delay every 10 domains
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check domains that need updating
|
||||
*
|
||||
* @return int Number of domains checked
|
||||
*/
|
||||
public function checkDomainsNeedingUpdate(): int
|
||||
{
|
||||
try {
|
||||
$domains = Domain::whereDoesntHave('spfCheck', function($query) {
|
||||
$query->where('next_check_at', '>', now());
|
||||
})->get();
|
||||
} catch (\Exception $e) {
|
||||
// Fallback if relationship doesn't exist
|
||||
$checkedDomains = SpfCheck::where('next_check_at', '>', now())
|
||||
->pluck('domain')
|
||||
->toArray();
|
||||
|
||||
$domains = Domain::whereNotIn('domain', $checkedDomains)->get();
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$result = $this->checkDomain($domain->domain);
|
||||
if ($result !== null) {
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($count % 10 === 0) {
|
||||
usleep(100000);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SPF statistics
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
$total = SpfCheck::count();
|
||||
$valid = SpfCheck::where('valid', true)->count();
|
||||
$noRecord = SpfCheck::whereNull('record')->count();
|
||||
$invalid = SpfCheck::where('valid', false)->whereNotNull('record')->count();
|
||||
|
||||
// Policy breakdown
|
||||
$policies = SpfCheck::whereNotNull('policy')
|
||||
->select('policy', \DB::raw('count(*) as count'))
|
||||
->groupBy('policy')
|
||||
->pluck('count', 'policy')
|
||||
->toArray();
|
||||
|
||||
// Lookup count distribution
|
||||
$lookupDistribution = SpfCheck::whereNotNull('lookup_count')
|
||||
->select('lookup_count', \DB::raw('count(*) as count'))
|
||||
->groupBy('lookup_count')
|
||||
->orderBy('lookup_count')
|
||||
->pluck('count', 'lookup_count')
|
||||
->toArray();
|
||||
|
||||
// High lookup count domains (> 10)
|
||||
$highLookups = SpfCheck::where('lookup_count', '>', 10)
|
||||
->where('valid', true)
|
||||
->pluck('domain')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'valid' => $valid,
|
||||
'no_record' => $noRecord,
|
||||
'invalid' => $invalid,
|
||||
'policies' => $policies,
|
||||
'lookup_distribution' => $lookupDistribution,
|
||||
'high_lookups' => $highLookups,
|
||||
'high_lookup_count' => count($highLookups),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user