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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user