Files
virtfusion-whmcs-module/modules/servers/VirtFusionDirect/admin.php
Prophet731 ad85439dfb 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>
2026-04-17 21:08:22 -04:00

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