This commit is contained in:
Purple
2026-01-17 23:59:48 +00:00
parent c16442718e
commit 9e272cbbd2
2 changed files with 339 additions and 13 deletions

View File

@@ -218,6 +218,10 @@ try {
handleWhitelabelSave($db);
break;
case 'aws_update_a_record':
handleAwsUpdateARecord($db);
break;
default:
jsonResponse(['error' => 'Invalid action'], 400);
}
@@ -2496,7 +2500,7 @@ function handlePtrLookup($db) {
* Make AWS Route53 API request with Signature Version 4
* Note: Route53 is a global service, always uses us-east-1 for signing
*/
function awsRoute53Request($method, $path, $body, $accessKeyId, $secretAccessKey, $region) {
function awsRoute53Request($method, $path, $body, $accessKeyId, $secretAccessKey, $region, $contentType = null) {
$service = 'route53';
$host = 'route53.amazonaws.com';
// Route53 is a global service, always use us-east-1 for signing
@@ -2533,9 +2537,23 @@ function awsRoute53Request($method, $path, $body, $accessKeyId, $secretAccessKey
$payloadHash = hash('sha256', $payload);
// Build canonical headers - must be sorted alphabetically and lowercase
$canonicalHeaders = "host:{$host}\n" .
"x-amz-date:{$timestamp}\n";
$signedHeaders = 'host;x-amz-date';
$canonicalHeaders = '';
$signedHeadersList = ['host', 'x-amz-date'];
if ($contentType) {
$canonicalHeaders .= "content-type:{$contentType}\n";
$signedHeadersList[] = 'content-type';
}
$canonicalHeaders .= "host:{$host}\n";
$canonicalHeaders .= "x-amz-date:{$timestamp}\n";
sort($signedHeadersList);
$signedHeaders = implode(';', $signedHeadersList);
// Sort canonical headers alphabetically
$headerLines = explode("\n", trim($canonicalHeaders));
sort($headerLines);
$canonicalHeaders = implode("\n", $headerLines) . "\n";
// Build canonical request
$canonicalRequest = implode("\n", [
@@ -2567,17 +2585,24 @@ function awsRoute53Request($method, $path, $body, $accessKeyId, $secretAccessKey
// Create authorization header
$authorizationHeader = "{$algorithm} Credential={$accessKeyId}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}";
// Build HTTP headers
$httpHeaders = [
"Host: {$host}",
"X-Amz-Date: {$timestamp}",
"Authorization: {$authorizationHeader}"
];
if ($contentType) {
$httpHeaders[] = "Content-Type: {$contentType}";
}
// Make request
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
"Host: {$host}",
"X-Amz-Date: {$timestamp}",
"Authorization: {$authorizationHeader}"
]
CURLOPT_HTTPHEADER => $httpHeaders
]);
if ($method === 'POST') {
@@ -2941,3 +2966,131 @@ function handleWhitelabelSave($db) {
jsonResponse(['success' => false, 'error' => $e->getMessage()]);
}
}
/**
* Update A record in Route53
*/
function handleAwsUpdateARecord($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
// Verify CSRF token
if (!isset($input['csrf_token']) || !verifyCsrfToken($input['csrf_token'])) {
jsonResponse(['error' => 'Invalid CSRF token'], 403);
}
$zoneId = $input['zone_id'] ?? '';
$oldHostname = $input['old_hostname'] ?? '';
$hostname = $input['hostname'] ?? '';
$ip = $input['ip'] ?? '';
$ttl = intval($input['ttl'] ?? 3600);
if (empty($zoneId) || empty($hostname) || empty($ip)) {
jsonResponse(['error' => 'Zone ID, hostname, and IP are 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 {
$cleanZoneId = preg_replace('/^\/hostedzone\//', '', $zoneId);
// Ensure hostname ends with a dot (FQDN)
$fqdnHostname = rtrim($hostname, '.') . '.';
$fqdnOldHostname = rtrim($oldHostname, '.') . '.';
// Build the ChangeResourceRecordSets XML request
$changes = [];
// If hostname changed, we need to DELETE the old and CREATE the new
if ($oldHostname && $oldHostname !== $hostname) {
// Delete old record
$changes[] = [
'Action' => 'DELETE',
'Name' => $fqdnOldHostname,
'Type' => 'A',
'TTL' => $ttl,
'Value' => $ip
];
// Create new record
$changes[] = [
'Action' => 'CREATE',
'Name' => $fqdnHostname,
'Type' => 'A',
'TTL' => $ttl,
'Value' => $ip
];
} else {
// Just UPSERT the record
$changes[] = [
'Action' => 'UPSERT',
'Name' => $fqdnHostname,
'Type' => 'A',
'TTL' => $ttl,
'Value' => $ip
];
}
// Build XML body
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$xml .= '<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">';
$xml .= '<ChangeBatch>';
$xml .= '<Comment>Updated via Geofeed Manager</Comment>';
$xml .= '<Changes>';
foreach ($changes as $change) {
$xml .= '<Change>';
$xml .= '<Action>' . $change['Action'] . '</Action>';
$xml .= '<ResourceRecordSet>';
$xml .= '<Name>' . htmlspecialchars($change['Name']) . '</Name>';
$xml .= '<Type>' . $change['Type'] . '</Type>';
$xml .= '<TTL>' . $change['TTL'] . '</TTL>';
$xml .= '<ResourceRecords>';
$xml .= '<ResourceRecord>';
$xml .= '<Value>' . htmlspecialchars($change['Value']) . '</Value>';
$xml .= '</ResourceRecord>';
$xml .= '</ResourceRecords>';
$xml .= '</ResourceRecordSet>';
$xml .= '</Change>';
}
$xml .= '</Changes>';
$xml .= '</ChangeBatch>';
$xml .= '</ChangeResourceRecordSetsRequest>';
// Make the Route53 API call
$result = awsRoute53Request(
'POST',
"2013-04-01/hostedzone/{$cleanZoneId}/rrset",
$xml,
$accessKeyId,
$secretAccessKey,
$region,
'application/xml'
);
if (isset($result['error'])) {
jsonResponse(['success' => false, 'error' => $result['error']]);
}
// Update the local cache
$stmt = $db->prepare("
UPDATE ptr_records_cache
SET hostname = ?, ttl = ?, updated_at = NOW()
WHERE zone_id = ? AND ip_address = ?
");
$stmt->execute([$hostname, $ttl, $cleanZoneId, $ip]);
jsonResponse(['success' => true, 'message' => 'A record updated successfully']);
} catch (Exception $e) {
jsonResponse(['success' => false, 'error' => $e->getMessage()]);
}
}

View File

@@ -2618,6 +2618,48 @@ if (function_exists('requireAuth')) {
</div>
</div>
<!-- Edit A Record Modal -->
<div class="modal-overlay" id="editARecordModal">
<div class="modal" style="max-width: 500px;">
<div class="modal-header">
<h2 class="modal-title">Edit A Record</h2>
<p class="modal-subtitle">Update the A record in Route53</p>
</div>
<form id="editARecordForm" onsubmit="saveARecord(event)">
<input type="hidden" id="editARecordId">
<input type="hidden" id="editARecordOldHostname">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Hostname <span class="required">*</span></label>
<input type="text" class="form-input" id="editARecordHostname" required>
<div class="form-hint">The DNS hostname (e.g., server1.example.com)</div>
</div>
<div class="form-group">
<label class="form-label">IP Address</label>
<input type="text" class="form-input" id="editARecordIp" readonly style="background: var(--bg-tertiary); cursor: not-allowed;">
<div class="form-hint">IP address cannot be changed</div>
</div>
<div class="form-group">
<label class="form-label">TTL (seconds)</label>
<input type="number" class="form-input" id="editARecordTtl" min="60" max="86400" value="3600">
<div class="form-hint">Time to live (60-86400 seconds)</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEditARecordModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="saveARecordBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Update A Record
</button>
</div>
</form>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
@@ -4797,24 +4839,33 @@ if (function_exists('requireAuth')) {
return;
}
// Store records globally for IPXO export
window.cachedPtrRecords = records;
document.getElementById('exportIpxoBtn').disabled = !records.some(r => r.ptr_status === 'mismatch' || r.ptr_status === 'missing');
tbody.innerHTML = records.map((record) => {
const statusColor = getStatusColor(record.ptr_status);
const statusLabel = (record.ptr_status || 'unknown').toUpperCase();
return `
<tr data-id="${record.id}" data-ip="${escapeHtml(record.ip_address)}" data-hostname="${escapeHtml(record.hostname)}">
<td><span class="cell-truncate" title="${escapeHtml(record.hostname)}">${escapeHtml(record.hostname)}</span></td>
<tr data-id="${record.id}" data-ip="${escapeHtml(record.ip_address)}" data-hostname="${escapeHtml(record.hostname)}" data-ttl="${record.ttl || 3600}" data-zone-id="${escapeHtml(record.zone_id || '')}">
<td><code style="font-size: 12px;">${escapeHtml(record.ip_address)}</code></td>
<td><span class="cell-truncate" title="${escapeHtml(record.hostname)}">${escapeHtml(record.hostname)}</span></td>
<td style="color: var(--text-secondary);">${record.ttl || '-'}</td>
<td class="ptr-value" style="color: ${record.ptr_record ? 'var(--text-primary)' : 'var(--text-tertiary)'};">${record.ptr_record || '-'}</td>
<td class="ptr-status">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: ${statusColor}; color: white;">${statusLabel}</span>
</td>
<td>
<td style="display: flex; gap: 4px;">
<button class="btn btn-secondary btn-sm" onclick="checkSinglePtr(${record.id}, this)" title="Check PTR">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Check
</button>
<button class="btn btn-ghost btn-sm" onclick="editARecord(${record.id}, '${escapeHtml(record.hostname)}', '${escapeHtml(record.ip_address)}', ${record.ttl || 3600})" title="Edit A Record">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</td>
</tr>
@@ -4974,6 +5025,128 @@ if (function_exists('requireAuth')) {
`;
}
// Edit A Record modal functions
function editARecord(id, hostname, ip, ttl) {
document.getElementById('editARecordId').value = id;
document.getElementById('editARecordOldHostname').value = hostname;
document.getElementById('editARecordHostname').value = hostname;
document.getElementById('editARecordIp').value = ip;
document.getElementById('editARecordTtl').value = ttl || 3600;
document.getElementById('editARecordModal').classList.add('active');
}
function closeEditARecordModal() {
document.getElementById('editARecordModal').classList.remove('active');
}
async function saveARecord(event) {
event.preventDefault();
const zoneId = document.getElementById('ptrZoneSelect').value;
if (!zoneId) {
showToast('No zone selected', 'error');
return;
}
const id = document.getElementById('editARecordId').value;
const oldHostname = document.getElementById('editARecordOldHostname').value;
const hostname = document.getElementById('editARecordHostname').value;
const ip = document.getElementById('editARecordIp').value;
const ttl = parseInt(document.getElementById('editARecordTtl').value) || 3600;
const saveBtn = document.getElementById('saveARecordBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Updating...';
try {
const result = await api('aws_update_a_record', {}, 'POST', {
zone_id: zoneId,
old_hostname: oldHostname,
hostname: hostname,
ip: ip,
ttl: ttl
});
if (result.success) {
showToast('A record updated successfully', 'success');
closeEditARecordModal();
// Refresh the records from AWS
await refreshFromAws();
} else {
showToast(result.error || 'Failed to update A record', 'error');
}
} catch (error) {
showToast('Failed to update A record: ' + error.message, 'error');
}
saveBtn.disabled = false;
saveBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Update A Record
`;
}
// Export IPXO JSON for mismatched/missing PTRs
function exportIpxoJson() {
const records = window.cachedPtrRecords || [];
const mismatchedOrMissing = records.filter(r => r.ptr_status === 'mismatch' || r.ptr_status === 'missing');
if (mismatchedOrMissing.length === 0) {
showToast('No mismatched or missing PTR records to export', 'warning');
return;
}
// Group records by subnet (calculate /24 for IPv4)
const subnets = {};
mismatchedOrMissing.forEach(record => {
const ip = record.ip_address;
// Calculate /24 subnet (for IPv4)
const parts = ip.split('.');
if (parts.length === 4) {
// Try /23 first by checking if this is an odd or even third octet
const thirdOctet = parseInt(parts[2]);
const subnetBase = thirdOctet % 2 === 0 ? thirdOctet : thirdOctet - 1;
const subnet = `${parts[0]}.${parts[1]}.${subnetBase}.0/23`;
if (!subnets[subnet]) {
subnets[subnet] = [];
}
subnets[subnet].push({
address: ip,
dname: record.hostname,
ttl: record.ttl || 3600
});
}
});
const ipxoData = {
subnets: Object.keys(subnets).map(prefix => ({
prefix: prefix,
records: subnets[prefix]
}))
};
const jsonStr = JSON.stringify(ipxoData, null, 2);
// Copy to clipboard
navigator.clipboard.writeText(jsonStr).then(() => {
showToast(`Copied ${mismatchedOrMissing.length} PTR record(s) to clipboard in IPXO format`, 'success');
}).catch(err => {
// Fallback for browsers that don't support clipboard API
const textArea = document.createElement('textarea');
textArea.value = jsonStr;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showToast(`Copied ${mismatchedOrMissing.length} PTR record(s) to clipboard in IPXO format`, 'success');
});
}
// Clear error logs
async function clearErrorLogs() {
if (!confirm('Are you sure you want to clear the error log? This action cannot be undone.')) {