System Settings -> Addon Modules -> Activate -> Configure. * * API key handling: WHMCS encrypts password-type addon fields in tbladdonmodules; * the server module calls decrypt() on read (see lib/PowerDns/Config.php). */ if (! defined('WHMCS')) { exit('This file cannot be accessed directly'); } /** * Load the server module's PowerDNS classes on demand. Done inside functions rather * than at file scope so the WHMCS addon list still works if the server module is * absent (e.g., uninstalled while the addon is still activated). Returns true when * the classes are available. */ function virtfusiondns_load_server_libs(): bool { $base = __DIR__ . '/../../servers/VirtFusionDirect/lib/'; $files = [ 'Curl.php', 'Log.php', 'Cache.php', 'PowerDns/Config.php', 'PowerDns/IpUtil.php', 'PowerDns/Client.php', ]; foreach ($files as $f) { if (! is_file($base . $f)) { return false; } 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; } /** * WHMCS addon metadata. */ function VirtFusionDns_config() { return [ 'name' => 'VirtFusion DNS', 'description' => 'Adds reverse DNS (PTR) management to the VirtFusionDirect server module using a PowerDNS HTTP API. Zones must already exist in PowerDNS; the addon never creates zones. Requires the VirtFusionDirect server module.', 'version' => '1.0', 'author' => 'VirtFusionDirect', 'language' => 'english', 'fields' => [ 'enabled' => [ 'FriendlyName' => 'Enable rDNS Sync', 'Type' => 'yesno', 'Description' => 'Master switch. When off, the server module skips every PowerDNS call.', ], 'endpoint' => [ 'FriendlyName' => 'PowerDNS API Endpoint', 'Type' => 'text', 'Size' => '60', 'Default' => 'http://ns1.example.com:8081', 'Description' => 'Scheme + host + port (no path). The /api/v1/... path is appended automatically.', ], 'apiKey' => [ 'FriendlyName' => 'PowerDNS API Key', 'Type' => 'password', 'Size' => '60', 'Description' => 'X-API-Key. Stored encrypted by WHMCS; decrypted only server-side when PowerDNS is called.', ], 'serverId' => [ 'FriendlyName' => 'PowerDNS Server ID', 'Type' => 'text', 'Size' => '20', 'Default' => 'localhost', 'Description' => 'Almost always "localhost" (the PowerDNS API server identifier, not a hostname).', ], 'defaultTtl' => [ 'FriendlyName' => 'Default PTR TTL (seconds)', 'Type' => 'text', 'Size' => '10', 'Default' => '3600', 'Description' => 'TTL applied to PTR records created by the module.', ], 'cacheTtl' => [ 'FriendlyName' => 'Cache TTL (seconds)', 'Type' => 'text', 'Size' => '10', 'Default' => '60', 'Description' => 'How long zone lists and DNS-resolution results are cached. Minimum 10s.', ], ], ]; } /** * Called when the addon is activated. No schema to create — settings live in tbladdonmodules. */ function VirtFusionDns_activate() { return [ 'status' => 'success', 'description' => 'VirtFusion DNS activated. Fill in the endpoint + API key in the addon configuration, then use the Test Connection button on the addon page.', ]; } /** * Called when the addon is deactivated. Settings preserved (re-activating restores them). */ function VirtFusionDns_deactivate() { return [ 'status' => 'success', 'description' => 'VirtFusion DNS deactivated. Server lifecycle PowerDNS calls will now be skipped. Settings are preserved.', ]; } /** * Admin status page — rendered by WHMCS when the addon is clicked from the Addons menu. * * Shows a settings summary, a Test Connection button (calls PowerDNS ping), the current * zone count, and a recent log extract filtered to PowerDNS-related entries. */ function VirtFusionDns_output($vars) { if (! virtfusiondns_load_server_libs()) { echo '
'; echo 'VirtFusionDirect server module not found. '; echo 'This addon requires the VirtFusionDirect server module at modules/servers/VirtFusionDirect/. '; echo 'Install or restore that module and reload this page.'; echo '
'; return; } Config::reset(); $config = Config::get(); $pingResult = null; $zoneCount = null; $zoneSample = []; if (! empty($_GET['vfdns_test'])) { if (Config::isEnabled()) { $client = new Client; $pingResult = $client->ping(); if ($pingResult['ok']) { $client->forgetZoneCache(); $zones = $client->listZones(); $zoneCount = count($zones); $zoneSample = array_slice($zones, 0, 8); } } else { $pingResult = ['ok' => false, 'http' => 0, 'error' => 'Not enabled or missing endpoint/apiKey.']; } } $modulelink = htmlspecialchars($vars['modulelink'] ?? '', ENT_QUOTES, 'UTF-8'); $endpoint = htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8'); $serverId = htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8'); $ttl = (int) $config['defaultTtl']; $cacheTtl = (int) $config['cacheTtl']; $enabledBadge = $config['enabled'] ? 'enabled' : 'disabled'; $keyBadge = $config['apiKey'] !== '' ? 'set' : 'missing'; echo '
'; echo '

VirtFusion DNS

'; echo '

Reverse DNS management for the VirtFusionDirect server module. All PTR writes happen through the VirtFusion server lifecycle (create, rename, terminate) and through the client-area Reverse DNS panel. Forward DNS (A/AAAA) is verified before every PTR write; mismatches are skipped and logged.

'; echo '

Current settings

'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
Status' . $enabledBadge . '
Endpoint' . ($endpoint ?: 'not set') . '
API Key' . $keyBadge . '
Server ID' . $serverId . '
Default PTR TTL' . $ttl . 's
Cache TTL' . $cacheTtl . 's
'; echo '

Test Connection

'; echo '

Calls GET /api/v1/servers/' . $serverId . ' and, on success, lists available zones.

'; echo 'Run Test'; if ($pingResult !== null) { echo '
'; if ($pingResult['ok']) { echo 'OK. PowerDNS reachable and authenticated. '; if ($zoneCount !== null) { echo $zoneCount . ' zone(s) visible.'; if (! empty($zoneSample)) { echo '
'; foreach ($zoneSample as $z) { echo htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . '
'; } if ($zoneCount > count($zoneSample)) { echo '... and ' . ($zoneCount - count($zoneSample)) . ' more'; } echo '
'; } } } else { echo 'Failed. HTTP ' . (int) $pingResult['http'] . ': ' . htmlspecialchars((string) ($pingResult['error'] ?? 'unknown error'), ENT_QUOTES, 'UTF-8'); } echo '
'; } echo '

Operation

'; echo ''; echo '

Requirements

'; 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 '
'; }