feat: add PowerDNS reverse DNS (PTR) integration

Introduces an opt-in reverse DNS management subsystem backed by a PowerDNS
Authoritative HTTP API. Runs via a companion WHMCS addon module
(modules/addons/VirtFusionDns) that holds settings and a Test Connection
page; the server module reads those settings from tbladdonmodules and
short-circuits when the addon is absent or disabled, so provisioning is
unaffected for operators who don't use the feature.

Lifecycle hooks:
- createAccount creates PTRs for every assigned IP (forward DNS must
  already resolve to the IP — FCrDNS enforcement)
- renameServer updates only PTRs whose content matched the old hostname,
  preserving client-custom records
- terminateAccount deletes all PTRs before the local state is purged
- TestConnection merges PowerDNS health check with the existing VirtFusion
  check
- A DailyCronJob hook reconciles missing PTRs additive-only (never
  overwrites)

Client UI: new "Reverse DNS" panel on the service overview with one
editable PTR input per assigned IP, per-row status badges, and
forward-DNS rejection on save. Admin services tab gets a parallel
widget with Reconcile (additive) and Reconcile (force reset) buttons.

New subsystem at lib/PowerDns/:
- Client.php    PowerDNS API wrapper (X-API-Key, listZones/getZone/
                patchRRset/notifyZone), auto-NOTIFY on successful PATCH
- Config.php    Loads + decrypts addon settings from tbladdonmodules
- IpUtil.php    PTR-name generation (IPv4 + IPv6), zone matching,
                RFC 2317 classless parsing
- Resolver.php  FCrDNS verification via dns_get_record with CNAME-chain
                following and per-(hostname,ip) caching
- PtrManager.php Orchestrator: syncServer, deleteForServer, listPtrs,
                setPtr, reconcile, reconcileAll

Security hardening helpers added to Module and applied to the rDNS
endpoints:
- requirePost()           HTTP method gate (405 on non-POST mutations)
- requireSameOrigin()     Origin/Referer check against WHMCS host (CSRF
                          defence against cross-site form POST)
- requireServiceStatus()  tblhosting.domainstatus filter (Active for
                          writes, Active+Suspended for reads)

RFC 2317 classless delegations (e.g. 64/64.113.0.203.in-addr.arpa.)
supported with alignment validation: rejects misaligned start addresses
that don't correspond to any real delegation boundary.

PowerDNS zone IDs containing '/' are URL-encoded as '=2F' per the
PowerDNS API convention. PATCH success triggers PUT /zones/{id}/notify
so slaves pick up the SOA-bumped serial immediately.

Includes IPv4 + IPv6 support, per-IP write rate limit (10s), fresh
IP-ownership re-verification on every client write (defends against
stale-ownership after IP reassignment), and audit logging of every
successful edit to the WHMCS module log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Prophet731
2026-04-17 21:08:22 -04:00
parent d253bd44e6
commit ad85439dfb
18 changed files with 3312 additions and 21 deletions

View File

@@ -45,10 +45,11 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes | | `modules/servers/VirtFusionDirect/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. | | `modules/servers/VirtFusionDirect/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 | | `modules/servers/VirtFusionDirect/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/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/`) ### 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. | | `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. | | `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. | | `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. | | `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). | | `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons, `rdnsSection()` widget). |
| `Log` | Thin wrapper around WHMCS module logging. | | `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 ### 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` 4. Dry-run validation → actual API POST to `/servers`
5. Stores server ID in `mod_virtfusion_direct` table 5. Stores server ID in `mod_virtfusion_direct` table
6. Updates WHMCS hosting record (IP, username, password, domain) 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. 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 ### 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`. 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 - **Self-service billing:** Requires self-service feature enabled in VirtFusion
- **OS icon path:** `{baseUrl}/img/logo/{icon_filename}` (public, no auth required) - **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 ## Product Config Options
| Option | Name | Description | Default | | Option | Name | Description | Default |

View File

@@ -20,6 +20,7 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co
- [Module Configuration Options](#module-configuration-options) - [Module Configuration Options](#module-configuration-options)
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing) - [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
- [Custom Option Name Mapping](#custom-option-name-mapping) - [Custom Option Name Mapping](#custom-option-name-mapping)
- [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
- [Client Area Features](#client-area-features) - [Client Area Features](#client-area-features)
- [Admin Area Features](#admin-area-features) - [Admin Area Features](#admin-area-features)
- [Theme Compatibility](#theme-compatibility) - [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 - Auto top-off via WHMCS cron when credit falls below threshold
- Self-service mode configurable per product (Hourly, Resource Packs, or Both) - 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 ## Installation
```bash ```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. 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. 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. That's it. Hooks activate automatically and custom fields are created on module load.
## Upgrading ## Upgrading
```bash ```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. > **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**. 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 ## Client Area Features
### Server Overview ### Server Overview
@@ -250,6 +315,14 @@ Four power control buttons:
- Registration and next due dates - Registration and next due dates
- Payment method - 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 Area Features
### Admin Services Tab ### 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 Info** - Button to load live data from VirtFusion API
- **Server Object** - Full JSON response viewer - **Server Object** - Full JSON response viewer
- **Options** - Admin impersonation link - **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) ### Module Commands (Admin Buttons)
- **Create** - Provision a new server - **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+) | | `PUT` | `/servers/{id}/modify/traffic` | Modify traffic (v6.0.0+) |
| `POST/DELETE` | `/servers/{id}/backup/plan` | Backup plan management (v4.3.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) ## Usage Update (Cron)
The module implements the `UsageUpdate` function that is called by the WHMCS daily cron. It automatically syncs: 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) VirtFusionDirect.php # WHMCS module entry point (MetaData, ConfigOptions, all module functions)
client.php # Client-facing AJAX API (authenticated, ownership-validated) client.php # Client-facing AJAX API (authenticated, ownership-validated)
admin.php # Admin-facing AJAX API (admin authentication required) 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/ lib/
Module.php # Base class: API communication, power, network, VNC, rebuild Module.php # Base class: API communication, power, network, VNC, rebuild
ModuleFunctions.php # Provisioning: create, suspend, unsuspend, terminate, change package 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 ServerResource.php # Data transformer: VirtFusion API response -> display format
AdminHTML.php # Admin interface: HTML generation for admin services tab AdminHTML.php # Admin interface: HTML generation for admin services tab
Log.php # Logging: WHMCS module log integration 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/ templates/
overview.tpl # Client area Smarty template (all management panels) overview.tpl # Client area Smarty template (all management panels)
error.tpl # Error display template error.tpl # Error display template
@@ -499,6 +591,9 @@ modules/servers/VirtFusionDirect/
js/keygen.js # SSH Ed25519 key generator (Web Crypto API) js/keygen.js # SSH Ed25519 key generator (Web Crypto API)
config/ config/
ConfigOptionMapping-example.php # Example custom option name mapping 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 ## Contributing

View File

@@ -0,0 +1,234 @@
<?php
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client;
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config;
/**
* VirtFusion DNS — companion WHMCS addon module that holds PowerDNS settings for
* the VirtFusionDirect server module. Keeps the server module decoupled from the
* addon: the server module reads settings from tbladdonmodules and never loads
* addon code at runtime.
*
* Activation: WHMCS Admin -> 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 '<div style="max-width:900px;padding:16px;border-radius:4px;background:#f8d7da;color:#721c24">';
echo '<strong>VirtFusionDirect server module not found.</strong> ';
echo 'This addon requires the VirtFusionDirect server module at <code>modules/servers/VirtFusionDirect/</code>. ';
echo 'Install or restore that module and reload this page.';
echo '</div>';
return;
}
Config::reset();
$config = Config::get();
$pingResult = null;
$zoneCount = null;
$zoneSample = [];
if (! empty($_GET['vfdns_test'])) {
if (Config::isEnabled()) {
$client = new Client;
$pingResult = $client->ping();
if ($pingResult['ok']) {
$client->forgetZoneCache();
$zones = $client->listZones();
$zoneCount = count($zones);
$zoneSample = array_slice($zones, 0, 8);
}
} else {
$pingResult = ['ok' => false, 'http' => 0, 'error' => 'Not enabled or missing endpoint/apiKey.'];
}
}
$modulelink = htmlspecialchars($vars['modulelink'] ?? '', ENT_QUOTES, 'UTF-8');
$endpoint = htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8');
$serverId = htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8');
$ttl = (int) $config['defaultTtl'];
$cacheTtl = (int) $config['cacheTtl'];
$enabledBadge = $config['enabled']
? '<span style="color:#28a745;font-weight:bold">enabled</span>'
: '<span style="color:#dc3545;font-weight:bold">disabled</span>';
$keyBadge = $config['apiKey'] !== '' ? '<span style="color:#28a745">set</span>' : '<span style="color:#dc3545">missing</span>';
echo '<div style="max-width:900px">';
echo '<h2 style="margin-top:0">VirtFusion DNS</h2>';
echo '<p>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.</p>';
echo '<h3>Current settings</h3>';
echo '<table class="table table-sm" style="max-width:700px"><tbody>';
echo '<tr><th style="text-align:left;width:180px">Status</th><td>' . $enabledBadge . '</td></tr>';
echo '<tr><th style="text-align:left">Endpoint</th><td><code>' . ($endpoint ?: '<em>not set</em>') . '</code></td></tr>';
echo '<tr><th style="text-align:left">API Key</th><td>' . $keyBadge . '</td></tr>';
echo '<tr><th style="text-align:left">Server ID</th><td><code>' . $serverId . '</code></td></tr>';
echo '<tr><th style="text-align:left">Default PTR TTL</th><td>' . $ttl . 's</td></tr>';
echo '<tr><th style="text-align:left">Cache TTL</th><td>' . $cacheTtl . 's</td></tr>';
echo '</tbody></table>';
echo '<h3>Test Connection</h3>';
echo '<p>Calls <code>GET /api/v1/servers/' . $serverId . '</code> and, on success, lists available zones.</p>';
echo '<a href="' . $modulelink . '&vfdns_test=1" class="btn btn-primary btn-sm">Run Test</a>';
if ($pingResult !== null) {
echo '<div style="margin-top:12px;padding:10px;border-radius:4px;background:' . ($pingResult['ok'] ? '#d4edda' : '#f8d7da') . ';color:' . ($pingResult['ok'] ? '#155724' : '#721c24') . '">';
if ($pingResult['ok']) {
echo '<strong>OK.</strong> PowerDNS reachable and authenticated. ';
if ($zoneCount !== null) {
echo $zoneCount . ' zone(s) visible.';
if (! empty($zoneSample)) {
echo '<div style="margin-top:8px;font-family:monospace;font-size:12px">';
foreach ($zoneSample as $z) {
echo htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . '<br>';
}
if ($zoneCount > count($zoneSample)) {
echo '<em>... and ' . ($zoneCount - count($zoneSample)) . ' more</em>';
}
echo '</div>';
}
}
} else {
echo '<strong>Failed.</strong> HTTP ' . (int) $pingResult['http'] . ': ' . htmlspecialchars((string) ($pingResult['error'] ?? 'unknown error'), ENT_QUOTES, 'UTF-8');
}
echo '</div>';
}
echo '<h3 style="margin-top:24px">Operation</h3>';
echo '<ul>';
echo '<li><strong>On server creation:</strong> a PTR is created for each assigned IP, set to the server hostname, <em>only if the forward DNS already resolves to that IP</em>.</li>';
echo '<li><strong>On server rename:</strong> PTRs whose current content matches the <em>previous</em> hostname are updated to the new hostname; custom PTRs set by the client are preserved.</li>';
echo '<li><strong>On server termination:</strong> every PTR for the server\'s IPs is deleted from PowerDNS.</li>';
echo '<li><strong>Clients:</strong> 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.</li>';
echo '<li><strong>Reconcile cron:</strong> runs daily, additive-only — creates PTRs where none exist, never overwrites.</li>';
echo '<li><strong>Reconcile (admin):</strong> a button on the admin services tab triggers an explicit reconcile with optional <em>force</em> to reset client-custom PTRs back to the server hostname.</li>';
echo '</ul>';
echo '<h3>Requirements</h3>';
echo '<ul>';
echo '<li>PowerDNS Authoritative with HTTP API enabled (<code>webserver=yes</code>, <code>api=yes</code>).</li>';
echo '<li>Reverse zones (<code>*.in-addr.arpa</code> / <code>*.ip6.arpa</code>) for your IP ranges must exist in PowerDNS already — the addon never creates zones.</li>';
echo '<li><code>api-allow-from</code> must include the WHMCS host\'s IP.</li>';
echo '</ul>';
echo '</div>';
}

View File

@@ -1,5 +1,41 @@
<?php <?php
/**
* VirtFusion Direct Provisioning Module — WHMCS server module entry point.
*
* This file contains the non-namespaced functions WHMCS calls via its reflection-
* based module dispatcher. They follow the naming convention:
*
* {ModuleDirectoryName}_{FunctionName}(...)
*
* WHMCS looks for these on every relevant event (provisioning, UI rendering,
* daily cron, test connection, etc.). Every function here is a thin shim that
* instantiates ModuleFunctions (or Module) and delegates to a method — keeping
* the dispatch surface small and the business logic in unit-exercisable classes.
*
* DO NOT add significant logic directly in these shims. If you need a new
* lifecycle behaviour, add it as a method on ModuleFunctions and point the
* shim at it. This makes the module predictable: one public function, one method.
*
* RESERVED NAMES — DO NOT CHANGE
* ------------------------------
* WHMCS looks up these specific function names by convention; renaming them
* disables the corresponding feature in WHMCS silently:
* VirtFusionDirect_MetaData → Displayed name + API version
* VirtFusionDirect_ConfigOptions → Product-level settings fields
* VirtFusionDirect_TestConnection → Admin "Test Connection" button
* VirtFusionDirect_CreateAccount → Provisioning on order-activation
* VirtFusionDirect_SuspendAccount → Suspension
* VirtFusionDirect_UnsuspendAccount → Unsuspension
* VirtFusionDirect_TerminateAccount → Termination
* VirtFusionDirect_ChangePackage → Package change on upgrade/downgrade
* VirtFusionDirect_AdminServicesTabFields → Admin services tab renderer
* VirtFusionDirect_AdminServicesTabFieldsSave → Admin services tab save handler
* VirtFusionDirect_ClientArea → Client-area template + vars
* VirtFusionDirect_ServiceSingleSignOn → SSO button handler
* VirtFusionDirect_AdminCustomButtonArray → Custom admin action buttons
* VirtFusionDirect_UsageUpdate → Daily cron bandwidth/disk usage sync
*/
if (! defined('WHMCS')) { if (! defined('WHMCS')) {
exit('This file cannot be accessed directly'); exit('This file cannot be accessed directly');
} }
@@ -9,6 +45,8 @@ use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log; use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\Module;
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions; use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client as PowerDnsClient;
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
/** /**
* Returns module metadata consumed by WHMCS. * Returns module metadata consumed by WHMCS.
@@ -97,6 +135,20 @@ function VirtFusionDirect_TestConnection(array $params)
$httpCode = $request->getRequestInfo('http_code'); $httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200) { 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' => '']; return ['success' => true, 'error' => ''];
} }

View File

@@ -5,13 +5,39 @@ require dirname(__DIR__, 3) . '/init.php';
/** /**
* Admin-facing AJAX API endpoint. * Admin-facing AJAX API endpoint.
* *
* Requires WHMCS admin authentication. Provides server data lookup * MIRRORS client.php STRUCTURE
* and user impersonation for the admin services tab. * ----------------------------
* 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\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log; use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module; 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; use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
$vf = new Module; $vf = new Module;
@@ -88,6 +114,61 @@ try {
$vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502); $vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502);
break; 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: default:
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400); $vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
} }

View File

@@ -5,12 +5,58 @@ require dirname(__DIR__, 3) . '/init.php';
/** /**
* Client-facing AJAX API endpoint. * Client-facing AJAX API endpoint.
* *
* Authenticated by WHMCS session + service ownership validation. * ROUTING MODEL
* POST for mutations (power, rebuild, rename, credit), GET for reads (serverData, templates, backups). * -------------
* 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\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module; 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; use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
$vf = new Module; $vf = new Module;
@@ -405,6 +451,147 @@ try {
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500); $vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
break; 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: default:
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400); $vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
} }

View File

@@ -1,14 +1,68 @@
<?php <?php
/**
* WHMCS hooks for the VirtFusion module.
*
* HOW HOOKS WORK IN WHMCS
* -----------------------
* add_hook('EventName', $priority, $callback) registers $callback to fire on
* the named event. WHMCS discovers hook files by walking modules/servers/*
* /hooks.php and modules/addons/* /hooks.php on every page load, then invokes
* every registered hook for the current event.
*
* Hooks run IN-REQUEST — there's no queue or background worker. Anything
* expensive in a hook (like an external API call) blocks the user's page
* load. For that reason we only do:
* - Fast in-process work (building DOM snippets, validating session state)
* - Scheduled work on DailyCronJob where "in-request" means the cron worker,
* not a user session
*
* HOOKS REGISTERED HERE
* ---------------------
* DailyCronJob — PowerDNS reconciliation across all services
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
*
* FAILURE SEMANTICS
* -----------------
* Every hook wraps its body in try/catch and silently absorbs any exception.
* A hook that throws would potentially break the entire WHMCS request for
* all users, not just this module — so we log and swallow, preferring
* degraded functionality over site-wide breakage.
*/
use WHMCS\Database\Capsule; use WHMCS\Database\Capsule;
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService; use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
use WHMCS\Module\Server\VirtFusionDirect\Database; use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\Module;
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
if (! defined('WHMCS')) { if (! defined('WHMCS')) {
exit('This file cannot be accessed directly'); exit('This file cannot be accessed directly');
} }
/**
* Daily PowerDNS reconciliation.
*
* Walks every managed service and creates any missing PTRs (never overwrites existing
* values — cron is additive-only). Requires the VirtFusion DNS addon to be activated
* and enabled; otherwise short-circuits immediately.
*
* All error handling lives inside reconcileAll(); this wrapper just logs any escape
* without disturbing the rest of the daily cron run.
*/
add_hook('DailyCronJob', 1, function ($vars) {
try {
if (PowerDnsConfig::isEnabled()) {
(new PtrManager)->reconcileAll();
}
} catch (Throwable $e) {
Log::insert('PowerDns:DailyCronJob', [], $e->getMessage());
}
});
/** /**
* Shopping Cart Validation Hook * Shopping Cart Validation Hook
* *

View File

@@ -4,6 +4,27 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
/** /**
* Static methods that generate HTML fragments for the WHMCS admin services tab. * Static methods that generate HTML fragments for the WHMCS admin services tab.
*
* WHY RAW HTML STRINGS INSTEAD OF TEMPLATES
* -----------------------------------------
* WHMCS's AdminServicesTabFields hook expects an associative array of
* label => HTML-string pairs. It renders each entry as a table row with the
* label on the left and the raw HTML inserted verbatim on the right. There's
* no way to return a Smarty template reference from that hook — WHMCS doesn't
* know how to render one in that context.
*
* So we concatenate HTML here. All variable interpolation uses htmlspecialchars()
* at the PHP boundary — never trust that a value passed in is safe for HTML.
*
* ASSET INJECTION
* ---------------
* Some renderers (serverInfo, rdnsSection) embed <link> and <script> tags so
* the admin services tab picks up our CSS and JS without a separate loader
* hook. This is safe because WHMCS's admin CSP allows same-origin resources
* and the admin page is already inside an authenticated admin session.
*
* Cache-busting uses time() as a query string — fine for an admin-only surface
* where we'd rather pay for the extra fetch than let stale JS cause bugs.
*/ */
class AdminHTML class AdminHTML
{ {
@@ -147,6 +168,38 @@ EOT;
</div> </div>
</div> </div>
<script>vfServerDataAdmin("${serviceId}","${systemUrl}");</script> <script>vfServerDataAdmin("${serviceId}","${systemUrl}");</script>
EOT;
}
/**
* Render the admin Reverse DNS section for the services tab.
*
* Ships an empty container + a Reconcile button. Data is loaded client-side via
* the admin rdnsStatus AJAX endpoint once the page opens. The JS function
* vfAdminLoadRdns (defined in templates/js/module.js) populates #vf-rdns-list
* and wires up the Reconcile button's onclick to admin.php?action=rdnsReconcile.
*
* @param string $systemUrl WHMCS system URL
* @param int $serviceId WHMCS service ID
* @return string HTML fragment for the admin services tab
*/
public static function rdnsSection($systemUrl, $serviceId)
{
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
$serviceId = (int) $serviceId;
return <<<EOT
<div id="vf-rdns-admin-wrap">
<div id="vf-rdns-list" class="vf-rdns-list">
<em class="text-muted">Loading reverse DNS…</em>
</div>
<div class="vf-rdns-actions" style="margin-top:10px">
<button type="button" class="btn btn-default btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', false)">Reconcile (additive)</button>
<button type="button" class="btn btn-warning btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', true)">Reconcile (force reset)</button>
<span id="vf-rdns-report" style="margin-left:10px"></span>
</div>
</div>
<script>if(typeof vfAdminLoadRdns==='function'){vfAdminLoadRdns(${serviceId},"${systemUrl}");}</script>
EOT; EOT;
} }
} }

View File

@@ -10,8 +10,49 @@ use WHMCS\Database\Capsule;
* server feature methods (power, network, VNC, backup, resource modification, * server feature methods (power, network, VNC, backup, resource modification,
* self-service billing, traffic, rename, password reset). * self-service billing, traffic, rename, password reset).
* *
* Extended by ModuleFunctions (service lifecycle) and ConfigureService (order-time * INHERITANCE SHAPE
* operations). Most business logic lives here; subclasses delegate to these methods. * -----------------
* 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 class Module
{ {
@@ -73,10 +114,23 @@ class Module
/** /**
* Resolve service context: system service, WHMCS service, control panel, and curl client. * 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 * @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) protected function resolveServiceContext($serviceID)
{ {
@@ -328,13 +382,37 @@ class Module
return false; 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])); $ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
$data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name'); $data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data); Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code'); $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) { } catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage()); 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. * 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 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 * @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 * @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); $this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
} }
/**
* Enforce POST as the HTTP method. Emits a 405 JSON response and exits otherwise.
*
* WHY THIS EXISTS
* ---------------
* The REST principle says mutations should be POST, and PHP's $_POST / $_GET
* separation means a mutation that reads from $_POST would fail quietly when
* called via GET. But "fail quietly" isn't what we want — an attacker probing
* endpoints via crafted <img src="?action=...&ip=...&ptr=..."> tags shouldn't
* even reach our input-validation code. This gate kills that path with a 405
* before any per-endpoint logic runs.
*
* Combined with requireSameOrigin() below, this closes the most common
* cross-site request forgery vectors (form POST, image GET) without needing
* explicit CSRF tokens threaded through every AJAX call.
*
* @return bool|void
*/
public function requirePost()
{
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
return true;
}
$this->output(['success' => false, 'errors' => 'method not allowed'], true, true, 405);
}
/**
* Verify the request's Origin/Referer belongs to this WHMCS install.
*
* THREAT MODEL
* ------------
* A logged-in WHMCS user visits a malicious page. That page makes a POST
* to our rDNS endpoint; because the session cookie is tied to our domain,
* the browser attaches it automatically. Without this check, the attacker
* could silently rewrite the user's PTRs.
*
* The defence: browsers attach an Origin header on cross-origin fetch/XHR
* and a Referer on cross-origin form POST. Those headers carry the
* attacker's origin, not ours — so we compare them against our own
* hostname and reject mismatches with a 403.
*
* This is NOT a full CSRF token scheme. It defends against the common
* cross-site-POST and cross-site-form-submit vectors but a same-site XSS
* that can read the user's DOM could still circumvent it. For that you'd
* need per-request tokens bound to the session — out of scope for the
* current module, but the helper stays here ready to be composed with
* a token check if one's added later.
*
* IMPLEMENTATION
* --------------
* 1. Collect our "known good" host set from HTTP_HOST (what the browser
* connected to) plus the SystemURL host from tblconfiguration (what
* WHMCS thinks its canonical URL is). Behind a reverse proxy these
* can differ; accepting either closes the false-positive gap.
* 2. Parse HTTP_ORIGIN and HTTP_REFERER and pull out their host:port.
* 3. Require at least one of those headers to match.
*
* Fails closed: if we can't determine our own host OR if neither Origin
* nor Referer is present, we reject. A legitimate same-origin AJAX call
* from the module's own JS always sets Origin (fetch API) or Referer
* (form submit), so the "both absent" case only happens with scripted
* non-browser clients — which are exactly who we want to filter out.
*
* @return bool|void true on success; emits 403 JSON and exits otherwise
*/
public function requireSameOrigin()
{
$expected = [];
$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
if ($host !== '') {
$expected[] = strtolower($host);
}
$systemUrl = Database::getSystemUrl();
if ($systemUrl) {
$parsed = parse_url($systemUrl);
if (! empty($parsed['host'])) {
$expected[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
$expected[] = strtolower($parsed['host']);
}
}
$expected = array_unique(array_filter($expected));
if (empty($expected)) {
// Can't determine our own host; fail closed rather than silently allow.
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
}
$origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
$candidates = [];
foreach ([$origin, $referer] as $raw) {
if ($raw === '') {
continue;
}
$parsed = parse_url($raw);
if (! empty($parsed['host'])) {
$candidates[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
$candidates[] = strtolower($parsed['host']);
}
}
if (empty($candidates)) {
$this->output(['success' => false, 'errors' => 'cross-origin check failed (missing origin)'], true, true, 403);
}
foreach ($candidates as $c) {
if (in_array($c, $expected, true)) {
return true;
}
}
Log::insert('csrf:origin-mismatch', ['origin' => $origin, 'referer' => $referer, 'expected' => $expected], 'cross-origin request rejected');
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
}
/**
* Ensure the WHMCS service is in a status where client-initiated writes make sense.
*
* tblhosting.domainstatus can be: Active, Suspended, Terminated, Pending,
* Cancelled, Fraud. Not every action makes sense in every status:
* - Reads (rdnsList, serverData) usually allow Active + Suspended so a
* suspended user can still see their current config.
* - Writes (rdnsUpdate, power, etc.) typically require Active only —
* mutating a cancelled service's rDNS has no sensible business meaning.
*
* Pass the allowed set explicitly per endpoint rather than trying to encode
* a global policy here. Some endpoints (admin reconcile) don't call this at
* all because the admin is allowed to touch any service.
*
* Fails with 404 if the service doesn't exist, 400 otherwise — keeping the
* two conditions distinct in the response code helps client-side error
* handling (a 404 usually means "link is stale", a 400 means "not right now").
*
* @param int $serviceID WHMCS service ID
* @param string[] $allowedStatuses Service statuses that permit the operation
* @return bool|void true on success; emits 400/404 JSON and exits otherwise
*/
public function requireServiceStatus(int $serviceID, array $allowedStatuses = ['Active'])
{
$row = Database::getWhmcsService($serviceID);
if (! $row) {
$this->output(['success' => false, 'errors' => 'service not found'], true, true, 404);
}
if (! in_array((string) $row->domainstatus, $allowedStatuses, true)) {
$this->output(
['success' => false, 'errors' => 'service status "' . (string) $row->domainstatus . '" does not permit this action'],
true,
true,
400,
);
}
return true;
}
/** /**
* Create a pre-configured Curl instance with JSON Accept/Content-Type headers * Create a pre-configured Curl instance with JSON Accept/Content-Type headers
* and a Bearer token for authenticating against the VirtFusion API. * and a Bearer token for authenticating against the VirtFusion API.

View File

@@ -5,8 +5,38 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
/** /**
* Extends Module to handle the WHMCS service lifecycle for VirtFusion servers. * Extends Module to handle the WHMCS service lifecycle for VirtFusion servers.
* *
* Responsibilities include: provisioning (create, suspend, unsuspend, terminate), * WHY A SEPARATE CLASS FROM MODULE
* package changes, usage updates, client area rendering, and admin tab fields. * --------------------------------
* 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 class ModuleFunctions extends Module
{ {
@@ -163,6 +193,33 @@ class ModuleFunctions extends Module
Database::systemOnServerCreate($params['serviceid'], $data); Database::systemOnServerCreate($params['serviceid'], $data);
$this->updateWhmcsServiceParamsOnServerObject($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. // If the server is created successfully, we can initialize the server build.
$cs = new ConfigureService; $cs = new ConfigureService;
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null; $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')) { switch ($request->getRequestInfo('http_code')) {
case 204: case 204:
$this->cleanupPowerDnsForService($service);
Database::deleteSystemService($params['serviceid']); Database::deleteSystemService($params['serviceid']);
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']); $this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
@@ -312,6 +370,7 @@ class ModuleFunctions extends Module
case 404: case 404:
if (isset($data->msg)) { if (isset($data->msg)) {
if ($data->msg == 'server not found') { if ($data->msg == 'server not found') {
$this->cleanupPowerDnsForService($service);
Database::deleteSystemService($params['serviceid']); Database::deleteSystemService($params['serviceid']);
return 'success'; 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. * 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') { if ($params['status'] != 'Terminated') {
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']); $fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
if (PowerDns\Config::isEnabled()) {
$fields['Reverse DNS'] = AdminHTML::rdnsSection($systemUrl, $params['serviceid']);
}
} }
return $fields; return $fields;
@@ -659,6 +748,7 @@ class ModuleFunctions extends Module
'serviceStatus' => $params['status'], 'serviceStatus' => $params['status'],
'serverHostname' => $serverHostname, 'serverHostname' => $serverHostname,
'selfServiceMode' => (int) ($params['configoption4'] ?? 0), 'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
'rdnsEnabled' => PowerDns\Config::isEnabled(),
], ],
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -0,0 +1,310 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
use WHMCS\Module\Server\VirtFusionDirect\Cache;
use WHMCS\Module\Server\VirtFusionDirect\Curl;
use WHMCS\Module\Server\VirtFusionDirect\Log;
/**
* Thin HTTP wrapper around the PowerDNS Authoritative HTTP API.
*
* WHY A SEPARATE CLIENT INSTEAD OF REUSING MODULE::INITCURL()
* -----------------------------------------------------------
* Module::initCurl() is hardcoded to Bearer auth for VirtFusion. PowerDNS uses
* X-API-Key, and mixing the two authorization styles inside one factory method
* would either require a new flag (leaky abstraction) or accidental leakage of
* the VirtFusion token into a PowerDNS request. A dedicated wrapper keeps the
* two credential flows completely isolated — a bug in PowerDNS handling can
* never leak a VirtFusion token, and vice versa.
*
* LOGGING RULES
* -------------
* We NEVER pass the API key or any header containing it to Log::insert().
* PATCH/NOTIFY calls log the zone+operation+HTTP code, successes log minimally,
* errors include up to 500 bytes of response body (PowerDNS error responses are
* small JSON fragments, not customer data). The Curl class doesn't capture
* request headers by default (CURLOPT_HEADER is off), so even the internal
* request_header field doesn't contain the API key.
*
* CACHING
* -------
* listZones() caches the zone list via the module's Cache class (Redis/filesystem)
* for Config::cacheTtl() seconds. Zone lists rarely change — the TTL balances
* "pick up a newly-created zone soon" against "don't hammer PowerDNS for every
* listZones call across unrelated lifecycle events".
*
* getZone() and patchRRset() are NOT cached here; per-request memoisation of
* getZone results lives in PtrManager::getZoneCached so it can invalidate on
* write from within the same request.
*
* SINGLE-USE CURL INSTANCES
* -------------------------
* newCurl() returns a fresh Curl for every HTTP call. That's how the existing
* module's Curl class is designed — reusing a handle across requests produces
* undefined behaviour because options from the first call bleed into the second.
* It's cheap (curl_init is microseconds).
*/
class Client
{
/** @var string */
private $endpoint;
/** @var string */
private $apiKey;
/** @var string */
private $serverId;
/**
* @param array<string,mixed>|null $config Optional pre-resolved config; defaults to PowerDns\Config::get()
*/
public function __construct(?array $config = null)
{
$config = $config ?? Config::get();
$this->endpoint = rtrim((string) ($config['endpoint'] ?? ''), '/');
$this->apiKey = (string) ($config['apiKey'] ?? '');
$this->serverId = (string) ($config['serverId'] ?? 'localhost');
}
/** Base URL for the configured PowerDNS server (no trailing slash). */
private function base(): string
{
return $this->endpoint . '/api/v1/servers/' . rawurlencode($this->serverId);
}
/**
* Encode a zone name to its PowerDNS URL-safe id form.
*
* PowerDNS's API uses a custom URL encoding for zone names that have characters
* like "/" which would collide with path semantics. Instead of using %-encoding
* (which many HTTP frameworks would parse back out at routing time), PowerDNS
* uses "=HH" where HH is the hex code — so "/" becomes "=2F".
*
* This only matters for RFC 2317 classless-delegation zone names like
* "64/64.113.0.203.in-addr.arpa." whose zone id in the API is
* "64=2F64.113.0.203.in-addr.arpa.". Standard zones pass through unchanged
* because they contain no "/" characters.
*
* Using rawurlencode() here would produce "%2F" which PowerDNS does NOT accept.
* That's why this is a plain str_replace.
*/
private function zoneIdEncode(string $zoneName): string
{
return str_replace('/', '=2F', rtrim($zoneName, '.') . '.');
}
/** Fresh Curl instance with PowerDNS auth + JSON headers. */
private function newCurl(): Curl
{
$curl = new Curl;
$curl->addOption(CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json; charset=utf-8',
'X-API-Key: ' . $this->apiKey,
]);
return $curl;
}
/**
* Healthcheck. Returns [ok: bool, http: int, error: ?string].
* Used by the addon's Test Connection button and by VirtFusionDirect_TestConnection().
*
* @return array{ok: bool, http: int, error: ?string}
*/
public function ping(): array
{
try {
$curl = $this->newCurl();
$body = $curl->get($this->base());
$http = (int) $curl->getRequestInfo('http_code');
if ($http === 200) {
return ['ok' => true, 'http' => 200, 'error' => null];
}
if ($http === 0) {
$err = (string) ($curl->getRequestInfo('curl_error') ?: 'connection failed');
return ['ok' => false, 'http' => 0, 'error' => $err];
}
if ($http === 401 || $http === 403) {
return ['ok' => false, 'http' => $http, 'error' => 'authentication failed (check API key)'];
}
return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)];
} catch (\Throwable $e) {
Log::insert('PowerDns:ping', [], $e->getMessage());
return ['ok' => false, 'http' => 0, 'error' => $e->getMessage()];
}
}
/**
* List every zone on the configured PowerDNS server.
*
* Result is cached for the configured cacheTtl. Used as the primary zone-discovery
* strategy: PtrManager finds the containing zone for a PTR name by longest-suffix
* matching against this list rather than probing individual zones.
*
* @return string[] Zone names with trailing dot
*/
public function listZones(): array
{
$ttl = Config::cacheTtl();
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$zones = [];
try {
$curl = $this->newCurl();
$body = $curl->get($this->base() . '/zones');
$http = (int) $curl->getRequestInfo('http_code');
if ($http === 200) {
$decoded = json_decode((string) $body, true);
if (is_array($decoded)) {
foreach ($decoded as $z) {
if (! empty($z['name'])) {
$zones[] = rtrim((string) $z['name'], '.') . '.';
}
}
}
} else {
Log::insert('PowerDns:listZones', ['http' => $http], substr((string) $body, 0, 500));
}
} catch (\Throwable $e) {
Log::insert('PowerDns:listZones', [], $e->getMessage());
}
Cache::set($cacheKey, $zones, $ttl);
return $zones;
}
/** Drop any cached zone list (call after PATCHes or settings changes). */
public function forgetZoneCache(): void
{
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
Cache::forget($cacheKey);
}
/**
* Fetch a single zone by name. Returns decoded JSON array, or null on 404/error.
*
* @return array<string,mixed>|null
*/
public function getZone(string $zoneName): ?array
{
try {
$zoneName = rtrim($zoneName, '.') . '.';
$curl = $this->newCurl();
$body = $curl->get($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
$http = (int) $curl->getRequestInfo('http_code');
if ($http === 200) {
$decoded = json_decode((string) $body, true);
return is_array($decoded) ? $decoded : null;
}
if ($http !== 404) {
Log::insert('PowerDns:getZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 500));
}
} catch (\Throwable $e) {
Log::insert('PowerDns:getZone', ['zone' => $zoneName], $e->getMessage());
}
return null;
}
/**
* Apply an RRset change to a zone via PATCH.
*
* $rrset keys (per PowerDNS API): name, type, ttl?, changetype (REPLACE|DELETE|EXTEND), records[].
* On success PowerDNS returns 204 No Content.
*
* @return array{ok: bool, http: int, body: string}
*/
public function patchRRset(string $zoneName, array $rrset): array
{
try {
$zoneName = rtrim($zoneName, '.') . '.';
if (isset($rrset['name'])) {
$rrset['name'] = rtrim((string) $rrset['name'], '.') . '.';
}
$payload = ['rrsets' => [$rrset]];
$curl = $this->newCurl();
$curl->addOption(CURLOPT_POSTFIELDS, json_encode($payload));
$body = $curl->patch($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
$http = (int) $curl->getRequestInfo('http_code');
Log::insert(
'PowerDns:patchRRset',
[
'zone' => $zoneName,
'name' => $rrset['name'] ?? null,
'type' => $rrset['type'] ?? null,
'changetype' => $rrset['changetype'] ?? null,
],
['http' => $http, 'body' => substr((string) $body, 0, 500)],
);
if ($http === 204) {
// Fire-and-forget NOTIFY so slaves pick up the bumped SOA serial immediately.
//
// Background: PowerDNS auto-increments SOA on every API write when the zone
// has soa_edit_api=INCREASE (recommended; see README). Slaves normally learn
// about the new serial via polling at the refresh interval (often 15+ min)
// OR via a NOTIFY push from the master. Without our NOTIFY, rDNS changes
// made via this module would take effect on the authoritative master
// immediately but wouldn't propagate until the next scheduled poll.
//
// Only meaningful for Master-kind zones. For Native zones (no slaves) or
// Slave zones (reverse direction), PowerDNS returns a 422 or similar —
// notifyZone() logs that and returns ok=false, but we don't care here:
// the PATCH itself succeeded, which is what we report upward.
$this->notifyZone($zoneName);
}
return ['ok' => $http === 204, 'http' => $http, 'body' => (string) $body];
} catch (\Throwable $e) {
Log::insert('PowerDns:patchRRset', ['zone' => $zoneName], $e->getMessage());
return ['ok' => false, 'http' => 0, 'body' => $e->getMessage()];
}
}
/**
* Send a DNS NOTIFY to all slaves for this zone. Only applicable to Master-kind zones;
* PowerDNS returns 400/422 for Native/Slave kinds and that's fine — we log and continue.
*
* SOA serial bumping itself is handled by PowerDNS (soa_edit_api=INCREASE or similar
* on the zone); this call just ensures slaves learn about the new serial right away
* rather than waiting for the next scheduled refresh.
*
* @return array{ok: bool, http: int}
*/
public function notifyZone(string $zoneName): array
{
try {
$zoneName = rtrim($zoneName, '.') . '.';
$curl = $this->newCurl();
$body = $curl->put($this->base() . '/zones/' . $this->zoneIdEncode($zoneName) . '/notify');
$http = (int) $curl->getRequestInfo('http_code');
if ($http !== 200) {
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 300));
}
return ['ok' => $http === 200, 'http' => $http];
} catch (\Throwable $e) {
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName], $e->getMessage());
return ['ok' => false, 'http' => 0];
}
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
use WHMCS\Database\Capsule as DB;
use WHMCS\Module\Server\VirtFusionDirect\Log;
/**
* Loads PowerDNS addon settings from tbladdonmodules (module="virtfusiondns") and
* decrypts the API key using WHMCS's native decrypt() helper.
*
* WHY "LOOSE COUPLING" VIA TBLADDONMODULES
* ----------------------------------------
* WHMCS lets an operator activate/deactivate addon modules independently of server
* modules. If the server module required addon PHP code at load time (e.g. via
* require_once on the addon's files), deactivating the addon would fatal-error every
* checkout and service page.
*
* Instead, the server module reads raw rows from tbladdonmodules. If the addon is
* missing OR deactivated OR "enabled" is set to No, isEnabled() returns false and
* every PtrManager call site short-circuits. The server module never dereferences
* addon code directly; it just asks the DB "what are the PowerDNS settings?" and
* does nothing with them if they're absent.
*
* REQUEST-SCOPED CACHE
* --------------------
* get() caches the resolved config in a static property for the remainder of the
* PHP request. Without that, every PtrManager call would re-query tbladdonmodules
* and re-decrypt the API key — wasteful on the provisioning path where we touch
* PowerDNS 1-5 times per server. reset() is exposed for scenarios where settings
* change mid-request (the addon's _output() page after a vfdns_test click).
*
* API KEY HANDLING
* ----------------
* WHMCS stores password-type addon config fields encrypted in tbladdonmodules.value.
* We call decrypt() — the same helper the server-module uses for the VirtFusion
* bearer token — to get plaintext. If decryption fails (e.g. the WHMCS encryption
* key changed or the value was inserted manually as plaintext), we fall back to
* using the raw value. This is defensive; logs note the failure so an operator
* can diagnose.
*
* The decrypted key exists only in memory inside this process's request lifetime.
* It's passed to PowerDns\Client via the get() array and used for the X-API-Key
* header; it's never written to disk, logged, or sent anywhere except to the
* configured PowerDNS endpoint.
*/
class Config
{
/**
* Name used for this addon in modules/addons/ AND stored in tbladdonmodules.module.
* These two MUST match — WHMCS auto-lowercases the module directory name when
* writing to the DB, so "VirtFusionDns" (directory) becomes "virtfusiondns" here.
*/
public const MODULE_NAME = 'virtfusiondns';
/** @var array<string,mixed>|null Null = not loaded yet; an array = resolved settings */
private static $cached = null;
/**
* Force a reload on next get().
*
* Primary use case: the addon's _output() page calls this before re-fetching
* config so a test-connection click after saving settings sees the saved values.
* Most other code should NOT call this — the request-scoped cache is there for
* good performance reasons.
*/
public static function reset(): void
{
self::$cached = null;
}
/**
* Return the fully-resolved configuration array with decrypted apiKey.
*
* Keys: enabled(bool), endpoint(string), apiKey(string), serverId(string),
* defaultTtl(int), cacheTtl(int).
*/
public static function get(): array
{
if (self::$cached !== null) {
return self::$cached;
}
$config = [
'enabled' => false,
'endpoint' => '',
'apiKey' => '',
'serverId' => 'localhost',
'defaultTtl' => 3600,
'cacheTtl' => 60,
];
try {
// pluck('value', 'setting') returns a Collection keyed by 'setting' with
// 'value' as the values — so $rows['enabled'] reads the row where
// setting='enabled'. Efficient: one query regardless of how many
// settings exist.
$rows = DB::table('tbladdonmodules')
->where('module', self::MODULE_NAME)
->pluck('value', 'setting')
->toArray();
// WHMCS yesno fields store either "on"/"" or "1"/"0" depending on version
// and form handling. Accept all common truthy representations rather than
// relying on a single literal.
$enabledRaw = $rows['enabled'] ?? '';
$config['enabled'] = in_array(strtolower((string) $enabledRaw), ['on', 'yes', '1', 'true'], true);
// Trim trailing slash from endpoint so Client::base() can safely concatenate
// "/api/v1/..." without producing doubled slashes.
$config['endpoint'] = rtrim((string) ($rows['endpoint'] ?? ''), '/');
$config['serverId'] = (string) ($rows['serverId'] ?? 'localhost');
// Floor at 60s for defaultTtl and 10s for cacheTtl. Prevents a foot-gun
// where an operator accidentally saves "0" and causes PowerDNS to treat
// PTRs as non-cacheable (which some resolvers refuse) or this module to
// hammer PowerDNS on every call.
$config['defaultTtl'] = max(60, (int) ($rows['defaultTtl'] ?? 3600));
$config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60));
if (! empty($rows['apiKey'])) {
try {
// decrypt() is WHMCS's global helper — matches how the VirtFusion
// bearer token is handled in Module::getCP().
$decrypted = decrypt($rows['apiKey']);
// Fallback to raw value if decrypt returned empty or non-string —
// defends against the rare case where decrypt silently fails
// (wrong encryption key at rest) or the value was inserted
// manually as plaintext during development.
$config['apiKey'] = is_string($decrypted) && $decrypted !== '' ? $decrypted : (string) $rows['apiKey'];
} catch (\Throwable $e) {
// Even when decrypt throws, we try the raw value so a diagnostic
// path exists. Operator sees the decrypt error in the module log
// but isn't locked out of using the addon while they investigate.
$config['apiKey'] = (string) $rows['apiKey'];
Log::insert('PowerDns:Config', 'decrypt skipped', $e->getMessage());
}
}
} catch (\Throwable $e) {
// Any DB-level failure (table doesn't exist, connection dropped, etc.)
// leaves $config at its safe defaults — isEnabled() returns false,
// nothing gets written to PowerDNS, and the server module continues
// to provision as if the addon weren't installed.
Log::insert('PowerDns:Config', 'load failed', $e->getMessage());
}
self::$cached = $config;
return $config;
}
/** True only when the addon is activated, configured, and has both endpoint and key. */
public static function isEnabled(): bool
{
$c = self::get();
return $c['enabled'] && $c['endpoint'] !== '' && $c['apiKey'] !== '';
}
public static function endpoint(): string
{
return self::get()['endpoint'];
}
public static function apiKey(): string
{
return self::get()['apiKey'];
}
public static function serverId(): string
{
return self::get()['serverId'];
}
public static function defaultTtl(): int
{
return self::get()['defaultTtl'];
}
public static function cacheTtl(): int
{
return self::get()['cacheTtl'];
}
}

View File

@@ -0,0 +1,426 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
/**
* Pure static helpers for IP address manipulation and PTR-name construction.
*
* DESIGN NOTES
* ------------
* Everything here is pure — no I/O, no globals, no state. That matters for two reasons:
* 1. PtrManager can compose these helpers freely without worrying about test isolation.
* 2. They are safe to call inside tight loops (e.g. iterating every zone in PowerDNS
* and testing it against a PTR name) without triggering hidden network or DB hits.
*
* Naming conventions used here:
* - "PTR name" = the fully-qualified record name the PTR lives at,
* e.g. "5.113.0.203.in-addr.arpa." (trailing dot always).
* - "zone name" = the zone the record belongs to,
* e.g. "113.0.203.in-addr.arpa." (trailing dot always).
* - "nibble" = a single hex digit representing 4 bits, used in IPv6 reverse names.
* - "classless" = an RFC 2317 sub-zone like "64/64.113.0.203.in-addr.arpa." —
* a delegation of a sub-range of a /24, covered in parseClasslessZone().
*
* All zone/PTR strings are normalised with a trailing dot because PowerDNS's canonical
* form always carries one, and mixing dotted/undotted forms makes string comparison
* unreliable (".example.com." ≠ ".example.com").
*/
class IpUtil
{
/** Strict IPv4 validation (rejects "1", "::1", and other ambiguous forms). */
public static function isIpv4(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
}
/** Strict IPv6 validation (rejects IPv4-mapped, etc. — only pure v6 addresses). */
public static function isIpv6(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
/**
* Fully-expand an IPv6 address to 32 lowercase hex characters (no colons).
* e.g. 2001:db8::1 -> "20010db8000000000000000000000001"
*
* Why: PTR names under ip6.arpa use *all* 32 nibbles (no compression, no :: shorthand),
* so we need the fully-expanded form before we can reverse the nibbles.
*
* Implementation: inet_pton normalises any valid IPv6 notation to 16 raw bytes,
* and bin2hex turns that into 32 lowercase hex chars. No manual padding/splitting
* logic means we can't get ":" vs "::" compression wrong.
*
* @return string|null 32-char hex string, or null if input isn't valid IPv6
*/
public static function expandIpv6(string $ip): ?string
{
$bin = @inet_pton($ip);
// inet_pton returns 16 bytes for v6, 4 bytes for v4. Guard on both conditions
// so a valid IPv4 like "192.0.2.1" doesn't silently pass through this v6 helper.
if ($bin === false || strlen($bin) !== 16) {
return null;
}
return bin2hex($bin);
}
/**
* Build the fully-qualified PTR name (trailing dot) for an IPv4 or IPv6 address.
*
* IPv4 example: 203.0.113.5 -> "5.113.0.203.in-addr.arpa."
* IPv6 example: 2001:db8::1 -> "1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa."
*
* @return string|null PTR name with trailing dot, or null if input isn't a valid IP
*/
public static function ptrNameForIp(string $ip): ?string
{
// IPv4: reverse the four octets and suffix with in-addr.arpa.
// 203.0.113.5 -> 5.113.0.203.in-addr.arpa.
if (self::isIpv4($ip)) {
$octets = array_reverse(explode('.', $ip));
return implode('.', $octets) . '.in-addr.arpa.';
}
// IPv6: expand to 32 nibbles, reverse each nibble, suffix with ip6.arpa.
// 2001:db8::1 -> 1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa.
// The nibble-level reversal (not byte-level) is important: each hex digit
// becomes its own DNS label. inet_pton/bin2hex give us the 32-char form;
// str_split with no length arg defaults to 1 so each char becomes one label.
if (self::isIpv6($ip)) {
$hex = self::expandIpv6($ip);
if ($hex === null) {
return null;
}
$nibbles = array_reverse(str_split($hex));
return implode('.', $nibbles) . '.ip6.arpa.';
}
return null;
}
/**
* Extract every host IP address (v4 and v6) from a VirtFusion server object.
*
* Walks every interface, not just interfaces[0] (ServerResource only reads the primary).
* Handles both explicit `address` fields and `subnet`+`cidr` pairs.
* For IPv6 entries exposed only as `subnet`+`cidr`, the subnet base is used when
* the cidr is /128 (single host); otherwise the entry is skipped and reported.
*
* @param object|array $serverObject Raw VirtFusion server payload (may be wrapped in `data`)
* @return array{addresses: string[], skipped: array} Deduped IP strings + array of skipped entries with reasons
*/
public static function extractIps($serverObject): array
{
$addresses = [];
$skipped = [];
// Normalise object-or-array input. json_decode(json_encode($x), true) is the
// cheapest defensive way to turn a stdClass tree (VirtFusion's response) or
// an already-decoded array (stored server_object blob) into a uniform array.
if (is_object($serverObject)) {
$serverObject = json_decode(json_encode($serverObject), true);
}
if (! is_array($serverObject)) {
return ['addresses' => [], 'skipped' => []];
}
// VirtFusion wraps the payload in a "data" key on GET responses but the stored
// server_object blob is sometimes already unwrapped. Accept both shapes.
$data = $serverObject['data'] ?? $serverObject;
$interfaces = $data['network']['interfaces'] ?? [];
if (! is_array($interfaces)) {
return ['addresses' => [], 'skipped' => []];
}
// Walk every interface (not just interfaces[0]). ServerResource only reads [0]
// because it's building display data for the "primary" IP; rDNS needs PTRs
// for every IP no matter which interface it lives on.
foreach ($interfaces as $iface) {
foreach (($iface['ipv4'] ?? []) as $v4) {
// Accept both "address" and "ip" field names — VirtFusion's schema
// has evolved and we want the module to survive minor shape changes.
$candidate = $v4['address'] ?? ($v4['ip'] ?? null);
if ($candidate && self::isIpv4($candidate)) {
// Use the IP as an array key for free de-duplication. If the same
// IP appears on two interfaces (unusual but possible), we write
// one PTR not two.
$addresses[$candidate] = true;
}
}
foreach (($iface['ipv6'] ?? []) as $v6) {
// Preferred shape: a discrete host address (the normal v6 pattern).
$candidate = $v6['address'] ?? ($v6['ip'] ?? null);
if ($candidate && self::isIpv6($candidate)) {
$addresses[$candidate] = true;
continue;
}
// Fallback shape: VirtFusion sometimes exposes v6 only as subnet+cidr
// (common when a /64 is routed to the VPS and the OS auto-assigns
// specific host addresses). We can't set a PTR for the whole subnet,
// so we only accept /128 (single-host) entries and report the rest
// via the "skipped" channel so callers can surface a UI note.
$subnet = $v6['subnet'] ?? null;
$cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null;
if ($subnet && self::isIpv6($subnet)) {
if ($cidr === 128) {
$addresses[$subnet] = true;
} else {
$skipped[] = [
'subnet' => $subnet,
'cidr' => $cidr,
'reason' => 'ipv6-subnet-without-explicit-host-address',
];
}
}
}
}
// array_keys gives us the de-duplicated list in insertion order.
return ['addresses' => array_keys($addresses), 'skipped' => $skipped];
}
/**
* Find the longest-suffix zone from a list of zone names that contains a given PTR name.
* Both inputs are normalised to a trailing dot before matching.
*
* @param string $ptrName Fully-qualified PTR name (with or without trailing dot)
* @param string[] $zones List of zone names from PowerDNS (with or without trailing dots)
* @return string|null Matching zone name with trailing dot, or null if no zone covers the PTR
*/
public static function findContainingZone(string $ptrName, array $zones): ?string
{
$ptrName = rtrim($ptrName, '.') . '.';
$best = null;
$bestLen = 0;
foreach ($zones as $zone) {
if (! is_string($zone) || $zone === '') {
continue;
}
if (strpos($zone, '/') !== false) {
// RFC 2317 classless zones can't be identified by plain suffix match:
// a PTR like "5.113.0.203.in-addr.arpa." does NOT end with
// ".64/64.113.0.203.in-addr.arpa." even when 5 is in range. Range
// matching lives in findZoneAndPtrName; this helper is kept for any
// caller that only deals with standard zones.
continue;
}
$z = rtrim($zone, '.') . '.';
// Prefix with "." so a zone "example.com." doesn't accidentally match
// "foo.anotherexample.com." via naive substring compare.
$suffix = '.' . $z;
if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) {
// Longest match wins. For nested delegations (e.g. both
// "0.203.in-addr.arpa." and "113.0.203.in-addr.arpa." exist),
// the more specific one is the correct authoritative zone.
$len = strlen($z);
if ($len > $bestLen) {
$best = $z;
$bestLen = $len;
}
}
}
return $best;
}
/**
* Parse an RFC 2317 classless-delegation IPv4 reverse zone name.
*
* RFC 2317 lets a /24 owner delegate sub-ranges of that /24 to separate
* authoritative servers by creating CNAMEs in the parent zone that point
* into a named sub-zone. The sub-zone's label conventionally uses "X/Y"
* where the slash carries structural meaning, not path semantics.
*
* Two "Y" conventions exist in the wild. We accept both:
*
* (a) Y is a CIDR prefix length, Y ∈ [24, 32]. Standard per the RFC.
* "64/26.113.0.203.in-addr.arpa." — /26 → 64 addresses → covers 64..127
* "0/25.1.168.192.in-addr.arpa." — /25 → 128 addresses → covers 0..127
*
* (b) Y is a block size (count of addresses), Y > 32. Non-standard but
* used by some operators because the label reads naturally:
* "64/64.113.0.203.in-addr.arpa." — size 64 → covers 64..127
*
* We disambiguate by Y's magnitude: ≤32 is a prefix length, >32 is a count.
* (Y=32 would be "a single-host delegation", valid under convention (a).)
*
* ALIGNMENT CHECK
* ---------------
* We also verify X is a multiple of the block size. Misaligned entries
* like "3/26.x.y.z" don't correspond to any real DNS delegation — a /26
* must start at a multiple of 64 (0, 64, 128, or 192). Rejecting these
* prevents silent write-into-wrong-zone if an operator mis-names a zone.
*
* @return array{parent: string, start: int, end: int}|null
* parent: parent /24 reverse zone name with trailing dot (e.g. "113.0.203.in-addr.arpa.")
* start/end: inclusive last-octet range covered by this classless zone
*/
public static function parseClasslessZone(string $zone): ?array
{
$zone = rtrim($zone, '.') . '.';
// Structural gate 1: must end in .in-addr.arpa. — classless only applies to IPv4.
if (substr($zone, -strlen('.in-addr.arpa.')) !== '.in-addr.arpa.') {
return null;
}
// Structural gate 2: must have at least 5 labels to contain both the
// classless label and a full /24 parent: "X/Y . o . o . o . in-addr . arpa . ''"
// The trailing empty label from the terminal dot bumps this to ≥ 7 in practice,
// but 5 is the minimum we need to safely slice below.
$labels = explode('.', $zone);
if (count($labels) < 5) {
return null;
}
// Structural gate 3: the first label must contain a "/". If not, this is a
// standard zone (e.g. "113.0.203.in-addr.arpa.") — let the caller handle it.
$first = $labels[0];
if (strpos($first, '/') === false) {
return null;
}
// Parse "X/Y" — reject if either side isn't a non-negative integer.
$parts = explode('/', $first, 2);
if (count($parts) !== 2 || ! ctype_digit($parts[0]) || ! ctype_digit($parts[1])) {
return null;
}
$x = (int) $parts[0];
$y = (int) $parts[1];
// X must fit in an octet; Y must be positive (0 and negative make no sense).
if ($x < 0 || $x > 255 || $y <= 0) {
return null;
}
// Map Y → block size using the dual-convention rule described in the doc-block.
if ($y <= 32) {
// CIDR prefix convention. Values <24 would cross /24 boundaries (outside
// the scope of a single-/24 delegation), >32 is impossible for IPv4.
if ($y < 24 || $y > 32) {
return null;
}
// 1 << (32 - Y) gives the block size. Y=24→256 (whole /24), Y=32→1 (host).
$size = 1 << (32 - $y);
} else {
// Block-size convention. Accept any positive Y that fits the /24 range check below.
$size = $y;
}
// Alignment: X must sit on a block boundary. For size=64, legal starts are
// 0, 64, 128, 192. Mis-alignments indicate a misconfigured zone label.
if ($x % $size !== 0) {
return null;
}
$end = $x + $size - 1;
// The range must stay within the parent /24 (last octet 0..255).
if ($end > 255) {
return null;
}
// The parent zone is everything after the first label, i.e. the /24 reverse zone.
// array_slice(labels, 1) drops "X/Y" and the implode reconstructs the trailing-dot form.
$parent = implode('.', array_slice($labels, 1));
return ['parent' => $parent, 'start' => $x, 'end' => $end];
}
/**
* Resolve an IP to its (zone, ptrName) pair in one shot, handling both standard
* reverse zones and RFC 2317 classless delegations.
*
* For a classless match, the returned ptrName includes the classless zone
* label (e.g. "100.64/64.113.0.203.in-addr.arpa.") — this is the actual DNS
* name the PTR record lives at in PowerDNS. Classless zones take precedence
* over any matching parent zone, because in a properly-delegated setup the
* parent only holds CNAMEs pointing into the classless sub-zone.
*
* @param string[] $zones Zone names from PowerDNS (trailing dots optional)
* @return array{zone: string, ptrName: string}|null
*/
public static function findZoneAndPtrName(string $ip, array $zones): ?array
{
$ptrName = self::ptrNameForIp($ip);
if ($ptrName === null) {
return null;
}
$ipv4 = self::isIpv4($ip);
// Extract the last octet up front for classless range comparison.
// Only meaningful for IPv4 since RFC 2317 is IPv4-only (IPv6 delegations
// naturally align on nibble boundaries and don't need classless tricks).
$lastOctet = null;
if ($ipv4) {
$octets = explode('.', $ip);
$lastOctet = (int) $octets[3];
}
$bestDirect = null;
$bestDirectLen = 0;
$classlessMatch = null;
// Single pass over the zone list, bucketing each candidate into the
// classless path or the direct-suffix-match path.
foreach ($zones as $zone) {
if (! is_string($zone) || $zone === '') {
continue;
}
$z = rtrim($zone, '.') . '.';
if (strpos($z, '/') !== false) {
// Classless path. Skip for IPv6 entirely.
if (! $ipv4) {
continue;
}
$parsed = self::parseClasslessZone($z);
if ($parsed === null) {
// Malformed classless zone name (misaligned, wrong TLD, etc.) — skip.
continue;
}
// The PTR still needs to suffix-match the PARENT zone; otherwise the
// classless zone lives under a different /24 and isn't relevant.
$parentSuffix = '.' . $parsed['parent'];
if (substr($ptrName, -strlen($parentSuffix)) !== $parentSuffix) {
continue;
}
// Range gate: the host octet must fall inside this classless zone's window.
if ($lastOctet < $parsed['start'] || $lastOctet > $parsed['end']) {
continue;
}
// The record name inside a classless zone prepends the full host octet
// to the classless label, e.g. PTR "100" lives at:
// "100.64/64.113.0.203.in-addr.arpa."
// (NOT "100.113.0.203.in-addr.arpa." — the classless sub-zone holds the RRset).
$classlessMatch = [
'zone' => $z,
'ptrName' => $lastOctet . '.' . $z,
];
continue;
}
// Direct suffix-match path (standard reverse zones).
$suffix = '.' . $z;
if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) {
// Longest-match wins (see findContainingZone() for rationale).
if (strlen($z) > $bestDirectLen) {
$bestDirect = ['zone' => $z, 'ptrName' => $ptrName];
$bestDirectLen = strlen($z);
}
}
}
// PRECEDENCE: classless beats direct. In a correctly-delegated RFC 2317 setup
// the parent /24 zone only contains CNAMEs pointing into the classless sub-zone —
// it does NOT hold the PTR RRset directly. Writing to the parent would create a
// record that's shadowed by the CNAME and never consulted during resolution.
return $classlessMatch ?? $bestDirect;
}
}

View File

@@ -0,0 +1,716 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
use WHMCS\Database\Capsule as DB;
use WHMCS\Module\Server\VirtFusionDirect\Cache;
use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log;
/**
* Orchestrates PTR lifecycle against PowerDNS for VirtFusion servers.
*
* RESPONSIBILITIES
* ----------------
* - Compute zone membership for a given IP by matching against PowerDNS's zone list
* - Verify forward DNS (A/AAAA) before writing any PTR; never write a PTR whose
* hostname doesn't already resolve to the target IP
* - Preserve client-customised PTRs during server renames (only overwrite PTRs
* whose current content equals the previous hostname)
* - Provide read-through views for client-area and admin panels with status flags
* - Support an explicit admin reconcile (optionally forceful) and an additive-only
* cron reconciliation that never overwrites existing values
*
* CACHING MODEL
* -------------
* Two tiers, both serving different purposes:
*
* $zoneListCache — the list of every zone PowerDNS knows about. Populated once
* per PtrManager instance via locate(). The underlying Client
* caches the HTTP response for Config::cacheTtl() seconds across
* requests; this instance field just memoises the lookup within
* one request so multiple IPs on the same server don't each
* call Client::listZones().
*
* $zoneCache — decoded RRset contents of individual zones, keyed by zone
* name. Populated lazily as findPtrRRset() looks up each IP's
* zone. IMPORTANT: request-scoped only — we must invalidate on
* writes (see invalidateZone) so a read-after-write within the
* same request sees fresh data. This is why deletePtr/writePtr
* call invalidateZone before returning.
*
* Neither cache is shared between PtrManager instances (new PtrManager per WHMCS
* request is cheap). The Client's HTTP-response cache IS shared across requests via
* the module's Cache class (Redis or filesystem), which is where cross-request
* amortisation happens.
*
* SHORT-CIRCUIT BEHAVIOUR
* -----------------------
* Every public method checks Config::isEnabled() and returns an empty/no-op summary
* when the addon is inactive. This means unrelated calling code (createAccount,
* terminateAccount, renameServer, the client panel endpoint, cron) can always
* invoke PtrManager without a feature flag — the gate lives here.
*
* The summary arrays deliberately include 'enabled' => bool so test harnesses and
* admin UIs can tell "we did nothing because disabled" apart from "we did nothing
* because there were no IPs".
*/
class PtrManager
{
/** @var Client */
private $client;
/** @var array<string, array<string,mixed>|null> Request-scoped zone contents cache, keyed by zone name */
private $zoneCache = [];
/** @var string[]|null Request-scoped zone-list memo (Client handles cross-request caching) */
private $zoneListCache = null;
public function __construct(?Client $client = null)
{
// Dependency-inject the Client so tests can pass a mock; default to the
// Config-driven instance so production code never has to wire this up.
$this->client = $client ?? new Client;
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/**
* Sync PTRs for every IP on the given server object.
*
* TWO MODES OF OPERATION
* ----------------------
* CREATE ($oldHostname = null) — provisioning path.
* Write $newHostname to every IP that doesn't
* already have a PTR. Pre-existing PTRs are
* preserved (shouldn't exist on a new server,
* but if they do they're likely left over from
* a previous owner of the IP and must not be
* silently overwritten).
*
* RENAME ($oldHostname given) — rename path.
* Only overwrite PTRs whose current content
* equals $oldHostname. Anything else was set
* by the client (custom rDNS like mail servers
* need to match HELO) and must be preserved.
*
* The forward-DNS check runs before every write. A PTR without a matching
* A/AAAA is FCrDNS-broken and actively harms deliverability, so we'd rather
* leave the PTR absent than set a broken one.
*
* ERROR SEMANTICS
* ---------------
* This method never throws. Every per-IP failure is caught, logged, and
* recorded in $summary['errors']. Lifecycle callers (createAccount,
* renameServer) wrap the call in their own try/catch as belt-and-braces,
* but the expectation is that DNS issues never bubble up to WHMCS as
* provisioning failures.
*
* @param object|array $serverObject VirtFusion server payload
* @return array Summary counts: written, preserved, forward_missing, no_zone, skipped_ipv6, errors, details[]
*/
public function syncServer($serverObject, ?string $oldHostname, string $newHostname): array
{
$summary = [
'enabled' => false,
'written' => 0,
'preserved' => 0,
'forward_missing' => 0,
'no_zone' => 0,
'skipped_ipv6' => 0,
'errors' => 0,
'details' => [],
];
if (! Config::isEnabled()) {
return $summary;
}
$summary['enabled'] = true;
$extracted = IpUtil::extractIps($serverObject);
// Report (not write) v6 subnet-only allocations. UI can surface "IPv6 PTR
// not configured — /64 without explicit host" as guidance.
$summary['skipped_ipv6'] = count($extracted['skipped']);
foreach ($extracted['addresses'] as $ip) {
try {
$loc = $this->locate($ip);
if ($loc === null) {
// IP isn't covered by any zone we host. Not an error — the
// operator may manage reverse DNS for this range elsewhere.
$summary['no_zone']++;
$summary['details'][] = ['ip' => $ip, 'status' => 'no-zone'];
continue;
}
$current = $this->readPtr($loc);
// Rename-mode preservation check. The "current PTR equals old
// hostname" comparison is the whole safety mechanism for protecting
// client-custom rDNS across server renames — see class docblock.
// On CREATE mode ($oldHostname === null) we skip this branch,
// which means pre-existing PTRs on a new IP get overwritten; this
// is acceptable because a fresh IP shouldn't have PTRs yet.
if ($oldHostname !== null && $current !== null) {
if (self::normalizeHost($current) !== self::normalizeHost($oldHostname)) {
$summary['preserved']++;
$summary['details'][] = ['ip' => $ip, 'status' => 'preserved', 'current' => $current];
continue;
}
}
if (! Resolver::resolvesTo($newHostname, $ip, Config::cacheTtl())) {
$summary['forward_missing']++;
$summary['details'][] = ['ip' => $ip, 'status' => 'forward-missing', 'desired' => $newHostname];
Log::insert('PowerDns:syncServer', ['ip' => $ip, 'hostname' => $newHostname], 'forward DNS mismatch; PTR skipped');
continue;
}
$result = $this->writePtr($loc, $newHostname);
if ($result['ok']) {
$summary['written']++;
$summary['details'][] = ['ip' => $ip, 'status' => 'written', 'content' => $newHostname];
} else {
$summary['errors']++;
$summary['details'][] = ['ip' => $ip, 'status' => 'error', 'http' => $result['http']];
}
} catch (\Throwable $e) {
$summary['errors']++;
Log::insert('PowerDns:syncServer', ['ip' => $ip], $e->getMessage());
}
}
return $summary;
}
/**
* Delete every PTR belonging to the given server.
*
* @return array Summary counts: deleted, no_zone, errors
*/
public function deleteForServer($serverObject): array
{
$summary = ['enabled' => false, 'deleted' => 0, 'no_zone' => 0, 'errors' => 0];
if (! Config::isEnabled()) {
return $summary;
}
$summary['enabled'] = true;
$extracted = IpUtil::extractIps($serverObject);
foreach ($extracted['addresses'] as $ip) {
try {
$loc = $this->locate($ip);
if ($loc === null) {
$summary['no_zone']++;
continue;
}
$result = $this->deletePtr($loc);
if ($result['ok']) {
$summary['deleted']++;
} else {
$summary['errors']++;
}
} catch (\Throwable $e) {
$summary['errors']++;
Log::insert('PowerDns:deleteForServer', ['ip' => $ip], $e->getMessage());
}
}
return $summary;
}
/**
* Produce a per-IP status list suitable for client-area and admin display.
*
* Each entry: [ip, ptr, ttl, zone, status]
* Status values: ok, unverified, missing, no-zone, error, disabled.
*
* @return array<int, array<string,mixed>>
*/
public function listPtrs($serverObject, ?string $expectedHostname = null): array
{
$out = [];
$extracted = IpUtil::extractIps($serverObject);
if (! Config::isEnabled()) {
foreach ($extracted['addresses'] as $ip) {
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'disabled'];
}
return $out;
}
foreach ($extracted['addresses'] as $ip) {
try {
$loc = $this->locate($ip);
if ($loc === null) {
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'no-zone'];
continue;
}
$rrset = $this->findPtrRRset($loc);
if ($rrset === null) {
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => $loc['zone'], 'status' => 'missing'];
continue;
}
$ptr = $rrset['content'];
$status = Resolver::resolvesTo($ptr, $ip, Config::cacheTtl()) ? 'ok' : 'unverified';
$out[] = [
'ip' => $ip,
'ptr' => $ptr,
'ttl' => $rrset['ttl'],
'zone' => $loc['zone'],
'status' => $status,
];
} catch (\Throwable $e) {
Log::insert('PowerDns:listPtrs', ['ip' => $ip], $e->getMessage());
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'error'];
}
}
return $out;
}
/**
* Client-initiated PTR set/delete.
*
* Differences from syncServer():
* - Only ever writes one PTR, not a whole server's worth
* - Rate-limited per IP (10s window) to stop save-button abuse
* - Forward-DNS failure is a HARD REJECT that surfaces to the user — not a
* silent skip like the automatic paths. The client wants immediate feedback
* when their A record is missing.
* - Empty content path is an explicit delete (DELETE changetype, not REPLACE-empty)
*
* IP-OWNERSHIP NOTE
* -----------------
* This method TRUSTS that the caller has already verified the client owns $ip —
* that check lives in the calling endpoint (client.php rdnsUpdate) where it has
* access to the WHMCS session. If you call setPtr() from a new code path, you
* MUST add the ownership guard upstream of it.
*
* @return array{ok: bool, reason: string, http?: int}
* reason values: disabled, invalid-ip, rate-limited, no-zone,
* forward-missing, deleted, delete-failed, written, write-failed
*/
public function setPtr(string $ip, string $content): array
{
if (! Config::isEnabled()) {
return ['ok' => false, 'reason' => 'disabled'];
}
if (! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
return ['ok' => false, 'reason' => 'invalid-ip'];
}
// Rate limit: one successful check per IP per 10s. Uses the module's
// two-tier Cache (Redis or filesystem), so the limit spans PHP processes.
// md5 of IP as the key keeps filesystem filenames short and safe.
$rateKey = 'pdns:write-lock:' . md5($ip);
if (Cache::get($rateKey) !== null) {
return ['ok' => false, 'reason' => 'rate-limited'];
}
// Set the lock BEFORE any downstream work so a parallel request racing
// through the same IP sees the lock and gets rate-limited cleanly.
Cache::set($rateKey, 1, 10);
$loc = $this->locate($ip);
if ($loc === null) {
return ['ok' => false, 'reason' => 'no-zone'];
}
$content = trim($content);
if ($content === '') {
$result = $this->deletePtr($loc);
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'deleted' : 'delete-failed', 'http' => $result['http']];
}
if (! Resolver::resolvesTo($content, $ip, Config::cacheTtl())) {
return ['ok' => false, 'reason' => 'forward-missing'];
}
$result = $this->writePtr($loc, $content);
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'written' : 'write-failed', 'http' => $result['http']];
}
/**
* Admin reconciliation for a single service.
*
* The user-facing purpose: "make the PTRs match what they should be, but don't
* step on client customisations unless I explicitly ask".
*
* Uses the STORED server_object (from mod_virtfusion_direct) rather than fetching
* fresh from VirtFusion. Reasons:
* 1. Admin reconcile runs from the services tab — no live-data dependency
* 2. Cron calls this once per service; fetching fresh would mean N VirtFusion
* calls per reconcile run
* 3. The stored object is the ground truth for "what IPs/hostname did this
* service have at last sync" — if VirtFusion temporarily returns a different
* shape, we'd rather work from known-good data than retry.
*
* If the stored state is materially out of date (e.g. IPs were added in VirtFusion
* after last sync), an admin should hit "Update Server Object" first.
*
* FORCE MODE
* ----------
* $force = true is the only code path in the entire module that overwrites a
* non-matching PTR. It's reachable exclusively via the admin "Reconcile (force
* reset)" button — never from cron, never from client writes, never from
* automatic lifecycle. This asymmetry is deliberate: forceful overrides are
* the admin's explicit choice, not a silent automation.
*
* @return array Summary counts: added, reset, preserved, forward_missing, no_zone, errors
*/
public function reconcile(int $serviceId, bool $force = false): array
{
$summary = [
'enabled' => false,
'added' => 0,
'reset' => 0,
'preserved' => 0,
'forward_missing' => 0,
'no_zone' => 0,
'errors' => 0,
];
if (! Config::isEnabled()) {
return $summary;
}
$summary['enabled'] = true;
$row = Database::getSystemService($serviceId);
if (! $row || empty($row->server_object)) {
$summary['errors']++;
return $summary;
}
$serverObject = json_decode($row->server_object, true);
if (! is_array($serverObject)) {
$summary['errors']++;
return $summary;
}
$hostname = self::extractHostname($serverObject);
if ($hostname === null) {
$summary['errors']++;
return $summary;
}
$extracted = IpUtil::extractIps($serverObject);
foreach ($extracted['addresses'] as $ip) {
try {
$loc = $this->locate($ip);
if ($loc === null) {
$summary['no_zone']++;
continue;
}
$current = $this->readPtr($loc);
$verified = Resolver::resolvesTo($hostname, $ip, Config::cacheTtl());
if ($current === null) {
if (! $verified) {
$summary['forward_missing']++;
continue;
}
$result = $this->writePtr($loc, $hostname);
if ($result['ok']) {
$summary['added']++;
} else {
$summary['errors']++;
}
continue;
}
if ($force && self::normalizeHost($current) !== self::normalizeHost($hostname)) {
if (! $verified) {
$summary['forward_missing']++;
continue;
}
$result = $this->writePtr($loc, $hostname);
if ($result['ok']) {
$summary['reset']++;
} else {
$summary['errors']++;
}
continue;
}
$summary['preserved']++;
} catch (\Throwable $e) {
$summary['errors']++;
Log::insert('PowerDns:reconcile', ['ip' => $ip, 'service' => $serviceId], $e->getMessage());
}
}
return $summary;
}
/**
* Cron reconciliation across every managed service.
*
* Called from the DailyCronJob hook. Iterates every row in mod_virtfusion_direct
* and runs reconcile() on each with $force = false. That means:
*
* - IPs missing a PTR get one (if forward DNS resolves)
* - Existing PTRs are NEVER touched, even if they differ from the hostname
*
* This asymmetry is the safety property. A brief forward-DNS blip during the
* cron window shouldn't trigger mass-rewrites that corrupt client-custom
* records. Admins who need forceful re-alignment must run the per-service
* "Reconcile (force reset)" button explicitly.
*
* Failures on individual services are logged and counted but never abort the
* job — a misconfigured single zone or one VirtFusion-unreachable service
* should not block reconciliation for the rest of the fleet.
*
* @return array Aggregate summary across all services
*/
public function reconcileAll(): array
{
$summary = [
'enabled' => false,
'services' => 0,
'added' => 0,
'preserved' => 0,
'forward_missing' => 0,
'no_zone' => 0,
'errors' => 0,
];
if (! Config::isEnabled()) {
return $summary;
}
$summary['enabled'] = true;
try {
$rows = DB::table(Database::SYSTEM_TABLE)->pluck('service_id');
} catch (\Throwable $e) {
Log::insert('PowerDns:reconcileAll', [], $e->getMessage());
return $summary;
}
foreach ($rows as $serviceId) {
$summary['services']++;
try {
$r = $this->reconcile((int) $serviceId, false);
$summary['added'] += $r['added'];
$summary['preserved'] += $r['preserved'];
$summary['forward_missing'] += $r['forward_missing'];
$summary['no_zone'] += $r['no_zone'];
$summary['errors'] += $r['errors'];
} catch (\Throwable $e) {
$summary['errors']++;
Log::insert('PowerDns:reconcileAll:service', ['service' => $serviceId], $e->getMessage());
}
}
Log::insert('PowerDns:reconcileAll', [], $summary);
return $summary;
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
/**
* Resolve an IP to the (zone, ptrName) pair using the cached zone list.
* Handles both standard and RFC 2317 classless zones (delegates to IpUtil).
*
* Memoised within this instance: the zone list is fetched once (via the Client,
* which itself caches across requests per Config::cacheTtl()) and reused for
* every IP of the current server. A server with 3 IPs in the same /24 therefore
* triggers ONE listZones call, not three.
*
* @return array{zone: string, ptrName: string}|null null means "no zone covers this IP"
*/
private function locate(string $ip): ?array
{
if ($this->zoneListCache === null) {
$this->zoneListCache = $this->client->listZones();
}
return IpUtil::findZoneAndPtrName($ip, $this->zoneListCache);
}
/** @return array<string,mixed>|null */
private function getZoneCached(string $zoneName): ?array
{
if (array_key_exists($zoneName, $this->zoneCache)) {
return $this->zoneCache[$zoneName];
}
$this->zoneCache[$zoneName] = $this->client->getZone($zoneName);
return $this->zoneCache[$zoneName];
}
/**
* Current PTR content for a located address, or null if absent.
*
* @param array{zone: string, ptrName: string} $loc
*/
private function readPtr(array $loc): ?string
{
$rrset = $this->findPtrRRset($loc);
return $rrset === null ? null : $rrset['content'];
}
/**
* Find a PTR RRset at the located name.
*
* @param array{zone: string, ptrName: string} $loc
* @return array{content: string, ttl: int}|null
*/
private function findPtrRRset(array $loc): ?array
{
$zone = $this->getZoneCached($loc['zone']);
if ($zone === null || empty($zone['rrsets']) || ! is_array($zone['rrsets'])) {
return null;
}
foreach ($zone['rrsets'] as $rrset) {
if (($rrset['type'] ?? '') !== 'PTR') {
continue;
}
if (self::normalizeHost($rrset['name'] ?? '') !== self::normalizeHost($loc['ptrName'])) {
continue;
}
$records = $rrset['records'] ?? [];
foreach ($records as $record) {
if (! empty($record['disabled'])) {
continue;
}
if (! empty($record['content'])) {
return [
'content' => rtrim((string) $record['content'], '.'),
'ttl' => (int) ($rrset['ttl'] ?? Config::defaultTtl()),
];
}
}
}
return null;
}
/**
* Write/replace a PTR record.
*
* Always uses REPLACE changetype rather than a create-then-update pattern —
* REPLACE is idempotent and atomic from PowerDNS's view, whereas separate
* create + update would briefly leave the record absent.
*
* Content is canonicalised to end with a trailing dot before sending (PowerDNS
* treats unqualified names as relative to the zone, which is not what we want
* for PTR content — "host.example.com" without a trailing dot would be stored
* as "host.example.com.113.0.203.in-addr.arpa.").
*
* @param array{zone: string, ptrName: string} $loc
* @return array{ok: bool, http: int}
*/
private function writePtr(array $loc, string $content): array
{
$content = rtrim(trim($content), '.') . '.';
$ttl = Config::defaultTtl();
$result = $this->client->patchRRset($loc['zone'], [
'name' => $loc['ptrName'],
'type' => 'PTR',
'ttl' => $ttl,
'changetype' => 'REPLACE',
'records' => [['content' => $content, 'disabled' => false]],
]);
$this->invalidateZone($loc['zone']);
return ['ok' => $result['ok'], 'http' => $result['http']];
}
/**
* Delete a PTR record.
*
* @param array{zone: string, ptrName: string} $loc
* @return array{ok: bool, http: int}
*/
private function deletePtr(array $loc): array
{
$result = $this->client->patchRRset($loc['zone'], [
'name' => $loc['ptrName'],
'type' => 'PTR',
'changetype' => 'DELETE',
]);
$this->invalidateZone($loc['zone']);
return ['ok' => $result['ok'], 'http' => $result['http']];
}
/**
* Drop the cached zone contents so the next read re-fetches from PowerDNS.
* Called after every successful write so read-after-write in the same request
* (e.g. listPtrs right after setPtr in a test harness) observes fresh data.
*/
private function invalidateZone(string $zoneName): void
{
unset($this->zoneCache[$zoneName]);
}
/**
* Normalise a hostname for comparison: lowercase, no trailing dot.
*
* DNS hostnames are case-insensitive and the trailing dot is syntactic, not
* semantic. PowerDNS returns content with a trailing dot ("host.example.com.");
* user input typically doesn't have one. Both forms of "FooBar.example.com."
* vs "foobar.example.com" should compare equal, which is what this produces.
*/
private static function normalizeHost(string $h): string
{
return strtolower(rtrim(trim($h), '.'));
}
/**
* Extract the server hostname from a VirtFusion server payload.
*
* Accepts either object or array shape, wrapped or unwrapped by a `data` property.
* Falls back to `name` when `hostname` is absent or "-", matching the semantics
* of the existing ServerResource::process() behavior.
*
* Public so lifecycle call sites (createAccount, renameServer) can pull the
* hostname from a response or stored JSON blob without duplicating the logic.
*
* @param object|array $serverObject
*/
public static function extractHostname($serverObject): ?string
{
if (is_object($serverObject)) {
$serverObject = json_decode(json_encode($serverObject), true);
}
if (! is_array($serverObject)) {
return null;
}
$data = $serverObject['data'] ?? $serverObject;
if (! empty($data['hostname']) && $data['hostname'] !== '-') {
return (string) $data['hostname'];
}
if (! empty($data['name']) && $data['name'] !== '-') {
return (string) $data['name'];
}
return null;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
use WHMCS\Module\Server\VirtFusionDirect\Cache;
use WHMCS\Module\Server\VirtFusionDirect\Log;
/**
* Public-DNS verification helper used for forward-confirmed reverse DNS (FCrDNS) checks.
*
* WHAT FCrDNS IS AND WHY IT MATTERS HERE
* --------------------------------------
* A PTR record by itself is easy to lie about — anyone who controls a reverse zone
* can say "this IP is mail.example.com". Receivers defend against that by looking
* UP the hostname the PTR claims and checking that its A/AAAA records point back
* at the IP. That "two-way agreement" is FCrDNS.
*
* For mail deliverability in particular, a PTR without matching forward DNS is
* worse than no PTR at all — some filters treat it as evidence of a compromised
* host. The module enforces FCrDNS before every PTR write: if the user asks us
* to set "mail.example.com" as the PTR for 1.2.3.4 but mail.example.com resolves
* to something other than 1.2.3.4, we refuse.
*
* USES PUBLIC DNS, NOT POWERDNS
* -----------------------------
* This calls dns_get_record(), which hits the system's configured recursive
* resolver. That's deliberate: the hostname in a PTR may live in a zone hosted
* anywhere (client's own domain, another DNS provider, etc.) — not necessarily
* in the PowerDNS instance we're managing. Using the recursive public view means
* our verification matches what mail servers and other FCrDNS checkers actually
* see downstream.
*
* CNAME FOLLOWING
* ---------------
* If the hostname is itself a CNAME, dns_get_record returns the CNAME record
* (with DNS_CNAME flag) rather than auto-resolving to the ultimate A/AAAA. We
* follow up to MAX_CNAME_DEPTH hops before giving up. The depth cap prevents
* accidental infinite loops from misconfigured zones and bounds work per check.
*
* CACHING
* -------
* Keyed by md5(hostname|ip). A bad-A-record result lives in the cache just like
* a good one, which means a client who fixes their forward DNS must wait up to
* cacheTtl seconds before a retry succeeds. Documented in the admin settings
* tooltip as the tradeoff for not hammering authoritative resolvers when a
* user mashes the Save button while debugging.
*/
class Resolver
{
private const CACHE_PREFIX = 'pdns:resolve:';
/**
* Maximum hops through a CNAME chain before we give up.
* Real-world chains are usually 0-2 hops; 5 is generous headroom without
* letting a loop run unbounded.
*/
private const MAX_CNAME_DEPTH = 5;
/**
* Does the public DNS A/AAAA of $hostname resolve to $ip?
* Follows up to 5 CNAME hops. Cached for $ttl seconds on the initial call.
*/
public static function resolvesTo(string $hostname, string $ip, int $ttl = 60): bool
{
$hostname = rtrim(trim($hostname), '.');
if ($hostname === '' || ! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
return false;
}
$cacheKey = self::CACHE_PREFIX . md5($hostname . '|' . $ip);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return (bool) $cached;
}
$match = self::resolveInternal($hostname, $ip, 0);
Cache::set($cacheKey, $match ? 1 : 0, $ttl);
return $match;
}
private static function resolveInternal(string $hostname, string $ip, int $depth): bool
{
if ($depth > self::MAX_CNAME_DEPTH) {
return false;
}
// Request both the matching forward type AND CNAME in one query so we see
// the whole picture at each hop. If the hostname is a direct A/AAAA, we
// see that and match immediately; if it's a CNAME, we see the target and
// recurse.
$type = IpUtil::isIpv6($ip) ? DNS_AAAA | DNS_CNAME : DNS_A | DNS_CNAME;
$records = [];
try {
// @-suppress: dns_get_record emits a PHP warning on NXDOMAIN, which we'd
// rather just treat as "no match". The return value (empty array or false)
// tells us the same thing without polluting the error log.
$records = @dns_get_record($hostname, $type);
} catch (\Throwable $e) {
// Some PHP configurations throw on resolver failure instead of returning false.
// We treat those as "no match" and log once per (hostname, ip) since callers
// cache the result — we won't spam the log even for a permanently-broken name.
Log::insert('PowerDns:Resolver', ['hostname' => $hostname, 'ip' => $ip], $e->getMessage());
return false;
}
if (! is_array($records)) {
// dns_get_record returns false on resolver failure. Same semantics as above.
return false;
}
// Convert target to binary once, outside the loop. inet_pton normalises
// "2001:db8::1" and "2001:0db8:0000:0000:0000:0000:0000:0001" to the same
// bytes, so we can compare regardless of how the resolver formatted its reply.
$targetBin = @inet_pton($ip);
foreach ($records as $r) {
$t = $r['type'] ?? null;
if ($t === 'CNAME') {
// CNAME hop: recurse on the target. We don't use a visited-set to
// detect cycles — MAX_CNAME_DEPTH is a simpler, sufficient guard.
$next = $r['target'] ?? null;
if ($next && self::resolveInternal(rtrim($next, '.'), $ip, $depth + 1)) {
return true;
}
continue;
}
// A records expose the address under 'ip', AAAA records under 'ipv6'.
// Only one of these will be set per record; the other is null.
$candidate = $r['ip'] ?? ($r['ipv6'] ?? null);
if ($candidate && $targetBin !== false && @inet_pton($candidate) === $targetBin) {
return true;
}
}
return false;
}
}

View File

@@ -471,3 +471,77 @@
flex-wrap: wrap; 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; }
}

View File

@@ -1,7 +1,82 @@
/** /**
* VirtFusion Direct Provisioning Module - Client JavaScript * 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 * - Server data display
* - Power management (boot, shutdown, restart, power off) * - Power management (boot, shutdown, restart, power off)
* - Control panel login (SSO) * - Control panel login (SSO)
@@ -12,6 +87,7 @@
* - Backup listing * - Backup listing
* - VNC management * - VNC management
* - Server naming * - Server naming
* - Reverse DNS (PowerDNS addon)
*/ */
// ========================================================================= // =========================================================================
@@ -1011,3 +1087,196 @@ function vfCopyButton(text) {
}); });
return btn; 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 class="vf-rdns-badge"></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('<div class="text-muted">Unable to load reverse DNS.</div>');
return;
}
if (!resp.data.enabled) {
list.closest(".panel").hide();
return;
}
vfRenderRdnsPanel(serviceId, systemUrl, resp.data.ips || []);
}).fail(function () {
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
});
}
function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
var list = $("#vf-rdns-list");
list.empty();
if (!ips.length) {
list.html('<div class="text-muted">No IP addresses assigned to this server yet.</div>');
return;
}
ips.forEach(function (row) {
var wrap = $('<div class="vf-rdns-row"></div>');
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
var badge = vfRdnsBadge(row.status);
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="host.example.com (blank to delete)">');
input.val(row.ptr || "");
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
var msg = $('<div class="vf-rdns-msg"></div>');
saveBtn.on("click", function () {
vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge);
});
input.on("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
});
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
wrap.append(ipLabel).append(editor).append(badge).append(msg);
list.append(wrap);
});
}
function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge) {
var ptr = (input.val() || "").trim();
// Light client-side regex mirrors the server-side one — strict enforcement is on the server.
if (ptr !== "" && !/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/.test(ptr)) {
msg.text("Invalid hostname.").css("color", "#dc3545").show();
return;
}
saveBtn.prop("disabled", true);
msg.hide();
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsUpdate"),
method: "POST",
data: { ip: ip, ptr: ptr },
dataType: "json"
}).done(function (resp) {
saveBtn.prop("disabled", false);
if (resp && resp.success) {
var verb = (ptr === "") ? "deleted" : "saved";
msg.text("rDNS " + verb + ".").css("color", "#28a745").show();
setTimeout(function () { msg.fadeOut(); }, 2500);
// Optimistically update the badge; a background refresh will correct it.
if (ptr === "") {
badge.replaceWith(vfRdnsBadge("missing"));
} else {
badge.replaceWith(vfRdnsBadge("ok"));
}
} else {
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
msg.text(err).css("color", "#dc3545").show();
}
}).fail(function (xhr) {
saveBtn.prop("disabled", false);
var err = "Save failed.";
try {
var r = JSON.parse(xhr.responseText);
if (r && r.errors) err = r.errors;
} catch (e) {}
msg.text(err).css("color", "#dc3545").show();
});
}
// Admin-side wrappers — different endpoint ("admin"), no ownership check on server side.
function vfAdminLoadRdns(serviceId, systemUrl) {
var list = $("#vf-rdns-list");
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsStatus", "admin"),
method: "GET",
dataType: "json"
}).done(function (resp) {
if (!resp || !resp.success) {
list.html('<em class="text-muted">Unable to load PTR state.</em>');
return;
}
if (!resp.data.enabled) {
list.html('<em class="text-muted">Reverse DNS addon is not activated.</em>');
return;
}
list.empty();
if (!resp.data.ips.length) {
list.html('<em class="text-muted">No IPs assigned.</em>');
return;
}
resp.data.ips.forEach(function (row) {
var line = $('<div class="vf-rdns-admin-row"></div>');
$('<span class="vf-rdns-ip-admin"></span>').text(row.ip).appendTo(line);
$('<span class="vf-rdns-ptr-admin"></span>').text(row.ptr || "(no PTR)").appendTo(line);
vfRdnsBadge(row.status).appendTo(line);
list.append(line);
});
}).fail(function () {
list.html('<em class="text-muted">Unable to load PTR state.</em>');
});
}
function vfAdminReconcileRdns(serviceId, systemUrl, force) {
var out = $("#vf-rdns-report");
out.text("Reconciling…").css("color", "#555");
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsReconcile", "admin"),
method: "POST",
data: { force: force ? 1 : 0 },
dataType: "json"
}).done(function (resp) {
if (resp && resp.success) {
var s = resp.data;
var parts = [];
["added", "reset", "preserved", "forward_missing", "no_zone", "errors"].forEach(function (k) {
if (s[k] > 0) parts.push(k + "=" + s[k]);
});
out.text(parts.length ? parts.join(" ") : "no changes needed").css("color", "#28a745");
vfAdminLoadRdns(serviceId, systemUrl);
} else {
out.text((resp && resp.errors) ? resp.errors : "Reconcile failed").css("color", "#dc3545");
}
}).fail(function () {
out.text("Reconcile failed").css("color", "#dc3545");
});
}

View File

@@ -237,6 +237,28 @@
</div> </div>
</div> </div>
{if $rdnsEnabled}
{* Reverse DNS Panel *}
<div class="panel card panel-default mb-3">
<div class="panel-heading card-header">
<h3 class="panel-title card-title m-0">Reverse DNS</h3>
</div>
<div class="panel-body card-body p-4">
<p class="vf-small text-muted mb-3">Set a custom PTR record for each assigned IP. Forward DNS (A/AAAA) for the hostname must already resolve to the IP before the PTR can be saved.</p>
<div id="vf-rdns-alert" class="alert" style="display:none;"></div>
<div id="vf-rdns-list">
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
</div>
<script>
if (typeof vfLoadRdns === 'function') {
vfLoadRdns('{$serviceid}', '{$systemURL}');
}
</script>
</div>
</div>
{/if}
{* Resources Panel — populated by JS after server data loads *} {* Resources Panel — populated by JS after server data loads *}
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;"> <div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
<div class="panel-heading card-header"> <div class="panel-heading card-header">