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>
180 lines
6.9 KiB
PHP
180 lines
6.9 KiB
PHP
<?php
|
|
|
|
require dirname(__DIR__, 3) . '/init.php';
|
|
|
|
/**
|
|
* Admin-facing AJAX API endpoint.
|
|
*
|
|
* 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;
|
|
|
|
try {
|
|
|
|
$vf->adminOnly();
|
|
|
|
switch ($vf->validateAction(true)) {
|
|
|
|
/**
|
|
* Get server information.
|
|
*/
|
|
case 'serverData':
|
|
|
|
$serviceID = $vf->validateServiceID(true);
|
|
|
|
$whmcsService = Database::getWhmcsService($serviceID);
|
|
|
|
if (! $whmcsService) {
|
|
$vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 404);
|
|
break;
|
|
}
|
|
|
|
if (in_array($whmcsService->domainstatus, ['Pending', 'Terminated', 'Cancelled', 'Fraud'], true)) {
|
|
$vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 400);
|
|
break;
|
|
}
|
|
|
|
$data = $vf->fetchServerData($serviceID);
|
|
|
|
if (! $data) {
|
|
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 502);
|
|
break;
|
|
}
|
|
|
|
$vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
|
|
$vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200);
|
|
break;
|
|
|
|
/**
|
|
* Impersonate server owner.
|
|
*/
|
|
case 'impersonateServerOwner':
|
|
|
|
$serviceID = $vf->validateServiceID(true);
|
|
|
|
$service = Database::getSystemService($serviceID);
|
|
if (! $service) {
|
|
$vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 404);
|
|
break;
|
|
}
|
|
|
|
$whmcsService = Database::getWhmcsService($serviceID);
|
|
if (! $whmcsService) {
|
|
$vf->output(['success' => false, 'errors' => 'WHMCS service not found'], true, true, 404);
|
|
break;
|
|
}
|
|
|
|
$cp = $vf->getCP($whmcsService->server);
|
|
if (! $cp) {
|
|
$vf->output(['success' => false, 'errors' => 'Control server not found'], true, true, 500);
|
|
break;
|
|
}
|
|
|
|
$request = $vf->initCurl($cp['token']);
|
|
$data = $request->get($cp['url'] . '/users/' . (int) $whmcsService->userid . '/byExtRelation');
|
|
|
|
if ($request->getRequestInfo('http_code') === 200) {
|
|
$vf->output(['success' => true, 'url' => $cp['base_url'], 'user' => json_decode($data, true)['data']], true, true, 200);
|
|
break;
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
} catch (Exception $e) {
|
|
Log::insert('admin.php', [], $e->getMessage());
|
|
$vf->output(['success' => false, 'errors' => 'An unexpected error occurred'], true, true, 500);
|
|
}
|