diff --git a/modules/addons/VirtFusionDns/VirtFusionDns.php b/modules/addons/VirtFusionDns/VirtFusionDns.php
index 1cc7c6a..49b5566 100644
--- a/modules/addons/VirtFusionDns/VirtFusionDns.php
+++ b/modules/addons/VirtFusionDns/VirtFusionDns.php
@@ -41,6 +41,13 @@ function virtfusiondns_load_server_libs(): bool
}
require_once $base . $f;
}
+ // PtrManager + IpUtil are only needed for the diagnostic tool below; load them
+ // if present but don't require them for the basic status page to work.
+ foreach (['PowerDns/Resolver.php', 'PowerDns/PtrManager.php'] as $optional) {
+ if (is_file($base . $optional)) {
+ require_once $base . $optional;
+ }
+ }
return true;
}
@@ -230,5 +237,131 @@ function VirtFusionDns_output($vars)
echo '
api-allow-from must include the WHMCS host\'s IP.';
echo '';
+ // -----------------------------------------------------------------------
+ // Diagnostic: "What does the module see for IP X?"
+ //
+ // Runs the full pipeline an admin would otherwise have to trace through
+ // multiple log lines to reproduce:
+ // 1. Current config (what values is Config::get() actually returning?)
+ // 2. Zone list (what does Client::listZones() return right now, post-cache?)
+ // 3. Zone match for an input IP (is findZoneAndPtrName selecting the right zone?)
+ // 4. Current PTR content at the located (zone, ptrName) pair
+ //
+ // Catches every common failure mode: wrong API key (empty zones, auth error),
+ // wrong server ID (404), forgotten zone (no match), stale cache (mismatched
+ // zones), and typos in the RFC 2317 zone name (parseClasslessZone rejection).
+ // -----------------------------------------------------------------------
+
+ echo 'Diagnose an IP
';
+ echo 'Runs the exact same pipeline the client-area rDNS panel uses. Useful when a specific IP shows "no zone" in the UI and you need to see why.
';
+
+ $diagIp = isset($_GET['diag_ip']) ? trim((string) $_GET['diag_ip']) : '';
+ echo '';
+
+ if ($diagIp !== '') {
+ echo '';
+
+ if (filter_var($diagIp, FILTER_VALIDATE_IP) === false) {
+ echo 'Invalid IP address.';
+ } elseif (! Config::isEnabled()) {
+ echo 'Addon disabled or missing endpoint/API key. Diagnosis skipped.';
+ } else {
+ $client = new Client;
+
+ echo 'Config snapshot:' . "\n";
+ echo ' endpoint = ' . htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8') . "\n";
+ echo ' serverId = ' . htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8') . "\n";
+ echo ' cacheTtl = ' . $cacheTtl . 's' . "\n";
+ echo ' apiKey = ' . ($config['apiKey'] !== '' ? '(set, ' . strlen($config['apiKey']) . ' chars)' : '(MISSING)') . "\n\n";
+
+ // Always forget cache before diagnose so we see the LIVE state, not a
+ // potentially-stale cached list from an earlier misconfigured call.
+ $client->forgetZoneCache();
+ $zones = $client->listZones();
+
+ echo 'Live zone list (cache purged, ' . count($zones) . ' zones):' . "\n";
+ if (empty($zones)) {
+ echo ' NO ZONES RETURNED.' . "\n";
+ echo ' Likely causes: wrong API key (PowerDNS returned 401/403), wrong Server ID' . "\n";
+ echo ' (PowerDNS returned 404), or api-allow-from blocking the WHMCS host IP.' . "\n";
+ echo ' Run the Test Connection button above to see the exact HTTP error.' . "\n\n";
+ } else {
+ foreach (array_slice($zones, 0, 15) as $z) {
+ echo ' ' . htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . "\n";
+ }
+ if (count($zones) > 15) {
+ echo ' ... and ' . (count($zones) - 15) . ' more' . "\n";
+ }
+ echo "\n";
+ }
+
+ $ptrName = IpUtil::ptrNameForIp($diagIp);
+ echo 'Computed PTR name for ' . htmlspecialchars($diagIp, ENT_QUOTES, 'UTF-8') . ':' . "\n";
+ echo ' ' . htmlspecialchars((string) $ptrName, ENT_QUOTES, 'UTF-8') . "\n\n";
+
+ $loc = IpUtil::findZoneAndPtrName($diagIp, $zones);
+ echo 'Zone match (IpUtil::findZoneAndPtrName):' . "\n";
+ if ($loc === null) {
+ echo ' NO MATCH.' . "\n";
+ echo ' The IP does not fall within any zone returned above.' . "\n";
+ if (IpUtil::isIpv4($diagIp)) {
+ $oct = (int) explode('.', $diagIp)[3];
+ echo " For IPv4: confirm a standard reverse zone exists (one of the listed\n";
+ echo " zones should end with the first-three-octets-reversed of $diagIp), OR\n";
+ echo " that an RFC 2317 classless zone exists whose range covers octet $oct.\n";
+ }
+ if (IpUtil::isIpv6($diagIp)) {
+ echo " For IPv6: confirm a reverse zone exists ending in .ip6.arpa. whose\n";
+ echo " nibble prefix matches the high-order bits of $diagIp.\n";
+ }
+ echo "\n";
+ } else {
+ echo ' zone = ' . htmlspecialchars($loc['zone'], ENT_QUOTES, 'UTF-8') . "\n";
+ echo ' ptrName = ' . htmlspecialchars($loc['ptrName'], ENT_QUOTES, 'UTF-8') . "\n\n";
+
+ // Actual current PTR content, if any.
+ echo 'Current PTR record in PowerDNS:' . "\n";
+ $zoneData = $client->getZone($loc['zone']);
+ if ($zoneData === null) {
+ echo ' Unable to fetch zone contents (HTTP error or not found).' . "\n";
+ } else {
+ $found = null;
+ foreach ($zoneData['rrsets'] ?? [] as $rr) {
+ if (($rr['type'] ?? '') === 'PTR' && rtrim($rr['name'], '.') === rtrim($loc['ptrName'], '.')) {
+ foreach ($rr['records'] ?? [] as $rec) {
+ if (empty($rec['disabled']) && ! empty($rec['content'])) {
+ $found = [
+ 'content' => $rec['content'],
+ 'ttl' => (int) ($rr['ttl'] ?? 0),
+ ];
+ break 2;
+ }
+ }
+ }
+ }
+ if ($found === null) {
+ echo ' (no PTR record present at ' . htmlspecialchars($loc['ptrName'], ENT_QUOTES, 'UTF-8') . ')' . "\n";
+ } else {
+ echo ' content = ' . htmlspecialchars($found['content'], ENT_QUOTES, 'UTF-8') . "\n";
+ echo ' ttl = ' . $found['ttl'] . 's' . "\n";
+ }
+ }
+ }
+ }
+
+ echo '
';
+ }
+
echo '';
}
diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
index 6c4a57e..ff9b6a6 100644
--- a/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
+++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
@@ -128,7 +128,38 @@ class Client
return ['ok' => false, 'http' => 0, 'error' => $err];
}
if ($http === 401 || $http === 403) {
- return ['ok' => false, 'http' => $http, 'error' => 'authentication failed (check API key)'];
+ // Three distinct causes all produce 401/403 here:
+ // (a) Actual wrong API key — the #1 obvious cause.
+ // (b) `api-allow-from` in PowerDNS config excludes the WHMCS
+ // host's IP. PowerDNS rejects pre-auth in some configs,
+ // producing 401/403 even with a valid key.
+ // (c) Invisible whitespace in the stored key (fixed in Config
+ // via trim(), but a pre-upgrade install might still have
+ // a cached request dating from before the fix).
+ // Listing all three gives the operator a concrete checklist.
+ return [
+ 'ok' => false,
+ 'http' => $http,
+ 'error' => 'HTTP ' . $http . ' — PowerDNS rejected authentication. Check: ' .
+ '(1) the X-API-Key matches the `api-key=` in PowerDNS config, ' .
+ '(2) `api-allow-from=` includes this WHMCS host\'s IP, and ' .
+ '(3) the key has no trailing whitespace/newlines (re-paste it if unsure).',
+ ];
+ }
+ if ($http === 404) {
+ // The endpoint reached PowerDNS (no 0/connection-refused) but the
+ // server ID path segment isn't known. By far the most common cause
+ // is an addon misconfiguration where someone entered the nameserver
+ // FQDN instead of the literal string "localhost" into the Server ID
+ // field. Surface that hypothesis directly — it's the single highest-
+ // probability fix and turns a mystery into an actionable error.
+ return [
+ 'ok' => false,
+ 'http' => 404,
+ 'error' => 'HTTP 404 — PowerDNS does not recognise server id "' . $this->serverId .
+ '". This field should almost always be the literal string "localhost" ' .
+ '(the PowerDNS API server identifier, NOT your nameserver hostname).',
+ ];
}
return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)];