feat: add PowerDNS reverse DNS (PTR) integration
Introduces an opt-in reverse DNS management subsystem backed by a PowerDNS
Authoritative HTTP API. Runs via a companion WHMCS addon module
(modules/addons/VirtFusionDns) that holds settings and a Test Connection
page; the server module reads those settings from tbladdonmodules and
short-circuits when the addon is absent or disabled, so provisioning is
unaffected for operators who don't use the feature.
Lifecycle hooks:
- createAccount creates PTRs for every assigned IP (forward DNS must
already resolve to the IP — FCrDNS enforcement)
- renameServer updates only PTRs whose content matched the old hostname,
preserving client-custom records
- terminateAccount deletes all PTRs before the local state is purged
- TestConnection merges PowerDNS health check with the existing VirtFusion
check
- A DailyCronJob hook reconciles missing PTRs additive-only (never
overwrites)
Client UI: new "Reverse DNS" panel on the service overview with one
editable PTR input per assigned IP, per-row status badges, and
forward-DNS rejection on save. Admin services tab gets a parallel
widget with Reconcile (additive) and Reconcile (force reset) buttons.
New subsystem at lib/PowerDns/:
- Client.php PowerDNS API wrapper (X-API-Key, listZones/getZone/
patchRRset/notifyZone), auto-NOTIFY on successful PATCH
- Config.php Loads + decrypts addon settings from tbladdonmodules
- IpUtil.php PTR-name generation (IPv4 + IPv6), zone matching,
RFC 2317 classless parsing
- Resolver.php FCrDNS verification via dns_get_record with CNAME-chain
following and per-(hostname,ip) caching
- PtrManager.php Orchestrator: syncServer, deleteForServer, listPtrs,
setPtr, reconcile, reconcileAll
Security hardening helpers added to Module and applied to the rDNS
endpoints:
- requirePost() HTTP method gate (405 on non-POST mutations)
- requireSameOrigin() Origin/Referer check against WHMCS host (CSRF
defence against cross-site form POST)
- requireServiceStatus() tblhosting.domainstatus filter (Active for
writes, Active+Suspended for reads)
RFC 2317 classless delegations (e.g. 64/64.113.0.203.in-addr.arpa.)
supported with alignment validation: rejects misaligned start addresses
that don't correspond to any real delegation boundary.
PowerDNS zone IDs containing '/' are URL-encoded as '=2F' per the
PowerDNS API convention. PATCH success triggers PUT /zones/{id}/notify
so slaves pick up the SOA-bumped serial immediately.
Includes IPv4 + IPv6 support, per-IP write rate limit (10s), fresh
IP-ownership re-verification on every client write (defends against
stale-ownership after IP reassignment), and audit logging of every
successful edit to the WHMCS module log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
234
modules/addons/VirtFusionDns/VirtFusionDns.php
Normal file
234
modules/addons/VirtFusionDns/VirtFusionDns.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config;
|
||||
|
||||
/**
|
||||
* VirtFusion DNS — companion WHMCS addon module that holds PowerDNS settings for
|
||||
* the VirtFusionDirect server module. Keeps the server module decoupled from the
|
||||
* addon: the server module reads settings from tbladdonmodules and never loads
|
||||
* addon code at runtime.
|
||||
*
|
||||
* Activation: WHMCS Admin -> 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;
|
||||
}
|
||||
|
||||
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 '<div style="max-width:900px;padding:16px;border-radius:4px;background:#f8d7da;color:#721c24">';
|
||||
echo '<strong>VirtFusionDirect server module not found.</strong> ';
|
||||
echo 'This addon requires the VirtFusionDirect server module at <code>modules/servers/VirtFusionDirect/</code>. ';
|
||||
echo 'Install or restore that module and reload this page.';
|
||||
echo '</div>';
|
||||
|
||||
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']
|
||||
? '<span style="color:#28a745;font-weight:bold">enabled</span>'
|
||||
: '<span style="color:#dc3545;font-weight:bold">disabled</span>';
|
||||
$keyBadge = $config['apiKey'] !== '' ? '<span style="color:#28a745">set</span>' : '<span style="color:#dc3545">missing</span>';
|
||||
|
||||
echo '<div style="max-width:900px">';
|
||||
echo '<h2 style="margin-top:0">VirtFusion DNS</h2>';
|
||||
echo '<p>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.</p>';
|
||||
|
||||
echo '<h3>Current settings</h3>';
|
||||
echo '<table class="table table-sm" style="max-width:700px"><tbody>';
|
||||
echo '<tr><th style="text-align:left;width:180px">Status</th><td>' . $enabledBadge . '</td></tr>';
|
||||
echo '<tr><th style="text-align:left">Endpoint</th><td><code>' . ($endpoint ?: '<em>not set</em>') . '</code></td></tr>';
|
||||
echo '<tr><th style="text-align:left">API Key</th><td>' . $keyBadge . '</td></tr>';
|
||||
echo '<tr><th style="text-align:left">Server ID</th><td><code>' . $serverId . '</code></td></tr>';
|
||||
echo '<tr><th style="text-align:left">Default PTR TTL</th><td>' . $ttl . 's</td></tr>';
|
||||
echo '<tr><th style="text-align:left">Cache TTL</th><td>' . $cacheTtl . 's</td></tr>';
|
||||
echo '</tbody></table>';
|
||||
|
||||
echo '<h3>Test Connection</h3>';
|
||||
echo '<p>Calls <code>GET /api/v1/servers/' . $serverId . '</code> and, on success, lists available zones.</p>';
|
||||
echo '<a href="' . $modulelink . '&vfdns_test=1" class="btn btn-primary btn-sm">Run Test</a>';
|
||||
|
||||
if ($pingResult !== null) {
|
||||
echo '<div style="margin-top:12px;padding:10px;border-radius:4px;background:' . ($pingResult['ok'] ? '#d4edda' : '#f8d7da') . ';color:' . ($pingResult['ok'] ? '#155724' : '#721c24') . '">';
|
||||
if ($pingResult['ok']) {
|
||||
echo '<strong>OK.</strong> PowerDNS reachable and authenticated. ';
|
||||
if ($zoneCount !== null) {
|
||||
echo $zoneCount . ' zone(s) visible.';
|
||||
if (! empty($zoneSample)) {
|
||||
echo '<div style="margin-top:8px;font-family:monospace;font-size:12px">';
|
||||
foreach ($zoneSample as $z) {
|
||||
echo htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . '<br>';
|
||||
}
|
||||
if ($zoneCount > count($zoneSample)) {
|
||||
echo '<em>... and ' . ($zoneCount - count($zoneSample)) . ' more</em>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo '<strong>Failed.</strong> HTTP ' . (int) $pingResult['http'] . ': ' . htmlspecialchars((string) ($pingResult['error'] ?? 'unknown error'), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
echo '<h3 style="margin-top:24px">Operation</h3>';
|
||||
echo '<ul>';
|
||||
echo '<li><strong>On server creation:</strong> a PTR is created for each assigned IP, set to the server hostname, <em>only if the forward DNS already resolves to that IP</em>.</li>';
|
||||
echo '<li><strong>On server rename:</strong> PTRs whose current content matches the <em>previous</em> hostname are updated to the new hostname; custom PTRs set by the client are preserved.</li>';
|
||||
echo '<li><strong>On server termination:</strong> every PTR for the server\'s IPs is deleted from PowerDNS.</li>';
|
||||
echo '<li><strong>Clients:</strong> may set a custom PTR per IP via the Reverse DNS panel on the service overview page. Forward DNS must resolve to the IP; mismatch rejects the write.</li>';
|
||||
echo '<li><strong>Reconcile cron:</strong> runs daily, additive-only — creates PTRs where none exist, never overwrites.</li>';
|
||||
echo '<li><strong>Reconcile (admin):</strong> a button on the admin services tab triggers an explicit reconcile with optional <em>force</em> to reset client-custom PTRs back to the server hostname.</li>';
|
||||
echo '</ul>';
|
||||
|
||||
echo '<h3>Requirements</h3>';
|
||||
echo '<ul>';
|
||||
echo '<li>PowerDNS Authoritative with HTTP API enabled (<code>webserver=yes</code>, <code>api=yes</code>).</li>';
|
||||
echo '<li>Reverse zones (<code>*.in-addr.arpa</code> / <code>*.ip6.arpa</code>) for your IP ranges must exist in PowerDNS already — the addon never creates zones.</li>';
|
||||
echo '<li><code>api-allow-from</code> must include the WHMCS host\'s IP.</li>';
|
||||
echo '</ul>';
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* VirtFusion Direct Provisioning Module — WHMCS server module entry point.
|
||||
*
|
||||
* This file contains the non-namespaced functions WHMCS calls via its reflection-
|
||||
* based module dispatcher. They follow the naming convention:
|
||||
*
|
||||
* {ModuleDirectoryName}_{FunctionName}(...)
|
||||
*
|
||||
* WHMCS looks for these on every relevant event (provisioning, UI rendering,
|
||||
* daily cron, test connection, etc.). Every function here is a thin shim that
|
||||
* instantiates ModuleFunctions (or Module) and delegates to a method — keeping
|
||||
* the dispatch surface small and the business logic in unit-exercisable classes.
|
||||
*
|
||||
* DO NOT add significant logic directly in these shims. If you need a new
|
||||
* lifecycle behaviour, add it as a method on ModuleFunctions and point the
|
||||
* shim at it. This makes the module predictable: one public function, one method.
|
||||
*
|
||||
* RESERVED NAMES — DO NOT CHANGE
|
||||
* ------------------------------
|
||||
* WHMCS looks up these specific function names by convention; renaming them
|
||||
* disables the corresponding feature in WHMCS silently:
|
||||
* VirtFusionDirect_MetaData → Displayed name + API version
|
||||
* VirtFusionDirect_ConfigOptions → Product-level settings fields
|
||||
* VirtFusionDirect_TestConnection → Admin "Test Connection" button
|
||||
* VirtFusionDirect_CreateAccount → Provisioning on order-activation
|
||||
* VirtFusionDirect_SuspendAccount → Suspension
|
||||
* VirtFusionDirect_UnsuspendAccount → Unsuspension
|
||||
* VirtFusionDirect_TerminateAccount → Termination
|
||||
* VirtFusionDirect_ChangePackage → Package change on upgrade/downgrade
|
||||
* VirtFusionDirect_AdminServicesTabFields → Admin services tab renderer
|
||||
* VirtFusionDirect_AdminServicesTabFieldsSave → Admin services tab save handler
|
||||
* VirtFusionDirect_ClientArea → Client-area template + vars
|
||||
* VirtFusionDirect_ServiceSingleSignOn → SSO button handler
|
||||
* VirtFusionDirect_AdminCustomButtonArray → Custom admin action buttons
|
||||
* VirtFusionDirect_UsageUpdate → Daily cron bandwidth/disk usage sync
|
||||
*/
|
||||
if (! defined('WHMCS')) {
|
||||
exit('This file cannot be accessed directly');
|
||||
}
|
||||
@@ -9,6 +45,8 @@ use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client as PowerDnsClient;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
|
||||
/**
|
||||
* Returns module metadata consumed by WHMCS.
|
||||
@@ -97,6 +135,20 @@ function VirtFusionDirect_TestConnection(array $params)
|
||||
$httpCode = $request->getRequestInfo('http_code');
|
||||
|
||||
if ($httpCode == 200) {
|
||||
// Also verify PowerDNS health when the DNS addon is activated, so the
|
||||
// admin's Test Connection button reflects the full provisioning path.
|
||||
if (PowerDnsConfig::isEnabled()) {
|
||||
$pdns = (new PowerDnsClient)->ping();
|
||||
if (! $pdns['ok']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'VirtFusion OK; PowerDNS unreachable — '
|
||||
. ($pdns['error'] ?? 'unknown')
|
||||
. ' (HTTP ' . (int) $pdns['http'] . '). Fix the VirtFusion DNS addon settings.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,39 @@ require dirname(__DIR__, 3) . '/init.php';
|
||||
/**
|
||||
* Admin-facing AJAX API endpoint.
|
||||
*
|
||||
* Requires WHMCS admin authentication. Provides server data lookup
|
||||
* and user impersonation for the admin services tab.
|
||||
* MIRRORS client.php STRUCTURE
|
||||
* ----------------------------
|
||||
* Same switch-on-$action dispatch pattern, same JSON response shape, same
|
||||
* "output + break" convention. The only substantive difference is the auth
|
||||
* gate at the top: $vf->adminOnly() instead of $vf->isAuthenticated().
|
||||
*
|
||||
* WHY SEPARATE FROM client.php
|
||||
* ----------------------------
|
||||
* A single file with a per-action admin/client switch would risk one bug
|
||||
* (e.g. forgetting to call adminOnly on a new admin-only action) giving a
|
||||
* client authenticated but without admin privileges access to admin data.
|
||||
* Having two physical entry points means the admin auth gate is enforced
|
||||
* at file scope — any action routed here already went through adminOnly().
|
||||
*
|
||||
* ADMIN-LEVEL AUTH ONLY — NO SERVICE OWNERSHIP CHECK
|
||||
* --------------------------------------------------
|
||||
* An admin is allowed to view/operate on any service, so we don't call
|
||||
* validateUserOwnsService() here. If you add an action that needs finer-
|
||||
* grained auth (e.g. restrict to the admin role that owns the product
|
||||
* group), compose the additional check inside the case branch.
|
||||
*
|
||||
* SAME-ORIGIN / POST GATES STILL APPLY TO MUTATIONS
|
||||
* -------------------------------------------------
|
||||
* Admins are still subject to requirePost + requireSameOrigin on writes —
|
||||
* admin sessions are just as CSRF-vulnerable as client sessions. See the
|
||||
* rdnsReconcile case for the pattern.
|
||||
*/
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
||||
|
||||
$vf = new Module;
|
||||
@@ -88,6 +114,61 @@ try {
|
||||
$vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502);
|
||||
break;
|
||||
|
||||
// =================================================================
|
||||
// Reverse DNS (PowerDNS)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Admin-side PTR status for a service. Same shape as client-side rdnsList but
|
||||
* accessible without being the service owner (admin-only guard at top).
|
||||
*/
|
||||
case 'rdnsStatus':
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! PowerDnsConfig::isEnabled()) {
|
||||
$vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200);
|
||||
break;
|
||||
}
|
||||
|
||||
$serverData = $vf->fetchServerData($serviceID);
|
||||
if (! $serverData) {
|
||||
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502);
|
||||
break;
|
||||
}
|
||||
|
||||
$ptrs = (new PtrManager)->listPtrs($serverData);
|
||||
$vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200);
|
||||
break;
|
||||
|
||||
/**
|
||||
* Trigger PTR reconciliation for a single service. Additive-only by default
|
||||
* (missing PTRs are created with the current hostname); pass force=1 to also
|
||||
* reset PTRs that differ from the server hostname.
|
||||
*/
|
||||
case 'rdnsReconcile':
|
||||
|
||||
// Mutating action — enforce POST + same-origin even though the session is admin-authenticated.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! PowerDnsConfig::isEnabled()) {
|
||||
$vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
|
||||
$force = ! empty($_POST['force']);
|
||||
$summary = (new PtrManager)->reconcile($serviceID, $force);
|
||||
Log::insert(
|
||||
'rdnsReconcile:ok',
|
||||
['serviceID' => $serviceID, 'force' => $force],
|
||||
$summary,
|
||||
);
|
||||
$vf->output(['success' => true, 'data' => $summary], true, true, 200);
|
||||
break;
|
||||
|
||||
default:
|
||||
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,58 @@ require dirname(__DIR__, 3) . '/init.php';
|
||||
/**
|
||||
* Client-facing AJAX API endpoint.
|
||||
*
|
||||
* Authenticated by WHMCS session + service ownership validation.
|
||||
* POST for mutations (power, rebuild, rename, credit), GET for reads (serverData, templates, backups).
|
||||
* ROUTING MODEL
|
||||
* -------------
|
||||
* Every request carries ?action=X&serviceID=Y. We dispatch on $action via the
|
||||
* switch below. Because PHP's switch() is O(N) over case labels that's still
|
||||
* fine at ~20 actions; if this grows large enough that dispatch cost matters
|
||||
* we'd want a lookup table, but we're nowhere near that.
|
||||
*
|
||||
* WHMCS requires every action URL to re-authenticate on each request (no
|
||||
* cross-request sticky state beyond the session cookie). That's why the
|
||||
* isAuthenticated() call is the first thing inside the try block — nothing
|
||||
* downstream may assume a session exists.
|
||||
*
|
||||
* AUTH LAYERS (ORDER MATTERS)
|
||||
* ---------------------------
|
||||
* Each case composes the defenses it needs:
|
||||
*
|
||||
* 1. $vf->isAuthenticated() — client session (401 otherwise)
|
||||
* 2. $vf->validateServiceID(true) — numeric coercion + presence
|
||||
* 3. $vf->validateUserOwnsService($id) — the session owns this service (403)
|
||||
* 4. Optional: requireServiceStatus — filter by tblhosting.domainstatus
|
||||
* 5. Optional (mutations): requirePost — HTTP method gate (405)
|
||||
* 6. Optional (mutations): requireSameOrigin — CSRF origin gate (403)
|
||||
*
|
||||
* The helpers are "fail loudly" — they exit on failure rather than returning.
|
||||
* So everything AFTER a guard in a case branch knows the guard passed.
|
||||
*
|
||||
* EVERY $vf->output() FOLLOWED BY break
|
||||
* -------------------------------------
|
||||
* output() emits a JSON response and exits by default, so in theory `break`
|
||||
* is redundant. In practice we always break explicitly for two reasons:
|
||||
* 1. If someone later passes exit=false to output() the switch would fall
|
||||
* through to the default case and emit a second response body.
|
||||
* 2. Code readers shouldn't have to remember that one function exits.
|
||||
*
|
||||
* RESPONSE SHAPE
|
||||
* --------------
|
||||
* Success: { success: true, data: { ... } }
|
||||
* Error: { success: false, errors: "human-readable message" }
|
||||
* Status codes match HTTP semantics (200/400/401/403/404/405/429/500/502).
|
||||
*
|
||||
* CATCH-ALL
|
||||
* ---------
|
||||
* The outer try/catch guarantees we never expose a raw PHP stack trace to the
|
||||
* client, even on bugs in our own code. All uncaught exceptions are logged and
|
||||
* the user sees a generic 500.
|
||||
*/
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\IpUtil;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
||||
|
||||
$vf = new Module;
|
||||
@@ -405,6 +451,147 @@ try {
|
||||
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
|
||||
break;
|
||||
|
||||
// =================================================================
|
||||
// Reverse DNS (PowerDNS)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* List PTR state for every IP assigned to the service's server.
|
||||
*
|
||||
* Always fetches fresh server data from VirtFusion (not cached server_object)
|
||||
* so the displayed IPs match current reality — if an IP was reassigned out
|
||||
* of this server since last sync, it won't appear here.
|
||||
*/
|
||||
case 'rdnsList':
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||
break;
|
||||
}
|
||||
|
||||
// Reads are permitted for Active + Suspended (a suspended user can still see their rDNS);
|
||||
// Terminated/Pending/Cancelled/Fraud return a clear 400 upfront.
|
||||
$vf->requireServiceStatus($serviceID, ['Active', 'Suspended']);
|
||||
|
||||
if (! PowerDnsConfig::isEnabled()) {
|
||||
$vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200);
|
||||
break;
|
||||
}
|
||||
|
||||
$serverData = $vf->fetchServerData($serviceID);
|
||||
if (! $serverData) {
|
||||
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502);
|
||||
break;
|
||||
}
|
||||
|
||||
$ptrs = (new PtrManager)->listPtrs($serverData);
|
||||
$vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200);
|
||||
break;
|
||||
|
||||
/**
|
||||
* Update (or delete) the PTR for a single IP assigned to the user's server.
|
||||
*
|
||||
* Validation order: ownership -> IP format -> PTR regex -> IP belongs to this server
|
||||
* -> rate-limit/forward-DNS checks inside PtrManager. Sending an empty `ptr` deletes.
|
||||
*/
|
||||
case 'rdnsUpdate':
|
||||
|
||||
// Mutation: enforce POST, same-origin, active service status in that order.
|
||||
// requirePost/requireSameOrigin exit on failure (405/403 respectively), so nothing below runs.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
$clientId = $vf->validateUserOwnsService($serviceID);
|
||||
if (! $clientId) {
|
||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||
break;
|
||||
}
|
||||
|
||||
// Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS.
|
||||
$vf->requireServiceStatus($serviceID, ['Active']);
|
||||
|
||||
if (! PowerDnsConfig::isEnabled()) {
|
||||
$vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled on this installation'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
|
||||
$ip = isset($_POST['ip']) ? trim((string) $_POST['ip']) : '';
|
||||
$ptr = isset($_POST['ptr']) ? trim((string) $_POST['ptr']) : '';
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
$vf->output(['success' => false, 'errors' => 'Invalid IP address'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($ptr !== '' && ! preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/', $ptr)) {
|
||||
$vf->output(['success' => false, 'errors' => 'Invalid hostname for PTR record'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
if (strlen($ptr) > 253) {
|
||||
$vf->output(['success' => false, 'errors' => 'Hostname too long'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
|
||||
// Cross-check: the submitted IP must be currently assigned to this user's server.
|
||||
// Fetch fresh from VirtFusion (not the stored object) to prevent stale-ownership writes
|
||||
// after an IP reassignment.
|
||||
$serverData = $vf->fetchServerData($serviceID);
|
||||
if (! $serverData) {
|
||||
$vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502);
|
||||
break;
|
||||
}
|
||||
$assigned = IpUtil::extractIps($serverData)['addresses'];
|
||||
$targetBin = @inet_pton($ip);
|
||||
$owns = false;
|
||||
foreach ($assigned as $a) {
|
||||
if (@inet_pton($a) === $targetBin) {
|
||||
$owns = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $owns) {
|
||||
Log::insert('rdnsUpdate:ownership', ['serviceID' => $serviceID, 'ip' => $ip], 'IP not assigned to this service');
|
||||
$vf->output(['success' => false, 'errors' => 'This IP is not assigned to your server'], true, true, 403);
|
||||
break;
|
||||
}
|
||||
|
||||
$result = (new PtrManager)->setPtr($ip, $ptr);
|
||||
|
||||
if ($result['ok']) {
|
||||
// Audit trail for successful edits — surfaces in Utilities → Logs → Module Log,
|
||||
// searchable by clientId / serviceId / ip for "who changed this PTR".
|
||||
Log::insert(
|
||||
'rdnsUpdate:ok',
|
||||
['clientId' => $clientId, 'serviceID' => $serviceID, 'ip' => $ip, 'reason' => $result['reason']],
|
||||
['ptr' => $ptr === '' ? '(deleted)' : $ptr],
|
||||
);
|
||||
$vf->output(['success' => true, 'data' => ['reason' => $result['reason']]], true, true, 200);
|
||||
break;
|
||||
}
|
||||
|
||||
// Map internal reasons to client-facing messages/status codes.
|
||||
switch ($result['reason']) {
|
||||
case 'forward-missing':
|
||||
$vf->output(['success' => false, 'errors' => 'Forward DNS for "' . $ptr . '" does not resolve to ' . $ip . '. Configure the A/AAAA record with your DNS provider first, then try again.'], true, true, 400);
|
||||
break;
|
||||
case 'rate-limited':
|
||||
$vf->output(['success' => false, 'errors' => 'Too many updates for this IP. Try again in a few seconds.'], true, true, 429);
|
||||
break;
|
||||
case 'no-zone':
|
||||
$vf->output(['success' => false, 'errors' => 'This IP has no reverse DNS zone configured on the nameserver.'], true, true, 400);
|
||||
break;
|
||||
case 'disabled':
|
||||
$vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400);
|
||||
break;
|
||||
default:
|
||||
$vf->output(['success' => false, 'errors' => 'Reverse DNS update failed (' . $result['reason'] . ')'], true, true, 500);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* WHMCS hooks for the VirtFusion module.
|
||||
*
|
||||
* HOW HOOKS WORK IN WHMCS
|
||||
* -----------------------
|
||||
* add_hook('EventName', $priority, $callback) registers $callback to fire on
|
||||
* the named event. WHMCS discovers hook files by walking modules/servers/*
|
||||
* /hooks.php and modules/addons/* /hooks.php on every page load, then invokes
|
||||
* every registered hook for the current event.
|
||||
*
|
||||
* Hooks run IN-REQUEST — there's no queue or background worker. Anything
|
||||
* expensive in a hook (like an external API call) blocks the user's page
|
||||
* load. For that reason we only do:
|
||||
* - Fast in-process work (building DOM snippets, validating session state)
|
||||
* - Scheduled work on DailyCronJob where "in-request" means the cron worker,
|
||||
* not a user session
|
||||
*
|
||||
* HOOKS REGISTERED HERE
|
||||
* ---------------------
|
||||
* DailyCronJob — PowerDNS reconciliation across all services
|
||||
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
|
||||
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
|
||||
*
|
||||
* FAILURE SEMANTICS
|
||||
* -----------------
|
||||
* Every hook wraps its body in try/catch and silently absorbs any exception.
|
||||
* A hook that throws would potentially break the entire WHMCS request for
|
||||
* all users, not just this module — so we log and swallow, preferring
|
||||
* degraded functionality over site-wide breakage.
|
||||
*/
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||
|
||||
if (! defined('WHMCS')) {
|
||||
exit('This file cannot be accessed directly');
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily PowerDNS reconciliation.
|
||||
*
|
||||
* Walks every managed service and creates any missing PTRs (never overwrites existing
|
||||
* values — cron is additive-only). Requires the VirtFusion DNS addon to be activated
|
||||
* and enabled; otherwise short-circuits immediately.
|
||||
*
|
||||
* All error handling lives inside reconcileAll(); this wrapper just logs any escape
|
||||
* without disturbing the rest of the daily cron run.
|
||||
*/
|
||||
add_hook('DailyCronJob', 1, function ($vars) {
|
||||
try {
|
||||
if (PowerDnsConfig::isEnabled()) {
|
||||
(new PtrManager)->reconcileAll();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::insert('PowerDns:DailyCronJob', [], $e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Shopping Cart Validation Hook
|
||||
*
|
||||
|
||||
@@ -4,6 +4,27 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
|
||||
/**
|
||||
* Static methods that generate HTML fragments for the WHMCS admin services tab.
|
||||
*
|
||||
* WHY RAW HTML STRINGS INSTEAD OF TEMPLATES
|
||||
* -----------------------------------------
|
||||
* WHMCS's AdminServicesTabFields hook expects an associative array of
|
||||
* label => HTML-string pairs. It renders each entry as a table row with the
|
||||
* label on the left and the raw HTML inserted verbatim on the right. There's
|
||||
* no way to return a Smarty template reference from that hook — WHMCS doesn't
|
||||
* know how to render one in that context.
|
||||
*
|
||||
* So we concatenate HTML here. All variable interpolation uses htmlspecialchars()
|
||||
* at the PHP boundary — never trust that a value passed in is safe for HTML.
|
||||
*
|
||||
* ASSET INJECTION
|
||||
* ---------------
|
||||
* Some renderers (serverInfo, rdnsSection) embed <link> and <script> tags so
|
||||
* the admin services tab picks up our CSS and JS without a separate loader
|
||||
* hook. This is safe because WHMCS's admin CSP allows same-origin resources
|
||||
* and the admin page is already inside an authenticated admin session.
|
||||
*
|
||||
* Cache-busting uses time() as a query string — fine for an admin-only surface
|
||||
* where we'd rather pay for the extra fetch than let stale JS cause bugs.
|
||||
*/
|
||||
class AdminHTML
|
||||
{
|
||||
@@ -147,6 +168,38 @@ EOT;
|
||||
</div>
|
||||
</div>
|
||||
<script>vfServerDataAdmin("${serviceId}","${systemUrl}");</script>
|
||||
EOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin Reverse DNS section for the services tab.
|
||||
*
|
||||
* Ships an empty container + a Reconcile button. Data is loaded client-side via
|
||||
* the admin rdnsStatus AJAX endpoint once the page opens. The JS function
|
||||
* vfAdminLoadRdns (defined in templates/js/module.js) populates #vf-rdns-list
|
||||
* and wires up the Reconcile button's onclick to admin.php?action=rdnsReconcile.
|
||||
*
|
||||
* @param string $systemUrl WHMCS system URL
|
||||
* @param int $serviceId WHMCS service ID
|
||||
* @return string HTML fragment for the admin services tab
|
||||
*/
|
||||
public static function rdnsSection($systemUrl, $serviceId)
|
||||
{
|
||||
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
||||
$serviceId = (int) $serviceId;
|
||||
|
||||
return <<<EOT
|
||||
<div id="vf-rdns-admin-wrap">
|
||||
<div id="vf-rdns-list" class="vf-rdns-list">
|
||||
<em class="text-muted">Loading reverse DNS…</em>
|
||||
</div>
|
||||
<div class="vf-rdns-actions" style="margin-top:10px">
|
||||
<button type="button" class="btn btn-default btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', false)">Reconcile (additive)</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', true)">Reconcile (force reset)</button>
|
||||
<span id="vf-rdns-report" style="margin-left:10px"></span>
|
||||
</div>
|
||||
</div>
|
||||
<script>if(typeof vfAdminLoadRdns==='function'){vfAdminLoadRdns(${serviceId},"${systemUrl}");}</script>
|
||||
EOT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,49 @@ use WHMCS\Database\Capsule;
|
||||
* server feature methods (power, network, VNC, backup, resource modification,
|
||||
* self-service billing, traffic, rename, password reset).
|
||||
*
|
||||
* Extended by ModuleFunctions (service lifecycle) and ConfigureService (order-time
|
||||
* operations). Most business logic lives here; subclasses delegate to these methods.
|
||||
* INHERITANCE SHAPE
|
||||
* -----------------
|
||||
* Extended by:
|
||||
* - ModuleFunctions — service lifecycle (create, suspend, unsuspend, terminate, change package)
|
||||
* - ConfigureService — order-time operations (package/template discovery, server build init)
|
||||
*
|
||||
* Most business logic lives HERE, not in the subclasses. Subclasses are intentionally
|
||||
* thin — they orchestrate sequences of calls to methods defined on this base, which
|
||||
* lets us unit-exercise any single feature (e.g. "what happens during rename when
|
||||
* the VirtFusion API returns 423?") without standing up a full WHMCS lifecycle.
|
||||
*
|
||||
* THE resolveServiceContext() PATTERN
|
||||
* -----------------------------------
|
||||
* Almost every method follows the same preamble: look up the module table row,
|
||||
* look up the WHMCS tblhosting row, resolve the control panel credentials, build
|
||||
* a Curl client with the bearer token. That preamble is consolidated into
|
||||
* resolveServiceContext() which returns everything as an array or false on any
|
||||
* missing piece. Every feature method starts with "$ctx = $this->resolveServiceContext($id);
|
||||
* if (! $ctx) return false;" and can then use $ctx['request'], $ctx['serverId'], etc.
|
||||
*
|
||||
* This pattern is the most important abstraction in the module — violating it
|
||||
* (e.g. reading tblservers directly in a feature method) leads to drift where
|
||||
* some features handle missing servers gracefully and others don't.
|
||||
*
|
||||
* ENDPOINT OUTPUT CONVENTION
|
||||
* --------------------------
|
||||
* client.php and admin.php call $this->output() to emit JSON responses. Every
|
||||
* output() call in a switch case MUST be followed by a `break` — the module
|
||||
* deliberately does NOT rely on exit() inside output() for flow control because
|
||||
* that couples the HTTP response format to the control-flow mechanism and makes
|
||||
* refactoring fragile.
|
||||
*
|
||||
* SECURITY HELPERS
|
||||
* ----------------
|
||||
* Five guards callers compose in front of sensitive actions:
|
||||
* - isAuthenticated() — client session required
|
||||
* - adminOnly() — admin session required
|
||||
* - requirePost() — HTTP method gate (mutations only)
|
||||
* - requireSameOrigin() — CSRF origin check
|
||||
* - requireServiceStatus() — filter by tblhosting.domainstatus
|
||||
*
|
||||
* Each exits on failure with the appropriate HTTP status — callers treat them
|
||||
* as "throw on failure" style assertions rather than having to check return values.
|
||||
*/
|
||||
class Module
|
||||
{
|
||||
@@ -73,10 +114,23 @@ class Module
|
||||
|
||||
/**
|
||||
* Resolve service context: system service, WHMCS service, control panel, and curl client.
|
||||
* Returns false if any lookup fails.
|
||||
*
|
||||
* This is the most-called method in the module. Every feature action begins
|
||||
* by calling it, so think of the return value as "everything you need to
|
||||
* touch VirtFusion for this service":
|
||||
*
|
||||
* service — row from mod_virtfusion_direct (has server_id, server_object)
|
||||
* whmcsService — row from tblhosting (has server, userid, domain, etc.)
|
||||
* cp — ['url', 'base_url', 'token'] for the VirtFusion API
|
||||
* request — a fresh Curl instance pre-configured with the bearer token
|
||||
* serverId — (int) of service.server_id — used in every URL downstream
|
||||
*
|
||||
* Returning false on ANY missing piece lets callers write a single
|
||||
* "if (! $ctx) return false;" check at the top of each feature method
|
||||
* rather than threading nullability through three separate lookups.
|
||||
*
|
||||
* @param int $serviceID
|
||||
* @return array{service: object, whmcsService: object, cp: array, request: Curl}|false
|
||||
* @return array{service: object, whmcsService: object, cp: array, request: Curl, serverId: int}|false
|
||||
*/
|
||||
protected function resolveServiceContext($serviceID)
|
||||
{
|
||||
@@ -328,13 +382,37 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
// Capture old hostname + server object from stored state so we can sync rDNS
|
||||
// after the rename. We read from the cached server_object rather than a fresh
|
||||
// fetch; this is the hostname the PTR would be set to (if module-managed).
|
||||
$oldHostname = null;
|
||||
$serverObject = null;
|
||||
if (! empty($ctx['service']->server_object)) {
|
||||
$serverObject = json_decode($ctx['service']->server_object, true);
|
||||
if (is_array($serverObject)) {
|
||||
$oldHostname = PowerDns\PtrManager::extractHostname($serverObject);
|
||||
}
|
||||
}
|
||||
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
|
||||
$data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||||
$success = $httpCode == 200 || $httpCode == 204;
|
||||
|
||||
return $httpCode == 200 || $httpCode == 204;
|
||||
if ($success && $serverObject !== null && PowerDns\Config::isEnabled()) {
|
||||
// Sync PTRs: only records whose current content equals the old hostname
|
||||
// will be rewritten; client-customized PTRs are preserved automatically.
|
||||
// Non-blocking: rDNS failures log but never fail the rename.
|
||||
try {
|
||||
(new PowerDns\PtrManager)->syncServer($serverObject, $oldHostname, $newName);
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:renameServer', ['serviceID' => $serviceID], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
} catch (\Exception $e) {
|
||||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||
|
||||
@@ -773,6 +851,26 @@ class Module
|
||||
/**
|
||||
* Resolve a WHMCS server record into an API base URL and decrypted Bearer token.
|
||||
*
|
||||
* OUTPUT SHAPE
|
||||
* ------------
|
||||
* url — full API base like "https://vf.example.com/api/v1". Append
|
||||
* path components to this for every VirtFusion call.
|
||||
* base_url — scheme + host only, "https://vf.example.com". Used for SSO
|
||||
* redirects where we need to hit the panel UI, not the API.
|
||||
* token — decrypted bearer token. Pass to initCurl() to get an
|
||||
* authenticated Curl handle.
|
||||
*
|
||||
* $any=true is an unusual behaviour: when a WHMCS product doesn't have a
|
||||
* specific server pinned (allowed if the module is the only VF module on
|
||||
* the install), we fall back to any enabled VirtFusion server. This mostly
|
||||
* exists for the "Test Connection" button which doesn't know which server
|
||||
* to use until after a successful connection. Normal provisioning always
|
||||
* passes a real server ID.
|
||||
*
|
||||
* The token is stored encrypted in tblservers.password and decrypted here
|
||||
* via WHMCS's global decrypt() — the same encryption key used for addon
|
||||
* module password fields.
|
||||
*
|
||||
* @param int|object $server WHMCS server ID or server object
|
||||
* @param bool $any When true, fall back to any available server if the given one is not found
|
||||
* @return array{url: string, base_url: string, token: string}|false
|
||||
@@ -825,6 +923,164 @@ class Module
|
||||
$this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce POST as the HTTP method. Emits a 405 JSON response and exits otherwise.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* The REST principle says mutations should be POST, and PHP's $_POST / $_GET
|
||||
* separation means a mutation that reads from $_POST would fail quietly when
|
||||
* called via GET. But "fail quietly" isn't what we want — an attacker probing
|
||||
* endpoints via crafted <img src="?action=...&ip=...&ptr=..."> tags shouldn't
|
||||
* even reach our input-validation code. This gate kills that path with a 405
|
||||
* before any per-endpoint logic runs.
|
||||
*
|
||||
* Combined with requireSameOrigin() below, this closes the most common
|
||||
* cross-site request forgery vectors (form POST, image GET) without needing
|
||||
* explicit CSRF tokens threaded through every AJAX call.
|
||||
*
|
||||
* @return bool|void
|
||||
*/
|
||||
public function requirePost()
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->output(['success' => false, 'errors' => 'method not allowed'], true, true, 405);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the request's Origin/Referer belongs to this WHMCS install.
|
||||
*
|
||||
* THREAT MODEL
|
||||
* ------------
|
||||
* A logged-in WHMCS user visits a malicious page. That page makes a POST
|
||||
* to our rDNS endpoint; because the session cookie is tied to our domain,
|
||||
* the browser attaches it automatically. Without this check, the attacker
|
||||
* could silently rewrite the user's PTRs.
|
||||
*
|
||||
* The defence: browsers attach an Origin header on cross-origin fetch/XHR
|
||||
* and a Referer on cross-origin form POST. Those headers carry the
|
||||
* attacker's origin, not ours — so we compare them against our own
|
||||
* hostname and reject mismatches with a 403.
|
||||
*
|
||||
* This is NOT a full CSRF token scheme. It defends against the common
|
||||
* cross-site-POST and cross-site-form-submit vectors but a same-site XSS
|
||||
* that can read the user's DOM could still circumvent it. For that you'd
|
||||
* need per-request tokens bound to the session — out of scope for the
|
||||
* current module, but the helper stays here ready to be composed with
|
||||
* a token check if one's added later.
|
||||
*
|
||||
* IMPLEMENTATION
|
||||
* --------------
|
||||
* 1. Collect our "known good" host set from HTTP_HOST (what the browser
|
||||
* connected to) plus the SystemURL host from tblconfiguration (what
|
||||
* WHMCS thinks its canonical URL is). Behind a reverse proxy these
|
||||
* can differ; accepting either closes the false-positive gap.
|
||||
* 2. Parse HTTP_ORIGIN and HTTP_REFERER and pull out their host:port.
|
||||
* 3. Require at least one of those headers to match.
|
||||
*
|
||||
* Fails closed: if we can't determine our own host OR if neither Origin
|
||||
* nor Referer is present, we reject. A legitimate same-origin AJAX call
|
||||
* from the module's own JS always sets Origin (fetch API) or Referer
|
||||
* (form submit), so the "both absent" case only happens with scripted
|
||||
* non-browser clients — which are exactly who we want to filter out.
|
||||
*
|
||||
* @return bool|void true on success; emits 403 JSON and exits otherwise
|
||||
*/
|
||||
public function requireSameOrigin()
|
||||
{
|
||||
$expected = [];
|
||||
|
||||
$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
|
||||
if ($host !== '') {
|
||||
$expected[] = strtolower($host);
|
||||
}
|
||||
|
||||
$systemUrl = Database::getSystemUrl();
|
||||
if ($systemUrl) {
|
||||
$parsed = parse_url($systemUrl);
|
||||
if (! empty($parsed['host'])) {
|
||||
$expected[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
|
||||
$expected[] = strtolower($parsed['host']);
|
||||
}
|
||||
}
|
||||
$expected = array_unique(array_filter($expected));
|
||||
if (empty($expected)) {
|
||||
// Can't determine our own host; fail closed rather than silently allow.
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||||
}
|
||||
|
||||
$origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
|
||||
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
|
||||
|
||||
$candidates = [];
|
||||
foreach ([$origin, $referer] as $raw) {
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$parsed = parse_url($raw);
|
||||
if (! empty($parsed['host'])) {
|
||||
$candidates[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
|
||||
$candidates[] = strtolower($parsed['host']);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed (missing origin)'], true, true, 403);
|
||||
}
|
||||
|
||||
foreach ($candidates as $c) {
|
||||
if (in_array($c, $expected, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Log::insert('csrf:origin-mismatch', ['origin' => $origin, 'referer' => $referer, 'expected' => $expected], 'cross-origin request rejected');
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WHMCS service is in a status where client-initiated writes make sense.
|
||||
*
|
||||
* tblhosting.domainstatus can be: Active, Suspended, Terminated, Pending,
|
||||
* Cancelled, Fraud. Not every action makes sense in every status:
|
||||
* - Reads (rdnsList, serverData) usually allow Active + Suspended so a
|
||||
* suspended user can still see their current config.
|
||||
* - Writes (rdnsUpdate, power, etc.) typically require Active only —
|
||||
* mutating a cancelled service's rDNS has no sensible business meaning.
|
||||
*
|
||||
* Pass the allowed set explicitly per endpoint rather than trying to encode
|
||||
* a global policy here. Some endpoints (admin reconcile) don't call this at
|
||||
* all because the admin is allowed to touch any service.
|
||||
*
|
||||
* Fails with 404 if the service doesn't exist, 400 otherwise — keeping the
|
||||
* two conditions distinct in the response code helps client-side error
|
||||
* handling (a 404 usually means "link is stale", a 400 means "not right now").
|
||||
*
|
||||
* @param int $serviceID WHMCS service ID
|
||||
* @param string[] $allowedStatuses Service statuses that permit the operation
|
||||
* @return bool|void true on success; emits 400/404 JSON and exits otherwise
|
||||
*/
|
||||
public function requireServiceStatus(int $serviceID, array $allowedStatuses = ['Active'])
|
||||
{
|
||||
$row = Database::getWhmcsService($serviceID);
|
||||
if (! $row) {
|
||||
$this->output(['success' => false, 'errors' => 'service not found'], true, true, 404);
|
||||
}
|
||||
if (! in_array((string) $row->domainstatus, $allowedStatuses, true)) {
|
||||
$this->output(
|
||||
['success' => false, 'errors' => 'service status "' . (string) $row->domainstatus . '" does not permit this action'],
|
||||
true,
|
||||
true,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-configured Curl instance with JSON Accept/Content-Type headers
|
||||
* and a Bearer token for authenticating against the VirtFusion API.
|
||||
|
||||
@@ -5,8 +5,38 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
/**
|
||||
* Extends Module to handle the WHMCS service lifecycle for VirtFusion servers.
|
||||
*
|
||||
* Responsibilities include: provisioning (create, suspend, unsuspend, terminate),
|
||||
* package changes, usage updates, client area rendering, and admin tab fields.
|
||||
* WHY A SEPARATE CLASS FROM MODULE
|
||||
* --------------------------------
|
||||
* The WHMCS module interface (VirtFusionDirect.php) expects top-level functions
|
||||
* like VirtFusionDirect_CreateAccount(). Those functions delegate into methods
|
||||
* on this class so:
|
||||
* 1. The top-level functions stay one-liners that are easy to audit.
|
||||
* 2. All lifecycle logic lives in an object we can instantiate and unit-exercise
|
||||
* without going through WHMCS's dispatch machinery.
|
||||
* 3. The shared behaviour with Module (API calls, auth, validation) comes for
|
||||
* free via inheritance — no copy-pasted curl setup or error handling.
|
||||
*
|
||||
* ERROR MESSAGE CONVENTION
|
||||
* ------------------------
|
||||
* Every public method either returns the literal string 'success' or an error
|
||||
* string that WHMCS will render to the admin in the service activity log. Do NOT
|
||||
* return arrays, objects, or booleans — WHMCS treats anything other than
|
||||
* 'success' as an error and displays it verbatim.
|
||||
*
|
||||
* EXCEPTION HANDLING
|
||||
* ------------------
|
||||
* Every public method is wrapped in try/catch. Uncaught exceptions bubbling up
|
||||
* to WHMCS appear as stack traces in the admin UI and leak implementation detail,
|
||||
* so we catch and convert to a human error string. Log::insert() captures the
|
||||
* original exception message for diagnostics in the module log.
|
||||
*
|
||||
* PowerDNS INTEGRATION
|
||||
* --------------------
|
||||
* createAccount(), terminateAccount(), and (via parent Module) renameServer()
|
||||
* call into PowerDns\PtrManager to sync rDNS. Those calls are wrapped in their
|
||||
* OWN try/catch so DNS failures never bubble up to WHMCS — provisioning must
|
||||
* succeed even if PowerDNS is temporarily unreachable. See cleanupPowerDnsForService()
|
||||
* for the termination-time cleanup helper.
|
||||
*/
|
||||
class ModuleFunctions extends Module
|
||||
{
|
||||
@@ -163,6 +193,33 @@ class ModuleFunctions extends Module
|
||||
Database::systemOnServerCreate($params['serviceid'], $data);
|
||||
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
|
||||
|
||||
// Initialize reverse DNS for the newly-assigned IPs.
|
||||
//
|
||||
// Ordering: after Database::systemOnServerCreate() AND
|
||||
// updateWhmcsServiceParamsOnServerObject() so mod_virtfusion_direct
|
||||
// has the stored server_object (admin reconcile later reads it) and
|
||||
// tblhosting has the primary IP (for cross-check on client edits).
|
||||
//
|
||||
// But BEFORE ConfigureService::initServerBuild() so rDNS is in place
|
||||
// when the VPS first boots — mail servers and other services that
|
||||
// check FCrDNS during early-boot see correct PTRs.
|
||||
//
|
||||
// Non-blocking: rDNS failures are logged but never fail provisioning.
|
||||
// A broken PowerDNS or missing zone must not prevent a customer
|
||||
// from getting the VPS they paid for.
|
||||
try {
|
||||
if (PowerDns\Config::isEnabled()) {
|
||||
// syncServer with $oldHostname=null means "create mode" — see
|
||||
// PtrManager::syncServer() docblock for the semantics.
|
||||
$hostname = PowerDns\PtrManager::extractHostname($data);
|
||||
if ($hostname !== null) {
|
||||
(new PowerDns\PtrManager)->syncServer($data, null, $hostname);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:createAccount', ['serviceid' => $params['serviceid']], $e->getMessage());
|
||||
}
|
||||
|
||||
// If the server is created successfully, we can initialize the server build.
|
||||
$cs = new ConfigureService;
|
||||
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
|
||||
@@ -304,6 +361,7 @@ class ModuleFunctions extends Module
|
||||
switch ($request->getRequestInfo('http_code')) {
|
||||
|
||||
case 204:
|
||||
$this->cleanupPowerDnsForService($service);
|
||||
Database::deleteSystemService($params['serviceid']);
|
||||
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
|
||||
|
||||
@@ -312,6 +370,7 @@ class ModuleFunctions extends Module
|
||||
case 404:
|
||||
if (isset($data->msg)) {
|
||||
if ($data->msg == 'server not found') {
|
||||
$this->cleanupPowerDnsForService($service);
|
||||
Database::deleteSystemService($params['serviceid']);
|
||||
|
||||
return 'success';
|
||||
@@ -335,6 +394,33 @@ class ModuleFunctions extends Module
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete any PTR records owned by this service before the local record is erased.
|
||||
* The stored server_object is the last source of the IP list; once deleted from
|
||||
* the module table we'd have no way to find them again. Non-fatal — DNS failures
|
||||
* never block termination.
|
||||
*
|
||||
* @param object|null $service Row from mod_virtfusion_direct (has server_object JSON)
|
||||
*/
|
||||
protected function cleanupPowerDnsForService($service): void
|
||||
{
|
||||
try {
|
||||
if (! PowerDns\Config::isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (! $service || empty($service->server_object)) {
|
||||
return;
|
||||
}
|
||||
$decoded = json_decode($service->server_object, true);
|
||||
if (! is_array($decoded)) {
|
||||
return;
|
||||
}
|
||||
(new PowerDns\PtrManager)->deleteForServer($decoded);
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:terminate', ['service' => $service->service_id ?? null], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a VirtFusion server, queuing the action if another operation is in progress.
|
||||
*
|
||||
@@ -552,6 +638,9 @@ class ModuleFunctions extends Module
|
||||
|
||||
if ($params['status'] != 'Terminated') {
|
||||
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
|
||||
if (PowerDns\Config::isEnabled()) {
|
||||
$fields['Reverse DNS'] = AdminHTML::rdnsSection($systemUrl, $params['serviceid']);
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
@@ -659,6 +748,7 @@ class ModuleFunctions extends Module
|
||||
'serviceStatus' => $params['status'],
|
||||
'serverHostname' => $serverHostname,
|
||||
'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
|
||||
'rdnsEnabled' => PowerDns\Config::isEnabled(),
|
||||
],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
310
modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
Normal file
310
modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Curl;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Thin HTTP wrapper around the PowerDNS Authoritative HTTP API.
|
||||
*
|
||||
* WHY A SEPARATE CLIENT INSTEAD OF REUSING MODULE::INITCURL()
|
||||
* -----------------------------------------------------------
|
||||
* Module::initCurl() is hardcoded to Bearer auth for VirtFusion. PowerDNS uses
|
||||
* X-API-Key, and mixing the two authorization styles inside one factory method
|
||||
* would either require a new flag (leaky abstraction) or accidental leakage of
|
||||
* the VirtFusion token into a PowerDNS request. A dedicated wrapper keeps the
|
||||
* two credential flows completely isolated — a bug in PowerDNS handling can
|
||||
* never leak a VirtFusion token, and vice versa.
|
||||
*
|
||||
* LOGGING RULES
|
||||
* -------------
|
||||
* We NEVER pass the API key or any header containing it to Log::insert().
|
||||
* PATCH/NOTIFY calls log the zone+operation+HTTP code, successes log minimally,
|
||||
* errors include up to 500 bytes of response body (PowerDNS error responses are
|
||||
* small JSON fragments, not customer data). The Curl class doesn't capture
|
||||
* request headers by default (CURLOPT_HEADER is off), so even the internal
|
||||
* request_header field doesn't contain the API key.
|
||||
*
|
||||
* CACHING
|
||||
* -------
|
||||
* listZones() caches the zone list via the module's Cache class (Redis/filesystem)
|
||||
* for Config::cacheTtl() seconds. Zone lists rarely change — the TTL balances
|
||||
* "pick up a newly-created zone soon" against "don't hammer PowerDNS for every
|
||||
* listZones call across unrelated lifecycle events".
|
||||
*
|
||||
* getZone() and patchRRset() are NOT cached here; per-request memoisation of
|
||||
* getZone results lives in PtrManager::getZoneCached so it can invalidate on
|
||||
* write from within the same request.
|
||||
*
|
||||
* SINGLE-USE CURL INSTANCES
|
||||
* -------------------------
|
||||
* newCurl() returns a fresh Curl for every HTTP call. That's how the existing
|
||||
* module's Curl class is designed — reusing a handle across requests produces
|
||||
* undefined behaviour because options from the first call bleed into the second.
|
||||
* It's cheap (curl_init is microseconds).
|
||||
*/
|
||||
class Client
|
||||
{
|
||||
/** @var string */
|
||||
private $endpoint;
|
||||
|
||||
/** @var string */
|
||||
private $apiKey;
|
||||
|
||||
/** @var string */
|
||||
private $serverId;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $config Optional pre-resolved config; defaults to PowerDns\Config::get()
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config = $config ?? Config::get();
|
||||
$this->endpoint = rtrim((string) ($config['endpoint'] ?? ''), '/');
|
||||
$this->apiKey = (string) ($config['apiKey'] ?? '');
|
||||
$this->serverId = (string) ($config['serverId'] ?? 'localhost');
|
||||
}
|
||||
|
||||
/** Base URL for the configured PowerDNS server (no trailing slash). */
|
||||
private function base(): string
|
||||
{
|
||||
return $this->endpoint . '/api/v1/servers/' . rawurlencode($this->serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a zone name to its PowerDNS URL-safe id form.
|
||||
*
|
||||
* PowerDNS's API uses a custom URL encoding for zone names that have characters
|
||||
* like "/" which would collide with path semantics. Instead of using %-encoding
|
||||
* (which many HTTP frameworks would parse back out at routing time), PowerDNS
|
||||
* uses "=HH" where HH is the hex code — so "/" becomes "=2F".
|
||||
*
|
||||
* This only matters for RFC 2317 classless-delegation zone names like
|
||||
* "64/64.113.0.203.in-addr.arpa." whose zone id in the API is
|
||||
* "64=2F64.113.0.203.in-addr.arpa.". Standard zones pass through unchanged
|
||||
* because they contain no "/" characters.
|
||||
*
|
||||
* Using rawurlencode() here would produce "%2F" which PowerDNS does NOT accept.
|
||||
* That's why this is a plain str_replace.
|
||||
*/
|
||||
private function zoneIdEncode(string $zoneName): string
|
||||
{
|
||||
return str_replace('/', '=2F', rtrim($zoneName, '.') . '.');
|
||||
}
|
||||
|
||||
/** Fresh Curl instance with PowerDNS auth + JSON headers. */
|
||||
private function newCurl(): Curl
|
||||
{
|
||||
$curl = new Curl;
|
||||
$curl->addOption(CURLOPT_HTTPHEADER, [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'X-API-Key: ' . $this->apiKey,
|
||||
]);
|
||||
|
||||
return $curl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Healthcheck. Returns [ok: bool, http: int, error: ?string].
|
||||
* Used by the addon's Test Connection button and by VirtFusionDirect_TestConnection().
|
||||
*
|
||||
* @return array{ok: bool, http: int, error: ?string}
|
||||
*/
|
||||
public function ping(): array
|
||||
{
|
||||
try {
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->get($this->base());
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
if ($http === 200) {
|
||||
return ['ok' => true, 'http' => 200, 'error' => null];
|
||||
}
|
||||
if ($http === 0) {
|
||||
$err = (string) ($curl->getRequestInfo('curl_error') ?: 'connection failed');
|
||||
|
||||
return ['ok' => false, 'http' => 0, 'error' => $err];
|
||||
}
|
||||
if ($http === 401 || $http === 403) {
|
||||
return ['ok' => false, 'http' => $http, 'error' => 'authentication failed (check API key)'];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:ping', [], $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'http' => 0, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List every zone on the configured PowerDNS server.
|
||||
*
|
||||
* Result is cached for the configured cacheTtl. Used as the primary zone-discovery
|
||||
* strategy: PtrManager finds the containing zone for a PTR name by longest-suffix
|
||||
* matching against this list rather than probing individual zones.
|
||||
*
|
||||
* @return string[] Zone names with trailing dot
|
||||
*/
|
||||
public function listZones(): array
|
||||
{
|
||||
$ttl = Config::cacheTtl();
|
||||
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$zones = [];
|
||||
|
||||
try {
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->get($this->base() . '/zones');
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
|
||||
if ($http === 200) {
|
||||
$decoded = json_decode((string) $body, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $z) {
|
||||
if (! empty($z['name'])) {
|
||||
$zones[] = rtrim((string) $z['name'], '.') . '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log::insert('PowerDns:listZones', ['http' => $http], substr((string) $body, 0, 500));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:listZones', [], $e->getMessage());
|
||||
}
|
||||
|
||||
Cache::set($cacheKey, $zones, $ttl);
|
||||
|
||||
return $zones;
|
||||
}
|
||||
|
||||
/** Drop any cached zone list (call after PATCHes or settings changes). */
|
||||
public function forgetZoneCache(): void
|
||||
{
|
||||
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single zone by name. Returns decoded JSON array, or null on 404/error.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getZone(string $zoneName): ?array
|
||||
{
|
||||
try {
|
||||
$zoneName = rtrim($zoneName, '.') . '.';
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->get($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
if ($http === 200) {
|
||||
$decoded = json_decode((string) $body, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
if ($http !== 404) {
|
||||
Log::insert('PowerDns:getZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 500));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:getZone', ['zone' => $zoneName], $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an RRset change to a zone via PATCH.
|
||||
*
|
||||
* $rrset keys (per PowerDNS API): name, type, ttl?, changetype (REPLACE|DELETE|EXTEND), records[].
|
||||
* On success PowerDNS returns 204 No Content.
|
||||
*
|
||||
* @return array{ok: bool, http: int, body: string}
|
||||
*/
|
||||
public function patchRRset(string $zoneName, array $rrset): array
|
||||
{
|
||||
try {
|
||||
$zoneName = rtrim($zoneName, '.') . '.';
|
||||
if (isset($rrset['name'])) {
|
||||
$rrset['name'] = rtrim((string) $rrset['name'], '.') . '.';
|
||||
}
|
||||
|
||||
$payload = ['rrsets' => [$rrset]];
|
||||
$curl = $this->newCurl();
|
||||
$curl->addOption(CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
$body = $curl->patch($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
|
||||
Log::insert(
|
||||
'PowerDns:patchRRset',
|
||||
[
|
||||
'zone' => $zoneName,
|
||||
'name' => $rrset['name'] ?? null,
|
||||
'type' => $rrset['type'] ?? null,
|
||||
'changetype' => $rrset['changetype'] ?? null,
|
||||
],
|
||||
['http' => $http, 'body' => substr((string) $body, 0, 500)],
|
||||
);
|
||||
|
||||
if ($http === 204) {
|
||||
// Fire-and-forget NOTIFY so slaves pick up the bumped SOA serial immediately.
|
||||
//
|
||||
// Background: PowerDNS auto-increments SOA on every API write when the zone
|
||||
// has soa_edit_api=INCREASE (recommended; see README). Slaves normally learn
|
||||
// about the new serial via polling at the refresh interval (often 15+ min)
|
||||
// OR via a NOTIFY push from the master. Without our NOTIFY, rDNS changes
|
||||
// made via this module would take effect on the authoritative master
|
||||
// immediately but wouldn't propagate until the next scheduled poll.
|
||||
//
|
||||
// Only meaningful for Master-kind zones. For Native zones (no slaves) or
|
||||
// Slave zones (reverse direction), PowerDNS returns a 422 or similar —
|
||||
// notifyZone() logs that and returns ok=false, but we don't care here:
|
||||
// the PATCH itself succeeded, which is what we report upward.
|
||||
$this->notifyZone($zoneName);
|
||||
}
|
||||
|
||||
return ['ok' => $http === 204, 'http' => $http, 'body' => (string) $body];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:patchRRset', ['zone' => $zoneName], $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'http' => 0, 'body' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DNS NOTIFY to all slaves for this zone. Only applicable to Master-kind zones;
|
||||
* PowerDNS returns 400/422 for Native/Slave kinds and that's fine — we log and continue.
|
||||
*
|
||||
* SOA serial bumping itself is handled by PowerDNS (soa_edit_api=INCREASE or similar
|
||||
* on the zone); this call just ensures slaves learn about the new serial right away
|
||||
* rather than waiting for the next scheduled refresh.
|
||||
*
|
||||
* @return array{ok: bool, http: int}
|
||||
*/
|
||||
public function notifyZone(string $zoneName): array
|
||||
{
|
||||
try {
|
||||
$zoneName = rtrim($zoneName, '.') . '.';
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->put($this->base() . '/zones/' . $this->zoneIdEncode($zoneName) . '/notify');
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
|
||||
if ($http !== 200) {
|
||||
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 300));
|
||||
}
|
||||
|
||||
return ['ok' => $http === 200, 'http' => $http];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName], $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'http' => 0];
|
||||
}
|
||||
}
|
||||
}
|
||||
185
modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
Normal file
185
modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Database\Capsule as DB;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Loads PowerDNS addon settings from tbladdonmodules (module="virtfusiondns") and
|
||||
* decrypts the API key using WHMCS's native decrypt() helper.
|
||||
*
|
||||
* WHY "LOOSE COUPLING" VIA TBLADDONMODULES
|
||||
* ----------------------------------------
|
||||
* WHMCS lets an operator activate/deactivate addon modules independently of server
|
||||
* modules. If the server module required addon PHP code at load time (e.g. via
|
||||
* require_once on the addon's files), deactivating the addon would fatal-error every
|
||||
* checkout and service page.
|
||||
*
|
||||
* Instead, the server module reads raw rows from tbladdonmodules. If the addon is
|
||||
* missing OR deactivated OR "enabled" is set to No, isEnabled() returns false and
|
||||
* every PtrManager call site short-circuits. The server module never dereferences
|
||||
* addon code directly; it just asks the DB "what are the PowerDNS settings?" and
|
||||
* does nothing with them if they're absent.
|
||||
*
|
||||
* REQUEST-SCOPED CACHE
|
||||
* --------------------
|
||||
* get() caches the resolved config in a static property for the remainder of the
|
||||
* PHP request. Without that, every PtrManager call would re-query tbladdonmodules
|
||||
* and re-decrypt the API key — wasteful on the provisioning path where we touch
|
||||
* PowerDNS 1-5 times per server. reset() is exposed for scenarios where settings
|
||||
* change mid-request (the addon's _output() page after a vfdns_test click).
|
||||
*
|
||||
* API KEY HANDLING
|
||||
* ----------------
|
||||
* WHMCS stores password-type addon config fields encrypted in tbladdonmodules.value.
|
||||
* We call decrypt() — the same helper the server-module uses for the VirtFusion
|
||||
* bearer token — to get plaintext. If decryption fails (e.g. the WHMCS encryption
|
||||
* key changed or the value was inserted manually as plaintext), we fall back to
|
||||
* using the raw value. This is defensive; logs note the failure so an operator
|
||||
* can diagnose.
|
||||
*
|
||||
* The decrypted key exists only in memory inside this process's request lifetime.
|
||||
* It's passed to PowerDns\Client via the get() array and used for the X-API-Key
|
||||
* header; it's never written to disk, logged, or sent anywhere except to the
|
||||
* configured PowerDNS endpoint.
|
||||
*/
|
||||
class Config
|
||||
{
|
||||
/**
|
||||
* Name used for this addon in modules/addons/ AND stored in tbladdonmodules.module.
|
||||
* These two MUST match — WHMCS auto-lowercases the module directory name when
|
||||
* writing to the DB, so "VirtFusionDns" (directory) becomes "virtfusiondns" here.
|
||||
*/
|
||||
public const MODULE_NAME = 'virtfusiondns';
|
||||
|
||||
/** @var array<string,mixed>|null Null = not loaded yet; an array = resolved settings */
|
||||
private static $cached = null;
|
||||
|
||||
/**
|
||||
* Force a reload on next get().
|
||||
*
|
||||
* Primary use case: the addon's _output() page calls this before re-fetching
|
||||
* config so a test-connection click after saving settings sees the saved values.
|
||||
* Most other code should NOT call this — the request-scoped cache is there for
|
||||
* good performance reasons.
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fully-resolved configuration array with decrypted apiKey.
|
||||
*
|
||||
* Keys: enabled(bool), endpoint(string), apiKey(string), serverId(string),
|
||||
* defaultTtl(int), cacheTtl(int).
|
||||
*/
|
||||
public static function get(): array
|
||||
{
|
||||
if (self::$cached !== null) {
|
||||
return self::$cached;
|
||||
}
|
||||
|
||||
$config = [
|
||||
'enabled' => false,
|
||||
'endpoint' => '',
|
||||
'apiKey' => '',
|
||||
'serverId' => 'localhost',
|
||||
'defaultTtl' => 3600,
|
||||
'cacheTtl' => 60,
|
||||
];
|
||||
|
||||
try {
|
||||
// pluck('value', 'setting') returns a Collection keyed by 'setting' with
|
||||
// 'value' as the values — so $rows['enabled'] reads the row where
|
||||
// setting='enabled'. Efficient: one query regardless of how many
|
||||
// settings exist.
|
||||
$rows = DB::table('tbladdonmodules')
|
||||
->where('module', self::MODULE_NAME)
|
||||
->pluck('value', 'setting')
|
||||
->toArray();
|
||||
|
||||
// WHMCS yesno fields store either "on"/"" or "1"/"0" depending on version
|
||||
// and form handling. Accept all common truthy representations rather than
|
||||
// relying on a single literal.
|
||||
$enabledRaw = $rows['enabled'] ?? '';
|
||||
$config['enabled'] = in_array(strtolower((string) $enabledRaw), ['on', 'yes', '1', 'true'], true);
|
||||
|
||||
// Trim trailing slash from endpoint so Client::base() can safely concatenate
|
||||
// "/api/v1/..." without producing doubled slashes.
|
||||
$config['endpoint'] = rtrim((string) ($rows['endpoint'] ?? ''), '/');
|
||||
$config['serverId'] = (string) ($rows['serverId'] ?? 'localhost');
|
||||
|
||||
// Floor at 60s for defaultTtl and 10s for cacheTtl. Prevents a foot-gun
|
||||
// where an operator accidentally saves "0" and causes PowerDNS to treat
|
||||
// PTRs as non-cacheable (which some resolvers refuse) or this module to
|
||||
// hammer PowerDNS on every call.
|
||||
$config['defaultTtl'] = max(60, (int) ($rows['defaultTtl'] ?? 3600));
|
||||
$config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60));
|
||||
|
||||
if (! empty($rows['apiKey'])) {
|
||||
try {
|
||||
// decrypt() is WHMCS's global helper — matches how the VirtFusion
|
||||
// bearer token is handled in Module::getCP().
|
||||
$decrypted = decrypt($rows['apiKey']);
|
||||
|
||||
// Fallback to raw value if decrypt returned empty or non-string —
|
||||
// defends against the rare case where decrypt silently fails
|
||||
// (wrong encryption key at rest) or the value was inserted
|
||||
// manually as plaintext during development.
|
||||
$config['apiKey'] = is_string($decrypted) && $decrypted !== '' ? $decrypted : (string) $rows['apiKey'];
|
||||
} catch (\Throwable $e) {
|
||||
// Even when decrypt throws, we try the raw value so a diagnostic
|
||||
// path exists. Operator sees the decrypt error in the module log
|
||||
// but isn't locked out of using the addon while they investigate.
|
||||
$config['apiKey'] = (string) $rows['apiKey'];
|
||||
Log::insert('PowerDns:Config', 'decrypt skipped', $e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Any DB-level failure (table doesn't exist, connection dropped, etc.)
|
||||
// leaves $config at its safe defaults — isEnabled() returns false,
|
||||
// nothing gets written to PowerDNS, and the server module continues
|
||||
// to provision as if the addon weren't installed.
|
||||
Log::insert('PowerDns:Config', 'load failed', $e->getMessage());
|
||||
}
|
||||
|
||||
self::$cached = $config;
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/** True only when the addon is activated, configured, and has both endpoint and key. */
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$c = self::get();
|
||||
|
||||
return $c['enabled'] && $c['endpoint'] !== '' && $c['apiKey'] !== '';
|
||||
}
|
||||
|
||||
public static function endpoint(): string
|
||||
{
|
||||
return self::get()['endpoint'];
|
||||
}
|
||||
|
||||
public static function apiKey(): string
|
||||
{
|
||||
return self::get()['apiKey'];
|
||||
}
|
||||
|
||||
public static function serverId(): string
|
||||
{
|
||||
return self::get()['serverId'];
|
||||
}
|
||||
|
||||
public static function defaultTtl(): int
|
||||
{
|
||||
return self::get()['defaultTtl'];
|
||||
}
|
||||
|
||||
public static function cacheTtl(): int
|
||||
{
|
||||
return self::get()['cacheTtl'];
|
||||
}
|
||||
}
|
||||
426
modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
Normal file
426
modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
Normal file
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
/**
|
||||
* Pure static helpers for IP address manipulation and PTR-name construction.
|
||||
*
|
||||
* DESIGN NOTES
|
||||
* ------------
|
||||
* Everything here is pure — no I/O, no globals, no state. That matters for two reasons:
|
||||
* 1. PtrManager can compose these helpers freely without worrying about test isolation.
|
||||
* 2. They are safe to call inside tight loops (e.g. iterating every zone in PowerDNS
|
||||
* and testing it against a PTR name) without triggering hidden network or DB hits.
|
||||
*
|
||||
* Naming conventions used here:
|
||||
* - "PTR name" = the fully-qualified record name the PTR lives at,
|
||||
* e.g. "5.113.0.203.in-addr.arpa." (trailing dot always).
|
||||
* - "zone name" = the zone the record belongs to,
|
||||
* e.g. "113.0.203.in-addr.arpa." (trailing dot always).
|
||||
* - "nibble" = a single hex digit representing 4 bits, used in IPv6 reverse names.
|
||||
* - "classless" = an RFC 2317 sub-zone like "64/64.113.0.203.in-addr.arpa." —
|
||||
* a delegation of a sub-range of a /24, covered in parseClasslessZone().
|
||||
*
|
||||
* All zone/PTR strings are normalised with a trailing dot because PowerDNS's canonical
|
||||
* form always carries one, and mixing dotted/undotted forms makes string comparison
|
||||
* unreliable (".example.com." ≠ ".example.com").
|
||||
*/
|
||||
class IpUtil
|
||||
{
|
||||
/** Strict IPv4 validation (rejects "1", "::1", and other ambiguous forms). */
|
||||
public static function isIpv4(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
||||
}
|
||||
|
||||
/** Strict IPv6 validation (rejects IPv4-mapped, etc. — only pure v6 addresses). */
|
||||
public static function isIpv6(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-expand an IPv6 address to 32 lowercase hex characters (no colons).
|
||||
* e.g. 2001:db8::1 -> "20010db8000000000000000000000001"
|
||||
*
|
||||
* Why: PTR names under ip6.arpa use *all* 32 nibbles (no compression, no :: shorthand),
|
||||
* so we need the fully-expanded form before we can reverse the nibbles.
|
||||
*
|
||||
* Implementation: inet_pton normalises any valid IPv6 notation to 16 raw bytes,
|
||||
* and bin2hex turns that into 32 lowercase hex chars. No manual padding/splitting
|
||||
* logic means we can't get ":" vs "::" compression wrong.
|
||||
*
|
||||
* @return string|null 32-char hex string, or null if input isn't valid IPv6
|
||||
*/
|
||||
public static function expandIpv6(string $ip): ?string
|
||||
{
|
||||
$bin = @inet_pton($ip);
|
||||
// inet_pton returns 16 bytes for v6, 4 bytes for v4. Guard on both conditions
|
||||
// so a valid IPv4 like "192.0.2.1" doesn't silently pass through this v6 helper.
|
||||
if ($bin === false || strlen($bin) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bin2hex($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fully-qualified PTR name (trailing dot) for an IPv4 or IPv6 address.
|
||||
*
|
||||
* IPv4 example: 203.0.113.5 -> "5.113.0.203.in-addr.arpa."
|
||||
* IPv6 example: 2001:db8::1 -> "1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa."
|
||||
*
|
||||
* @return string|null PTR name with trailing dot, or null if input isn't a valid IP
|
||||
*/
|
||||
public static function ptrNameForIp(string $ip): ?string
|
||||
{
|
||||
// IPv4: reverse the four octets and suffix with in-addr.arpa.
|
||||
// 203.0.113.5 -> 5.113.0.203.in-addr.arpa.
|
||||
if (self::isIpv4($ip)) {
|
||||
$octets = array_reverse(explode('.', $ip));
|
||||
|
||||
return implode('.', $octets) . '.in-addr.arpa.';
|
||||
}
|
||||
|
||||
// IPv6: expand to 32 nibbles, reverse each nibble, suffix with ip6.arpa.
|
||||
// 2001:db8::1 -> 1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa.
|
||||
// The nibble-level reversal (not byte-level) is important: each hex digit
|
||||
// becomes its own DNS label. inet_pton/bin2hex give us the 32-char form;
|
||||
// str_split with no length arg defaults to 1 so each char becomes one label.
|
||||
if (self::isIpv6($ip)) {
|
||||
$hex = self::expandIpv6($ip);
|
||||
if ($hex === null) {
|
||||
return null;
|
||||
}
|
||||
$nibbles = array_reverse(str_split($hex));
|
||||
|
||||
return implode('.', $nibbles) . '.ip6.arpa.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every host IP address (v4 and v6) from a VirtFusion server object.
|
||||
*
|
||||
* Walks every interface, not just interfaces[0] (ServerResource only reads the primary).
|
||||
* Handles both explicit `address` fields and `subnet`+`cidr` pairs.
|
||||
* For IPv6 entries exposed only as `subnet`+`cidr`, the subnet base is used when
|
||||
* the cidr is /128 (single host); otherwise the entry is skipped and reported.
|
||||
*
|
||||
* @param object|array $serverObject Raw VirtFusion server payload (may be wrapped in `data`)
|
||||
* @return array{addresses: string[], skipped: array} Deduped IP strings + array of skipped entries with reasons
|
||||
*/
|
||||
public static function extractIps($serverObject): array
|
||||
{
|
||||
$addresses = [];
|
||||
$skipped = [];
|
||||
|
||||
// Normalise object-or-array input. json_decode(json_encode($x), true) is the
|
||||
// cheapest defensive way to turn a stdClass tree (VirtFusion's response) or
|
||||
// an already-decoded array (stored server_object blob) into a uniform array.
|
||||
if (is_object($serverObject)) {
|
||||
$serverObject = json_decode(json_encode($serverObject), true);
|
||||
}
|
||||
if (! is_array($serverObject)) {
|
||||
return ['addresses' => [], 'skipped' => []];
|
||||
}
|
||||
|
||||
// VirtFusion wraps the payload in a "data" key on GET responses but the stored
|
||||
// server_object blob is sometimes already unwrapped. Accept both shapes.
|
||||
$data = $serverObject['data'] ?? $serverObject;
|
||||
$interfaces = $data['network']['interfaces'] ?? [];
|
||||
if (! is_array($interfaces)) {
|
||||
return ['addresses' => [], 'skipped' => []];
|
||||
}
|
||||
|
||||
// Walk every interface (not just interfaces[0]). ServerResource only reads [0]
|
||||
// because it's building display data for the "primary" IP; rDNS needs PTRs
|
||||
// for every IP no matter which interface it lives on.
|
||||
foreach ($interfaces as $iface) {
|
||||
foreach (($iface['ipv4'] ?? []) as $v4) {
|
||||
// Accept both "address" and "ip" field names — VirtFusion's schema
|
||||
// has evolved and we want the module to survive minor shape changes.
|
||||
$candidate = $v4['address'] ?? ($v4['ip'] ?? null);
|
||||
if ($candidate && self::isIpv4($candidate)) {
|
||||
// Use the IP as an array key for free de-duplication. If the same
|
||||
// IP appears on two interfaces (unusual but possible), we write
|
||||
// one PTR not two.
|
||||
$addresses[$candidate] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (($iface['ipv6'] ?? []) as $v6) {
|
||||
// Preferred shape: a discrete host address (the normal v6 pattern).
|
||||
$candidate = $v6['address'] ?? ($v6['ip'] ?? null);
|
||||
if ($candidate && self::isIpv6($candidate)) {
|
||||
$addresses[$candidate] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback shape: VirtFusion sometimes exposes v6 only as subnet+cidr
|
||||
// (common when a /64 is routed to the VPS and the OS auto-assigns
|
||||
// specific host addresses). We can't set a PTR for the whole subnet,
|
||||
// so we only accept /128 (single-host) entries and report the rest
|
||||
// via the "skipped" channel so callers can surface a UI note.
|
||||
$subnet = $v6['subnet'] ?? null;
|
||||
$cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null;
|
||||
if ($subnet && self::isIpv6($subnet)) {
|
||||
if ($cidr === 128) {
|
||||
$addresses[$subnet] = true;
|
||||
} else {
|
||||
$skipped[] = [
|
||||
'subnet' => $subnet,
|
||||
'cidr' => $cidr,
|
||||
'reason' => 'ipv6-subnet-without-explicit-host-address',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// array_keys gives us the de-duplicated list in insertion order.
|
||||
return ['addresses' => array_keys($addresses), 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the longest-suffix zone from a list of zone names that contains a given PTR name.
|
||||
* Both inputs are normalised to a trailing dot before matching.
|
||||
*
|
||||
* @param string $ptrName Fully-qualified PTR name (with or without trailing dot)
|
||||
* @param string[] $zones List of zone names from PowerDNS (with or without trailing dots)
|
||||
* @return string|null Matching zone name with trailing dot, or null if no zone covers the PTR
|
||||
*/
|
||||
public static function findContainingZone(string $ptrName, array $zones): ?string
|
||||
{
|
||||
$ptrName = rtrim($ptrName, '.') . '.';
|
||||
$best = null;
|
||||
$bestLen = 0;
|
||||
|
||||
foreach ($zones as $zone) {
|
||||
if (! is_string($zone) || $zone === '') {
|
||||
continue;
|
||||
}
|
||||
if (strpos($zone, '/') !== false) {
|
||||
// RFC 2317 classless zones can't be identified by plain suffix match:
|
||||
// a PTR like "5.113.0.203.in-addr.arpa." does NOT end with
|
||||
// ".64/64.113.0.203.in-addr.arpa." even when 5 is in range. Range
|
||||
// matching lives in findZoneAndPtrName; this helper is kept for any
|
||||
// caller that only deals with standard zones.
|
||||
continue;
|
||||
}
|
||||
$z = rtrim($zone, '.') . '.';
|
||||
// Prefix with "." so a zone "example.com." doesn't accidentally match
|
||||
// "foo.anotherexample.com." via naive substring compare.
|
||||
$suffix = '.' . $z;
|
||||
if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) {
|
||||
// Longest match wins. For nested delegations (e.g. both
|
||||
// "0.203.in-addr.arpa." and "113.0.203.in-addr.arpa." exist),
|
||||
// the more specific one is the correct authoritative zone.
|
||||
$len = strlen($z);
|
||||
if ($len > $bestLen) {
|
||||
$best = $z;
|
||||
$bestLen = $len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an RFC 2317 classless-delegation IPv4 reverse zone name.
|
||||
*
|
||||
* RFC 2317 lets a /24 owner delegate sub-ranges of that /24 to separate
|
||||
* authoritative servers by creating CNAMEs in the parent zone that point
|
||||
* into a named sub-zone. The sub-zone's label conventionally uses "X/Y"
|
||||
* where the slash carries structural meaning, not path semantics.
|
||||
*
|
||||
* Two "Y" conventions exist in the wild. We accept both:
|
||||
*
|
||||
* (a) Y is a CIDR prefix length, Y ∈ [24, 32]. Standard per the RFC.
|
||||
* "64/26.113.0.203.in-addr.arpa." — /26 → 64 addresses → covers 64..127
|
||||
* "0/25.1.168.192.in-addr.arpa." — /25 → 128 addresses → covers 0..127
|
||||
*
|
||||
* (b) Y is a block size (count of addresses), Y > 32. Non-standard but
|
||||
* used by some operators because the label reads naturally:
|
||||
* "64/64.113.0.203.in-addr.arpa." — size 64 → covers 64..127
|
||||
*
|
||||
* We disambiguate by Y's magnitude: ≤32 is a prefix length, >32 is a count.
|
||||
* (Y=32 would be "a single-host delegation", valid under convention (a).)
|
||||
*
|
||||
* ALIGNMENT CHECK
|
||||
* ---------------
|
||||
* We also verify X is a multiple of the block size. Misaligned entries
|
||||
* like "3/26.x.y.z" don't correspond to any real DNS delegation — a /26
|
||||
* must start at a multiple of 64 (0, 64, 128, or 192). Rejecting these
|
||||
* prevents silent write-into-wrong-zone if an operator mis-names a zone.
|
||||
*
|
||||
* @return array{parent: string, start: int, end: int}|null
|
||||
* parent: parent /24 reverse zone name with trailing dot (e.g. "113.0.203.in-addr.arpa.")
|
||||
* start/end: inclusive last-octet range covered by this classless zone
|
||||
*/
|
||||
public static function parseClasslessZone(string $zone): ?array
|
||||
{
|
||||
$zone = rtrim($zone, '.') . '.';
|
||||
|
||||
// Structural gate 1: must end in .in-addr.arpa. — classless only applies to IPv4.
|
||||
if (substr($zone, -strlen('.in-addr.arpa.')) !== '.in-addr.arpa.') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Structural gate 2: must have at least 5 labels to contain both the
|
||||
// classless label and a full /24 parent: "X/Y . o . o . o . in-addr . arpa . ''"
|
||||
// The trailing empty label from the terminal dot bumps this to ≥ 7 in practice,
|
||||
// but 5 is the minimum we need to safely slice below.
|
||||
$labels = explode('.', $zone);
|
||||
if (count($labels) < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Structural gate 3: the first label must contain a "/". If not, this is a
|
||||
// standard zone (e.g. "113.0.203.in-addr.arpa.") — let the caller handle it.
|
||||
$first = $labels[0];
|
||||
if (strpos($first, '/') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse "X/Y" — reject if either side isn't a non-negative integer.
|
||||
$parts = explode('/', $first, 2);
|
||||
if (count($parts) !== 2 || ! ctype_digit($parts[0]) || ! ctype_digit($parts[1])) {
|
||||
return null;
|
||||
}
|
||||
$x = (int) $parts[0];
|
||||
$y = (int) $parts[1];
|
||||
|
||||
// X must fit in an octet; Y must be positive (0 and negative make no sense).
|
||||
if ($x < 0 || $x > 255 || $y <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map Y → block size using the dual-convention rule described in the doc-block.
|
||||
if ($y <= 32) {
|
||||
// CIDR prefix convention. Values <24 would cross /24 boundaries (outside
|
||||
// the scope of a single-/24 delegation), >32 is impossible for IPv4.
|
||||
if ($y < 24 || $y > 32) {
|
||||
return null;
|
||||
}
|
||||
// 1 << (32 - Y) gives the block size. Y=24→256 (whole /24), Y=32→1 (host).
|
||||
$size = 1 << (32 - $y);
|
||||
} else {
|
||||
// Block-size convention. Accept any positive Y that fits the /24 range check below.
|
||||
$size = $y;
|
||||
}
|
||||
|
||||
// Alignment: X must sit on a block boundary. For size=64, legal starts are
|
||||
// 0, 64, 128, 192. Mis-alignments indicate a misconfigured zone label.
|
||||
if ($x % $size !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $x + $size - 1;
|
||||
// The range must stay within the parent /24 (last octet 0..255).
|
||||
if ($end > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The parent zone is everything after the first label, i.e. the /24 reverse zone.
|
||||
// array_slice(labels, 1) drops "X/Y" and the implode reconstructs the trailing-dot form.
|
||||
$parent = implode('.', array_slice($labels, 1));
|
||||
|
||||
return ['parent' => $parent, 'start' => $x, 'end' => $end];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an IP to its (zone, ptrName) pair in one shot, handling both standard
|
||||
* reverse zones and RFC 2317 classless delegations.
|
||||
*
|
||||
* For a classless match, the returned ptrName includes the classless zone
|
||||
* label (e.g. "100.64/64.113.0.203.in-addr.arpa.") — this is the actual DNS
|
||||
* name the PTR record lives at in PowerDNS. Classless zones take precedence
|
||||
* over any matching parent zone, because in a properly-delegated setup the
|
||||
* parent only holds CNAMEs pointing into the classless sub-zone.
|
||||
*
|
||||
* @param string[] $zones Zone names from PowerDNS (trailing dots optional)
|
||||
* @return array{zone: string, ptrName: string}|null
|
||||
*/
|
||||
public static function findZoneAndPtrName(string $ip, array $zones): ?array
|
||||
{
|
||||
$ptrName = self::ptrNameForIp($ip);
|
||||
if ($ptrName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ipv4 = self::isIpv4($ip);
|
||||
// Extract the last octet up front for classless range comparison.
|
||||
// Only meaningful for IPv4 since RFC 2317 is IPv4-only (IPv6 delegations
|
||||
// naturally align on nibble boundaries and don't need classless tricks).
|
||||
$lastOctet = null;
|
||||
if ($ipv4) {
|
||||
$octets = explode('.', $ip);
|
||||
$lastOctet = (int) $octets[3];
|
||||
}
|
||||
|
||||
$bestDirect = null;
|
||||
$bestDirectLen = 0;
|
||||
$classlessMatch = null;
|
||||
|
||||
// Single pass over the zone list, bucketing each candidate into the
|
||||
// classless path or the direct-suffix-match path.
|
||||
foreach ($zones as $zone) {
|
||||
if (! is_string($zone) || $zone === '') {
|
||||
continue;
|
||||
}
|
||||
$z = rtrim($zone, '.') . '.';
|
||||
|
||||
if (strpos($z, '/') !== false) {
|
||||
// Classless path. Skip for IPv6 entirely.
|
||||
if (! $ipv4) {
|
||||
continue;
|
||||
}
|
||||
$parsed = self::parseClasslessZone($z);
|
||||
if ($parsed === null) {
|
||||
// Malformed classless zone name (misaligned, wrong TLD, etc.) — skip.
|
||||
continue;
|
||||
}
|
||||
// The PTR still needs to suffix-match the PARENT zone; otherwise the
|
||||
// classless zone lives under a different /24 and isn't relevant.
|
||||
$parentSuffix = '.' . $parsed['parent'];
|
||||
if (substr($ptrName, -strlen($parentSuffix)) !== $parentSuffix) {
|
||||
continue;
|
||||
}
|
||||
// Range gate: the host octet must fall inside this classless zone's window.
|
||||
if ($lastOctet < $parsed['start'] || $lastOctet > $parsed['end']) {
|
||||
continue;
|
||||
}
|
||||
// The record name inside a classless zone prepends the full host octet
|
||||
// to the classless label, e.g. PTR "100" lives at:
|
||||
// "100.64/64.113.0.203.in-addr.arpa."
|
||||
// (NOT "100.113.0.203.in-addr.arpa." — the classless sub-zone holds the RRset).
|
||||
$classlessMatch = [
|
||||
'zone' => $z,
|
||||
'ptrName' => $lastOctet . '.' . $z,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct suffix-match path (standard reverse zones).
|
||||
$suffix = '.' . $z;
|
||||
if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) {
|
||||
// Longest-match wins (see findContainingZone() for rationale).
|
||||
if (strlen($z) > $bestDirectLen) {
|
||||
$bestDirect = ['zone' => $z, 'ptrName' => $ptrName];
|
||||
$bestDirectLen = strlen($z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRECEDENCE: classless beats direct. In a correctly-delegated RFC 2317 setup
|
||||
// the parent /24 zone only contains CNAMEs pointing into the classless sub-zone —
|
||||
// it does NOT hold the PTR RRset directly. Writing to the parent would create a
|
||||
// record that's shadowed by the CNAME and never consulted during resolution.
|
||||
return $classlessMatch ?? $bestDirect;
|
||||
}
|
||||
}
|
||||
716
modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
Normal file
716
modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
Normal file
@@ -0,0 +1,716 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Database\Capsule as DB;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Orchestrates PTR lifecycle against PowerDNS for VirtFusion servers.
|
||||
*
|
||||
* RESPONSIBILITIES
|
||||
* ----------------
|
||||
* - Compute zone membership for a given IP by matching against PowerDNS's zone list
|
||||
* - Verify forward DNS (A/AAAA) before writing any PTR; never write a PTR whose
|
||||
* hostname doesn't already resolve to the target IP
|
||||
* - Preserve client-customised PTRs during server renames (only overwrite PTRs
|
||||
* whose current content equals the previous hostname)
|
||||
* - Provide read-through views for client-area and admin panels with status flags
|
||||
* - Support an explicit admin reconcile (optionally forceful) and an additive-only
|
||||
* cron reconciliation that never overwrites existing values
|
||||
*
|
||||
* CACHING MODEL
|
||||
* -------------
|
||||
* Two tiers, both serving different purposes:
|
||||
*
|
||||
* $zoneListCache — the list of every zone PowerDNS knows about. Populated once
|
||||
* per PtrManager instance via locate(). The underlying Client
|
||||
* caches the HTTP response for Config::cacheTtl() seconds across
|
||||
* requests; this instance field just memoises the lookup within
|
||||
* one request so multiple IPs on the same server don't each
|
||||
* call Client::listZones().
|
||||
*
|
||||
* $zoneCache — decoded RRset contents of individual zones, keyed by zone
|
||||
* name. Populated lazily as findPtrRRset() looks up each IP's
|
||||
* zone. IMPORTANT: request-scoped only — we must invalidate on
|
||||
* writes (see invalidateZone) so a read-after-write within the
|
||||
* same request sees fresh data. This is why deletePtr/writePtr
|
||||
* call invalidateZone before returning.
|
||||
*
|
||||
* Neither cache is shared between PtrManager instances (new PtrManager per WHMCS
|
||||
* request is cheap). The Client's HTTP-response cache IS shared across requests via
|
||||
* the module's Cache class (Redis or filesystem), which is where cross-request
|
||||
* amortisation happens.
|
||||
*
|
||||
* SHORT-CIRCUIT BEHAVIOUR
|
||||
* -----------------------
|
||||
* Every public method checks Config::isEnabled() and returns an empty/no-op summary
|
||||
* when the addon is inactive. This means unrelated calling code (createAccount,
|
||||
* terminateAccount, renameServer, the client panel endpoint, cron) can always
|
||||
* invoke PtrManager without a feature flag — the gate lives here.
|
||||
*
|
||||
* The summary arrays deliberately include 'enabled' => bool so test harnesses and
|
||||
* admin UIs can tell "we did nothing because disabled" apart from "we did nothing
|
||||
* because there were no IPs".
|
||||
*/
|
||||
class PtrManager
|
||||
{
|
||||
/** @var Client */
|
||||
private $client;
|
||||
|
||||
/** @var array<string, array<string,mixed>|null> Request-scoped zone contents cache, keyed by zone name */
|
||||
private $zoneCache = [];
|
||||
|
||||
/** @var string[]|null Request-scoped zone-list memo (Client handles cross-request caching) */
|
||||
private $zoneListCache = null;
|
||||
|
||||
public function __construct(?Client $client = null)
|
||||
{
|
||||
// Dependency-inject the Client so tests can pass a mock; default to the
|
||||
// Config-driven instance so production code never has to wire this up.
|
||||
$this->client = $client ?? new Client;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync PTRs for every IP on the given server object.
|
||||
*
|
||||
* TWO MODES OF OPERATION
|
||||
* ----------------------
|
||||
* CREATE ($oldHostname = null) — provisioning path.
|
||||
* Write $newHostname to every IP that doesn't
|
||||
* already have a PTR. Pre-existing PTRs are
|
||||
* preserved (shouldn't exist on a new server,
|
||||
* but if they do they're likely left over from
|
||||
* a previous owner of the IP and must not be
|
||||
* silently overwritten).
|
||||
*
|
||||
* RENAME ($oldHostname given) — rename path.
|
||||
* Only overwrite PTRs whose current content
|
||||
* equals $oldHostname. Anything else was set
|
||||
* by the client (custom rDNS like mail servers
|
||||
* need to match HELO) and must be preserved.
|
||||
*
|
||||
* The forward-DNS check runs before every write. A PTR without a matching
|
||||
* A/AAAA is FCrDNS-broken and actively harms deliverability, so we'd rather
|
||||
* leave the PTR absent than set a broken one.
|
||||
*
|
||||
* ERROR SEMANTICS
|
||||
* ---------------
|
||||
* This method never throws. Every per-IP failure is caught, logged, and
|
||||
* recorded in $summary['errors']. Lifecycle callers (createAccount,
|
||||
* renameServer) wrap the call in their own try/catch as belt-and-braces,
|
||||
* but the expectation is that DNS issues never bubble up to WHMCS as
|
||||
* provisioning failures.
|
||||
*
|
||||
* @param object|array $serverObject VirtFusion server payload
|
||||
* @return array Summary counts: written, preserved, forward_missing, no_zone, skipped_ipv6, errors, details[]
|
||||
*/
|
||||
public function syncServer($serverObject, ?string $oldHostname, string $newHostname): array
|
||||
{
|
||||
$summary = [
|
||||
'enabled' => false,
|
||||
'written' => 0,
|
||||
'preserved' => 0,
|
||||
'forward_missing' => 0,
|
||||
'no_zone' => 0,
|
||||
'skipped_ipv6' => 0,
|
||||
'errors' => 0,
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
// Report (not write) v6 subnet-only allocations. UI can surface "IPv6 PTR
|
||||
// not configured — /64 without explicit host" as guidance.
|
||||
$summary['skipped_ipv6'] = count($extracted['skipped']);
|
||||
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
// IP isn't covered by any zone we host. Not an error — the
|
||||
// operator may manage reverse DNS for this range elsewhere.
|
||||
$summary['no_zone']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'no-zone'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $this->readPtr($loc);
|
||||
|
||||
// Rename-mode preservation check. The "current PTR equals old
|
||||
// hostname" comparison is the whole safety mechanism for protecting
|
||||
// client-custom rDNS across server renames — see class docblock.
|
||||
// On CREATE mode ($oldHostname === null) we skip this branch,
|
||||
// which means pre-existing PTRs on a new IP get overwritten; this
|
||||
// is acceptable because a fresh IP shouldn't have PTRs yet.
|
||||
if ($oldHostname !== null && $current !== null) {
|
||||
if (self::normalizeHost($current) !== self::normalizeHost($oldHostname)) {
|
||||
$summary['preserved']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'preserved', 'current' => $current];
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! Resolver::resolvesTo($newHostname, $ip, Config::cacheTtl())) {
|
||||
$summary['forward_missing']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'forward-missing', 'desired' => $newHostname];
|
||||
Log::insert('PowerDns:syncServer', ['ip' => $ip, 'hostname' => $newHostname], 'forward DNS mismatch; PTR skipped');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->writePtr($loc, $newHostname);
|
||||
if ($result['ok']) {
|
||||
$summary['written']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'written', 'content' => $newHostname];
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'error', 'http' => $result['http']];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:syncServer', ['ip' => $ip], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every PTR belonging to the given server.
|
||||
*
|
||||
* @return array Summary counts: deleted, no_zone, errors
|
||||
*/
|
||||
public function deleteForServer($serverObject): array
|
||||
{
|
||||
$summary = ['enabled' => false, 'deleted' => 0, 'no_zone' => 0, 'errors' => 0];
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
$summary['no_zone']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$result = $this->deletePtr($loc);
|
||||
if ($result['ok']) {
|
||||
$summary['deleted']++;
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:deleteForServer', ['ip' => $ip], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a per-IP status list suitable for client-area and admin display.
|
||||
*
|
||||
* Each entry: [ip, ptr, ttl, zone, status]
|
||||
* Status values: ok, unverified, missing, no-zone, error, disabled.
|
||||
*
|
||||
* @return array<int, array<string,mixed>>
|
||||
*/
|
||||
public function listPtrs($serverObject, ?string $expectedHostname = null): array
|
||||
{
|
||||
$out = [];
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
|
||||
if (! Config::isEnabled()) {
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'disabled'];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'no-zone'];
|
||||
|
||||
continue;
|
||||
}
|
||||
$rrset = $this->findPtrRRset($loc);
|
||||
if ($rrset === null) {
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => $loc['zone'], 'status' => 'missing'];
|
||||
|
||||
continue;
|
||||
}
|
||||
$ptr = $rrset['content'];
|
||||
$status = Resolver::resolvesTo($ptr, $ip, Config::cacheTtl()) ? 'ok' : 'unverified';
|
||||
$out[] = [
|
||||
'ip' => $ip,
|
||||
'ptr' => $ptr,
|
||||
'ttl' => $rrset['ttl'],
|
||||
'zone' => $loc['zone'],
|
||||
'status' => $status,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:listPtrs', ['ip' => $ip], $e->getMessage());
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'error'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-initiated PTR set/delete.
|
||||
*
|
||||
* Differences from syncServer():
|
||||
* - Only ever writes one PTR, not a whole server's worth
|
||||
* - Rate-limited per IP (10s window) to stop save-button abuse
|
||||
* - Forward-DNS failure is a HARD REJECT that surfaces to the user — not a
|
||||
* silent skip like the automatic paths. The client wants immediate feedback
|
||||
* when their A record is missing.
|
||||
* - Empty content path is an explicit delete (DELETE changetype, not REPLACE-empty)
|
||||
*
|
||||
* IP-OWNERSHIP NOTE
|
||||
* -----------------
|
||||
* This method TRUSTS that the caller has already verified the client owns $ip —
|
||||
* that check lives in the calling endpoint (client.php rdnsUpdate) where it has
|
||||
* access to the WHMCS session. If you call setPtr() from a new code path, you
|
||||
* MUST add the ownership guard upstream of it.
|
||||
*
|
||||
* @return array{ok: bool, reason: string, http?: int}
|
||||
* reason values: disabled, invalid-ip, rate-limited, no-zone,
|
||||
* forward-missing, deleted, delete-failed, written, write-failed
|
||||
*/
|
||||
public function setPtr(string $ip, string $content): array
|
||||
{
|
||||
if (! Config::isEnabled()) {
|
||||
return ['ok' => false, 'reason' => 'disabled'];
|
||||
}
|
||||
if (! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
|
||||
return ['ok' => false, 'reason' => 'invalid-ip'];
|
||||
}
|
||||
|
||||
// Rate limit: one successful check per IP per 10s. Uses the module's
|
||||
// two-tier Cache (Redis or filesystem), so the limit spans PHP processes.
|
||||
// md5 of IP as the key keeps filesystem filenames short and safe.
|
||||
$rateKey = 'pdns:write-lock:' . md5($ip);
|
||||
if (Cache::get($rateKey) !== null) {
|
||||
return ['ok' => false, 'reason' => 'rate-limited'];
|
||||
}
|
||||
// Set the lock BEFORE any downstream work so a parallel request racing
|
||||
// through the same IP sees the lock and gets rate-limited cleanly.
|
||||
Cache::set($rateKey, 1, 10);
|
||||
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
return ['ok' => false, 'reason' => 'no-zone'];
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
$result = $this->deletePtr($loc);
|
||||
|
||||
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'deleted' : 'delete-failed', 'http' => $result['http']];
|
||||
}
|
||||
|
||||
if (! Resolver::resolvesTo($content, $ip, Config::cacheTtl())) {
|
||||
return ['ok' => false, 'reason' => 'forward-missing'];
|
||||
}
|
||||
|
||||
$result = $this->writePtr($loc, $content);
|
||||
|
||||
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'written' : 'write-failed', 'http' => $result['http']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reconciliation for a single service.
|
||||
*
|
||||
* The user-facing purpose: "make the PTRs match what they should be, but don't
|
||||
* step on client customisations unless I explicitly ask".
|
||||
*
|
||||
* Uses the STORED server_object (from mod_virtfusion_direct) rather than fetching
|
||||
* fresh from VirtFusion. Reasons:
|
||||
* 1. Admin reconcile runs from the services tab — no live-data dependency
|
||||
* 2. Cron calls this once per service; fetching fresh would mean N VirtFusion
|
||||
* calls per reconcile run
|
||||
* 3. The stored object is the ground truth for "what IPs/hostname did this
|
||||
* service have at last sync" — if VirtFusion temporarily returns a different
|
||||
* shape, we'd rather work from known-good data than retry.
|
||||
*
|
||||
* If the stored state is materially out of date (e.g. IPs were added in VirtFusion
|
||||
* after last sync), an admin should hit "Update Server Object" first.
|
||||
*
|
||||
* FORCE MODE
|
||||
* ----------
|
||||
* $force = true is the only code path in the entire module that overwrites a
|
||||
* non-matching PTR. It's reachable exclusively via the admin "Reconcile (force
|
||||
* reset)" button — never from cron, never from client writes, never from
|
||||
* automatic lifecycle. This asymmetry is deliberate: forceful overrides are
|
||||
* the admin's explicit choice, not a silent automation.
|
||||
*
|
||||
* @return array Summary counts: added, reset, preserved, forward_missing, no_zone, errors
|
||||
*/
|
||||
public function reconcile(int $serviceId, bool $force = false): array
|
||||
{
|
||||
$summary = [
|
||||
'enabled' => false,
|
||||
'added' => 0,
|
||||
'reset' => 0,
|
||||
'preserved' => 0,
|
||||
'forward_missing' => 0,
|
||||
'no_zone' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
$row = Database::getSystemService($serviceId);
|
||||
if (! $row || empty($row->server_object)) {
|
||||
$summary['errors']++;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
$serverObject = json_decode($row->server_object, true);
|
||||
if (! is_array($serverObject)) {
|
||||
$summary['errors']++;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$hostname = self::extractHostname($serverObject);
|
||||
if ($hostname === null) {
|
||||
$summary['errors']++;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
$summary['no_zone']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $this->readPtr($loc);
|
||||
$verified = Resolver::resolvesTo($hostname, $ip, Config::cacheTtl());
|
||||
|
||||
if ($current === null) {
|
||||
if (! $verified) {
|
||||
$summary['forward_missing']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$result = $this->writePtr($loc, $hostname);
|
||||
if ($result['ok']) {
|
||||
$summary['added']++;
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($force && self::normalizeHost($current) !== self::normalizeHost($hostname)) {
|
||||
if (! $verified) {
|
||||
$summary['forward_missing']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$result = $this->writePtr($loc, $hostname);
|
||||
if ($result['ok']) {
|
||||
$summary['reset']++;
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['preserved']++;
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:reconcile', ['ip' => $ip, 'service' => $serviceId], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron reconciliation across every managed service.
|
||||
*
|
||||
* Called from the DailyCronJob hook. Iterates every row in mod_virtfusion_direct
|
||||
* and runs reconcile() on each with $force = false. That means:
|
||||
*
|
||||
* - IPs missing a PTR get one (if forward DNS resolves)
|
||||
* - Existing PTRs are NEVER touched, even if they differ from the hostname
|
||||
*
|
||||
* This asymmetry is the safety property. A brief forward-DNS blip during the
|
||||
* cron window shouldn't trigger mass-rewrites that corrupt client-custom
|
||||
* records. Admins who need forceful re-alignment must run the per-service
|
||||
* "Reconcile (force reset)" button explicitly.
|
||||
*
|
||||
* Failures on individual services are logged and counted but never abort the
|
||||
* job — a misconfigured single zone or one VirtFusion-unreachable service
|
||||
* should not block reconciliation for the rest of the fleet.
|
||||
*
|
||||
* @return array Aggregate summary across all services
|
||||
*/
|
||||
public function reconcileAll(): array
|
||||
{
|
||||
$summary = [
|
||||
'enabled' => false,
|
||||
'services' => 0,
|
||||
'added' => 0,
|
||||
'preserved' => 0,
|
||||
'forward_missing' => 0,
|
||||
'no_zone' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
try {
|
||||
$rows = DB::table(Database::SYSTEM_TABLE)->pluck('service_id');
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:reconcileAll', [], $e->getMessage());
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
foreach ($rows as $serviceId) {
|
||||
$summary['services']++;
|
||||
|
||||
try {
|
||||
$r = $this->reconcile((int) $serviceId, false);
|
||||
$summary['added'] += $r['added'];
|
||||
$summary['preserved'] += $r['preserved'];
|
||||
$summary['forward_missing'] += $r['forward_missing'];
|
||||
$summary['no_zone'] += $r['no_zone'];
|
||||
$summary['errors'] += $r['errors'];
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:reconcileAll:service', ['service' => $serviceId], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Log::insert('PowerDns:reconcileAll', [], $summary);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve an IP to the (zone, ptrName) pair using the cached zone list.
|
||||
* Handles both standard and RFC 2317 classless zones (delegates to IpUtil).
|
||||
*
|
||||
* Memoised within this instance: the zone list is fetched once (via the Client,
|
||||
* which itself caches across requests per Config::cacheTtl()) and reused for
|
||||
* every IP of the current server. A server with 3 IPs in the same /24 therefore
|
||||
* triggers ONE listZones call, not three.
|
||||
*
|
||||
* @return array{zone: string, ptrName: string}|null null means "no zone covers this IP"
|
||||
*/
|
||||
private function locate(string $ip): ?array
|
||||
{
|
||||
if ($this->zoneListCache === null) {
|
||||
$this->zoneListCache = $this->client->listZones();
|
||||
}
|
||||
|
||||
return IpUtil::findZoneAndPtrName($ip, $this->zoneListCache);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function getZoneCached(string $zoneName): ?array
|
||||
{
|
||||
if (array_key_exists($zoneName, $this->zoneCache)) {
|
||||
return $this->zoneCache[$zoneName];
|
||||
}
|
||||
$this->zoneCache[$zoneName] = $this->client->getZone($zoneName);
|
||||
|
||||
return $this->zoneCache[$zoneName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Current PTR content for a located address, or null if absent.
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
*/
|
||||
private function readPtr(array $loc): ?string
|
||||
{
|
||||
$rrset = $this->findPtrRRset($loc);
|
||||
|
||||
return $rrset === null ? null : $rrset['content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a PTR RRset at the located name.
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
* @return array{content: string, ttl: int}|null
|
||||
*/
|
||||
private function findPtrRRset(array $loc): ?array
|
||||
{
|
||||
$zone = $this->getZoneCached($loc['zone']);
|
||||
if ($zone === null || empty($zone['rrsets']) || ! is_array($zone['rrsets'])) {
|
||||
return null;
|
||||
}
|
||||
foreach ($zone['rrsets'] as $rrset) {
|
||||
if (($rrset['type'] ?? '') !== 'PTR') {
|
||||
continue;
|
||||
}
|
||||
if (self::normalizeHost($rrset['name'] ?? '') !== self::normalizeHost($loc['ptrName'])) {
|
||||
continue;
|
||||
}
|
||||
$records = $rrset['records'] ?? [];
|
||||
foreach ($records as $record) {
|
||||
if (! empty($record['disabled'])) {
|
||||
continue;
|
||||
}
|
||||
if (! empty($record['content'])) {
|
||||
return [
|
||||
'content' => rtrim((string) $record['content'], '.'),
|
||||
'ttl' => (int) ($rrset['ttl'] ?? Config::defaultTtl()),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write/replace a PTR record.
|
||||
*
|
||||
* Always uses REPLACE changetype rather than a create-then-update pattern —
|
||||
* REPLACE is idempotent and atomic from PowerDNS's view, whereas separate
|
||||
* create + update would briefly leave the record absent.
|
||||
*
|
||||
* Content is canonicalised to end with a trailing dot before sending (PowerDNS
|
||||
* treats unqualified names as relative to the zone, which is not what we want
|
||||
* for PTR content — "host.example.com" without a trailing dot would be stored
|
||||
* as "host.example.com.113.0.203.in-addr.arpa.").
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
* @return array{ok: bool, http: int}
|
||||
*/
|
||||
private function writePtr(array $loc, string $content): array
|
||||
{
|
||||
$content = rtrim(trim($content), '.') . '.';
|
||||
$ttl = Config::defaultTtl();
|
||||
|
||||
$result = $this->client->patchRRset($loc['zone'], [
|
||||
'name' => $loc['ptrName'],
|
||||
'type' => 'PTR',
|
||||
'ttl' => $ttl,
|
||||
'changetype' => 'REPLACE',
|
||||
'records' => [['content' => $content, 'disabled' => false]],
|
||||
]);
|
||||
|
||||
$this->invalidateZone($loc['zone']);
|
||||
|
||||
return ['ok' => $result['ok'], 'http' => $result['http']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a PTR record.
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
* @return array{ok: bool, http: int}
|
||||
*/
|
||||
private function deletePtr(array $loc): array
|
||||
{
|
||||
$result = $this->client->patchRRset($loc['zone'], [
|
||||
'name' => $loc['ptrName'],
|
||||
'type' => 'PTR',
|
||||
'changetype' => 'DELETE',
|
||||
]);
|
||||
|
||||
$this->invalidateZone($loc['zone']);
|
||||
|
||||
return ['ok' => $result['ok'], 'http' => $result['http']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached zone contents so the next read re-fetches from PowerDNS.
|
||||
* Called after every successful write so read-after-write in the same request
|
||||
* (e.g. listPtrs right after setPtr in a test harness) observes fresh data.
|
||||
*/
|
||||
private function invalidateZone(string $zoneName): void
|
||||
{
|
||||
unset($this->zoneCache[$zoneName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a hostname for comparison: lowercase, no trailing dot.
|
||||
*
|
||||
* DNS hostnames are case-insensitive and the trailing dot is syntactic, not
|
||||
* semantic. PowerDNS returns content with a trailing dot ("host.example.com.");
|
||||
* user input typically doesn't have one. Both forms of "FooBar.example.com."
|
||||
* vs "foobar.example.com" should compare equal, which is what this produces.
|
||||
*/
|
||||
private static function normalizeHost(string $h): string
|
||||
{
|
||||
return strtolower(rtrim(trim($h), '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the server hostname from a VirtFusion server payload.
|
||||
*
|
||||
* Accepts either object or array shape, wrapped or unwrapped by a `data` property.
|
||||
* Falls back to `name` when `hostname` is absent or "-", matching the semantics
|
||||
* of the existing ServerResource::process() behavior.
|
||||
*
|
||||
* Public so lifecycle call sites (createAccount, renameServer) can pull the
|
||||
* hostname from a response or stored JSON blob without duplicating the logic.
|
||||
*
|
||||
* @param object|array $serverObject
|
||||
*/
|
||||
public static function extractHostname($serverObject): ?string
|
||||
{
|
||||
if (is_object($serverObject)) {
|
||||
$serverObject = json_decode(json_encode($serverObject), true);
|
||||
}
|
||||
if (! is_array($serverObject)) {
|
||||
return null;
|
||||
}
|
||||
$data = $serverObject['data'] ?? $serverObject;
|
||||
if (! empty($data['hostname']) && $data['hostname'] !== '-') {
|
||||
return (string) $data['hostname'];
|
||||
}
|
||||
if (! empty($data['name']) && $data['name'] !== '-') {
|
||||
return (string) $data['name'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
140
modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
Normal file
140
modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Public-DNS verification helper used for forward-confirmed reverse DNS (FCrDNS) checks.
|
||||
*
|
||||
* WHAT FCrDNS IS AND WHY IT MATTERS HERE
|
||||
* --------------------------------------
|
||||
* A PTR record by itself is easy to lie about — anyone who controls a reverse zone
|
||||
* can say "this IP is mail.example.com". Receivers defend against that by looking
|
||||
* UP the hostname the PTR claims and checking that its A/AAAA records point back
|
||||
* at the IP. That "two-way agreement" is FCrDNS.
|
||||
*
|
||||
* For mail deliverability in particular, a PTR without matching forward DNS is
|
||||
* worse than no PTR at all — some filters treat it as evidence of a compromised
|
||||
* host. The module enforces FCrDNS before every PTR write: if the user asks us
|
||||
* to set "mail.example.com" as the PTR for 1.2.3.4 but mail.example.com resolves
|
||||
* to something other than 1.2.3.4, we refuse.
|
||||
*
|
||||
* USES PUBLIC DNS, NOT POWERDNS
|
||||
* -----------------------------
|
||||
* This calls dns_get_record(), which hits the system's configured recursive
|
||||
* resolver. That's deliberate: the hostname in a PTR may live in a zone hosted
|
||||
* anywhere (client's own domain, another DNS provider, etc.) — not necessarily
|
||||
* in the PowerDNS instance we're managing. Using the recursive public view means
|
||||
* our verification matches what mail servers and other FCrDNS checkers actually
|
||||
* see downstream.
|
||||
*
|
||||
* CNAME FOLLOWING
|
||||
* ---------------
|
||||
* If the hostname is itself a CNAME, dns_get_record returns the CNAME record
|
||||
* (with DNS_CNAME flag) rather than auto-resolving to the ultimate A/AAAA. We
|
||||
* follow up to MAX_CNAME_DEPTH hops before giving up. The depth cap prevents
|
||||
* accidental infinite loops from misconfigured zones and bounds work per check.
|
||||
*
|
||||
* CACHING
|
||||
* -------
|
||||
* Keyed by md5(hostname|ip). A bad-A-record result lives in the cache just like
|
||||
* a good one, which means a client who fixes their forward DNS must wait up to
|
||||
* cacheTtl seconds before a retry succeeds. Documented in the admin settings
|
||||
* tooltip as the tradeoff for not hammering authoritative resolvers when a
|
||||
* user mashes the Save button while debugging.
|
||||
*/
|
||||
class Resolver
|
||||
{
|
||||
private const CACHE_PREFIX = 'pdns:resolve:';
|
||||
|
||||
/**
|
||||
* Maximum hops through a CNAME chain before we give up.
|
||||
* Real-world chains are usually 0-2 hops; 5 is generous headroom without
|
||||
* letting a loop run unbounded.
|
||||
*/
|
||||
private const MAX_CNAME_DEPTH = 5;
|
||||
|
||||
/**
|
||||
* Does the public DNS A/AAAA of $hostname resolve to $ip?
|
||||
* Follows up to 5 CNAME hops. Cached for $ttl seconds on the initial call.
|
||||
*/
|
||||
public static function resolvesTo(string $hostname, string $ip, int $ttl = 60): bool
|
||||
{
|
||||
$hostname = rtrim(trim($hostname), '.');
|
||||
if ($hostname === '' || ! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_PREFIX . md5($hostname . '|' . $ip);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return (bool) $cached;
|
||||
}
|
||||
|
||||
$match = self::resolveInternal($hostname, $ip, 0);
|
||||
Cache::set($cacheKey, $match ? 1 : 0, $ttl);
|
||||
|
||||
return $match;
|
||||
}
|
||||
|
||||
private static function resolveInternal(string $hostname, string $ip, int $depth): bool
|
||||
{
|
||||
if ($depth > self::MAX_CNAME_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Request both the matching forward type AND CNAME in one query so we see
|
||||
// the whole picture at each hop. If the hostname is a direct A/AAAA, we
|
||||
// see that and match immediately; if it's a CNAME, we see the target and
|
||||
// recurse.
|
||||
$type = IpUtil::isIpv6($ip) ? DNS_AAAA | DNS_CNAME : DNS_A | DNS_CNAME;
|
||||
$records = [];
|
||||
|
||||
try {
|
||||
// @-suppress: dns_get_record emits a PHP warning on NXDOMAIN, which we'd
|
||||
// rather just treat as "no match". The return value (empty array or false)
|
||||
// tells us the same thing without polluting the error log.
|
||||
$records = @dns_get_record($hostname, $type);
|
||||
} catch (\Throwable $e) {
|
||||
// Some PHP configurations throw on resolver failure instead of returning false.
|
||||
// We treat those as "no match" and log once per (hostname, ip) since callers
|
||||
// cache the result — we won't spam the log even for a permanently-broken name.
|
||||
Log::insert('PowerDns:Resolver', ['hostname' => $hostname, 'ip' => $ip], $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
if (! is_array($records)) {
|
||||
// dns_get_record returns false on resolver failure. Same semantics as above.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert target to binary once, outside the loop. inet_pton normalises
|
||||
// "2001:db8::1" and "2001:0db8:0000:0000:0000:0000:0000:0001" to the same
|
||||
// bytes, so we can compare regardless of how the resolver formatted its reply.
|
||||
$targetBin = @inet_pton($ip);
|
||||
foreach ($records as $r) {
|
||||
$t = $r['type'] ?? null;
|
||||
if ($t === 'CNAME') {
|
||||
// CNAME hop: recurse on the target. We don't use a visited-set to
|
||||
// detect cycles — MAX_CNAME_DEPTH is a simpler, sufficient guard.
|
||||
$next = $r['target'] ?? null;
|
||||
if ($next && self::resolveInternal(rtrim($next, '.'), $ip, $depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A records expose the address under 'ip', AAAA records under 'ipv6'.
|
||||
// Only one of these will be set per record; the other is null.
|
||||
$candidate = $r['ip'] ?? ($r['ipv6'] ?? null);
|
||||
if ($candidate && $targetBin !== false && @inet_pton($candidate) === $targetBin) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -471,3 +471,77 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Reverse DNS panel
|
||||
========================================================================= */
|
||||
.vf-rdns-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,.06);
|
||||
}
|
||||
.vf-rdns-row:last-child { border-bottom: none; }
|
||||
.vf-rdns-ip {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
min-width: 180px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vf-rdns-edit {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 240px;
|
||||
}
|
||||
.vf-rdns-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 180px;
|
||||
max-width: 420px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vf-rdns-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .02em;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vf-rdns-msg {
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
padding-left: 180px;
|
||||
}
|
||||
.vf-rdns-admin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vf-rdns-ip-admin {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
min-width: 180px;
|
||||
}
|
||||
.vf-rdns-ptr-admin {
|
||||
font-family: monospace;
|
||||
color: #333;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.vf-rdns-row { flex-direction: column; align-items: stretch; }
|
||||
.vf-rdns-edit { flex-direction: column; align-items: stretch; }
|
||||
.vf-rdns-msg { padding-left: 0; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,82 @@
|
||||
/**
|
||||
* VirtFusion Direct Provisioning Module - Client JavaScript
|
||||
*
|
||||
* Handles client-side interactions for server management including:
|
||||
* ========================================================================
|
||||
* ARCHITECTURE
|
||||
* ========================================================================
|
||||
*
|
||||
* This file is the single client-side script that powers both:
|
||||
* - The client area (service overview panel, loaded on every service page)
|
||||
* - The admin services tab (server info + rDNS widget)
|
||||
*
|
||||
* It uses vanilla JS + jQuery. jQuery is available because WHMCS's built-in
|
||||
* admin UI depends on it; we inherit that dependency rather than adding a
|
||||
* new one. The order form hooks (keygen.js, OS-gallery injector in hooks.php)
|
||||
* use vanilla JS only because those run on pre-auth checkout pages where
|
||||
* jQuery availability varies by theme.
|
||||
*
|
||||
* CONVENTION: every function is prefixed with "vf" to avoid collisions with
|
||||
* whatever else the page loads. Internal helpers start with "_vf".
|
||||
*
|
||||
* ========================================================================
|
||||
* SECTIONS (roughly in order below)
|
||||
* ========================================================================
|
||||
*
|
||||
* Shared Helpers — vfUrl, vfShowAlert
|
||||
* Progress Indicator — vfShowProgress / vfHideProgress
|
||||
* Server Data Display — vfServerData, vfServerDataAdmin
|
||||
* Power Management — vfPowerAction
|
||||
* SSO Login — vfLoginAsServerOwner
|
||||
* Password Reset — vfUserPasswordReset, vfResetServerPassword
|
||||
* Server Rebuild — vfRebuildServer, vfLoadOsTemplates, vfRenderOsGallery
|
||||
* Server Rename — vfRenameServer, vfShowNameDropdown
|
||||
* Traffic / Backups — vfLoadTrafficStats, vfDrawTrafficChart, vfLoadBackups
|
||||
* VNC Console — vfOpenVnc, vfToggleVnc
|
||||
* Self-Service Billing — vfLoadSelfServiceUsage, vfAddCredit
|
||||
* Reverse DNS (PowerDNS) — vfLoadRdns, vfRenderRdnsPanel, vfUpdateRdns,
|
||||
* vfAdminLoadRdns, vfAdminReconcileRdns
|
||||
*
|
||||
* ========================================================================
|
||||
* AJAX REQUEST SHAPE
|
||||
* ========================================================================
|
||||
*
|
||||
* URL: {systemUrl}modules/servers/VirtFusionDirect/{endpoint}.php
|
||||
* ?serviceID={id}&action={action}
|
||||
* where endpoint is "client" (default) or "admin".
|
||||
*
|
||||
* Method: GET for reads, POST for writes (server-side requirePost() gate
|
||||
* enforces this for rDNS mutations; other mutations rely on $_POST
|
||||
* being empty for GET → validation fails naturally).
|
||||
*
|
||||
* Response:
|
||||
* { success: true, data: { ... } }
|
||||
* { success: false, errors: "human message" }
|
||||
*
|
||||
* ========================================================================
|
||||
* ERROR HANDLING
|
||||
* ========================================================================
|
||||
*
|
||||
* Every AJAX call handles three outcomes:
|
||||
* 1. Network failure (.fail) → show a generic error in the panel's alert div
|
||||
* 2. Server returned success:false → show response.errors to the user
|
||||
* 3. Server returned success:true → render data into the DOM
|
||||
*
|
||||
* Error text ALWAYS comes from the server (we don't invent user-facing error
|
||||
* copy client-side). That way a server-side change to error phrasing
|
||||
* propagates everywhere without JS changes.
|
||||
*
|
||||
* ========================================================================
|
||||
* DOM UPDATE PATTERNS
|
||||
* ========================================================================
|
||||
*
|
||||
* Read actions render into named containers with id="vf-data-*".
|
||||
* Status badges use CSS classes "vf-badge-*" for color coding.
|
||||
* Text content is always set via .text() not .html() to prevent XSS
|
||||
* from whatever the API returned. Exception: panels built entirely
|
||||
* from server-trusted structured data use .append() with new jQuery
|
||||
* elements, not string concatenation.
|
||||
*
|
||||
* Handles client-side interactions for:
|
||||
* - Server data display
|
||||
* - Power management (boot, shutdown, restart, power off)
|
||||
* - Control panel login (SSO)
|
||||
@@ -12,6 +87,7 @@
|
||||
* - Backup listing
|
||||
* - VNC management
|
||||
* - Server naming
|
||||
* - Reverse DNS (PowerDNS addon)
|
||||
*/
|
||||
|
||||
// =========================================================================
|
||||
@@ -1011,3 +1087,196 @@ function vfCopyButton(text) {
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Reverse DNS (PowerDNS)
|
||||
// =========================================================================
|
||||
//
|
||||
// Feature gate: this section only activates when the VirtFusionDns addon is
|
||||
// installed AND enabled. The PHP side renders the rDNS panel in overview.tpl
|
||||
// only when $rdnsEnabled is true; if the panel isn't in the DOM, these
|
||||
// functions are never called.
|
||||
//
|
||||
// Admin-side counterparts (vfAdminLoadRdns, vfAdminReconcileRdns) target
|
||||
// admin.php instead of client.php and are used by the rdnsSection() admin
|
||||
// widget rendered via AdminHTML::rdnsSection().
|
||||
//
|
||||
// Status badge colours match what most operators expect:
|
||||
// OK (green) = PTR present, forward DNS agrees (FCrDNS passes)
|
||||
// unverified (amber) = PTR present but forward DNS no longer agrees
|
||||
// missing (gray) = No PTR exists yet
|
||||
// no-zone (red) = The IP's reverse zone isn't hosted in PowerDNS
|
||||
// error (red) = PowerDNS unreachable or similar
|
||||
//
|
||||
// The server-side always decides the status; we just colour it.
|
||||
|
||||
/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */
|
||||
var VF_RDNS_STATUS = {
|
||||
"ok": { label: "OK", bg: "#28a745", fg: "#fff" },
|
||||
"unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" },
|
||||
"missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" },
|
||||
"no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" },
|
||||
"error": { label: "error", bg: "#dc3545", fg: "#fff" },
|
||||
"disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" }
|
||||
};
|
||||
|
||||
function vfRdnsBadge(status) {
|
||||
var s = VF_RDNS_STATUS[status] || VF_RDNS_STATUS["error"];
|
||||
var span = $('<span class="vf-rdns-badge"></span>');
|
||||
span.text(s.label);
|
||||
span.css({ background: s.bg, color: s.fg });
|
||||
return span;
|
||||
}
|
||||
|
||||
function vfLoadRdns(serviceId, systemUrl) {
|
||||
var list = $("#vf-rdns-list");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsList"),
|
||||
method: "GET",
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (!resp || !resp.success) {
|
||||
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
|
||||
return;
|
||||
}
|
||||
if (!resp.data.enabled) {
|
||||
list.closest(".panel").hide();
|
||||
return;
|
||||
}
|
||||
vfRenderRdnsPanel(serviceId, systemUrl, resp.data.ips || []);
|
||||
}).fail(function () {
|
||||
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
|
||||
});
|
||||
}
|
||||
|
||||
function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
|
||||
var list = $("#vf-rdns-list");
|
||||
list.empty();
|
||||
if (!ips.length) {
|
||||
list.html('<div class="text-muted">No IP addresses assigned to this server yet.</div>');
|
||||
return;
|
||||
}
|
||||
ips.forEach(function (row) {
|
||||
var wrap = $('<div class="vf-rdns-row"></div>');
|
||||
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
||||
var badge = vfRdnsBadge(row.status);
|
||||
|
||||
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="host.example.com (blank to delete)">');
|
||||
input.val(row.ptr || "");
|
||||
|
||||
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
|
||||
var msg = $('<div class="vf-rdns-msg"></div>');
|
||||
|
||||
saveBtn.on("click", function () {
|
||||
vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge);
|
||||
});
|
||||
input.on("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
|
||||
});
|
||||
|
||||
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
|
||||
wrap.append(ipLabel).append(editor).append(badge).append(msg);
|
||||
list.append(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge) {
|
||||
var ptr = (input.val() || "").trim();
|
||||
// Light client-side regex mirrors the server-side one — strict enforcement is on the server.
|
||||
if (ptr !== "" && !/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/.test(ptr)) {
|
||||
msg.text("Invalid hostname.").css("color", "#dc3545").show();
|
||||
return;
|
||||
}
|
||||
saveBtn.prop("disabled", true);
|
||||
msg.hide();
|
||||
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsUpdate"),
|
||||
method: "POST",
|
||||
data: { ip: ip, ptr: ptr },
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
saveBtn.prop("disabled", false);
|
||||
if (resp && resp.success) {
|
||||
var verb = (ptr === "") ? "deleted" : "saved";
|
||||
msg.text("rDNS " + verb + ".").css("color", "#28a745").show();
|
||||
setTimeout(function () { msg.fadeOut(); }, 2500);
|
||||
// Optimistically update the badge; a background refresh will correct it.
|
||||
if (ptr === "") {
|
||||
badge.replaceWith(vfRdnsBadge("missing"));
|
||||
} else {
|
||||
badge.replaceWith(vfRdnsBadge("ok"));
|
||||
}
|
||||
} else {
|
||||
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
|
||||
msg.text(err).css("color", "#dc3545").show();
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
saveBtn.prop("disabled", false);
|
||||
var err = "Save failed.";
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
if (r && r.errors) err = r.errors;
|
||||
} catch (e) {}
|
||||
msg.text(err).css("color", "#dc3545").show();
|
||||
});
|
||||
}
|
||||
|
||||
// Admin-side wrappers — different endpoint ("admin"), no ownership check on server side.
|
||||
|
||||
function vfAdminLoadRdns(serviceId, systemUrl) {
|
||||
var list = $("#vf-rdns-list");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsStatus", "admin"),
|
||||
method: "GET",
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (!resp || !resp.success) {
|
||||
list.html('<em class="text-muted">Unable to load PTR state.</em>');
|
||||
return;
|
||||
}
|
||||
if (!resp.data.enabled) {
|
||||
list.html('<em class="text-muted">Reverse DNS addon is not activated.</em>');
|
||||
return;
|
||||
}
|
||||
list.empty();
|
||||
if (!resp.data.ips.length) {
|
||||
list.html('<em class="text-muted">No IPs assigned.</em>');
|
||||
return;
|
||||
}
|
||||
resp.data.ips.forEach(function (row) {
|
||||
var line = $('<div class="vf-rdns-admin-row"></div>');
|
||||
$('<span class="vf-rdns-ip-admin"></span>').text(row.ip).appendTo(line);
|
||||
$('<span class="vf-rdns-ptr-admin"></span>').text(row.ptr || "(no PTR)").appendTo(line);
|
||||
vfRdnsBadge(row.status).appendTo(line);
|
||||
list.append(line);
|
||||
});
|
||||
}).fail(function () {
|
||||
list.html('<em class="text-muted">Unable to load PTR state.</em>');
|
||||
});
|
||||
}
|
||||
|
||||
function vfAdminReconcileRdns(serviceId, systemUrl, force) {
|
||||
var out = $("#vf-rdns-report");
|
||||
out.text("Reconciling…").css("color", "#555");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsReconcile", "admin"),
|
||||
method: "POST",
|
||||
data: { force: force ? 1 : 0 },
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (resp && resp.success) {
|
||||
var s = resp.data;
|
||||
var parts = [];
|
||||
["added", "reset", "preserved", "forward_missing", "no_zone", "errors"].forEach(function (k) {
|
||||
if (s[k] > 0) parts.push(k + "=" + s[k]);
|
||||
});
|
||||
out.text(parts.length ? parts.join(" ") : "no changes needed").css("color", "#28a745");
|
||||
vfAdminLoadRdns(serviceId, systemUrl);
|
||||
} else {
|
||||
out.text((resp && resp.errors) ? resp.errors : "Reconcile failed").css("color", "#dc3545");
|
||||
}
|
||||
}).fail(function () {
|
||||
out.text("Reconcile failed").css("color", "#dc3545");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,6 +237,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if $rdnsEnabled}
|
||||
{* Reverse DNS Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Reverse DNS</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<p class="vf-small text-muted mb-3">Set a custom PTR record for each assigned IP. Forward DNS (A/AAAA) for the hostname must already resolve to the IP before the PTR can be saved.</p>
|
||||
<div id="vf-rdns-alert" class="alert" style="display:none;"></div>
|
||||
<div id="vf-rdns-list">
|
||||
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||
</div>
|
||||
<script>
|
||||
if (typeof vfLoadRdns === 'function') {
|
||||
vfLoadRdns('{$serviceid}', '{$systemURL}');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{* Resources Panel — populated by JS after server data loads *}
|
||||
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div class="panel-heading card-header">
|
||||
|
||||
Reference in New Issue
Block a user