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 '
    '; + // WHMCS passes the module slug via ?module=... — preserve any existing query params + // by re-emitting the current GET state as hidden fields (except diag_ip itself). + foreach ($_GET as $k => $v) { + if ($k === 'diag_ip') { + continue; + } + echo ''; + } + echo ''; + echo ''; + 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)];