diff --git a/database/schema.sql b/database/schema.sql index 76e6f5b..09d6502 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -116,6 +116,27 @@ CREATE TABLE IF NOT EXISTS user_sessions ( INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- PTR records cache table for Route53 A records +CREATE TABLE IF NOT EXISTS ptr_records_cache ( + id INT AUTO_INCREMENT PRIMARY KEY, + zone_id VARCHAR(50) NOT NULL, + zone_name VARCHAR(255) DEFAULT NULL, + hostname VARCHAR(255) NOT NULL, + ip_address VARCHAR(45) NOT NULL, + ttl INT DEFAULT NULL, + ptr_record VARCHAR(255) DEFAULT NULL, + ptr_status ENUM('unknown', 'match', 'mismatch', 'missing', 'error') DEFAULT 'unknown', + ptr_checked_at TIMESTAMP NULL DEFAULT NULL, + aws_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_zone_hostname (zone_id, hostname), + INDEX idx_zone (zone_id), + INDEX idx_ip (ip_address), + INDEX idx_status (ptr_status), + INDEX idx_synced (aws_synced_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Insert default settings INSERT INTO geofeed_settings (setting_key, setting_value) VALUES ('bunny_cdn_storage_zone', ''), diff --git a/webapp/api.php b/webapp/api.php index 9bdfd36..4ce9936 100644 --- a/webapp/api.php +++ b/webapp/api.php @@ -194,6 +194,22 @@ try { handlePtrLookup($db); break; + case 'ptr_cache_get': + handlePtrCacheGet($db); + break; + + case 'ptr_cache_refresh': + handlePtrCacheRefresh($db); + break; + + case 'ptr_check_all': + handlePtrCheckAll($db); + break; + + case 'ptr_cache_stats': + handlePtrCacheStats($db); + break; + default: jsonResponse(['error' => 'Invalid action'], 400); } @@ -2556,3 +2572,280 @@ function awsRoute53Request($method, $path, $body, $accessKeyId, $secretAccessKey return json_decode(json_encode($xml), true); } + +/** + * Get cached PTR records for a zone + */ +function handlePtrCacheGet($db) { + $zoneId = $_GET['zone_id'] ?? ''; + + if (empty($zoneId)) { + jsonResponse(['error' => 'Zone ID required'], 400); + } + + try { + $stmt = $db->prepare(" + SELECT id, zone_id, zone_name, hostname, ip_address, ttl, + ptr_record, ptr_status, ptr_checked_at, aws_synced_at + FROM ptr_records_cache + WHERE zone_id = ? + ORDER BY hostname ASC + "); + $stmt->execute([$zoneId]); + $records = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Get last sync time + $stmt = $db->prepare("SELECT MAX(aws_synced_at) as last_sync FROM ptr_records_cache WHERE zone_id = ?"); + $stmt->execute([$zoneId]); + $lastSync = $stmt->fetch(PDO::FETCH_ASSOC)['last_sync']; + + jsonResponse([ + 'success' => true, + 'records' => $records, + 'count' => count($records), + 'last_sync' => $lastSync + ]); + } catch (Exception $e) { + jsonResponse(['success' => false, 'error' => $e->getMessage()]); + } +} + +/** + * Refresh PTR cache from AWS Route53 + */ +function handlePtrCacheRefresh($db) { + $zoneId = $_GET['zone_id'] ?? ''; + + if (empty($zoneId)) { + jsonResponse(['error' => 'Zone ID required'], 400); + } + + $accessKeyId = getSetting($db, 'aws_access_key_id', ''); + $secretAccessKey = getSetting($db, 'aws_secret_access_key', ''); + $region = getSetting($db, 'aws_region', 'us-east-1'); + + if (empty($accessKeyId) || empty($secretAccessKey)) { + jsonResponse(['error' => 'AWS credentials not configured'], 400); + } + + try { + // Clean zone ID (remove /hostedzone/ prefix if present) + $cleanZoneId = preg_replace('/^\/hostedzone\//', '', $zoneId); + + // Get zone name first + $zoneResult = awsRoute53Request('GET', "2013-04-01/hostedzone/{$cleanZoneId}", [], $accessKeyId, $secretAccessKey, $region); + $zoneName = ''; + if (isset($zoneResult['HostedZone']['Name'])) { + $zoneName = rtrim($zoneResult['HostedZone']['Name'], '.'); + } + + // Get records from Route53 + $result = awsRoute53Request('GET', "2013-04-01/hostedzone/{$cleanZoneId}/rrset?type=A", [], $accessKeyId, $secretAccessKey, $region); + + if (isset($result['error'])) { + jsonResponse(['success' => false, 'error' => $result['error']]); + } + + $records = []; + $syncTime = date('Y-m-d H:i:s'); + + if (isset($result['ResourceRecordSets']['ResourceRecordSet'])) { + $rrsets = $result['ResourceRecordSets']['ResourceRecordSet']; + // Handle single record case + if (isset($rrsets['Name'])) { + $rrsets = [$rrsets]; + } + + foreach ($rrsets as $rrset) { + if (($rrset['Type'] ?? '') !== 'A') continue; + + $hostname = rtrim($rrset['Name'] ?? '', '.'); + $ttl = $rrset['TTL'] ?? null; + + // Get IPs from ResourceRecords + $ips = []; + if (isset($rrset['ResourceRecords']['ResourceRecord'])) { + $rrs = $rrset['ResourceRecords']['ResourceRecord']; + if (isset($rrs['Value'])) { + $ips[] = $rrs['Value']; + } else { + foreach ($rrs as $rr) { + if (isset($rr['Value'])) { + $ips[] = $rr['Value']; + } + } + } + } + + foreach ($ips as $ip) { + $records[] = [ + 'zone_id' => $cleanZoneId, + 'zone_name' => $zoneName, + 'hostname' => $hostname, + 'ip_address' => $ip, + 'ttl' => $ttl + ]; + } + } + } + + // Update database - use INSERT ... ON DUPLICATE KEY UPDATE + $inserted = 0; + $updated = 0; + + foreach ($records as $record) { + $stmt = $db->prepare(" + INSERT INTO ptr_records_cache + (zone_id, zone_name, hostname, ip_address, ttl, aws_synced_at) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + zone_name = VALUES(zone_name), + ip_address = VALUES(ip_address), + ttl = VALUES(ttl), + aws_synced_at = VALUES(aws_synced_at) + "); + $stmt->execute([ + $record['zone_id'], + $record['zone_name'], + $record['hostname'], + $record['ip_address'], + $record['ttl'], + $syncTime + ]); + + if ($stmt->rowCount() === 1) { + $inserted++; + } else { + $updated++; + } + } + + // Remove stale records that no longer exist in Route53 + $stmt = $db->prepare("DELETE FROM ptr_records_cache WHERE zone_id = ? AND aws_synced_at < ?"); + $stmt->execute([$cleanZoneId, $syncTime]); + $deleted = $stmt->rowCount(); + + jsonResponse([ + 'success' => true, + 'message' => "Synced {$inserted} new, {$updated} updated, {$deleted} removed", + 'count' => count($records), + 'inserted' => $inserted, + 'updated' => $updated, + 'deleted' => $deleted, + 'last_sync' => $syncTime + ]); + } catch (Exception $e) { + jsonResponse(['success' => false, 'error' => $e->getMessage()]); + } +} + +/** + * Check all PTR records for a zone + */ +function handlePtrCheckAll($db) { + $zoneId = $_GET['zone_id'] ?? ''; + + if (empty($zoneId)) { + jsonResponse(['error' => 'Zone ID required'], 400); + } + + try { + $cleanZoneId = preg_replace('/^\/hostedzone\//', '', $zoneId); + + // Get all records for this zone + $stmt = $db->prepare("SELECT id, hostname, ip_address FROM ptr_records_cache WHERE zone_id = ?"); + $stmt->execute([$cleanZoneId]); + $records = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $checked = 0; + $matches = 0; + $mismatches = 0; + $missing = 0; + $errors = 0; + + foreach ($records as $record) { + $ip = $record['ip_address']; + $expectedHostname = $record['hostname']; + + // Perform PTR lookup + $ptr = @gethostbyaddr($ip); + $ptrRecord = null; + $status = 'unknown'; + + if ($ptr === false || $ptr === $ip) { + $status = 'missing'; + $missing++; + } else { + $ptrRecord = $ptr; + // Normalize for comparison + $normalizedPtr = strtolower(rtrim($ptr, '.')); + $normalizedExpected = strtolower(rtrim($expectedHostname, '.')); + + if ($normalizedPtr === $normalizedExpected) { + $status = 'match'; + $matches++; + } else { + $status = 'mismatch'; + $mismatches++; + } + } + + // Update the record + $stmt = $db->prepare(" + UPDATE ptr_records_cache + SET ptr_record = ?, ptr_status = ?, ptr_checked_at = NOW() + WHERE id = ? + "); + $stmt->execute([$ptrRecord, $status, $record['id']]); + $checked++; + } + + jsonResponse([ + 'success' => true, + 'checked' => $checked, + 'matches' => $matches, + 'mismatches' => $mismatches, + 'missing' => $missing, + 'errors' => $errors + ]); + } catch (Exception $e) { + jsonResponse(['success' => false, 'error' => $e->getMessage()]); + } +} + +/** + * Get PTR cache stats + */ +function handlePtrCacheStats($db) { + try { + $stmt = $db->query(" + SELECT + COUNT(*) as total, + SUM(CASE WHEN ptr_status = 'match' THEN 1 ELSE 0 END) as matches, + SUM(CASE WHEN ptr_status = 'mismatch' THEN 1 ELSE 0 END) as mismatches, + SUM(CASE WHEN ptr_status = 'missing' THEN 1 ELSE 0 END) as missing, + SUM(CASE WHEN ptr_status = 'unknown' THEN 1 ELSE 0 END) as unchecked, + MAX(aws_synced_at) as last_aws_sync, + MAX(ptr_checked_at) as last_ptr_check + FROM ptr_records_cache + "); + $stats = $stmt->fetch(PDO::FETCH_ASSOC); + + // Get per-zone breakdown + $stmt = $db->query(" + SELECT zone_id, zone_name, COUNT(*) as count, MAX(aws_synced_at) as last_sync + FROM ptr_records_cache + GROUP BY zone_id, zone_name + ORDER BY zone_name + "); + $zones = $stmt->fetchAll(PDO::FETCH_ASSOC); + + jsonResponse([ + 'success' => true, + 'stats' => $stats, + 'zones' => $zones + ]); + } catch (Exception $e) { + jsonResponse(['success' => false, 'error' => $e->getMessage()]); + } +} diff --git a/webapp/index.php b/webapp/index.php index 736c4b1..22669a9 100644 --- a/webapp/index.php +++ b/webapp/index.php @@ -2103,7 +2103,7 @@ if (function_exists('requireAuth')) { PTR Record Management -

View A records from your Route53 hosted zones and check their PTR records at IPXO.

+

View A records from your Route53 hosted zones and check their PTR records. Records are cached locally for faster access.