Files
virtfusion-whmcs-module/modules/servers/VirtFusionDirect/client.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

603 lines
23 KiB
PHP

<?php
require dirname(__DIR__, 3) . '/init.php';
/**
* Client-facing AJAX API endpoint.
*
* 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;
try {
$vf->isAuthenticated();
$action = $vf->validateAction(true);
switch ($action) {
/**
* Reset Password.
*/
case 'resetPassword':
$serviceID = $vf->validateServiceID(true);
$client = $vf->validateUserOwnsService($serviceID);
if (! $client) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$data = $vf->resetUserPassword($serviceID, $client);
if ($data) {
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
break;
/**
* Get server information.
*/
case 'serverData':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$data = $vf->fetchServerData($serviceID);
if ($data) {
$vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
$vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
break;
/**
* Login as server owner.
*/
case 'loginAsServerOwner':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$token = $vf->fetchLoginTokens($serviceID);
if ($token) {
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
break;
/**
* Power management actions: boot, shutdown, restart, poweroff
*/
case 'powerAction':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
if (! in_array($powerAction, $allowedActions, true)) {
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
break;
}
$result = $vf->serverPowerAction($serviceID, $powerAction);
if ($result) {
$vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500);
break;
/**
* Rebuild/reinstall server with new OS.
*/
case 'rebuild':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
if ($osId <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
break;
}
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Server rebuild failed. The server may be locked or unavailable.'], true, true, 500);
break;
/**
* Rename server.
*/
case 'rename':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
if (empty($newName) || strlen($newName) > 63 || ! preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) {
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
break;
}
$result = $vf->renameServer($serviceID, $newName);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
break;
/**
* Get available OS templates for rebuild.
*/
case 'osTemplates':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$templates = $vf->fetchOsTemplates($serviceID);
if ($templates !== false) {
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
break;
// =================================================================
// Server Password Reset
// =================================================================
/**
* Reset server root password.
*/
case 'resetServerPassword':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->resetServerPassword($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
break;
// =================================================================
// Backup Listing
// =================================================================
/**
* Get server backups.
*/
case 'backups':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getServerBackups($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve backups'], true, true, 500);
break;
// =================================================================
// Traffic Statistics
// =================================================================
/**
* Get traffic statistics for a server.
*/
case 'trafficStats':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getTrafficStats($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve traffic statistics'], true, true, 500);
break;
// =================================================================
// VNC Console
// =================================================================
/**
* Get VNC console URL.
*/
case 'vnc':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getVncConsole($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
break;
/**
* Toggle VNC on/off.
*/
case 'toggleVnc':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
$result = $vf->toggleVnc($serviceID, $enabled);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Failed to toggle VNC'], true, true, 500);
break;
// =================================================================
// Self Service — Credit & Usage
// =================================================================
/**
* Get self-service usage data.
*/
case 'selfServiceUsage':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getSelfServiceUsage($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500);
break;
/**
* Get self-service billing report.
*/
case 'selfServiceReport':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getSelfServiceReport($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
break;
/**
* Add self-service credit.
*/
case 'selfServiceAddCredit':
$serviceID = $vf->validateServiceID(true);
if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0;
if ($tokens <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
break;
}
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$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);
}
} catch (Exception $e) {
Log::insert('client.php', [], $e->getMessage());
$vf->output(['success' => false, 'errors' => 'An unexpected error occurred'], true, true, 500);
}