Added support for SPF

This commit is contained in:
Mr Sleeps
2026-07-02 01:48:06 +01:00
parent 1ef8f39c3b
commit 5859d1f17f
6 changed files with 111 additions and 106 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
"php": "^8.4", "php": "^8.4",
"filament/filament": "^5.0", "filament/filament": "^5.0",
"cbowofrivia/dmarc-record-builder": "^4.0", "cbowofrivia/dmarc-record-builder": "^4.0",
"mlocati/spf-lib": "^3.3" "mlocati/spf-lib": "^3.3",
"mlocati/ip-lib": "^1.22"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
Generated
+1 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "246c2f9c61981a9ca6650dcb4ce074be", "content-hash": "b369fb991cc22ebbd6832aa81a937e0c",
"packages": [ "packages": [
{ {
"name": "blade-ui-kit/blade-heroicons", "name": "blade-ui-kit/blade-heroicons",
@@ -3,7 +3,7 @@
{{ $this->form }} {{ $this->form }}
</form> </form>
<x-filament::modal id="dmarc-record-modal" width="2xl"> <x-filament::modal id="spf-record-modal" width="2xl">
<x-slot name="heading"> <x-slot name="heading">
Generated SPF Record Generated SPF Record
</x-slot> </x-slot>
@@ -230,16 +230,17 @@ class GenerateDmarcPage extends Page
'name' => '_dmarc', 'name' => '_dmarc',
'content' => $this->generatedRecord 'content' => $this->generatedRecord
]); ]);
event(new \App\Events\DmarcKeyGenerated( if($data['update_dns']) {
zone: $domainName, event(new \App\Events\DmarcKeyGenerated(
name: '_dmarc', zone: $domainName,
type: 'TXT', name: '_dmarc',
content: $this->generatedRecord, type: 'TXT',
ttl: 3600, content: $this->generatedRecord,
operation: 'create' ttl: 3600,
)); operation: 'create'
));
};
$this->dispatch('open-modal', id: 'dmarc-record-modal'); $this->dispatch('open-modal', id: 'dmarc-record-modal');
} }
@@ -301,6 +301,7 @@ class GenerateSpfPage extends Page
{ {
$data = $this->form->getState(); $data = $this->form->getState();
\Log::debug('SPF Form data:' . json_encode($data)); \Log::debug('SPF Form data:' . json_encode($data));
\Log::debug('Update dms state:' . $data['update_dns']);
// Generate the SPF record // Generate the SPF record
$result = $spf->generate($this->domain, $data); $result = $spf->generate($this->domain, $data);
@@ -313,19 +314,20 @@ class GenerateSpfPage extends Page
// Log the generated record // Log the generated record
\Log::debug('SPF Event data:', [ \Log::debug('SPF Event data:', [
'zone' => $this->dnsName, 'zone' => $this->dnsName,
'name' => '_dmarc', 'name' => '',
'content' => $this->generatedRecord 'content' => $this->generatedRecord
]); ]);
if($data['update_dns']) {
// Dispatch event
event(new \App\Events\SpfKeyGenerated( event(new \App\Events\SpfRecordGenerated(
zone: $this->dnsName, zone: $this->dnsName,
name: '_dmarc', name: '',
type: 'TXT', type: 'TXT',
content: $this->generatedRecord, content: $this->generatedRecord,
ttl: (int) ($data['ttl'] ?? 3600), ttl: (int) ($data['ttl'] ?? 3600),
operation: 'create' operation: 'create'
)); ));
};
// Show the modal with the generated record // Show the modal with the generated record
$this->dispatch('open-modal', id: 'spf-record-modal'); $this->dispatch('open-modal', id: 'spf-record-modal');
+83 -82
View File
@@ -9,6 +9,9 @@ use SPFLib\SemanticValidator;
use SPFLib\Exception\InvalidTermException; use SPFLib\Exception\InvalidTermException;
use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain; use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use IPLib\Factory;
use IPLib\Address\IPv4;
use IPLib\Address\IPv6;
class SpfRecordService class SpfRecordService
{ {
@@ -25,9 +28,6 @@ class SpfRecordService
// Initialize the SPF record with the domain // Initialize the SPF record with the domain
$record = new Record($domain->domain); $record = new Record($domain->domain);
// Add the version mechanism (v=spf1)
$record->addTerm(new Mechanism\VersionMechanism());
// 1. Add Include mechanisms (sending services) // 1. Add Include mechanisms (sending services)
if (!empty($data['spf_includes'])) { if (!empty($data['spf_includes'])) {
foreach ($data['spf_includes'] as $include) { foreach ($data['spf_includes'] as $include) {
@@ -46,12 +46,17 @@ class SpfRecordService
if (!empty($data['spf_ipv4'])) { if (!empty($data['spf_ipv4'])) {
foreach ($data['spf_ipv4'] as $ipv4) { foreach ($data['spf_ipv4'] as $ipv4) {
if (!empty($ipv4['ipv4'])) { if (!empty($ipv4['ipv4'])) {
$record->addTerm( $ip = $this->parseIpAddress($ipv4['ipv4']);
new Mechanism\Ip4Mechanism( if ($ip instanceof IPv4) {
Mechanism::QUALIFIER_PASS, $record->addTerm(
$ipv4['ipv4'] new Mechanism\Ip4Mechanism(
) Mechanism::QUALIFIER_PASS,
); $ip
)
);
} else {
Log::warning('Invalid IPv4 address: ' . $ipv4['ipv4']);
}
} }
} }
} }
@@ -60,12 +65,17 @@ class SpfRecordService
if (!empty($data['spf_ipv6'])) { if (!empty($data['spf_ipv6'])) {
foreach ($data['spf_ipv6'] as $ipv6) { foreach ($data['spf_ipv6'] as $ipv6) {
if (!empty($ipv6['ipv6'])) { if (!empty($ipv6['ipv6'])) {
$record->addTerm( $ip = $this->parseIpAddress($ipv6['ipv6']);
new Mechanism\Ip6Mechanism( if ($ip instanceof IPv6) {
Mechanism::QUALIFIER_PASS, $record->addTerm(
$ipv6['ipv6'] new Mechanism\Ip6Mechanism(
) Mechanism::QUALIFIER_PASS,
); $ip
)
);
} else {
Log::warning('Invalid IPv6 address: ' . $ipv6['ipv6']);
}
} }
} }
} }
@@ -73,7 +83,6 @@ class SpfRecordService
// 4. Add MX mechanism // 4. Add MX mechanism
if (!empty($data['spf_use_mx'])) { if (!empty($data['spf_use_mx'])) {
if (!empty($data['spf_mx_domain'])) { if (!empty($data['spf_mx_domain'])) {
// Custom MX domain
$record->addTerm( $record->addTerm(
new Mechanism\MxMechanism( new Mechanism\MxMechanism(
Mechanism::QUALIFIER_PASS, Mechanism::QUALIFIER_PASS,
@@ -81,7 +90,6 @@ class SpfRecordService
) )
); );
} else { } else {
// Use current domain's MX records
$record->addTerm( $record->addTerm(
new Mechanism\MxMechanism( new Mechanism\MxMechanism(
Mechanism::QUALIFIER_PASS Mechanism::QUALIFIER_PASS
@@ -93,7 +101,6 @@ class SpfRecordService
// 5. Add A mechanism // 5. Add A mechanism
if (!empty($data['spf_use_a'])) { if (!empty($data['spf_use_a'])) {
if (!empty($data['spf_a_domain'])) { if (!empty($data['spf_a_domain'])) {
// Custom A domain
$record->addTerm( $record->addTerm(
new Mechanism\AMechanism( new Mechanism\AMechanism(
Mechanism::QUALIFIER_PASS, Mechanism::QUALIFIER_PASS,
@@ -101,7 +108,6 @@ class SpfRecordService
) )
); );
} else { } else {
// Use current domain's A record
$record->addTerm( $record->addTerm(
new Mechanism\AMechanism( new Mechanism\AMechanism(
Mechanism::QUALIFIER_PASS Mechanism::QUALIFIER_PASS
@@ -111,7 +117,6 @@ class SpfRecordService
} }
// 6. Add Advanced mechanisms // 6. Add Advanced mechanisms
// PTR (deprecated - use with caution)
if (!empty($data['spf_ptr']) && $data['spf_ptr'] === 'ptr') { if (!empty($data['spf_ptr']) && $data['spf_ptr'] === 'ptr') {
$record->addTerm( $record->addTerm(
new Mechanism\PtrMechanism( new Mechanism\PtrMechanism(
@@ -120,7 +125,6 @@ class SpfRecordService
); );
} }
// Exists mechanism
if (!empty($data['spf_exists'])) { if (!empty($data['spf_exists'])) {
$record->addTerm( $record->addTerm(
new Mechanism\ExistsMechanism( new Mechanism\ExistsMechanism(
@@ -130,7 +134,7 @@ class SpfRecordService
); );
} }
// 7. Add Redirect modifier (must be added before the ALL mechanism) // 7. Add Redirect modifier
if (!empty($data['spf_redirect'])) { if (!empty($data['spf_redirect'])) {
try { try {
$record->addTerm( $record->addTerm(
@@ -163,7 +167,6 @@ class SpfRecordService
$issues = $this->validateRecord($record); $issues = $this->validateRecord($record);
$lookupCount = $this->countLookups($record); $lookupCount = $this->countLookups($record);
// Log any issues
if (!empty($issues)) { if (!empty($issues)) {
Log::warning('SPF record validation issues:', [ Log::warning('SPF record validation issues:', [
'domain' => $domain->domain, 'domain' => $domain->domain,
@@ -185,7 +188,7 @@ class SpfRecordService
]); ]);
return [ return [
'record' => 'v=spf1 -all', // Fallback to a safe default 'record' => 'v=spf1 -all',
'lookups' => 0, 'lookups' => 0,
'issues' => ['Error: ' . $e->getMessage()], 'issues' => ['Error: ' . $e->getMessage()],
'is_valid' => false, 'is_valid' => false,
@@ -193,14 +196,35 @@ class SpfRecordService
} }
} }
/**
* 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 * Get the qualifier for the ALL mechanism
* Must return an integer constant from Mechanism class
* *
* @param string $policy * @param string $policy
* @return int * @return int
*/ */
protected function getQualifier(string $policy): int protected function getQualifier(string $policy): string
{ {
// Using match expression that returns integer constants
return match($policy) { return match($policy) {
'-all' => Mechanism::QUALIFIER_FAIL, '-all' => Mechanism::QUALIFIER_FAIL,
'~all' => Mechanism::QUALIFIER_SOFTFAIL, '~all' => Mechanism::QUALIFIER_SOFTFAIL,
@@ -225,7 +249,7 @@ class SpfRecordService
$formattedIssues = []; $formattedIssues = [];
foreach ($issues as $issue) { foreach ($issues as $issue) {
$formattedIssues[] = [ $formattedIssues[] = [
'level' => $issue->getLevel(), // 'info', 'warning', 'error' 'level' => $issue->getLevel(),
'message' => $issue->getMessage(), 'message' => $issue->getMessage(),
'term' => $issue->getTerm() ? (string) $issue->getTerm() : null, 'term' => $issue->getTerm() ? (string) $issue->getTerm() : null,
]; ];
@@ -255,7 +279,6 @@ class SpfRecordService
$terms = $record->getTerms(); $terms = $record->getTerms();
foreach ($terms as $term) { foreach ($terms as $term) {
// Mechanisms that cause DNS lookups
if ($term instanceof Mechanism\IncludeMechanism || if ($term instanceof Mechanism\IncludeMechanism ||
$term instanceof Mechanism\MxMechanism || $term instanceof Mechanism\MxMechanism ||
$term instanceof Mechanism\AMechanism || $term instanceof Mechanism\AMechanism ||
@@ -264,7 +287,6 @@ class SpfRecordService
$lookupCount++; $lookupCount++;
} }
// Redirect modifier also causes a lookup
if ($term instanceof Modifier\RedirectModifier) { if ($term instanceof Modifier\RedirectModifier) {
$lookupCount++; $lookupCount++;
} }
@@ -273,35 +295,6 @@ class SpfRecordService
return $lookupCount; return $lookupCount;
} }
/**
* Test if an IP address is allowed by the SPF record
*
* @param string $ip
* @param string $domain
* @param array $data
* @return bool
*/
public function testIp(string $ip, string $domain, array $data): bool
{
try {
// Generate the record first
$result = $this->generate(new Domain(['domain' => $domain]), $data);
// Parse the record
$record = Record::fromString($result['record']);
// Check if the IP is allowed
// Note: This is a simplified check. In production, you'd use a proper SPF checker
// that performs actual DNS lookups. The mlocati/spf-lib doesn't have a built-in
// checker, so you might want to use a separate package for this.
return true; // Placeholder
} catch (\Exception $e) {
Log::error('Failed to test SPF record: ' . $e->getMessage());
return false;
}
}
/** /**
* Get a human-readable description of the SPF record * Get a human-readable description of the SPF record
* *
@@ -322,10 +315,6 @@ class SpfRecordService
]; ];
foreach ($terms as $term) { foreach ($terms as $term) {
if ($term instanceof Mechanism\VersionMechanism) {
continue;
}
$termString = (string) $term; $termString = (string) $term;
if ($term instanceof Mechanism\AllMechanism) { if ($term instanceof Mechanism\AllMechanism) {
@@ -342,18 +331,20 @@ class SpfRecordService
'description' => 'Include SPF records from ' . $term->getDomain(), 'description' => 'Include SPF records from ' . $term->getDomain(),
]; ];
} elseif ($term instanceof Mechanism\Ip4Mechanism) { } elseif ($term instanceof Mechanism\Ip4Mechanism) {
$ip = $term->getIp();
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'ip4', 'type' => 'ip4',
'value' => $termString, 'value' => $termString,
'ip' => $term->getIp(), 'ip' => is_object($ip) ? (string) $ip : $ip,
'description' => 'Allow IP ' . $term->getIp(), 'description' => 'Allow IP ' . (is_object($ip) ? (string) $ip : $ip),
]; ];
} elseif ($term instanceof Mechanism\Ip6Mechanism) { } elseif ($term instanceof Mechanism\Ip6Mechanism) {
$ip = $term->getIp();
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'ip6', 'type' => 'ip6',
'value' => $termString, 'value' => $termString,
'ip' => $term->getIp(), 'ip' => is_object($ip) ? (string) $ip : $ip,
'description' => 'Allow IP ' . $term->getIp(), 'description' => 'Allow IP ' . (is_object($ip) ? (string) $ip : $ip),
]; ];
} elseif ($term instanceof Mechanism\MxMechanism) { } elseif ($term instanceof Mechanism\MxMechanism) {
$description['mechanisms'][] = [ $description['mechanisms'][] = [
@@ -369,6 +360,19 @@ class SpfRecordService
'domain' => $term->getDomain() ?? 'current domain', 'domain' => $term->getDomain() ?? 'current domain',
'description' => 'Allow A record for ' . ($term->getDomain() ?? 'this domain'), 'description' => 'Allow A record for ' . ($term->getDomain() ?? 'this domain'),
]; ];
} elseif ($term instanceof Mechanism\PtrMechanism) {
$description['mechanisms'][] = [
'type' => 'ptr',
'value' => $termString,
'description' => 'PTR mechanism (deprecated)',
];
} elseif ($term instanceof Mechanism\ExistsMechanism) {
$description['mechanisms'][] = [
'type' => 'exists',
'value' => $termString,
'domain' => $term->getDomain(),
'description' => 'Exists mechanism for ' . $term->getDomain(),
];
} elseif ($term instanceof Modifier\RedirectModifier) { } elseif ($term instanceof Modifier\RedirectModifier) {
$description['modifiers'][] = [ $description['modifiers'][] = [
'type' => 'redirect', 'type' => 'redirect',
@@ -392,7 +396,6 @@ class SpfRecordService
} }
} }
// Generate a summary
$parts = []; $parts = [];
foreach ($description['mechanisms'] as $mech) { foreach ($description['mechanisms'] as $mech) {
if ($mech['type'] !== 'all') { if ($mech['type'] !== 'all') {
@@ -413,27 +416,23 @@ class SpfRecordService
/** /**
* Get a description for a mechanism * Get a description for a mechanism
* *
* @param mixed $mechanism * @param Mechanism\AllMechanism $mechanism
* @return string * @return string
*/ */
protected function getMechanismDescription($mechanism): string protected function getMechanismDescription(Mechanism\AllMechanism $mechanism): string
{ {
if ($mechanism instanceof Mechanism\AllMechanism) { $qualifier = $mechanism->getQualifier();
$qualifier = $mechanism->getQualifier(); return match($qualifier) {
return match($qualifier) { Mechanism::QUALIFIER_FAIL => 'Reject all other senders (Hard Fail)',
Mechanism::QUALIFIER_FAIL => 'Reject all other senders (Hard Fail)', Mechanism::QUALIFIER_SOFTFAIL => 'Accept but mark as suspicious (Soft Fail)',
Mechanism::QUALIFIER_SOFTFAIL => 'Accept but mark as suspicious (Soft Fail)', Mechanism::QUALIFIER_NEUTRAL => 'Do nothing (Neutral)',
Mechanism::QUALIFIER_NEUTRAL => 'Do nothing (Neutral)', Mechanism::QUALIFIER_PASS => 'Allow all senders (DANGEROUS)',
Mechanism::QUALIFIER_PASS => 'Allow all senders (DANGEROUS)', default => 'Unknown qualifier',
default => 'Unknown qualifier', };
};
}
return 'SPF mechanism';
} }
/** /**
* Flatten a complex SPF record to its simplest form * Flatten a complex SPF record to its simplest form
* (Useful for reducing lookups)
* *
* @param string $recordString * @param string $recordString
* @return array * @return array
@@ -452,9 +451,11 @@ class SpfRecordService
foreach ($terms as $term) { foreach ($terms as $term) {
if ($term instanceof Mechanism\Ip4Mechanism) { if ($term instanceof Mechanism\Ip4Mechanism) {
$ip4List[] = $term->getIp(); $ip = $term->getIp();
$ip4List[] = is_object($ip) ? (string) $ip : $ip;
} elseif ($term instanceof Mechanism\Ip6Mechanism) { } elseif ($term instanceof Mechanism\Ip6Mechanism) {
$ip6List[] = $term->getIp(); $ip = $term->getIp();
$ip6List[] = is_object($ip) ? (string) $ip : $ip;
} elseif ($term instanceof Mechanism\IncludeMechanism) { } elseif ($term instanceof Mechanism\IncludeMechanism) {
$includes[] = $term->getDomain(); $includes[] = $term->getDomain();
} elseif ($term instanceof Mechanism\AllMechanism) { } elseif ($term instanceof Mechanism\AllMechanism) {