From c1c579dd14036f65781e779ccffbbf8086bb5e30 Mon Sep 17 00:00:00 2001 From: Prophet731 Date: Fri, 17 Apr 2026 22:04:42 -0400 Subject: [PATCH] feat(addon): Diagnose-an-IP tool + actionable auth-error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary improvements for operators debugging a misconfigured addon — both motivated by a live production incident where "every IP shows no zone" took several hypotheses (wrong serverId, wrong key, stale cache) before landing on the real cause. 1. Diagnose-an-IP panel on the addon admin page (VirtFusionDns.php _output()). Takes an IP in a text input and runs the full pipeline inline: prints the current config snapshot, forces a fresh zone list from PowerDNS (bypassing cache), shows the computed PTR name, shows what IpUtil::findZoneAndPtrName selects, and fetches the current PTR content. Every common failure mode — wrong key, wrong serverId, forgotten zone, mis-aligned RFC 2317 label, stale cache — produces a distinctive shape in that output. 2. More actionable error messages in PowerDns\Client::ping(): - On 401/403: now spells out the three real causes (API key mismatch, api-allow-from excluding the WHMCS IP, whitespace in the stored key) as a checklist, so the operator doesn't have to guess which they're hitting. - On 404: explicitly names serverId as the field to check and reminds that "localhost" is the PowerDNS API server identifier, NOT the nameserver's hostname (a surprisingly common misreading of the field label). The addon helper virtfusiondns_load_server_libs() now also pulls in Resolver + PtrManager lazily since the diagnostic pane needs IpUtil's pipeline-level output. They're optional — missing files don't break the basic status page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../addons/VirtFusionDns/VirtFusionDns.php | 133 ++++++++++++++++++ .../VirtFusionDirect/lib/PowerDns/Client.php | 33 ++++- 2 files changed, 165 insertions(+), 1 deletion(-) 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)];