diff --git a/CLAUDE.md b/CLAUDE.md
index 33b17d4..d3ca316 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,10 +45,11 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene
| File | Purpose |
|------|---------|
-| `VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes |
-| `client.php` | Client-facing AJAX API — authenticated by WHMCS session + service ownership validation. POST for mutations, GET for reads. |
-| `admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication |
-| `hooks.php` | WHMCS hooks — checkout validation (OS selection), OS gallery + SSH key UI injection, slider UI for configurable options |
+| `modules/servers/VirtFusionDirect/VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes |
+| `modules/servers/VirtFusionDirect/client.php` | Client-facing AJAX API — authenticated by WHMCS session + service ownership validation. POST for mutations, GET for reads. |
+| `modules/servers/VirtFusionDirect/admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication |
+| `modules/servers/VirtFusionDirect/hooks.php` | WHMCS hooks — checkout validation (OS selection), OS gallery + SSH key UI injection, slider UI for configurable options, daily PowerDNS reconciliation |
+| `modules/addons/VirtFusionDns/VirtFusionDns.php` | Optional companion addon — holds PowerDNS settings and provides a Test Connection admin page. See "Reverse DNS (PowerDNS)" below. |
### Core Classes (in `lib/`)
@@ -60,9 +61,14 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene
| `Database` | Static methods for `mod_virtfusion_direct` table operations and WHMCS DB queries. Auto-creates/migrates schema on first use. |
| `Curl` | HTTP client wrapper with Bearer token auth, SSL verification, 30s timeout. Methods: `get`, `post`, `put`, `patch`, `delete`. Single-use — each instance makes one request. |
| `Cache` | Two-tier caching: Redis (if `ext-redis` available) with atomic filesystem fallback. TTLs: OS templates 10min, traffic/backups 2min, packages 10min. |
-| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. |
-| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons). |
+| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. Only reads `interfaces[0]`; for rDNS use `PowerDns\IpUtil::extractIps()` which walks all interfaces. |
+| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons, `rdnsSection()` widget). |
| `Log` | Thin wrapper around WHMCS module logging. |
+| `PowerDns\Client` | PowerDNS HTTP API wrapper (`X-API-Key` auth): `ping`, `listZones`, `getZone`, `patchRRset`, `notifyZone`. PATCH success triggers an automatic NOTIFY so slaves pick up the SOA bump immediately. |
+| `PowerDns\Config` | Loads settings from `tbladdonmodules` (module="virtfusiondns") and decrypts `apiKey` via WHMCS `decrypt()`. `isEnabled()` gates every PowerDNS call site. |
+| `PowerDns\IpUtil` | Pure helpers: `ptrNameForIp` (v4/v6 nibble reversal), `expandIpv6`, `extractIps` (all interfaces), `findZoneAndPtrName` (standard + RFC 2317 classless), `parseClasslessZone`. |
+| `PowerDns\Resolver` | Forward-DNS verification via `dns_get_record()` with up-to-5-hop CNAME following. Cached per (hostname, ip) pair. |
+| `PowerDns\PtrManager` | Orchestrator: `syncServer`, `deleteForServer`, `listPtrs`, `setPtr`, `reconcile`, `reconcileAll`. Per-request zone cache. 10s per-IP write rate limit. Enforces FCrDNS before writes. |
### Class Hierarchy
@@ -89,10 +95,41 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene
4. Dry-run validation → actual API POST to `/servers`
5. Stores server ID in `mod_virtfusion_direct` table
6. Updates WHMCS hosting record (IP, username, password, domain)
-7. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key
+7. If the PowerDNS addon is enabled, calls `PowerDns\PtrManager::syncServer()` to write PTRs (non-blocking; failures log but never fail provisioning)
+8. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key
Custom fields (`Initial Operating System`, `Initial SSH Key`) are auto-created by `Database::ensureCustomFields()` on module load for all products using this module. No manual SQL setup required.
+### Reverse DNS (PowerDNS)
+
+Opt-in integration via the companion `VirtFusionDns` addon module. Loose-coupled: the server module never requires addon code at runtime; it queries the addon's `tbladdonmodules` row and short-circuits when `enabled=0` or the addon isn't activated. Activate via WHMCS Admin → Addon Modules → VirtFusion DNS.
+
+**Settings** (`tbladdonmodules`, module="virtfusiondns"): `enabled` (yesno), `endpoint` (e.g. `https://ns1.example.com:8081`), `apiKey` (encrypted by WHMCS), `serverId` (usually `localhost`), `defaultTtl` (3600), `cacheTtl` (60).
+
+**Lifecycle hooks:**
+- `createAccount` → sync PTRs to server hostname (forward DNS must match before each write)
+- `renameServer` → update only PTRs whose current content equals the old hostname (preserves client-custom PTRs)
+- `terminateAccount` → delete every PTR before `Database::deleteSystemService()`
+- `VirtFusionDirect_TestConnection` → merged VirtFusion + PowerDNS health check
+- `DailyCronJob` → `PtrManager::reconcileAll()` — additive-only (never overwrites)
+
+**Client-facing actions** (`client.php`): `rdnsList`, `rdnsUpdate`. Admin (`admin.php`): `rdnsStatus`, `rdnsReconcile` (accepts `force=1` for explicit reset).
+
+**Client UI:** Reverse DNS panel in `templates/overview.tpl` (rendered by `vfLoadRdns()` / `vfRenderRdnsPanel()` / `vfUpdateRdns()` in `module.js`). Admin services tab gets a status widget via `AdminHTML::rdnsSection()`.
+
+**FCrDNS rule:** Every PTR write (auto or client-initiated) requires the hostname's forward DNS (A/AAAA) to already resolve to the target IP. On mismatch, auto-sync logs and skips; client edits return a 400 with guidance.
+
+**Zone handling:** Zones are operator-managed — the module never creates zones. Zone discovery uses `GET /zones` (cached for `cacheTtl`) + longest-suffix match. RFC 2317 classless delegations (`X/Y.octet.octet.octet.in-addr.arpa.`) are supported: both CIDR-prefix (`0/26`) and block-size (`64/64`) conventions are parsed, and PTRs are written with the classless sub-zone label in the record name.
+
+**SOA / NOTIFY:** PowerDNS auto-bumps SOA serials when `soa_edit_api=INCREASE` is set on the zone. After every successful PATCH the module issues an explicit `PUT /zones/{id}/notify` so slaves refresh immediately rather than waiting for the next scheduled poll.
+
+**Safety properties:**
+- PowerDNS failures never block VirtFusion operations (try/catch at every call site)
+- Cron is additive-only — never auto-overwrites a PTR
+- Admin Reconcile button supports `force=1` for explicit reset to hostname
+- Client edits are IP-ownership-checked against a *fresh* VirtFusion fetch (not cached `server_object`), defending against reassigned-IP stale-ownership
+- Per-IP write rate limit (10s, via `Cache`) prevents save-button abuse
+
### Configurable Option Mapping
Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`.
@@ -116,6 +153,16 @@ Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from
- **Self-service billing:** Requires self-service feature enabled in VirtFusion
- **OS icon path:** `{baseUrl}/img/logo/{icon_filename}` (public, no auth required)
+## PowerDNS API Compatibility
+
+- **API reference:** https://doc.powerdns.com/authoritative/http-api/
+- **Tested against:** PowerDNS Authoritative 4.8+
+- **Auth:** `X-API-Key` header (not Bearer)
+- **Required endpoints:** `GET /servers/{id}`, `GET /servers/{id}/zones`, `GET /servers/{id}/zones/{zone}`, `PATCH /servers/{id}/zones/{zone}`, `PUT /servers/{id}/zones/{zone}/notify`
+- **Zone ID URL encoding:** `/` in zone names (RFC 2317) must be encoded as `=2F` not `%2F` — handled by `Client::zoneIdEncode()`
+- **`api-allow-from`:** must include the WHMCS host's IP (PowerDNS's own ACL)
+- **Recommended zone config:** `soa_edit_api: INCREASE` for automatic serial bumping on API-driven changes
+
## Product Config Options
| Option | Name | Description | Default |
diff --git a/README.md b/README.md
index acf349f..800166d 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co
- [Module Configuration Options](#module-configuration-options)
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
- [Custom Option Name Mapping](#custom-option-name-mapping)
+ - [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
- [Client Area Features](#client-area-features)
- [Admin Area Features](#admin-area-features)
- [Theme Compatibility](#theme-compatibility)
@@ -106,6 +107,17 @@ You also need a VirtFusion API token with the following permissions:
- Auto top-off via WHMCS cron when credit falls below threshold
- Self-service mode configurable per product (Hourly, Resource Packs, or Both)
+### Reverse DNS (Optional PowerDNS Addon)
+- **Automatic PTR sync** on server create, rename, and terminate
+- **Client-editable rDNS** panel in the service overview — one input per assigned IP
+- **Forward-confirmed reverse DNS (FCrDNS)** — every PTR write requires the hostname's A/AAAA to already resolve to the IP; mismatches are rejected with a clear error
+- **IPv4 + IPv6** support out of the box (IPv6 nibble-reversal, `.ip6.arpa` zones)
+- **RFC 2317 classless delegation** — supports both CIDR-prefix (`0/26`) and block-size (`64/64`) zone naming conventions
+- **Admin reconciliation** — a "Reconcile" button on the services tab and an additive-only daily cron that creates any missing PTRs
+- **Client-custom PTRs preserved across renames** — only PTRs whose content matches the previous hostname get rewritten
+- **Auto NOTIFY + SOA bump** so slaves pick up changes immediately (when `soa_edit_api=INCREASE` is set on the zone)
+- **Opt-in** via a companion WHMCS addon module — no impact on existing provisioning if not activated
+
## Installation
```bash
@@ -118,15 +130,21 @@ Then configure in WHMCS Admin:
1. **Add Server** — Configuration > System Settings > Servers > Add New Server. Set hostname to your VirtFusion panel (e.g. `cp.example.com`), type to "VirtFusion Direct Provisioning", and paste your API token in the Password field. Click **Test Connection** to verify.
2. **Create Product** — Configuration > System Settings > Products/Services. On the Module Settings tab, select "VirtFusion Direct Provisioning", choose your server, and set the Hypervisor Group ID, Package ID, and Default IPv4 count.
+3. *(Optional)* **Install the Reverse DNS Addon** — also sync the `modules/addons/VirtFusionDns/` directory if you want PowerDNS-backed rDNS management. See [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns) below for activation and configuration.
That's it. Hooks activate automatically and custom fields are created on module load.
## Upgrading
```bash
-git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf && rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/ && rm -rf /tmp/vf
+git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
+ && rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/ \
+ && rsync -ahP --delete /tmp/vf/modules/addons/VirtFusionDns/ /path/to/whmcs/modules/addons/VirtFusionDns/ \
+ && rm -rf /tmp/vf
```
+The second `rsync` line is only needed if you use the Reverse DNS addon; skip it otherwise. Addon settings live in `tbladdonmodules` and survive file updates.
+
> **Note:** If you have a custom `config/ConfigOptionMapping.php`, back it up first — `--delete` will remove it. Restore it after upgrading.
If you use theme-overridden templates, review them for any new template variables. Clear the WHMCS template cache after upgrading: **Configuration > System Settings > General Settings > clear template cache**.
@@ -208,6 +226,53 @@ return [
];
```
+### Reverse DNS Addon (PowerDNS)
+
+Optional. Activate the `VirtFusionDns` addon module to let the provisioning module manage PTR records in a PowerDNS instance automatically (and expose an rDNS editor to clients).
+
+**Prerequisites:**
+- PowerDNS Authoritative 4.x with the HTTP API enabled (`webserver=yes`, `api=yes`, and an `api-key=...` set)
+- `api-allow-from=` must include the IP of your WHMCS host
+- **All reverse zones you intend to use must already exist in PowerDNS.** The addon never creates zones; it only PATCHes PTR RRsets into zones that are already delegated to your nameservers.
+- Zones should have `soa_edit_api=INCREASE` (or similar) so PowerDNS auto-bumps the SOA serial on API writes. The addon additionally calls `PUT /zones/{id}/notify` after every PATCH to push changes to slaves immediately.
+
+**Activation:**
+
+1. Copy the addon into your WHMCS install (see the Installation section for the `rsync` command).
+2. In WHMCS Admin → **System Settings → Addon Modules**, find **VirtFusion DNS** and click **Activate**. Grant admin role access as needed.
+3. Click **Configure** and fill in:
+
+ | Field | Meaning |
+ |---|---|
+ | **Enable rDNS Sync** | Master switch. When off, every PowerDNS call short-circuits — the provisioning module behaves exactly as before the addon. |
+ | **PowerDNS API Endpoint** | Scheme + host + port, no path (e.g. `https://ns1.example.com:8081` or `http://10.0.0.5:8081`). The module appends `/api/v1/…` itself. |
+ | **PowerDNS API Key** | Password-type field. Encrypted at rest by WHMCS; decrypted server-side only when PowerDNS is called. |
+ | **PowerDNS Server ID** | Almost always `localhost` — the PowerDNS API server identifier, not a hostname. |
+ | **Default PTR TTL** | Applied to every PTR record the module creates. Default 3600. |
+ | **Cache TTL** | How long zone listings and DNS-resolution lookups are cached. Default 60, minimum 10. |
+
+4. Click **Save Changes**.
+5. Open the addon's admin page (same menu, usually **Addons → VirtFusion DNS**) and click **Run Test**. You should see "OK — PowerDNS reachable and authenticated" followed by a list of visible zones. If you don't see your expected reverse zones here, the module won't find them either — fix PowerDNS first.
+
+**How it behaves:**
+
+| Event | Behavior |
+|---|---|
+| Server provisioning | Creates a PTR for every assigned IP pointing to the VirtFusion hostname — but only if that hostname's A/AAAA already resolves to the IP. Forward-missing IPs are logged and skipped (provisioning still succeeds). |
+| Server rename (via client or admin) | Rewrites only PTRs whose current content equals the previous hostname. Client-customised PTRs are preserved. |
+| Server termination | Deletes every PTR belonging to the server before the local record is purged. |
+| Client edits PTR in the Reverse DNS panel | Validates IP ownership (cross-checked against a fresh VirtFusion fetch), PTR regex, per-IP 10-second rate limit, and forward-DNS match. Empty value deletes. |
+| Daily cron | Creates PTRs for IPs that don't have one yet (and whose forward DNS resolves correctly). **Additive-only — never overwrites.** |
+| Admin "Reconcile (force reset)" button | The only code path that overwrites a non-matching PTR — explicit admin action. |
+
+**RFC 2317 classless delegations** are supported: the module parses zones like `64/64.38.186.66.in-addr.arpa.` (both CIDR-prefix and block-size conventions), matches IPs by range rather than suffix, and writes PTRs with the correct classless RRset name. The PowerDNS URL-safe zone ID encoding (`/` → `=2F`) is handled transparently.
+
+**Security posture:**
+- PowerDNS integration is **opt-in** — if the addon is deactivated or `Enable rDNS Sync` is off, the provisioning module behaves exactly as before.
+- Every client-facing rDNS endpoint validates service ownership and re-verifies the IP is currently assigned to the requesting user's server (defends against stale-ownership after IP reassignment).
+- The API key is stored encrypted in `tbladdonmodules` by WHMCS; it is never logged.
+- DNS write failures never block VirtFusion operations — provisioning, rename, and termination all succeed regardless of PowerDNS state, and errors are recorded in the WHMCS Module Log for review.
+
## Client Area Features
### Server Overview
@@ -250,6 +315,14 @@ Four power control buttons:
- Registration and next due dates
- Payment method
+### Reverse DNS *(requires the VirtFusion DNS addon)*
+A panel listing every IP assigned to the server with an inline editor for the PTR record:
+- One input per IP — populate to set a custom PTR, leave blank to delete
+- Per-row status badge (OK / unverified / no PTR / no zone / error)
+- Saves are rate-limited to one write per IP per 10 seconds
+- Forward DNS must already resolve to the IP; mismatches show an inline error guiding the client to fix their A/AAAA first
+- Hidden entirely when the addon is not activated
+
## Admin Area Features
### Admin Services Tab
@@ -258,6 +331,7 @@ When viewing a service in WHMCS admin, the module adds:
- **Server Info** - Button to load live data from VirtFusion API
- **Server Object** - Full JSON response viewer
- **Options** - Admin impersonation link
+- **Reverse DNS** *(when the VirtFusion DNS addon is activated)* - Live per-IP PTR status plus **Reconcile (additive)** and **Reconcile (force reset)** buttons
### Module Commands (Admin Buttons)
- **Create** - Provision a new server
@@ -357,6 +431,18 @@ WHMCS automatically loads theme-specific templates when they exist. Copy the ori
| `PUT` | `/servers/{id}/modify/traffic` | Modify traffic (v6.0.0+) |
| `POST/DELETE` | `/servers/{id}/backup/plan` | Backup plan management (v4.3.0+) |
+### PowerDNS (Reverse DNS addon, PowerDNS Authoritative 4.x+)
+
+| Method | Endpoint | Purpose |
+|---|---|---|
+| `GET` | `/api/v1/servers/{id}` | Health check (Test Connection button) |
+| `GET` | `/api/v1/servers/{id}/zones` | Zone discovery (cached per `cacheTtl`) |
+| `GET` | `/api/v1/servers/{id}/zones/{zone}` | Fetch current RRsets for status + reads |
+| `PATCH` | `/api/v1/servers/{id}/zones/{zone}` | Create / replace / delete PTR RRsets |
+| `PUT` | `/api/v1/servers/{id}/zones/{zone}/notify` | NOTIFY slaves after every successful PATCH |
+
+Authentication is via the `X-API-Key` header (configured in the addon). Zone IDs containing `/` (RFC 2317 classless) are URL-encoded as `=2F` per PowerDNS convention.
+
## Usage Update (Cron)
The module implements the `UsageUpdate` function that is called by the WHMCS daily cron. It automatically syncs:
@@ -480,7 +566,7 @@ modules/servers/VirtFusionDirect/
VirtFusionDirect.php # WHMCS module entry point (MetaData, ConfigOptions, all module functions)
client.php # Client-facing AJAX API (authenticated, ownership-validated)
admin.php # Admin-facing AJAX API (admin authentication required)
- hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation)
+ hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation, daily rDNS cron)
lib/
Module.php # Base class: API communication, power, network, VNC, rebuild
ModuleFunctions.php # Provisioning: create, suspend, unsuspend, terminate, change package
@@ -491,6 +577,12 @@ modules/servers/VirtFusionDirect/
ServerResource.php # Data transformer: VirtFusion API response -> display format
AdminHTML.php # Admin interface: HTML generation for admin services tab
Log.php # Logging: WHMCS module log integration
+ PowerDns/
+ Client.php # PowerDNS HTTP API wrapper (X-API-Key, ping, listZones, getZone, patchRRset, notifyZone)
+ Config.php # Loads + decrypts addon settings from tbladdonmodules
+ IpUtil.php # PTR-name generation, IP extraction, RFC 2317 parsing, zone matching
+ Resolver.php # Forward-DNS verification (dns_get_record + CNAME chain, cached)
+ PtrManager.php # Orchestrator: syncServer, deleteForServer, listPtrs, setPtr, reconcile, reconcileAll
templates/
overview.tpl # Client area Smarty template (all management panels)
error.tpl # Error display template
@@ -499,6 +591,9 @@ modules/servers/VirtFusionDirect/
js/keygen.js # SSH Ed25519 key generator (Web Crypto API)
config/
ConfigOptionMapping-example.php # Example custom option name mapping
+
+modules/addons/VirtFusionDns/ # Optional — only needed for reverse DNS support
+ VirtFusionDns.php # Addon entry point: _config(), _activate(), _deactivate(), _output() (Test Connection page)
```
## Contributing
diff --git a/modules/addons/VirtFusionDns/VirtFusionDns.php b/modules/addons/VirtFusionDns/VirtFusionDns.php
new file mode 100644
index 0000000..1cc7c6a
--- /dev/null
+++ b/modules/addons/VirtFusionDns/VirtFusionDns.php
@@ -0,0 +1,234 @@
+ System Settings -> Addon Modules -> Activate -> Configure.
+ *
+ * API key handling: WHMCS encrypts password-type addon fields in tbladdonmodules;
+ * the server module calls decrypt() on read (see lib/PowerDns/Config.php).
+ */
+if (! defined('WHMCS')) {
+ exit('This file cannot be accessed directly');
+}
+
+/**
+ * Load the server module's PowerDNS classes on demand. Done inside functions rather
+ * than at file scope so the WHMCS addon list still works if the server module is
+ * absent (e.g., uninstalled while the addon is still activated). Returns true when
+ * the classes are available.
+ */
+function virtfusiondns_load_server_libs(): bool
+{
+ $base = __DIR__ . '/../../servers/VirtFusionDirect/lib/';
+ $files = [
+ 'Curl.php',
+ 'Log.php',
+ 'Cache.php',
+ 'PowerDns/Config.php',
+ 'PowerDns/IpUtil.php',
+ 'PowerDns/Client.php',
+ ];
+ foreach ($files as $f) {
+ if (! is_file($base . $f)) {
+ return false;
+ }
+ require_once $base . $f;
+ }
+
+ return true;
+}
+
+/**
+ * WHMCS addon metadata.
+ */
+function VirtFusionDns_config()
+{
+ return [
+ 'name' => 'VirtFusion DNS',
+ 'description' => 'Adds reverse DNS (PTR) management to the VirtFusionDirect server module using a PowerDNS HTTP API. Zones must already exist in PowerDNS; the addon never creates zones. Requires the VirtFusionDirect server module.',
+ 'version' => '1.0',
+ 'author' => 'VirtFusionDirect',
+ 'language' => 'english',
+ 'fields' => [
+ 'enabled' => [
+ 'FriendlyName' => 'Enable rDNS Sync',
+ 'Type' => 'yesno',
+ 'Description' => 'Master switch. When off, the server module skips every PowerDNS call.',
+ ],
+ 'endpoint' => [
+ 'FriendlyName' => 'PowerDNS API Endpoint',
+ 'Type' => 'text',
+ 'Size' => '60',
+ 'Default' => 'http://ns1.example.com:8081',
+ 'Description' => 'Scheme + host + port (no path). The /api/v1/... path is appended automatically.',
+ ],
+ 'apiKey' => [
+ 'FriendlyName' => 'PowerDNS API Key',
+ 'Type' => 'password',
+ 'Size' => '60',
+ 'Description' => 'X-API-Key. Stored encrypted by WHMCS; decrypted only server-side when PowerDNS is called.',
+ ],
+ 'serverId' => [
+ 'FriendlyName' => 'PowerDNS Server ID',
+ 'Type' => 'text',
+ 'Size' => '20',
+ 'Default' => 'localhost',
+ 'Description' => 'Almost always "localhost" (the PowerDNS API server identifier, not a hostname).',
+ ],
+ 'defaultTtl' => [
+ 'FriendlyName' => 'Default PTR TTL (seconds)',
+ 'Type' => 'text',
+ 'Size' => '10',
+ 'Default' => '3600',
+ 'Description' => 'TTL applied to PTR records created by the module.',
+ ],
+ 'cacheTtl' => [
+ 'FriendlyName' => 'Cache TTL (seconds)',
+ 'Type' => 'text',
+ 'Size' => '10',
+ 'Default' => '60',
+ 'Description' => 'How long zone lists and DNS-resolution results are cached. Minimum 10s.',
+ ],
+ ],
+ ];
+}
+
+/**
+ * Called when the addon is activated. No schema to create — settings live in tbladdonmodules.
+ */
+function VirtFusionDns_activate()
+{
+ return [
+ 'status' => 'success',
+ 'description' => 'VirtFusion DNS activated. Fill in the endpoint + API key in the addon configuration, then use the Test Connection button on the addon page.',
+ ];
+}
+
+/**
+ * Called when the addon is deactivated. Settings preserved (re-activating restores them).
+ */
+function VirtFusionDns_deactivate()
+{
+ return [
+ 'status' => 'success',
+ 'description' => 'VirtFusion DNS deactivated. Server lifecycle PowerDNS calls will now be skipped. Settings are preserved.',
+ ];
+}
+
+/**
+ * Admin status page — rendered by WHMCS when the addon is clicked from the Addons menu.
+ *
+ * Shows a settings summary, a Test Connection button (calls PowerDNS ping), the current
+ * zone count, and a recent log extract filtered to PowerDNS-related entries.
+ */
+function VirtFusionDns_output($vars)
+{
+ if (! virtfusiondns_load_server_libs()) {
+ echo '
';
+ echo 'VirtFusionDirect server module not found. ';
+ echo 'This addon requires the VirtFusionDirect server module at modules/servers/VirtFusionDirect/. ';
+ echo 'Install or restore that module and reload this page.';
+ echo '
Reverse DNS management for the VirtFusionDirect server module. All PTR writes happen through the VirtFusion server lifecycle (create, rename, terminate) and through the client-area Reverse DNS panel. Forward DNS (A/AAAA) is verified before every PTR write; mismatches are skipped and logged.
';
+
+ echo '
Current settings
';
+ echo '
';
+ echo '
Status
' . $enabledBadge . '
';
+ echo '
Endpoint
' . ($endpoint ?: 'not set') . '
';
+ echo '
API Key
' . $keyBadge . '
';
+ echo '
Server ID
' . $serverId . '
';
+ echo '
Default PTR TTL
' . $ttl . 's
';
+ echo '
Cache TTL
' . $cacheTtl . 's
';
+ echo '
';
+
+ echo '
Test Connection
';
+ echo '
Calls GET /api/v1/servers/' . $serverId . ' and, on success, lists available zones.
On server creation: a PTR is created for each assigned IP, set to the server hostname, only if the forward DNS already resolves to that IP.
';
+ echo '
On server rename: PTRs whose current content matches the previous hostname are updated to the new hostname; custom PTRs set by the client are preserved.
';
+ echo '
On server termination: every PTR for the server\'s IPs is deleted from PowerDNS.
';
+ echo '
Clients: may set a custom PTR per IP via the Reverse DNS panel on the service overview page. Forward DNS must resolve to the IP; mismatch rejects the write.
';
+ echo '
Reconcile cron: runs daily, additive-only — creates PTRs where none exist, never overwrites.
';
+ echo '
Reconcile (admin): a button on the admin services tab triggers an explicit reconcile with optional force to reset client-custom PTRs back to the server hostname.
';
+ echo '
';
+
+ echo '
Requirements
';
+ echo '
';
+ echo '
PowerDNS Authoritative with HTTP API enabled (webserver=yes, api=yes).
';
+ echo '
Reverse zones (*.in-addr.arpa / *.ip6.arpa) for your IP ranges must exist in PowerDNS already — the addon never creates zones.
';
+ echo '
api-allow-from must include the WHMCS host\'s IP.
';
+ echo '
';
+
+ echo '
';
+}
diff --git a/modules/servers/VirtFusionDirect/VirtFusionDirect.php b/modules/servers/VirtFusionDirect/VirtFusionDirect.php
index 2a3247c..5beb5e7 100644
--- a/modules/servers/VirtFusionDirect/VirtFusionDirect.php
+++ b/modules/servers/VirtFusionDirect/VirtFusionDirect.php
@@ -1,5 +1,41 @@
getRequestInfo('http_code');
if ($httpCode == 200) {
+ // Also verify PowerDNS health when the DNS addon is activated, so the
+ // admin's Test Connection button reflects the full provisioning path.
+ if (PowerDnsConfig::isEnabled()) {
+ $pdns = (new PowerDnsClient)->ping();
+ if (! $pdns['ok']) {
+ return [
+ 'success' => false,
+ 'error' => 'VirtFusion OK; PowerDNS unreachable — '
+ . ($pdns['error'] ?? 'unknown')
+ . ' (HTTP ' . (int) $pdns['http'] . '). Fix the VirtFusion DNS addon settings.',
+ ];
+ }
+ }
+
return ['success' => true, 'error' => ''];
}
diff --git a/modules/servers/VirtFusionDirect/admin.php b/modules/servers/VirtFusionDirect/admin.php
index 566c675..10239cf 100644
--- a/modules/servers/VirtFusionDirect/admin.php
+++ b/modules/servers/VirtFusionDirect/admin.php
@@ -5,13 +5,39 @@ require dirname(__DIR__, 3) . '/init.php';
/**
* Admin-facing AJAX API endpoint.
*
- * Requires WHMCS admin authentication. Provides server data lookup
- * and user impersonation for the admin services tab.
+ * MIRRORS client.php STRUCTURE
+ * ----------------------------
+ * Same switch-on-$action dispatch pattern, same JSON response shape, same
+ * "output + break" convention. The only substantive difference is the auth
+ * gate at the top: $vf->adminOnly() instead of $vf->isAuthenticated().
+ *
+ * WHY SEPARATE FROM client.php
+ * ----------------------------
+ * A single file with a per-action admin/client switch would risk one bug
+ * (e.g. forgetting to call adminOnly on a new admin-only action) giving a
+ * client authenticated but without admin privileges access to admin data.
+ * Having two physical entry points means the admin auth gate is enforced
+ * at file scope — any action routed here already went through adminOnly().
+ *
+ * ADMIN-LEVEL AUTH ONLY — NO SERVICE OWNERSHIP CHECK
+ * --------------------------------------------------
+ * An admin is allowed to view/operate on any service, so we don't call
+ * validateUserOwnsService() here. If you add an action that needs finer-
+ * grained auth (e.g. restrict to the admin role that owns the product
+ * group), compose the additional check inside the case branch.
+ *
+ * SAME-ORIGIN / POST GATES STILL APPLY TO MUTATIONS
+ * -------------------------------------------------
+ * Admins are still subject to requirePost + requireSameOrigin on writes —
+ * admin sessions are just as CSRF-vulnerable as client sessions. See the
+ * rdnsReconcile case for the pattern.
*/
use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module;
+use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
+use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
$vf = new Module;
@@ -88,6 +114,61 @@ try {
$vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502);
break;
+ // =================================================================
+ // Reverse DNS (PowerDNS)
+ // =================================================================
+
+ /**
+ * Admin-side PTR status for a service. Same shape as client-side rdnsList but
+ * accessible without being the service owner (admin-only guard at top).
+ */
+ case 'rdnsStatus':
+
+ $serviceID = $vf->validateServiceID(true);
+
+ if (! PowerDnsConfig::isEnabled()) {
+ $vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200);
+ break;
+ }
+
+ $serverData = $vf->fetchServerData($serviceID);
+ if (! $serverData) {
+ $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502);
+ break;
+ }
+
+ $ptrs = (new PtrManager)->listPtrs($serverData);
+ $vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200);
+ break;
+
+ /**
+ * Trigger PTR reconciliation for a single service. Additive-only by default
+ * (missing PTRs are created with the current hostname); pass force=1 to also
+ * reset PTRs that differ from the server hostname.
+ */
+ case 'rdnsReconcile':
+
+ // Mutating action — enforce POST + same-origin even though the session is admin-authenticated.
+ $vf->requirePost();
+ $vf->requireSameOrigin();
+
+ $serviceID = $vf->validateServiceID(true);
+
+ if (! PowerDnsConfig::isEnabled()) {
+ $vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400);
+ break;
+ }
+
+ $force = ! empty($_POST['force']);
+ $summary = (new PtrManager)->reconcile($serviceID, $force);
+ Log::insert(
+ 'rdnsReconcile:ok',
+ ['serviceID' => $serviceID, 'force' => $force],
+ $summary,
+ );
+ $vf->output(['success' => true, 'data' => $summary], true, true, 200);
+ break;
+
default:
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}
diff --git a/modules/servers/VirtFusionDirect/client.php b/modules/servers/VirtFusionDirect/client.php
index 1d3ee9a..9e4ec8d 100644
--- a/modules/servers/VirtFusionDirect/client.php
+++ b/modules/servers/VirtFusionDirect/client.php
@@ -5,12 +5,58 @@ require dirname(__DIR__, 3) . '/init.php';
/**
* Client-facing AJAX API endpoint.
*
- * Authenticated by WHMCS session + service ownership validation.
- * POST for mutations (power, rebuild, rename, credit), GET for reads (serverData, templates, backups).
+ * ROUTING MODEL
+ * -------------
+ * Every request carries ?action=X&serviceID=Y. We dispatch on $action via the
+ * switch below. Because PHP's switch() is O(N) over case labels that's still
+ * fine at ~20 actions; if this grows large enough that dispatch cost matters
+ * we'd want a lookup table, but we're nowhere near that.
+ *
+ * WHMCS requires every action URL to re-authenticate on each request (no
+ * cross-request sticky state beyond the session cookie). That's why the
+ * isAuthenticated() call is the first thing inside the try block — nothing
+ * downstream may assume a session exists.
+ *
+ * AUTH LAYERS (ORDER MATTERS)
+ * ---------------------------
+ * Each case composes the defenses it needs:
+ *
+ * 1. $vf->isAuthenticated() — client session (401 otherwise)
+ * 2. $vf->validateServiceID(true) — numeric coercion + presence
+ * 3. $vf->validateUserOwnsService($id) — the session owns this service (403)
+ * 4. Optional: requireServiceStatus — filter by tblhosting.domainstatus
+ * 5. Optional (mutations): requirePost — HTTP method gate (405)
+ * 6. Optional (mutations): requireSameOrigin — CSRF origin gate (403)
+ *
+ * The helpers are "fail loudly" — they exit on failure rather than returning.
+ * So everything AFTER a guard in a case branch knows the guard passed.
+ *
+ * EVERY $vf->output() FOLLOWED BY break
+ * -------------------------------------
+ * output() emits a JSON response and exits by default, so in theory `break`
+ * is redundant. In practice we always break explicitly for two reasons:
+ * 1. If someone later passes exit=false to output() the switch would fall
+ * through to the default case and emit a second response body.
+ * 2. Code readers shouldn't have to remember that one function exits.
+ *
+ * RESPONSE SHAPE
+ * --------------
+ * Success: { success: true, data: { ... } }
+ * Error: { success: false, errors: "human-readable message" }
+ * Status codes match HTTP semantics (200/400/401/403/404/405/429/500/502).
+ *
+ * CATCH-ALL
+ * ---------
+ * The outer try/catch guarantees we never expose a raw PHP stack trace to the
+ * client, even on bugs in our own code. All uncaught exceptions are logged and
+ * the user sees a generic 500.
*/
use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module;
+use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
+use WHMCS\Module\Server\VirtFusionDirect\PowerDns\IpUtil;
+use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
$vf = new Module;
@@ -405,6 +451,147 @@ try {
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
break;
+ // =================================================================
+ // Reverse DNS (PowerDNS)
+ // =================================================================
+
+ /**
+ * List PTR state for every IP assigned to the service's server.
+ *
+ * Always fetches fresh server data from VirtFusion (not cached server_object)
+ * so the displayed IPs match current reality — if an IP was reassigned out
+ * of this server since last sync, it won't appear here.
+ */
+ case 'rdnsList':
+
+ $serviceID = $vf->validateServiceID(true);
+
+ if (! $vf->validateUserOwnsService($serviceID)) {
+ $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
+ }
+
+ // Reads are permitted for Active + Suspended (a suspended user can still see their rDNS);
+ // Terminated/Pending/Cancelled/Fraud return a clear 400 upfront.
+ $vf->requireServiceStatus($serviceID, ['Active', 'Suspended']);
+
+ if (! PowerDnsConfig::isEnabled()) {
+ $vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200);
+ break;
+ }
+
+ $serverData = $vf->fetchServerData($serviceID);
+ if (! $serverData) {
+ $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502);
+ break;
+ }
+
+ $ptrs = (new PtrManager)->listPtrs($serverData);
+ $vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200);
+ break;
+
+ /**
+ * Update (or delete) the PTR for a single IP assigned to the user's server.
+ *
+ * Validation order: ownership -> IP format -> PTR regex -> IP belongs to this server
+ * -> rate-limit/forward-DNS checks inside PtrManager. Sending an empty `ptr` deletes.
+ */
+ case 'rdnsUpdate':
+
+ // Mutation: enforce POST, same-origin, active service status in that order.
+ // requirePost/requireSameOrigin exit on failure (405/403 respectively), so nothing below runs.
+ $vf->requirePost();
+ $vf->requireSameOrigin();
+
+ $serviceID = $vf->validateServiceID(true);
+
+ $clientId = $vf->validateUserOwnsService($serviceID);
+ if (! $clientId) {
+ $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
+ }
+
+ // Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS.
+ $vf->requireServiceStatus($serviceID, ['Active']);
+
+ if (! PowerDnsConfig::isEnabled()) {
+ $vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled on this installation'], true, true, 400);
+ break;
+ }
+
+ $ip = isset($_POST['ip']) ? trim((string) $_POST['ip']) : '';
+ $ptr = isset($_POST['ptr']) ? trim((string) $_POST['ptr']) : '';
+
+ if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
+ $vf->output(['success' => false, 'errors' => 'Invalid IP address'], true, true, 400);
+ break;
+ }
+
+ if ($ptr !== '' && ! preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/', $ptr)) {
+ $vf->output(['success' => false, 'errors' => 'Invalid hostname for PTR record'], true, true, 400);
+ break;
+ }
+ if (strlen($ptr) > 253) {
+ $vf->output(['success' => false, 'errors' => 'Hostname too long'], true, true, 400);
+ break;
+ }
+
+ // Cross-check: the submitted IP must be currently assigned to this user's server.
+ // Fetch fresh from VirtFusion (not the stored object) to prevent stale-ownership writes
+ // after an IP reassignment.
+ $serverData = $vf->fetchServerData($serviceID);
+ if (! $serverData) {
+ $vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502);
+ break;
+ }
+ $assigned = IpUtil::extractIps($serverData)['addresses'];
+ $targetBin = @inet_pton($ip);
+ $owns = false;
+ foreach ($assigned as $a) {
+ if (@inet_pton($a) === $targetBin) {
+ $owns = true;
+ break;
+ }
+ }
+ if (! $owns) {
+ Log::insert('rdnsUpdate:ownership', ['serviceID' => $serviceID, 'ip' => $ip], 'IP not assigned to this service');
+ $vf->output(['success' => false, 'errors' => 'This IP is not assigned to your server'], true, true, 403);
+ break;
+ }
+
+ $result = (new PtrManager)->setPtr($ip, $ptr);
+
+ if ($result['ok']) {
+ // Audit trail for successful edits — surfaces in Utilities → Logs → Module Log,
+ // searchable by clientId / serviceId / ip for "who changed this PTR".
+ Log::insert(
+ 'rdnsUpdate:ok',
+ ['clientId' => $clientId, 'serviceID' => $serviceID, 'ip' => $ip, 'reason' => $result['reason']],
+ ['ptr' => $ptr === '' ? '(deleted)' : $ptr],
+ );
+ $vf->output(['success' => true, 'data' => ['reason' => $result['reason']]], true, true, 200);
+ break;
+ }
+
+ // Map internal reasons to client-facing messages/status codes.
+ switch ($result['reason']) {
+ case 'forward-missing':
+ $vf->output(['success' => false, 'errors' => 'Forward DNS for "' . $ptr . '" does not resolve to ' . $ip . '. Configure the A/AAAA record with your DNS provider first, then try again.'], true, true, 400);
+ break;
+ case 'rate-limited':
+ $vf->output(['success' => false, 'errors' => 'Too many updates for this IP. Try again in a few seconds.'], true, true, 429);
+ break;
+ case 'no-zone':
+ $vf->output(['success' => false, 'errors' => 'This IP has no reverse DNS zone configured on the nameserver.'], true, true, 400);
+ break;
+ case 'disabled':
+ $vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400);
+ break;
+ default:
+ $vf->output(['success' => false, 'errors' => 'Reverse DNS update failed (' . $result['reason'] . ')'], true, true, 500);
+ }
+ break;
+
default:
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}
diff --git a/modules/servers/VirtFusionDirect/hooks.php b/modules/servers/VirtFusionDirect/hooks.php
index f4be8fc..bdb1f04 100644
--- a/modules/servers/VirtFusionDirect/hooks.php
+++ b/modules/servers/VirtFusionDirect/hooks.php
@@ -1,14 +1,68 @@
reconcileAll();
+ }
+ } catch (Throwable $e) {
+ Log::insert('PowerDns:DailyCronJob', [], $e->getMessage());
+ }
+});
+
/**
* Shopping Cart Validation Hook
*
diff --git a/modules/servers/VirtFusionDirect/lib/AdminHTML.php b/modules/servers/VirtFusionDirect/lib/AdminHTML.php
index 3a5c321..1152284 100644
--- a/modules/servers/VirtFusionDirect/lib/AdminHTML.php
+++ b/modules/servers/VirtFusionDirect/lib/AdminHTML.php
@@ -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 and
+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 <<
+
+ Loading reverse DNS…
+
+
+
+
+
+
+
+
EOT;
}
}
diff --git a/modules/servers/VirtFusionDirect/lib/Module.php b/modules/servers/VirtFusionDirect/lib/Module.php
index 2a73fb7..84241d0 100644
--- a/modules/servers/VirtFusionDirect/lib/Module.php
+++ b/modules/servers/VirtFusionDirect/lib/Module.php
@@ -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 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.
diff --git a/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php b/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php
index 5d8516b..093aaae 100644
--- a/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php
+++ b/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php
@@ -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) {
diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
new file mode 100644
index 0000000..6c4a57e
--- /dev/null
+++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
@@ -0,0 +1,310 @@
+|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|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];
+ }
+ }
+}
diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Config.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
new file mode 100644
index 0000000..7b8f14e
--- /dev/null
+++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
@@ -0,0 +1,185 @@
+|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'];
+ }
+}
diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php b/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
new file mode 100644
index 0000000..34fff5a
--- /dev/null
+++ b/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
@@ -0,0 +1,426 @@
+ "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;
+ }
+}
diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php b/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
new file mode 100644
index 0000000..0be140d
--- /dev/null
+++ b/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
@@ -0,0 +1,716 @@
+ 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|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>
+ */
+ 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|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;
+ }
+}
diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
new file mode 100644
index 0000000..1847207
--- /dev/null
+++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
@@ -0,0 +1,140 @@
+ 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;
+ }
+}
diff --git a/modules/servers/VirtFusionDirect/templates/css/module.css b/modules/servers/VirtFusionDirect/templates/css/module.css
index 1e958cb..489998e 100644
--- a/modules/servers/VirtFusionDirect/templates/css/module.css
+++ b/modules/servers/VirtFusionDirect/templates/css/module.css
@@ -471,3 +471,77 @@
flex-wrap: wrap;
}
}
+
+/* =========================================================================
+ Reverse DNS panel
+ ========================================================================= */
+.vf-rdns-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 0;
+ border-bottom: 1px solid rgba(0,0,0,.06);
+}
+.vf-rdns-row:last-child { border-bottom: none; }
+.vf-rdns-ip {
+ font-family: monospace;
+ font-size: 13px;
+ min-width: 180px;
+ font-weight: 600;
+}
+.vf-rdns-edit {
+ display: flex;
+ flex: 1 1 auto;
+ gap: 6px;
+ align-items: center;
+ min-width: 240px;
+}
+.vf-rdns-input {
+ flex: 1 1 auto;
+ min-width: 180px;
+ max-width: 420px;
+ font-family: monospace;
+ font-size: 13px;
+}
+.vf-rdns-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .02em;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+.vf-rdns-msg {
+ flex-basis: 100%;
+ font-size: 12px;
+ display: none;
+ padding-left: 180px;
+}
+.vf-rdns-admin-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 4px 0;
+ font-size: 13px;
+}
+.vf-rdns-ip-admin {
+ font-family: monospace;
+ font-weight: 600;
+ min-width: 180px;
+}
+.vf-rdns-ptr-admin {
+ font-family: monospace;
+ color: #333;
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+@media (max-width: 768px) {
+ .vf-rdns-row { flex-direction: column; align-items: stretch; }
+ .vf-rdns-edit { flex-direction: column; align-items: stretch; }
+ .vf-rdns-msg { padding-left: 0; }
+}
diff --git a/modules/servers/VirtFusionDirect/templates/js/module.js b/modules/servers/VirtFusionDirect/templates/js/module.js
index 9a79f99..93e32f6 100644
--- a/modules/servers/VirtFusionDirect/templates/js/module.js
+++ b/modules/servers/VirtFusionDirect/templates/js/module.js
@@ -1,7 +1,82 @@
/**
* VirtFusion Direct Provisioning Module - Client JavaScript
*
- * Handles client-side interactions for server management including:
+ * ========================================================================
+ * ARCHITECTURE
+ * ========================================================================
+ *
+ * This file is the single client-side script that powers both:
+ * - The client area (service overview panel, loaded on every service page)
+ * - The admin services tab (server info + rDNS widget)
+ *
+ * It uses vanilla JS + jQuery. jQuery is available because WHMCS's built-in
+ * admin UI depends on it; we inherit that dependency rather than adding a
+ * new one. The order form hooks (keygen.js, OS-gallery injector in hooks.php)
+ * use vanilla JS only because those run on pre-auth checkout pages where
+ * jQuery availability varies by theme.
+ *
+ * CONVENTION: every function is prefixed with "vf" to avoid collisions with
+ * whatever else the page loads. Internal helpers start with "_vf".
+ *
+ * ========================================================================
+ * SECTIONS (roughly in order below)
+ * ========================================================================
+ *
+ * Shared Helpers — vfUrl, vfShowAlert
+ * Progress Indicator — vfShowProgress / vfHideProgress
+ * Server Data Display — vfServerData, vfServerDataAdmin
+ * Power Management — vfPowerAction
+ * SSO Login — vfLoginAsServerOwner
+ * Password Reset — vfUserPasswordReset, vfResetServerPassword
+ * Server Rebuild — vfRebuildServer, vfLoadOsTemplates, vfRenderOsGallery
+ * Server Rename — vfRenameServer, vfShowNameDropdown
+ * Traffic / Backups — vfLoadTrafficStats, vfDrawTrafficChart, vfLoadBackups
+ * VNC Console — vfOpenVnc, vfToggleVnc
+ * Self-Service Billing — vfLoadSelfServiceUsage, vfAddCredit
+ * Reverse DNS (PowerDNS) — vfLoadRdns, vfRenderRdnsPanel, vfUpdateRdns,
+ * vfAdminLoadRdns, vfAdminReconcileRdns
+ *
+ * ========================================================================
+ * AJAX REQUEST SHAPE
+ * ========================================================================
+ *
+ * URL: {systemUrl}modules/servers/VirtFusionDirect/{endpoint}.php
+ * ?serviceID={id}&action={action}
+ * where endpoint is "client" (default) or "admin".
+ *
+ * Method: GET for reads, POST for writes (server-side requirePost() gate
+ * enforces this for rDNS mutations; other mutations rely on $_POST
+ * being empty for GET → validation fails naturally).
+ *
+ * Response:
+ * { success: true, data: { ... } }
+ * { success: false, errors: "human message" }
+ *
+ * ========================================================================
+ * ERROR HANDLING
+ * ========================================================================
+ *
+ * Every AJAX call handles three outcomes:
+ * 1. Network failure (.fail) → show a generic error in the panel's alert div
+ * 2. Server returned success:false → show response.errors to the user
+ * 3. Server returned success:true → render data into the DOM
+ *
+ * Error text ALWAYS comes from the server (we don't invent user-facing error
+ * copy client-side). That way a server-side change to error phrasing
+ * propagates everywhere without JS changes.
+ *
+ * ========================================================================
+ * DOM UPDATE PATTERNS
+ * ========================================================================
+ *
+ * Read actions render into named containers with id="vf-data-*".
+ * Status badges use CSS classes "vf-badge-*" for color coding.
+ * Text content is always set via .text() not .html() to prevent XSS
+ * from whatever the API returned. Exception: panels built entirely
+ * from server-trusted structured data use .append() with new jQuery
+ * elements, not string concatenation.
+ *
+ * Handles client-side interactions for:
* - Server data display
* - Power management (boot, shutdown, restart, power off)
* - Control panel login (SSO)
@@ -12,6 +87,7 @@
* - Backup listing
* - VNC management
* - Server naming
+ * - Reverse DNS (PowerDNS addon)
*/
// =========================================================================
@@ -1011,3 +1087,196 @@ function vfCopyButton(text) {
});
return btn;
}
+
+// =========================================================================
+// Reverse DNS (PowerDNS)
+// =========================================================================
+//
+// Feature gate: this section only activates when the VirtFusionDns addon is
+// installed AND enabled. The PHP side renders the rDNS panel in overview.tpl
+// only when $rdnsEnabled is true; if the panel isn't in the DOM, these
+// functions are never called.
+//
+// Admin-side counterparts (vfAdminLoadRdns, vfAdminReconcileRdns) target
+// admin.php instead of client.php and are used by the rdnsSection() admin
+// widget rendered via AdminHTML::rdnsSection().
+//
+// Status badge colours match what most operators expect:
+// OK (green) = PTR present, forward DNS agrees (FCrDNS passes)
+// unverified (amber) = PTR present but forward DNS no longer agrees
+// missing (gray) = No PTR exists yet
+// no-zone (red) = The IP's reverse zone isn't hosted in PowerDNS
+// error (red) = PowerDNS unreachable or similar
+//
+// The server-side always decides the status; we just colour it.
+
+/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */
+var VF_RDNS_STATUS = {
+ "ok": { label: "OK", bg: "#28a745", fg: "#fff" },
+ "unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" },
+ "missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" },
+ "no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" },
+ "error": { label: "error", bg: "#dc3545", fg: "#fff" },
+ "disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" }
+};
+
+function vfRdnsBadge(status) {
+ var s = VF_RDNS_STATUS[status] || VF_RDNS_STATUS["error"];
+ var span = $('');
+ span.text(s.label);
+ span.css({ background: s.bg, color: s.fg });
+ return span;
+}
+
+function vfLoadRdns(serviceId, systemUrl) {
+ var list = $("#vf-rdns-list");
+ $.ajax({
+ url: vfUrl(systemUrl, serviceId, "rdnsList"),
+ method: "GET",
+ dataType: "json"
+ }).done(function (resp) {
+ if (!resp || !resp.success) {
+ list.html('