1214 lines
47 KiB
PHP
1214 lines
47 KiB
PHP
<?php
|
|
/**
|
|
* Helper functions for the Domain post type.
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
use GuzzleHttp\Client;
|
|
|
|
require 'vendor/autoload.php'; // Ensure you install Guzzle via Composer
|
|
|
|
|
|
class RL_MailWarmer_Domain_Helper
|
|
{
|
|
/**
|
|
* Normalize the domain input (post object, array, or ID).
|
|
*
|
|
* @param mixed $domain The domain input.
|
|
* @return WP_Post|null The domain post object or null if invalid.
|
|
*/
|
|
private static function get_domain_post($domain)
|
|
{
|
|
if (is_numeric($domain)) {
|
|
return get_post($domain);
|
|
} elseif (is_array($domain) && isset($domain['ID'])) {
|
|
return get_post($domain['ID']);
|
|
} elseif ($domain instanceof WP_Post) {
|
|
return $domain;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get Cloudflare API credentials from the domain post.
|
|
*
|
|
* @param WP_Post $domain_post The domain post object.
|
|
* @return array|null Array with email and key or null if missing.
|
|
*/
|
|
private static function get_cloudflare_credentials(WP_Post $domain_post)
|
|
{
|
|
$email = get_post_meta($domain_post->ID, 'cloudflare_api_email', true);
|
|
$key = get_post_meta($domain_post->ID, 'cloudflare_api_key', true);
|
|
|
|
return $email && $key ? ['email' => $email, 'key' => $key] : null;
|
|
}
|
|
|
|
/**
|
|
* Fetch DNS records using the Cloudflare API.
|
|
*
|
|
* @param string $domain The domain name.
|
|
* @param string $type The record type (e.g., TXT, A).
|
|
* @param array $credentials Cloudflare credentials.
|
|
* @return array The DNS records.
|
|
*/
|
|
private static function fetch_dns_records($domain, $type, $credentials)
|
|
{
|
|
|
|
log_to_file("fetch_dns_records - Fetching records for $domain");
|
|
$endpoint = "https://api.cloudflare.com/client/v4/zones";
|
|
$client = new \GuzzleHttp\Client(['timeout' => 10]);
|
|
|
|
// Fetch zone ID
|
|
$response = $client->get($endpoint, [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'query' => ['name' => $domain],
|
|
]);
|
|
|
|
$zones = json_decode((string) $response->getBody(), true);
|
|
if (empty($zones['result'][0]['id'])) {
|
|
throw new RuntimeException('Zone ID not found for the domain.');
|
|
}
|
|
|
|
$zone_id = $zones['result'][0]['id'];
|
|
|
|
// Fetch DNS records
|
|
$dns_response = $client->get("{$endpoint}/{$zone_id}/dns_records", [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'query' => ['type' => $type],
|
|
]);
|
|
|
|
return json_decode((string) $dns_response->getBody(), true)['result'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Update or create a DNS record using the Cloudflare API.
|
|
*
|
|
* @param int $domainID The ID of the parent domain post.
|
|
* @param string $domain The domain name.
|
|
* @param string $type The record type (e.g., TXT).
|
|
* @param string $name The record name.
|
|
* @param string $content The record content.
|
|
* @param int $ttl The TTL value.
|
|
* @param array $credentials Cloudflare credentials.
|
|
* @return bool True on success.
|
|
*/
|
|
private static function update_dns_record($domain_id, $domain, $type, $name, $content, $ttl, $credentials)
|
|
{
|
|
|
|
// log_to_file("Updating $name record on $domain with: " . $content);
|
|
$domain_post = self::get_domain_post($domain_id);
|
|
if (!$domain_post) {
|
|
// log_to_file("Invalid domain input");
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$endpoint = "https://api.cloudflare.com/client/v4/zones";
|
|
$client = new \GuzzleHttp\Client(['timeout' => 10]);
|
|
|
|
// Fetch zone ID
|
|
$response = $client->get($endpoint, [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'query' => ['name' => $domain],
|
|
]);
|
|
|
|
$zones = json_decode((string) $response->getBody(), true);
|
|
if (empty($zones['result'][0]['id'])) {
|
|
throw new RuntimeException('Zone ID not found for the domain.');
|
|
}
|
|
|
|
$zone_id = $zones['result'][0]['id'];
|
|
|
|
// Fetch existing DNS records
|
|
$dns_response = $client->get("{$endpoint}/{$zone_id}/dns_records", [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'query' => ['type' => $type],
|
|
]);
|
|
|
|
$records = json_decode((string) $dns_response->getBody(), true)['result'] ?? [];
|
|
// log_to_file("All records", $records);
|
|
|
|
// Search existing records first, update it if found
|
|
foreach ($records as $record) {
|
|
if ($record['name'] === $name) {
|
|
if ($content === $record['content']) {
|
|
// log_to_file("Old and new values are the same. Not updating DNS $name record on $domain with: " . $content);
|
|
return false;
|
|
}
|
|
// log_to_file("Match found! Creating backup before updating");
|
|
// Backup the existing record before updating
|
|
$backup_response = self::backup_dns_record($record, $domain_post->ID);
|
|
// log_to_file("Backup response: $backup_response");
|
|
if ( $backup_response ){
|
|
// log_to_file("Updating $name record on $domain with: " . $content);
|
|
// log_to_file("Current value: " . $record['content']);
|
|
|
|
// Update existing record
|
|
$client->put("{$endpoint}/{$zone_id}/dns_records/{$record['id']}", [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'json' => [
|
|
'type' => $type,
|
|
'name' => $name,
|
|
'content' => $content,
|
|
'ttl' => $ttl,
|
|
],
|
|
]);
|
|
return true;
|
|
} else {
|
|
// log_to_file("Error creating Backup DNS Record $name for $domain_post->title. NOT updating DNS record.");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create new record if not found
|
|
$client->post("{$endpoint}/{$zone_id}/dns_records", [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'json' => [
|
|
'type' => $type,
|
|
'name' => $name,
|
|
'content' => $content,
|
|
'ttl' => $ttl,
|
|
],
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Backup DNS record before making changes.
|
|
*
|
|
* @param array $record The DNS record to backup.
|
|
* @param int $domain_id The ID of the associated domain post.
|
|
*/
|
|
private static function backup_dns_record($record, $domain_id)
|
|
{
|
|
|
|
/*
|
|
* TODO: Move records to a separate table; only save a new backup if the changes are diff-
|
|
* erent than the last backup; add roll-back system
|
|
*/
|
|
|
|
$post_data = [
|
|
'post_type' => 'dns_record_backup',
|
|
'post_title' => $record['name'] . ' - ' . current_time('mysql'),
|
|
'post_status' => 'private',
|
|
'post_content'=> json_encode($record, JSON_PRETTY_PRINT),
|
|
'meta_input' => [
|
|
'domain_id' => $domain_id, // Associated domain ID
|
|
'dns_type' => $record['type'],
|
|
'dns_name' => $record['name'],
|
|
'dns_content' => $record['content'],
|
|
'dns_ttl' => $record['ttl'],
|
|
],
|
|
];
|
|
|
|
// Insert the post into WordPress
|
|
$result = wp_insert_post($post_data);
|
|
if (is_wp_error($result)) {
|
|
// log_to_file("Error creating DNS Record Backup: " . $result->get_error_message());
|
|
return false;
|
|
}
|
|
// log_to_file("DNS record backup created successfully");
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create a backup of all DNS records for a domain using Cloudflare's export endpoint.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return int|WP_Error The post ID of the backup record or WP_Error on failure.
|
|
*/
|
|
public static function create_dns_backup($domain)
|
|
{
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$domain_name = $domain_post->post_title;
|
|
|
|
try {
|
|
// Fetch Cloudflare credentials
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
// Get the Cloudflare zone ID
|
|
$zone_id = self::get_cloudflare_zone_id($domain_name, $credentials);
|
|
if (!$zone_id) {
|
|
throw new RuntimeException("Zone ID not found for domain {$domain_name}.");
|
|
}
|
|
|
|
// Fetch all DNS records using Cloudflare export API
|
|
$client = new \GuzzleHttp\Client(['timeout' => 10]);
|
|
$response = $client->get("https://api.cloudflare.com/client/v4/zones/{$zone_id}/dns_records/export", [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
]);
|
|
|
|
if ($response->getStatusCode() !== 200) {
|
|
throw new RuntimeException('Failed to fetch DNS records from Cloudflare.');
|
|
}
|
|
|
|
$dns_records = $response->getBody()->getContents();
|
|
|
|
log_to_file("create_dns_backup - Exported DNS Records: $dns_records");
|
|
|
|
// Save the backup as a custom post
|
|
$backup_post = [
|
|
'post_type' => 'domain-dns-backup',
|
|
'post_title' => "{$domain_name}-backup-" . current_time('mysql'),
|
|
'post_status' => 'private',
|
|
'meta_input' => [
|
|
'domain_id' => $domain_post->ID,
|
|
'backup_data' => $dns_records,
|
|
],
|
|
];
|
|
|
|
$backup_post_id = wp_insert_post($backup_post);
|
|
if (is_wp_error($backup_post_id)) {
|
|
throw new RuntimeException('Failed to create DNS backup.');
|
|
}
|
|
|
|
return $backup_post_id;
|
|
} catch (Exception $e) {
|
|
error_log('Error creating DNS backup: ' . $e->getMessage());
|
|
return new WP_Error('dns_backup_error', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the Cloudflare zone ID for a domain.
|
|
*
|
|
* @param string $domain The domain name.
|
|
* @param array $credentials The Cloudflare credentials (email and API key).
|
|
* @return string|null The Cloudflare zone ID or null if not found.
|
|
*/
|
|
private static function get_cloudflare_zone_id($domain, $credentials)
|
|
{
|
|
$client = new \GuzzleHttp\Client(['timeout' => 10]);
|
|
$response = $client->get('https://api.cloudflare.com/client/v4/zones', [
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$credentials['key']}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'query' => ['name' => $domain],
|
|
]);
|
|
|
|
$zones = json_decode((string) $response->getBody(), true);
|
|
return $zones['result'][0]['id'] ?? null;
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
* Check domain health by verifying WHOIS information and basic attributes.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array Domain health details.
|
|
*/
|
|
public static function check_domain_health($domain)
|
|
{
|
|
|
|
log_to_file("check_domain_health - Running check_domain_health for $domain");
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
// Fetch WHOIS data
|
|
$whois_data = shell_exec("whois {$domain_post->post_title}");
|
|
|
|
if (!$whois_data) {
|
|
return [
|
|
'registration_valid' => false,
|
|
'domain_age' => null,
|
|
'days_to_expiration' => null,
|
|
];
|
|
}
|
|
|
|
// Parse WHOIS data
|
|
$registration_valid = true;
|
|
$creation_date = null;
|
|
$expiration_date = null;
|
|
|
|
if (preg_match('/Creation Date:\s*(.+)/i', $whois_data, $matches)) {
|
|
$creation_date = new DateTime(trim($matches[1]));
|
|
}
|
|
|
|
if (preg_match('/Expiration Date:\s*(.+)/i', $whois_data, $matches)) {
|
|
$expiration_date = new DateTime(trim($matches[1]));
|
|
} else if (preg_match('/Expiry Date:\s*(.+)/i', $whois_data, $matches)) {
|
|
$expiration_date = new DateTime(trim($matches[1]));
|
|
}
|
|
|
|
if (!$creation_date || !$expiration_date || $expiration_date < new DateTime()) {
|
|
$registration_valid = false;
|
|
}
|
|
|
|
$domain_age = $creation_date ? (new DateTime())->diff($creation_date)->format('%y years, %m months') : null;
|
|
$days_to_expiration = $expiration_date ? (new DateTime())->diff($expiration_date)->days : null;
|
|
|
|
return [
|
|
'registration_valid' => $registration_valid,
|
|
'domain_age' => $domain_age,
|
|
'days_to_expiration' => $days_to_expiration,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check SPF record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array SPF record details.
|
|
*/
|
|
public static function check_spf_record($domain)
|
|
{
|
|
log_to_file("check_spf_record - Running check_spf_record for $domain");
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$records = self::fetch_dns_records($domain_post->post_title, 'TXT', $credentials);
|
|
// log_to_file("All TXT records: ", $records);
|
|
foreach ($records as $record) {
|
|
// log_to_file("Found " . $record['name'] . " - ". $record['content']);
|
|
if ( ($record['name'] == $domain_post->post_title) && ( strpos($record['content'], 'v=spf1') !== false ) ) {
|
|
// log_to_file("Match Found: " . $record['name'] . ": " . $record['content']);
|
|
return [
|
|
'exists' => true,
|
|
'content' => $record['content'],
|
|
'ttl' => $record['ttl'],
|
|
'all_mechanism' => strpos($record['content'], '-all') !== false ? '-all' : (strpos($record['content'], '~all') !== false ? '~all' : 'none'),
|
|
];
|
|
}
|
|
}
|
|
|
|
return ['exists' => false];
|
|
}
|
|
|
|
/**
|
|
* Update SPF record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @param string $host The hostname or IP to add or remove.
|
|
* @param string $action The action to perform: 'add' or 'delete'.
|
|
* @param string $all_policy The `all` mechanism to set (default: '-all').
|
|
* @param int $ttl The TTL value (default: 3600).
|
|
* @return bool True on success.
|
|
*/
|
|
public static function update_spf_record($domain, $host, $action, $all_policy = '-all', $ttl = 3600)
|
|
{
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$records = self::fetch_dns_records($domain_post->post_title, 'TXT', $credentials);
|
|
$spf_record = null;
|
|
|
|
foreach ($records as $record) {
|
|
if (($record['name'] === $domain_post->post_title) && strpos($record['content'], 'v=spf1') !== false) {
|
|
$spf_record = $record;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Determine the correct format for the $host
|
|
$host_format = filter_var($host, FILTER_VALIDATE_IP) ? "ip4:$host" : "include:$host";
|
|
|
|
if (!$spf_record) {
|
|
// Create a new SPF record
|
|
$new_content = "v=spf1 +a +mx $host_format $all_policy";
|
|
|
|
// Ensure TXT content is wrapped in double quotes
|
|
$wrapped_content = '"' . trim($new_content, '"') . '"';
|
|
|
|
// log_to_file("No record found. Creating new record: $domain_post->post_title: $new_content");
|
|
return self::update_dns_record($domain_post->ID, $domain_post->post_title, 'TXT', $domain_post->post_title, $wrapped_content, $ttl, $credentials);
|
|
}
|
|
|
|
// Update the existing SPF record
|
|
$content_parts = explode(' ', $spf_record['content']);
|
|
|
|
if ($action === 'add') {
|
|
if (!in_array($host_format, $content_parts, true)) {
|
|
array_splice($content_parts, -1, 0, $host_format); // Insert before `all` mechanism
|
|
}
|
|
} elseif ($action === 'delete') {
|
|
$content_parts = array_filter($content_parts, fn($part) => $part !== $host_format);
|
|
}
|
|
|
|
// Ensure the `all` mechanism is set correctly
|
|
$content_parts = array_filter($content_parts, fn($part) => !preg_match('/~?all/', $part));
|
|
$content_parts[] = $all_policy .'"';
|
|
|
|
$updated_content = implode(' ', $content_parts);
|
|
// log_to_file("Existing record found. Updating");
|
|
// log_to_file("Old record: $domain_post->post_title: " .$record['content']);
|
|
// log_to_file("New record: $domain_post->post_title: $updated_content");
|
|
return self::update_dns_record($domain_post->ID, $domain_post->post_title, 'TXT', $domain_post->post_title, $updated_content, $ttl, $credentials);
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Check DMARC record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array DMARC record details.
|
|
*/
|
|
public static function check_dmarc_record($domain)
|
|
{
|
|
log_to_file("check_dmarc_record - Running check_dmarc_record for $domain");
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$records = self::fetch_dns_records($domain_post->post_title, 'TXT', $credentials);
|
|
foreach ($records as $record) {
|
|
if ($record['name'] === "_dmarc.{$domain_post->post_title}") {
|
|
return [
|
|
'exists' => true,
|
|
'content' => $record['content'],
|
|
'ttl' => $record['ttl'],
|
|
'policy' => preg_match('/p=(\w+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'sp_policy' => preg_match('/sp=(\w+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'percentage' => preg_match('/pct=(\d+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'aggregate_rpt' => preg_match('/rua=mailto:([^;]+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'forensic_rpt' => preg_match('/ruf=mailto:([^;]+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'report_interval' => preg_match('/ri=(\d+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'report_format' => preg_match('/rf=(\w+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'aspf' => preg_match('/aspf=(\w+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
'adkim' => preg_match('/adkim=(\w+)/', $record['content'], $matches) ? $matches[1] : null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return ['exists' => false];
|
|
}
|
|
|
|
|
|
/**
|
|
* Update DMARC record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @param array $params Array of DMARC record parameters to update.
|
|
* @return bool True on success.
|
|
*/
|
|
public static function update_dmarc_record($domain, $params = [])
|
|
{
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$name = "_dmarc.{$domain_post->post_title}";
|
|
|
|
// log_to_file("update_dmarc_record - Updating DMARC record for $name");
|
|
|
|
// Fetch existing DMARC record
|
|
$records = self::fetch_dns_records($domain_post->post_title, 'TXT', $credentials);
|
|
$existing_record = null;
|
|
|
|
foreach ($records as $record) {
|
|
if ($record['name'] === $name) {
|
|
log_to_file("update_dmarc_record - Match found: " . $record['name']);
|
|
$existing_record = $record;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Default DMARC record if $params is empty
|
|
if (empty($params)) {
|
|
$params = [
|
|
'v' => 'DMARC1',
|
|
'p' => 'quarantine',
|
|
'sp' => 'quarantine',
|
|
'pct' => 100,
|
|
'aspf' => 'r',
|
|
'adkim' => 'r',
|
|
];
|
|
}
|
|
|
|
// Parse existing record if available
|
|
$content_parts = [];
|
|
if ($existing_record) {
|
|
parse_str(str_replace('; ', '&', $existing_record['content']), $content_parts);
|
|
}
|
|
|
|
// Update or merge parameters
|
|
$updated_params = array_merge($content_parts, $params);
|
|
|
|
// Build the DMARC record string
|
|
$dmarc_content = '';
|
|
foreach ($updated_params as $key => $value) {
|
|
$dmarc_content .= "{$key}={$value}; ";
|
|
}
|
|
$dmarc_content = rtrim($dmarc_content, '; '); // Remove trailing semicolon and space
|
|
|
|
// Ensure TXT content is wrapped in double quotes
|
|
$wrapped_content = '"' . trim($dmarc_content, '"') . '"';
|
|
|
|
// log_to_file("update_dmarc_record - New DMARC value: $wrapped_content");
|
|
// $dmarc_content .= '"';
|
|
|
|
// Update or create the DNS record
|
|
if ($existing_record) {
|
|
return self::update_dns_record(
|
|
$domain_post->ID,
|
|
$domain_post->post_title,
|
|
'TXT',
|
|
$name,
|
|
$wrapped_content,
|
|
$existing_record['ttl'],
|
|
$credentials
|
|
);
|
|
} else {
|
|
return self::update_dns_record(
|
|
$domain_post->ID,
|
|
$domain_post->post_title,
|
|
'TXT',
|
|
$name,
|
|
$wrapped_content,
|
|
3600, // Default TTL
|
|
$credentials
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Check DKIM records for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @param array $selectors Array of DKIM selectors to check.
|
|
* @return array DKIM record details for each selector.
|
|
*/
|
|
public static function check_dkim_record($domain, $selectors = [])
|
|
{
|
|
log_to_file("check_dkim_record - Running check_dkim_record for $domain");
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$dns_records = self::fetch_dns_records($domain_post->post_title, 'TXT', $credentials);
|
|
|
|
$records = [];
|
|
$default_selectors = ['google', 'selector1', 'selector2', 'k1', 'k2', 'ctct1', 'ctct2', 'sm', 's1',
|
|
's2', 'sig1', 'litesrv', 'zendesk1', 'zendesk2', 'mail', 'email', 'dkim', 'default', '202410'];
|
|
$selectors = array_unique(array_merge($default_selectors, $selectors));
|
|
|
|
foreach ($selectors as $selector) {
|
|
$name = "{$selector}._domainkey.{$domain_post->post_title}";
|
|
|
|
foreach ($dns_records as $record) {
|
|
if ($record['name'] === $name) {
|
|
$records[$selector] = [
|
|
'exists' => true,
|
|
'ttl' => $record['ttl'],
|
|
'content'=> $record['content'],
|
|
];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
|
|
/**
|
|
* Update or create a DKIM record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @param string $selector The DKIM selector to update.
|
|
* @param string $action The action to perform: 'add' or 'delete'.
|
|
* @param string $value The DKIM public key (required for 'add').
|
|
* @param int $ttl The TTL value (default: 3600).
|
|
* @return bool True on success.
|
|
*/
|
|
public static function update_dkim_record($domain, $selector, $action, $value = '', $ttl = 3600)
|
|
{
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$name = "{$selector}._domainkey.{$domain_post->post_title}";
|
|
$dns_records = self::fetch_dns_records($domain_post->post_title, 'TXT', $credentials);
|
|
|
|
// Ensure TXT content is wrapped in double quotes
|
|
$wrapped_content = '"' . trim($value, '"') . '"';
|
|
|
|
foreach ($dns_records as $record) {
|
|
if ($record['name'] === $name) {
|
|
if ($action === 'delete') {
|
|
// Delete existing DKIM record
|
|
log_to_file("update_dkim_record - delete_dns_record() is not implemented!");
|
|
throw new RuntimeException('delete_dns_record() is not implemented!');
|
|
// return self::delete_dns_record($domain_post->post_title, $record['id'], $credentials);
|
|
}
|
|
|
|
if ($action === 'add') {
|
|
// Update existing DKIM record
|
|
return self::update_dns_record($domain_post->ID, $domain_post->post_title, 'TXT', $name, $wrapped_content, $ttl, $credentials);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($action === 'add') {
|
|
// Create new DKIM record if not found
|
|
return self::update_dns_record($domain_post->ID, $domain_post->post_title, 'TXT', $name, $wrapped_content, $ttl, $credentials);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
* Check A record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array A record details.
|
|
*/
|
|
public static function check_a_record($domain)
|
|
{
|
|
log_to_file("check_a_record - Running check_a_record for $domain");
|
|
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$dns_records = self::fetch_dns_records($domain_post->post_title, 'A', $credentials);
|
|
|
|
if (!empty($dns_records)) {
|
|
$record = $dns_records[0];
|
|
$ip = $record['content'];
|
|
$http_status = self::get_http_status($domain_post->post_title);
|
|
|
|
return [
|
|
'exists' => true,
|
|
'ip' => $ip,
|
|
'http_status' => $http_status,
|
|
'https_enabled'=> $http_status === 200 && self::is_https_enabled($domain_post->post_title),
|
|
];
|
|
}
|
|
|
|
return ['exists' => false];
|
|
}
|
|
|
|
/**
|
|
* Get HTTP status code for the domain.
|
|
*/
|
|
private static function get_http_status($domain)
|
|
{
|
|
$client = new Client(['timeout' => 10]);
|
|
try {
|
|
$response = $client->get("http://{$domain}");
|
|
return $response->getStatusCode();
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if HTTPS is enabled for the domain.
|
|
*/
|
|
private static function is_https_enabled($domain)
|
|
{
|
|
$client = new Client(['timeout' => 10]);
|
|
try {
|
|
$response = $client->get("https://{$domain}");
|
|
return $response->getStatusCode() === 200;
|
|
} catch (Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Check MX record for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array MX record details.
|
|
*/
|
|
public static function check_mx_record($domain)
|
|
{
|
|
log_to_file("check_mx_record - Running check_mx_record for $domain");
|
|
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
$dns_records = self::fetch_dns_records($domain_post->post_title, 'MX', $credentials);
|
|
|
|
if (!empty($dns_records)) {
|
|
$record = $dns_records[0];
|
|
$host = $record['content'];
|
|
$ptr_record = gethostbyaddr(gethostbyname($host));
|
|
|
|
return [
|
|
'exists' => true,
|
|
'host' => $host,
|
|
'ptr_valid' => $ptr_record !== false,
|
|
'ptr_matches' => $ptr_record === $host,
|
|
];
|
|
}
|
|
|
|
return ['exists' => false];
|
|
}
|
|
|
|
|
|
/**
|
|
* Check if the domain or its associated IP is on any blacklist.
|
|
*
|
|
* @param mixed $host The domain or IP address to check.
|
|
* @return array Blacklist check results.
|
|
*/
|
|
public static function check_blacklists($host)
|
|
{
|
|
log_to_file("check_blacklists - Running check_blacklists for $host");
|
|
|
|
$blacklist_servers = [
|
|
'zen.spamhaus.org',
|
|
'bl.spamcop.net',
|
|
'b.barracudacentral.org',
|
|
];
|
|
|
|
$results = [];
|
|
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
|
|
|
|
foreach ($blacklist_servers as $server) {
|
|
$query = implode('.', array_reverse(explode('.', $ip))) . '.' . $server;
|
|
$listed = gethostbyname($query) !== $query;
|
|
$results[$server] = $listed ? 'Listed' : 'Not Listed';
|
|
}
|
|
|
|
return [
|
|
'host' => $host,
|
|
'blacklists' => $results,
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* Generate a comprehensive email deliverability report for the domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array The generated report.
|
|
*/
|
|
public static function generate_domain_report($domain)
|
|
{
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
log_to_file("generate_domain_report - Running generate_domain_report for " . $domain_post->ID);
|
|
|
|
return [
|
|
'domain_health' => self::check_domain_health($domain),
|
|
'a_record' => self::check_a_record($domain),
|
|
'mx_record' => self::check_mx_record($domain),
|
|
'spf_record' => self::check_spf_record($domain),
|
|
'dkim_record' => self::check_dkim_record($domain),
|
|
'dmarc_record' => self::check_dmarc_record($domain),
|
|
'blacklists' => self::check_blacklists($domain_post->post_title),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Save a domain health report as a custom post.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return int|WP_Error The post ID of the saved report or WP_Error on failure.
|
|
*/
|
|
public static function save_domain_health_report($domain)
|
|
{
|
|
log_to_file("save_domain_health_report - Trying to save report for $domain");
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
log_to_file("save_domain_health_report - Before Report Generation");
|
|
$report = self::generate_domain_report($domain);
|
|
log_to_file("save_domain_health_report - After Report Generation");
|
|
|
|
// Prepare the post title and content
|
|
$title = "{$domain_post->post_title}-" . current_time('timestamp');
|
|
$content = json_encode($report, JSON_PRETTY_PRINT);
|
|
|
|
$meta_fields = [
|
|
'domain_id' => $domain_post->ID,
|
|
'domain_name' => $domain_post->post_title,
|
|
'domain_valid' => $report['domain_health']['registration_valid'],
|
|
'domain_age' => $report['domain_health']['domain_age'],
|
|
'domain_days_to_expiration' => $report['domain_health']['days_to_expiration'],
|
|
'a_record_valid' => $report['a_record']['exists'],
|
|
'a_record_resolves' => $report['a_record']['ip'] ?? null,
|
|
'http_status' => $report['a_record']['http_status'],
|
|
'https_enabled' => $report['a_record']['https_enabled'],
|
|
'mx_record_valid' => $report['mx_record']['exists'],
|
|
'mx_record_ptr_valid' => $report['mx_record']['ptr_valid'],
|
|
'mx_record_ptr_match' => $report['mx_record']['ptr_matches'],
|
|
'spf_record_exists' => $report['spf_record']['exists'],
|
|
'spf_record_content' => $report['spf_record']['content'] ?? null,
|
|
'spf_record_ttl' => $report['spf_record']['ttl'] ?? null,
|
|
'spf_record_all_mechanism' => $report['spf_record']['all_mechanism'] ?? null,
|
|
'dmarc_record_exists' => $report['dmarc_record']['exists'],
|
|
'dmarc_policy' => $report['dmarc_record']['policy'] ?? null,
|
|
'dmarc_sp_policy' => $report['dmarc_record']['sp_policy'] ?? null,
|
|
'dmarc_percentage' => $report['dmarc_record']['percentage'] ?? null,
|
|
'dmarc_aspf' => $report['dmarc_record']['aspf'] ?? null,
|
|
'dmarc_adkim' => $report['dmarc_record']['adkim'] ?? null,
|
|
'dmarc_aggregate_rpt' => $report['dmarc_record']['aggregate_rpt'] ?? null,
|
|
'dmarc_forensic_rpt' => $report['dmarc_record']['forensic_rpt'] ?? null,
|
|
'dmarc_report_format' => $report['dmarc_record']['rf'] ?? null,
|
|
'dmarc_report_interval' => $report['dmarc_record']['ri'] ?? null,
|
|
'dkim_records' => $report['dkim_record'],
|
|
];
|
|
|
|
// Create the Domain Health Report post
|
|
$post_id = wp_insert_post([
|
|
'post_type' => 'domain-health-report',
|
|
'post_title' => $title,
|
|
'post_status' => 'publish',
|
|
'post_content'=> $content,
|
|
'meta_input' => $meta_fields,
|
|
]);
|
|
|
|
if (is_wp_error($post_id)) {
|
|
// wp_send_json_error($post_id->get_error_message());
|
|
return false;
|
|
|
|
log_to_file("save_domain_health_report - Error creating Domain Health Report: " . $post_id->get_error_message());
|
|
} else {
|
|
log_to_file("Created Domain Health Report. Updating Domain metadata for {$domain_post->post_title}");
|
|
$removeKeys = array('domain_id', 'domain_name');
|
|
|
|
foreach($removeKeys as $key) {
|
|
unset($meta_fields[$key]);
|
|
}
|
|
$data = array(
|
|
'ID' => $domain_post->ID,
|
|
'meta_input' => $meta_fields
|
|
);
|
|
|
|
wp_update_post( $data );
|
|
}
|
|
|
|
// wp_send_json_success($post_id);
|
|
return $post_id;
|
|
}
|
|
|
|
/**
|
|
* Fix deliverability-related DNS issues for the given domain.
|
|
*
|
|
* @param mixed $domain The domain input (post object, array, or ID).
|
|
* @return array Results of the actions performed.
|
|
*/
|
|
public static function fix_deliverability_dns_issues($domain)
|
|
{
|
|
$domain_post = self::get_domain_post($domain);
|
|
if (!$domain_post) {
|
|
throw new InvalidArgumentException('Invalid domain input.');
|
|
}
|
|
|
|
$results = [];
|
|
$domain_name = $domain_post->post_title;
|
|
|
|
// Fetch credentials
|
|
$credentials = self::get_cloudflare_credentials($domain_post);
|
|
if (!$credentials) {
|
|
throw new RuntimeException('Cloudflare credentials not found for the domain.');
|
|
}
|
|
|
|
// Fetch current TXT records
|
|
try {
|
|
$current_txt_records = self::fetch_dns_records($domain_name, 'TXT', $credentials);
|
|
// log_to_file("fix_deliverability_dns_issues - Current TXT records for $domain_name: ", $current_txt_records);
|
|
// $results['txt_records'] = $current_txt_records;
|
|
} catch (Exception $e) {
|
|
$results['txt_records'] = 'Error fetching TXT records: ' . $e->getMessage();
|
|
return $results;
|
|
}
|
|
|
|
// Helper to find specific records
|
|
$findRecord = function ($records, $contains, $search_name = false) {
|
|
foreach ($records as $record) {
|
|
if ($search_name) {
|
|
if (strpos($record['name'], $contains) !== false) {
|
|
return $record;
|
|
}
|
|
} else {
|
|
if (strpos($record['content'], $contains) !== false) {
|
|
return $record;
|
|
}
|
|
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// SPF
|
|
$default_spf_include = get_field('default_spf_include', 'option');
|
|
if ($default_spf_include) {
|
|
$spf_record = $findRecord($current_txt_records, 'v=spf1');
|
|
if ($spf_record) {
|
|
// log_to_file("fix_deliverability_dns_issues - Current SPF record: ",$spf_record);
|
|
if (strpos($spf_record['content'], $default_spf_include) === false) {
|
|
// Add default_spf_include to the SPF record
|
|
// $updated_spf_content = rtrim(str_replace('-all', '', $spf_record['content'])) . " include:{$default_spf_include} -all";
|
|
try {
|
|
self::update_spf_record($domain_post, $default_spf_include, 'add');
|
|
$results['spf'] = "Updated SPF to include $default_spf_include";
|
|
} catch (Exception $e) {
|
|
$results['spf'] = 'Error: ' . $e->getMessage();
|
|
}
|
|
} else {
|
|
$results['spf'] = 'SPF record already includes default include.';
|
|
}
|
|
} else {
|
|
try {
|
|
$results['spf'] = self::update_spf_record($domain_post, $default_spf_include, 'add');
|
|
} catch (Exception $e) {
|
|
$results['spf'] = 'Error: ' . $e->getMessage();
|
|
}
|
|
// $results['spf'] = 'No SPF record found for the domain.';
|
|
}
|
|
} else {
|
|
$results['spf'] = 'No default SPF include found in settings.';
|
|
}
|
|
|
|
// DKIM
|
|
$servers = get_field('servers', $domain_post->ID);
|
|
if ($servers && is_array($servers)) {
|
|
$results['dkim'] = [];
|
|
foreach ($servers as $server) {
|
|
$selector = get_field('dkim_selector', $server);
|
|
$value = get_field('dkim_value', $server);
|
|
// log_to_file("fix_deliverability_dns_issues - DKIM $selector: $value");
|
|
if ($selector && $value) {
|
|
$dkim_record = $findRecord($current_txt_records, "{$selector}._domainkey", true);
|
|
if (!$dkim_record) {
|
|
try {
|
|
$dkim_result = self::update_dkim_record($domain_post, $selector, 'add', $value);
|
|
$results['dkim'][$selector] = $dkim_result
|
|
? "DKIM record for selector '{$selector}' added successfully."
|
|
: "Failed to add DKIM record for selector '{$selector}'.";
|
|
} catch (Exception $e) {
|
|
$results['dkim'][$selector] = 'Error: ' . $e->getMessage();
|
|
}
|
|
} else {
|
|
$results['dkim'][$selector] = "DKIM record for selector '{$selector}' already exists.";
|
|
}
|
|
} else {
|
|
$results['dkim'][$server] = 'Missing DKIM selector or value for server.';
|
|
}
|
|
}
|
|
} else {
|
|
$results['dkim'] = 'No servers selected for the domain.';
|
|
}
|
|
|
|
// DMARC
|
|
$dmarc_record = $findRecord($current_txt_records, '_dmarc', true);
|
|
if (!$dmarc_record) {
|
|
// log_to_file("fix_deliverability_dns_issues - No existing DMARC record found. Creating one.");
|
|
$dmarc_params = [
|
|
'v' => 'DMARC1',
|
|
'p' => 'quarantine',
|
|
'pct' => 100,
|
|
'aspf' => 'relaxed',
|
|
'adkim' => 'relaxed',
|
|
];
|
|
try {
|
|
$dmarc_result = self::update_dmarc_record($domain_post, $dmarc_params);
|
|
$results['dmarc'] = $dmarc_result
|
|
? 'Default DMARC record created successfully.'
|
|
: 'Failed to create DMARC record.';
|
|
} catch (Exception $e) {
|
|
$results['dmarc'] = 'Error: ' . $e->getMessage();
|
|
}
|
|
} else {
|
|
// log_to_file("fix_deliverability_dns_issues - Existing DMARC record found. Doing nothing.");
|
|
$results['dmarc'] = "Existing DMARC record found: {$dmarc_record['content']}";
|
|
}
|
|
// $results['health_report'] = '';
|
|
// Save a health report after fixing DNS issues
|
|
try {
|
|
$report_id = self::save_domain_health_report($domain);
|
|
$results['health_report'] = "Health report saved successfully with ID {$report_id}.";
|
|
} catch (Exception $e) {
|
|
$results['health_report'] = 'Error saving health report: ' . $e->getMessage();
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/*
|
|
* TO DO!
|
|
*
|
|
*/
|
|
// modify_domain_on_server - Creates/Updates/Deletes the specified domain on the specified server if it doesn't already exist (VirtualMin). Checks/Updates DNS config to include server IP in SPF, DKIM key, etc.
|
|
|
|
/**
|
|
* Modify a domain account on a VirtualMin server.
|
|
*
|
|
* Creates, updates, or deletes a virtual server for the specified domain.
|
|
*
|
|
* @param int $domain_id The domain post ID.
|
|
* @param string $action The action to perform: 'create', 'update', or 'delete'.
|
|
* @return bool|WP_Error True on success, WP_Error on failure.
|
|
*/
|
|
public static function modify_domain_account_on_server($domain_id, $action)
|
|
{
|
|
// Validate domain post
|
|
$domain = get_post($domain_id);
|
|
if (!$domain || $domain->post_type !== 'domain') {
|
|
return new WP_Error('invalid_domain', __('Invalid domain post.', 'rl-mailwarmer'));
|
|
}
|
|
|
|
// Fetch associated server posts
|
|
$server_ids = get_post_meta($domain_id, 'associated_servers', true); // Assume this holds server IDs
|
|
if (empty($server_ids) || !is_array($server_ids)) {
|
|
return new WP_Error('missing_servers', __('No associated servers found for the domain.', 'rl-mailwarmer'));
|
|
}
|
|
|
|
$domain_name = $domain->post_title;
|
|
|
|
// Iterate over servers and perform the action
|
|
foreach ($server_ids as $server_id) {
|
|
$server = get_post($server_id);
|
|
if (!$server || $server->post_type !== 'server') {
|
|
continue; // Skip invalid server posts
|
|
}
|
|
|
|
$server_ip = get_post_meta($server_id, 'ip_address', true);
|
|
$server_user = get_post_meta($server_id, 'username', true);
|
|
$server_port = get_post_meta($server_id, 'ssh_port', true) ?: 22;
|
|
$server_password = get_post_meta($server_id, 'ssh_private_key', true);
|
|
|
|
if (!$server_ip || !$server_user || !$server_password) {
|
|
return new WP_Error('missing_server_credentials', __('Missing server credentials.', 'rl-mailwarmer'));
|
|
}
|
|
|
|
// Build VirtualMin command
|
|
$command = "virtualmin";
|
|
if ($action === 'create') {
|
|
$command .= " create-domain --domain $domain_name --unix --dir --web --email";
|
|
} elseif ($action === 'update') {
|
|
$command .= " modify-domain --domain $domain_name";
|
|
} elseif ($action === 'delete') {
|
|
$command .= " delete-domain --domain $domain_name";
|
|
} else {
|
|
return new WP_Error('invalid_action', __('Invalid action specified.', 'rl-mailwarmer'));
|
|
}
|
|
|
|
// Execute the command via SSH
|
|
// $ssh = new phpseclib3\Net\SSH2($server_ip, $ssh_port);
|
|
// $key = phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey($server_password); // Adjust for SSH key or plain password
|
|
$ssh = new SSH2($server_ip, $server_port);
|
|
|
|
if (!empty($server_password)) {
|
|
// Load the private key from the postmeta field
|
|
$key = PublicKeyLoader::loadPrivateKey($server_password);
|
|
} else {
|
|
// Fallback to password-based authentication
|
|
// $key = $server_password;
|
|
log_to_file("modify_domain_account_on_server - Server $$server_id ssh_private_key empty");
|
|
return new WP_Error('ssh_login_failed', __('No private key found!', 'rl-mailwarmer'));
|
|
}
|
|
|
|
if (!$ssh->login($server_user, $key)) {
|
|
return new WP_Error('ssh_login_failed', __('Failed to log into the server.', 'rl-mailwarmer'));
|
|
}
|
|
|
|
$output = $ssh->exec($command);
|
|
|
|
if (strpos($output, 'failed') !== false) {
|
|
return new WP_Error('command_failed', __('Failed to execute VirtualMin command.', 'rl-mailwarmer'));
|
|
}
|
|
}
|
|
|
|
return true; // Success
|
|
}
|
|
|
|
|
|
|
|
}
|