diff --git a/CLAUDE.md b/CLAUDE.md index 33b17d4..d3ca316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,11 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene | File | Purpose | |------|---------| -| `VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes | -| `client.php` | Client-facing AJAX API — authenticated by WHMCS session + service ownership validation. POST for mutations, GET for reads. | -| `admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication | -| `hooks.php` | WHMCS hooks — checkout validation (OS selection), OS gallery + SSH key UI injection, slider UI for configurable options | +| `modules/servers/VirtFusionDirect/VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes | +| `modules/servers/VirtFusionDirect/client.php` | Client-facing AJAX API — authenticated by WHMCS session + service ownership validation. POST for mutations, GET for reads. | +| `modules/servers/VirtFusionDirect/admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication | +| `modules/servers/VirtFusionDirect/hooks.php` | WHMCS hooks — checkout validation (OS selection), OS gallery + SSH key UI injection, slider UI for configurable options, daily PowerDNS reconciliation | +| `modules/addons/VirtFusionDns/VirtFusionDns.php` | Optional companion addon — holds PowerDNS settings and provides a Test Connection admin page. See "Reverse DNS (PowerDNS)" below. | ### Core Classes (in `lib/`) @@ -60,9 +61,14 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene | `Database` | Static methods for `mod_virtfusion_direct` table operations and WHMCS DB queries. Auto-creates/migrates schema on first use. | | `Curl` | HTTP client wrapper with Bearer token auth, SSL verification, 30s timeout. Methods: `get`, `post`, `put`, `patch`, `delete`. Single-use — each instance makes one request. | | `Cache` | Two-tier caching: Redis (if `ext-redis` available) with atomic filesystem fallback. TTLs: OS templates 10min, traffic/backups 2min, packages 10min. | -| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. | -| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons). | +| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. Only reads `interfaces[0]`; for rDNS use `PowerDns\IpUtil::extractIps()` which walks all interfaces. | +| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons, `rdnsSection()` widget). | | `Log` | Thin wrapper around WHMCS module logging. | +| `PowerDns\Client` | PowerDNS HTTP API wrapper (`X-API-Key` auth): `ping`, `listZones`, `getZone`, `patchRRset`, `notifyZone`. PATCH success triggers an automatic NOTIFY so slaves pick up the SOA bump immediately. | +| `PowerDns\Config` | Loads settings from `tbladdonmodules` (module="virtfusiondns") and decrypts `apiKey` via WHMCS `decrypt()`. `isEnabled()` gates every PowerDNS call site. | +| `PowerDns\IpUtil` | Pure helpers: `ptrNameForIp` (v4/v6 nibble reversal), `expandIpv6`, `extractIps` (all interfaces), `findZoneAndPtrName` (standard + RFC 2317 classless), `parseClasslessZone`. | +| `PowerDns\Resolver` | Forward-DNS verification via `dns_get_record()` with up-to-5-hop CNAME following. Cached per (hostname, ip) pair. | +| `PowerDns\PtrManager` | Orchestrator: `syncServer`, `deleteForServer`, `listPtrs`, `setPtr`, `reconcile`, `reconcileAll`. Per-request zone cache. 10s per-IP write rate limit. Enforces FCrDNS before writes. | ### Class Hierarchy @@ -89,10 +95,41 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene 4. Dry-run validation → actual API POST to `/servers` 5. Stores server ID in `mod_virtfusion_direct` table 6. Updates WHMCS hosting record (IP, username, password, domain) -7. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key +7. If the PowerDNS addon is enabled, calls `PowerDns\PtrManager::syncServer()` to write PTRs (non-blocking; failures log but never fail provisioning) +8. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key Custom fields (`Initial Operating System`, `Initial SSH Key`) are auto-created by `Database::ensureCustomFields()` on module load for all products using this module. No manual SQL setup required. +### Reverse DNS (PowerDNS) + +Opt-in integration via the companion `VirtFusionDns` addon module. Loose-coupled: the server module never requires addon code at runtime; it queries the addon's `tbladdonmodules` row and short-circuits when `enabled=0` or the addon isn't activated. Activate via WHMCS Admin → Addon Modules → VirtFusion DNS. + +**Settings** (`tbladdonmodules`, module="virtfusiondns"): `enabled` (yesno), `endpoint` (e.g. `https://ns1.example.com:8081`), `apiKey` (encrypted by WHMCS), `serverId` (usually `localhost`), `defaultTtl` (3600), `cacheTtl` (60). + +**Lifecycle hooks:** +- `createAccount` → sync PTRs to server hostname (forward DNS must match before each write) +- `renameServer` → update only PTRs whose current content equals the old hostname (preserves client-custom PTRs) +- `terminateAccount` → delete every PTR before `Database::deleteSystemService()` +- `VirtFusionDirect_TestConnection` → merged VirtFusion + PowerDNS health check +- `DailyCronJob` → `PtrManager::reconcileAll()` — additive-only (never overwrites) + +**Client-facing actions** (`client.php`): `rdnsList`, `rdnsUpdate`. Admin (`admin.php`): `rdnsStatus`, `rdnsReconcile` (accepts `force=1` for explicit reset). + +**Client UI:** Reverse DNS panel in `templates/overview.tpl` (rendered by `vfLoadRdns()` / `vfRenderRdnsPanel()` / `vfUpdateRdns()` in `module.js`). Admin services tab gets a status widget via `AdminHTML::rdnsSection()`. + +**FCrDNS rule:** Every PTR write (auto or client-initiated) requires the hostname's forward DNS (A/AAAA) to already resolve to the target IP. On mismatch, auto-sync logs and skips; client edits return a 400 with guidance. + +**Zone handling:** Zones are operator-managed — the module never creates zones. Zone discovery uses `GET /zones` (cached for `cacheTtl`) + longest-suffix match. RFC 2317 classless delegations (`X/Y.octet.octet.octet.in-addr.arpa.`) are supported: both CIDR-prefix (`0/26`) and block-size (`64/64`) conventions are parsed, and PTRs are written with the classless sub-zone label in the record name. + +**SOA / NOTIFY:** PowerDNS auto-bumps SOA serials when `soa_edit_api=INCREASE` is set on the zone. After every successful PATCH the module issues an explicit `PUT /zones/{id}/notify` so slaves refresh immediately rather than waiting for the next scheduled poll. + +**Safety properties:** +- PowerDNS failures never block VirtFusion operations (try/catch at every call site) +- Cron is additive-only — never auto-overwrites a PTR +- Admin Reconcile button supports `force=1` for explicit reset to hostname +- Client edits are IP-ownership-checked against a *fresh* VirtFusion fetch (not cached `server_object`), defending against reassigned-IP stale-ownership +- Per-IP write rate limit (10s, via `Cache`) prevents save-button abuse + ### Configurable Option Mapping Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`. @@ -116,6 +153,16 @@ Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from - **Self-service billing:** Requires self-service feature enabled in VirtFusion - **OS icon path:** `{baseUrl}/img/logo/{icon_filename}` (public, no auth required) +## PowerDNS API Compatibility + +- **API reference:** https://doc.powerdns.com/authoritative/http-api/ +- **Tested against:** PowerDNS Authoritative 4.8+ +- **Auth:** `X-API-Key` header (not Bearer) +- **Required endpoints:** `GET /servers/{id}`, `GET /servers/{id}/zones`, `GET /servers/{id}/zones/{zone}`, `PATCH /servers/{id}/zones/{zone}`, `PUT /servers/{id}/zones/{zone}/notify` +- **Zone ID URL encoding:** `/` in zone names (RFC 2317) must be encoded as `=2F` not `%2F` — handled by `Client::zoneIdEncode()` +- **`api-allow-from`:** must include the WHMCS host's IP (PowerDNS's own ACL) +- **Recommended zone config:** `soa_edit_api: INCREASE` for automatic serial bumping on API-driven changes + ## Product Config Options | Option | Name | Description | Default | diff --git a/README.md b/README.md index acf349f..800166d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co - [Module Configuration Options](#module-configuration-options) - [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing) - [Custom Option Name Mapping](#custom-option-name-mapping) + - [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns) - [Client Area Features](#client-area-features) - [Admin Area Features](#admin-area-features) - [Theme Compatibility](#theme-compatibility) @@ -106,6 +107,17 @@ You also need a VirtFusion API token with the following permissions: - Auto top-off via WHMCS cron when credit falls below threshold - Self-service mode configurable per product (Hourly, Resource Packs, or Both) +### Reverse DNS (Optional PowerDNS Addon) +- **Automatic PTR sync** on server create, rename, and terminate +- **Client-editable rDNS** panel in the service overview — one input per assigned IP +- **Forward-confirmed reverse DNS (FCrDNS)** — every PTR write requires the hostname's A/AAAA to already resolve to the IP; mismatches are rejected with a clear error +- **IPv4 + IPv6** support out of the box (IPv6 nibble-reversal, `.ip6.arpa` zones) +- **RFC 2317 classless delegation** — supports both CIDR-prefix (`0/26`) and block-size (`64/64`) zone naming conventions +- **Admin reconciliation** — a "Reconcile" button on the services tab and an additive-only daily cron that creates any missing PTRs +- **Client-custom PTRs preserved across renames** — only PTRs whose content matches the previous hostname get rewritten +- **Auto NOTIFY + SOA bump** so slaves pick up changes immediately (when `soa_edit_api=INCREASE` is set on the zone) +- **Opt-in** via a companion WHMCS addon module — no impact on existing provisioning if not activated + ## Installation ```bash @@ -118,15 +130,21 @@ Then configure in WHMCS Admin: 1. **Add Server** — Configuration > System Settings > Servers > Add New Server. Set hostname to your VirtFusion panel (e.g. `cp.example.com`), type to "VirtFusion Direct Provisioning", and paste your API token in the Password field. Click **Test Connection** to verify. 2. **Create Product** — Configuration > System Settings > Products/Services. On the Module Settings tab, select "VirtFusion Direct Provisioning", choose your server, and set the Hypervisor Group ID, Package ID, and Default IPv4 count. +3. *(Optional)* **Install the Reverse DNS Addon** — also sync the `modules/addons/VirtFusionDns/` directory if you want PowerDNS-backed rDNS management. See [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns) below for activation and configuration. That's it. Hooks activate automatically and custom fields are created on module load. ## Upgrading ```bash -git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf && rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/ && rm -rf /tmp/vf +git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \ + && rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/ \ + && rsync -ahP --delete /tmp/vf/modules/addons/VirtFusionDns/ /path/to/whmcs/modules/addons/VirtFusionDns/ \ + && rm -rf /tmp/vf ``` +The second `rsync` line is only needed if you use the Reverse DNS addon; skip it otherwise. Addon settings live in `tbladdonmodules` and survive file updates. + > **Note:** If you have a custom `config/ConfigOptionMapping.php`, back it up first — `--delete` will remove it. Restore it after upgrading. If you use theme-overridden templates, review them for any new template variables. Clear the WHMCS template cache after upgrading: **Configuration > System Settings > General Settings > clear template cache**. @@ -208,6 +226,53 @@ return [ ]; ``` +### Reverse DNS Addon (PowerDNS) + +Optional. Activate the `VirtFusionDns` addon module to let the provisioning module manage PTR records in a PowerDNS instance automatically (and expose an rDNS editor to clients). + +**Prerequisites:** +- PowerDNS Authoritative 4.x with the HTTP API enabled (`webserver=yes`, `api=yes`, and an `api-key=...` set) +- `api-allow-from=` must include the IP of your WHMCS host +- **All reverse zones you intend to use must already exist in PowerDNS.** The addon never creates zones; it only PATCHes PTR RRsets into zones that are already delegated to your nameservers. +- Zones should have `soa_edit_api=INCREASE` (or similar) so PowerDNS auto-bumps the SOA serial on API writes. The addon additionally calls `PUT /zones/{id}/notify` after every PATCH to push changes to slaves immediately. + +**Activation:** + +1. Copy the addon into your WHMCS install (see the Installation section for the `rsync` command). +2. In WHMCS Admin → **System Settings → Addon Modules**, find **VirtFusion DNS** and click **Activate**. Grant admin role access as needed. +3. Click **Configure** and fill in: + + | Field | Meaning | + |---|---| + | **Enable rDNS Sync** | Master switch. When off, every PowerDNS call short-circuits — the provisioning module behaves exactly as before the addon. | + | **PowerDNS API Endpoint** | Scheme + host + port, no path (e.g. `https://ns1.example.com:8081` or `http://10.0.0.5:8081`). The module appends `/api/v1/…` itself. | + | **PowerDNS API Key** | Password-type field. Encrypted at rest by WHMCS; decrypted server-side only when PowerDNS is called. | + | **PowerDNS Server ID** | Almost always `localhost` — the PowerDNS API server identifier, not a hostname. | + | **Default PTR TTL** | Applied to every PTR record the module creates. Default 3600. | + | **Cache TTL** | How long zone listings and DNS-resolution lookups are cached. Default 60, minimum 10. | + +4. Click **Save Changes**. +5. Open the addon's admin page (same menu, usually **Addons → VirtFusion DNS**) and click **Run Test**. You should see "OK — PowerDNS reachable and authenticated" followed by a list of visible zones. If you don't see your expected reverse zones here, the module won't find them either — fix PowerDNS first. + +**How it behaves:** + +| Event | Behavior | +|---|---| +| Server provisioning | Creates a PTR for every assigned IP pointing to the VirtFusion hostname — but only if that hostname's A/AAAA already resolves to the IP. Forward-missing IPs are logged and skipped (provisioning still succeeds). | +| Server rename (via client or admin) | Rewrites only PTRs whose current content equals the previous hostname. Client-customised PTRs are preserved. | +| Server termination | Deletes every PTR belonging to the server before the local record is purged. | +| Client edits PTR in the Reverse DNS panel | Validates IP ownership (cross-checked against a fresh VirtFusion fetch), PTR regex, per-IP 10-second rate limit, and forward-DNS match. Empty value deletes. | +| Daily cron | Creates PTRs for IPs that don't have one yet (and whose forward DNS resolves correctly). **Additive-only — never overwrites.** | +| Admin "Reconcile (force reset)" button | The only code path that overwrites a non-matching PTR — explicit admin action. | + +**RFC 2317 classless delegations** are supported: the module parses zones like `64/64.38.186.66.in-addr.arpa.` (both CIDR-prefix and block-size conventions), matches IPs by range rather than suffix, and writes PTRs with the correct classless RRset name. The PowerDNS URL-safe zone ID encoding (`/` → `=2F`) is handled transparently. + +**Security posture:** +- PowerDNS integration is **opt-in** — if the addon is deactivated or `Enable rDNS Sync` is off, the provisioning module behaves exactly as before. +- Every client-facing rDNS endpoint validates service ownership and re-verifies the IP is currently assigned to the requesting user's server (defends against stale-ownership after IP reassignment). +- The API key is stored encrypted in `tbladdonmodules` by WHMCS; it is never logged. +- DNS write failures never block VirtFusion operations — provisioning, rename, and termination all succeed regardless of PowerDNS state, and errors are recorded in the WHMCS Module Log for review. + ## Client Area Features ### Server Overview @@ -250,6 +315,14 @@ Four power control buttons: - Registration and next due dates - Payment method +### Reverse DNS *(requires the VirtFusion DNS addon)* +A panel listing every IP assigned to the server with an inline editor for the PTR record: +- One input per IP — populate to set a custom PTR, leave blank to delete +- Per-row status badge (OK / unverified / no PTR / no zone / error) +- Saves are rate-limited to one write per IP per 10 seconds +- Forward DNS must already resolve to the IP; mismatches show an inline error guiding the client to fix their A/AAAA first +- Hidden entirely when the addon is not activated + ## Admin Area Features ### Admin Services Tab @@ -258,6 +331,7 @@ When viewing a service in WHMCS admin, the module adds: - **Server Info** - Button to load live data from VirtFusion API - **Server Object** - Full JSON response viewer - **Options** - Admin impersonation link +- **Reverse DNS** *(when the VirtFusion DNS addon is activated)* - Live per-IP PTR status plus **Reconcile (additive)** and **Reconcile (force reset)** buttons ### Module Commands (Admin Buttons) - **Create** - Provision a new server @@ -357,6 +431,18 @@ WHMCS automatically loads theme-specific templates when they exist. Copy the ori | `PUT` | `/servers/{id}/modify/traffic` | Modify traffic (v6.0.0+) | | `POST/DELETE` | `/servers/{id}/backup/plan` | Backup plan management (v4.3.0+) | +### PowerDNS (Reverse DNS addon, PowerDNS Authoritative 4.x+) + +| Method | Endpoint | Purpose | +|---|---|---| +| `GET` | `/api/v1/servers/{id}` | Health check (Test Connection button) | +| `GET` | `/api/v1/servers/{id}/zones` | Zone discovery (cached per `cacheTtl`) | +| `GET` | `/api/v1/servers/{id}/zones/{zone}` | Fetch current RRsets for status + reads | +| `PATCH` | `/api/v1/servers/{id}/zones/{zone}` | Create / replace / delete PTR RRsets | +| `PUT` | `/api/v1/servers/{id}/zones/{zone}/notify` | NOTIFY slaves after every successful PATCH | + +Authentication is via the `X-API-Key` header (configured in the addon). Zone IDs containing `/` (RFC 2317 classless) are URL-encoded as `=2F` per PowerDNS convention. + ## Usage Update (Cron) The module implements the `UsageUpdate` function that is called by the WHMCS daily cron. It automatically syncs: @@ -480,7 +566,7 @@ modules/servers/VirtFusionDirect/ VirtFusionDirect.php # WHMCS module entry point (MetaData, ConfigOptions, all module functions) client.php # Client-facing AJAX API (authenticated, ownership-validated) admin.php # Admin-facing AJAX API (admin authentication required) - hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation) + hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation, daily rDNS cron) lib/ Module.php # Base class: API communication, power, network, VNC, rebuild ModuleFunctions.php # Provisioning: create, suspend, unsuspend, terminate, change package @@ -491,6 +577,12 @@ modules/servers/VirtFusionDirect/ ServerResource.php # Data transformer: VirtFusion API response -> display format AdminHTML.php # Admin interface: HTML generation for admin services tab Log.php # Logging: WHMCS module log integration + PowerDns/ + Client.php # PowerDNS HTTP API wrapper (X-API-Key, ping, listZones, getZone, patchRRset, notifyZone) + Config.php # Loads + decrypts addon settings from tbladdonmodules + IpUtil.php # PTR-name generation, IP extraction, RFC 2317 parsing, zone matching + Resolver.php # Forward-DNS verification (dns_get_record + CNAME chain, cached) + PtrManager.php # Orchestrator: syncServer, deleteForServer, listPtrs, setPtr, reconcile, reconcileAll templates/ overview.tpl # Client area Smarty template (all management panels) error.tpl # Error display template @@ -499,6 +591,9 @@ modules/servers/VirtFusionDirect/ js/keygen.js # SSH Ed25519 key generator (Web Crypto API) config/ ConfigOptionMapping-example.php # Example custom option name mapping + +modules/addons/VirtFusionDns/ # Optional — only needed for reverse DNS support + VirtFusionDns.php # Addon entry point: _config(), _activate(), _deactivate(), _output() (Test Connection page) ``` ## Contributing diff --git a/modules/addons/VirtFusionDns/VirtFusionDns.php b/modules/addons/VirtFusionDns/VirtFusionDns.php new file mode 100644 index 0000000..1cc7c6a --- /dev/null +++ b/modules/addons/VirtFusionDns/VirtFusionDns.php @@ -0,0 +1,234 @@ + System Settings -> Addon Modules -> Activate -> Configure. + * + * API key handling: WHMCS encrypts password-type addon fields in tbladdonmodules; + * the server module calls decrypt() on read (see lib/PowerDns/Config.php). + */ +if (! defined('WHMCS')) { + exit('This file cannot be accessed directly'); +} + +/** + * Load the server module's PowerDNS classes on demand. Done inside functions rather + * than at file scope so the WHMCS addon list still works if the server module is + * absent (e.g., uninstalled while the addon is still activated). Returns true when + * the classes are available. + */ +function virtfusiondns_load_server_libs(): bool +{ + $base = __DIR__ . '/../../servers/VirtFusionDirect/lib/'; + $files = [ + 'Curl.php', + 'Log.php', + 'Cache.php', + 'PowerDns/Config.php', + 'PowerDns/IpUtil.php', + 'PowerDns/Client.php', + ]; + foreach ($files as $f) { + if (! is_file($base . $f)) { + return false; + } + require_once $base . $f; + } + + return true; +} + +/** + * WHMCS addon metadata. + */ +function VirtFusionDns_config() +{ + return [ + 'name' => 'VirtFusion DNS', + 'description' => 'Adds reverse DNS (PTR) management to the VirtFusionDirect server module using a PowerDNS HTTP API. Zones must already exist in PowerDNS; the addon never creates zones. Requires the VirtFusionDirect server module.', + 'version' => '1.0', + 'author' => 'VirtFusionDirect', + 'language' => 'english', + 'fields' => [ + 'enabled' => [ + 'FriendlyName' => 'Enable rDNS Sync', + 'Type' => 'yesno', + 'Description' => 'Master switch. When off, the server module skips every PowerDNS call.', + ], + 'endpoint' => [ + 'FriendlyName' => 'PowerDNS API Endpoint', + 'Type' => 'text', + 'Size' => '60', + 'Default' => 'http://ns1.example.com:8081', + 'Description' => 'Scheme + host + port (no path). The /api/v1/... path is appended automatically.', + ], + 'apiKey' => [ + 'FriendlyName' => 'PowerDNS API Key', + 'Type' => 'password', + 'Size' => '60', + 'Description' => 'X-API-Key. Stored encrypted by WHMCS; decrypted only server-side when PowerDNS is called.', + ], + 'serverId' => [ + 'FriendlyName' => 'PowerDNS Server ID', + 'Type' => 'text', + 'Size' => '20', + 'Default' => 'localhost', + 'Description' => 'Almost always "localhost" (the PowerDNS API server identifier, not a hostname).', + ], + 'defaultTtl' => [ + 'FriendlyName' => 'Default PTR TTL (seconds)', + 'Type' => 'text', + 'Size' => '10', + 'Default' => '3600', + 'Description' => 'TTL applied to PTR records created by the module.', + ], + 'cacheTtl' => [ + 'FriendlyName' => 'Cache TTL (seconds)', + 'Type' => 'text', + 'Size' => '10', + 'Default' => '60', + 'Description' => 'How long zone lists and DNS-resolution results are cached. Minimum 10s.', + ], + ], + ]; +} + +/** + * Called when the addon is activated. No schema to create — settings live in tbladdonmodules. + */ +function VirtFusionDns_activate() +{ + return [ + 'status' => 'success', + 'description' => 'VirtFusion DNS activated. Fill in the endpoint + API key in the addon configuration, then use the Test Connection button on the addon page.', + ]; +} + +/** + * Called when the addon is deactivated. Settings preserved (re-activating restores them). + */ +function VirtFusionDns_deactivate() +{ + return [ + 'status' => 'success', + 'description' => 'VirtFusion DNS deactivated. Server lifecycle PowerDNS calls will now be skipped. Settings are preserved.', + ]; +} + +/** + * Admin status page — rendered by WHMCS when the addon is clicked from the Addons menu. + * + * Shows a settings summary, a Test Connection button (calls PowerDNS ping), the current + * zone count, and a recent log extract filtered to PowerDNS-related entries. + */ +function VirtFusionDns_output($vars) +{ + if (! virtfusiondns_load_server_libs()) { + echo '
'; + echo 'VirtFusionDirect server module not found. '; + echo 'This addon requires the VirtFusionDirect server module at modules/servers/VirtFusionDirect/. '; + echo 'Install or restore that module and reload this page.'; + echo '
'; + + 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'] + ? 'enabled' + : 'disabled'; + $keyBadge = $config['apiKey'] !== '' ? 'set' : 'missing'; + + echo '
'; + echo '

VirtFusion DNS

'; + echo '

Reverse DNS management for the VirtFusionDirect server module. All PTR writes happen through the VirtFusion server lifecycle (create, rename, terminate) and through the client-area Reverse DNS panel. Forward DNS (A/AAAA) is verified before every PTR write; mismatches are skipped and logged.

'; + + echo '

Current settings

'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
Status' . $enabledBadge . '
Endpoint' . ($endpoint ?: 'not set') . '
API Key' . $keyBadge . '
Server ID' . $serverId . '
Default PTR TTL' . $ttl . 's
Cache TTL' . $cacheTtl . 's
'; + + echo '

Test Connection

'; + echo '

Calls GET /api/v1/servers/' . $serverId . ' and, on success, lists available zones.

'; + echo 'Run Test'; + + if ($pingResult !== null) { + echo '
'; + if ($pingResult['ok']) { + echo 'OK. PowerDNS reachable and authenticated. '; + if ($zoneCount !== null) { + echo $zoneCount . ' zone(s) visible.'; + if (! empty($zoneSample)) { + echo '
'; + foreach ($zoneSample as $z) { + echo htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . '
'; + } + if ($zoneCount > count($zoneSample)) { + echo '... and ' . ($zoneCount - count($zoneSample)) . ' more'; + } + echo '
'; + } + } + } else { + echo 'Failed. HTTP ' . (int) $pingResult['http'] . ': ' . htmlspecialchars((string) ($pingResult['error'] ?? 'unknown error'), ENT_QUOTES, 'UTF-8'); + } + echo '
'; + } + + echo '

Operation

'; + echo ''; + + echo '

Requirements

'; + echo ''; + + echo '
'; +} diff --git a/modules/servers/VirtFusionDirect/VirtFusionDirect.php b/modules/servers/VirtFusionDirect/VirtFusionDirect.php index 2a3247c..5beb5e7 100644 --- a/modules/servers/VirtFusionDirect/VirtFusionDirect.php +++ b/modules/servers/VirtFusionDirect/VirtFusionDirect.php @@ -1,5 +1,41 @@ getRequestInfo('http_code'); if ($httpCode == 200) { + // Also verify PowerDNS health when the DNS addon is activated, so the + // admin's Test Connection button reflects the full provisioning path. + if (PowerDnsConfig::isEnabled()) { + $pdns = (new PowerDnsClient)->ping(); + if (! $pdns['ok']) { + return [ + 'success' => false, + 'error' => 'VirtFusion OK; PowerDNS unreachable — ' + . ($pdns['error'] ?? 'unknown') + . ' (HTTP ' . (int) $pdns['http'] . '). Fix the VirtFusion DNS addon settings.', + ]; + } + } + return ['success' => true, 'error' => '']; } diff --git a/modules/servers/VirtFusionDirect/admin.php b/modules/servers/VirtFusionDirect/admin.php index 566c675..10239cf 100644 --- a/modules/servers/VirtFusionDirect/admin.php +++ b/modules/servers/VirtFusionDirect/admin.php @@ -5,13 +5,39 @@ require dirname(__DIR__, 3) . '/init.php'; /** * Admin-facing AJAX API endpoint. * - * Requires WHMCS admin authentication. Provides server data lookup - * and user impersonation for the admin services tab. + * MIRRORS client.php STRUCTURE + * ---------------------------- + * Same switch-on-$action dispatch pattern, same JSON response shape, same + * "output + break" convention. The only substantive difference is the auth + * gate at the top: $vf->adminOnly() instead of $vf->isAuthenticated(). + * + * WHY SEPARATE FROM client.php + * ---------------------------- + * A single file with a per-action admin/client switch would risk one bug + * (e.g. forgetting to call adminOnly on a new admin-only action) giving a + * client authenticated but without admin privileges access to admin data. + * Having two physical entry points means the admin auth gate is enforced + * at file scope — any action routed here already went through adminOnly(). + * + * ADMIN-LEVEL AUTH ONLY — NO SERVICE OWNERSHIP CHECK + * -------------------------------------------------- + * An admin is allowed to view/operate on any service, so we don't call + * validateUserOwnsService() here. If you add an action that needs finer- + * grained auth (e.g. restrict to the admin role that owns the product + * group), compose the additional check inside the case branch. + * + * SAME-ORIGIN / POST GATES STILL APPLY TO MUTATIONS + * ------------------------------------------------- + * Admins are still subject to requirePost + requireSameOrigin on writes — + * admin sessions are just as CSRF-vulnerable as client sessions. See the + * rdnsReconcile case for the pattern. */ use WHMCS\Module\Server\VirtFusionDirect\Database; use WHMCS\Module\Server\VirtFusionDirect\Log; use WHMCS\Module\Server\VirtFusionDirect\Module; +use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig; +use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager; use WHMCS\Module\Server\VirtFusionDirect\ServerResource; $vf = new Module; @@ -88,6 +114,61 @@ try { $vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502); break; + // ================================================================= + // Reverse DNS (PowerDNS) + // ================================================================= + + /** + * Admin-side PTR status for a service. Same shape as client-side rdnsList but + * accessible without being the service owner (admin-only guard at top). + */ + case 'rdnsStatus': + + $serviceID = $vf->validateServiceID(true); + + if (! PowerDnsConfig::isEnabled()) { + $vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200); + break; + } + + $serverData = $vf->fetchServerData($serviceID); + if (! $serverData) { + $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502); + break; + } + + $ptrs = (new PtrManager)->listPtrs($serverData); + $vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200); + break; + + /** + * Trigger PTR reconciliation for a single service. Additive-only by default + * (missing PTRs are created with the current hostname); pass force=1 to also + * reset PTRs that differ from the server hostname. + */ + case 'rdnsReconcile': + + // Mutating action — enforce POST + same-origin even though the session is admin-authenticated. + $vf->requirePost(); + $vf->requireSameOrigin(); + + $serviceID = $vf->validateServiceID(true); + + if (! PowerDnsConfig::isEnabled()) { + $vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400); + break; + } + + $force = ! empty($_POST['force']); + $summary = (new PtrManager)->reconcile($serviceID, $force); + Log::insert( + 'rdnsReconcile:ok', + ['serviceID' => $serviceID, 'force' => $force], + $summary, + ); + $vf->output(['success' => true, 'data' => $summary], true, true, 200); + break; + default: $vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400); } diff --git a/modules/servers/VirtFusionDirect/client.php b/modules/servers/VirtFusionDirect/client.php index 1d3ee9a..9e4ec8d 100644 --- a/modules/servers/VirtFusionDirect/client.php +++ b/modules/servers/VirtFusionDirect/client.php @@ -5,12 +5,58 @@ require dirname(__DIR__, 3) . '/init.php'; /** * Client-facing AJAX API endpoint. * - * Authenticated by WHMCS session + service ownership validation. - * POST for mutations (power, rebuild, rename, credit), GET for reads (serverData, templates, backups). + * ROUTING MODEL + * ------------- + * Every request carries ?action=X&serviceID=Y. We dispatch on $action via the + * switch below. Because PHP's switch() is O(N) over case labels that's still + * fine at ~20 actions; if this grows large enough that dispatch cost matters + * we'd want a lookup table, but we're nowhere near that. + * + * WHMCS requires every action URL to re-authenticate on each request (no + * cross-request sticky state beyond the session cookie). That's why the + * isAuthenticated() call is the first thing inside the try block — nothing + * downstream may assume a session exists. + * + * AUTH LAYERS (ORDER MATTERS) + * --------------------------- + * Each case composes the defenses it needs: + * + * 1. $vf->isAuthenticated() — client session (401 otherwise) + * 2. $vf->validateServiceID(true) — numeric coercion + presence + * 3. $vf->validateUserOwnsService($id) — the session owns this service (403) + * 4. Optional: requireServiceStatus — filter by tblhosting.domainstatus + * 5. Optional (mutations): requirePost — HTTP method gate (405) + * 6. Optional (mutations): requireSameOrigin — CSRF origin gate (403) + * + * The helpers are "fail loudly" — they exit on failure rather than returning. + * So everything AFTER a guard in a case branch knows the guard passed. + * + * EVERY $vf->output() FOLLOWED BY break + * ------------------------------------- + * output() emits a JSON response and exits by default, so in theory `break` + * is redundant. In practice we always break explicitly for two reasons: + * 1. If someone later passes exit=false to output() the switch would fall + * through to the default case and emit a second response body. + * 2. Code readers shouldn't have to remember that one function exits. + * + * RESPONSE SHAPE + * -------------- + * Success: { success: true, data: { ... } } + * Error: { success: false, errors: "human-readable message" } + * Status codes match HTTP semantics (200/400/401/403/404/405/429/500/502). + * + * CATCH-ALL + * --------- + * The outer try/catch guarantees we never expose a raw PHP stack trace to the + * client, even on bugs in our own code. All uncaught exceptions are logged and + * the user sees a generic 500. */ use WHMCS\Module\Server\VirtFusionDirect\Log; use WHMCS\Module\Server\VirtFusionDirect\Module; +use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig; +use WHMCS\Module\Server\VirtFusionDirect\PowerDns\IpUtil; +use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager; use WHMCS\Module\Server\VirtFusionDirect\ServerResource; $vf = new Module; @@ -405,6 +451,147 @@ try { $vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500); break; + // ================================================================= + // Reverse DNS (PowerDNS) + // ================================================================= + + /** + * List PTR state for every IP assigned to the service's server. + * + * Always fetches fresh server data from VirtFusion (not cached server_object) + * so the displayed IPs match current reality — if an IP was reassigned out + * of this server since last sync, it won't appear here. + */ + case 'rdnsList': + + $serviceID = $vf->validateServiceID(true); + + if (! $vf->validateUserOwnsService($serviceID)) { + $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; + } + + // Reads are permitted for Active + Suspended (a suspended user can still see their rDNS); + // Terminated/Pending/Cancelled/Fraud return a clear 400 upfront. + $vf->requireServiceStatus($serviceID, ['Active', 'Suspended']); + + if (! PowerDnsConfig::isEnabled()) { + $vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200); + break; + } + + $serverData = $vf->fetchServerData($serviceID); + if (! $serverData) { + $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502); + break; + } + + $ptrs = (new PtrManager)->listPtrs($serverData); + $vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200); + break; + + /** + * Update (or delete) the PTR for a single IP assigned to the user's server. + * + * Validation order: ownership -> IP format -> PTR regex -> IP belongs to this server + * -> rate-limit/forward-DNS checks inside PtrManager. Sending an empty `ptr` deletes. + */ + case 'rdnsUpdate': + + // Mutation: enforce POST, same-origin, active service status in that order. + // requirePost/requireSameOrigin exit on failure (405/403 respectively), so nothing below runs. + $vf->requirePost(); + $vf->requireSameOrigin(); + + $serviceID = $vf->validateServiceID(true); + + $clientId = $vf->validateUserOwnsService($serviceID); + if (! $clientId) { + $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; + } + + // Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS. + $vf->requireServiceStatus($serviceID, ['Active']); + + if (! PowerDnsConfig::isEnabled()) { + $vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled on this installation'], true, true, 400); + break; + } + + $ip = isset($_POST['ip']) ? trim((string) $_POST['ip']) : ''; + $ptr = isset($_POST['ptr']) ? trim((string) $_POST['ptr']) : ''; + + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + $vf->output(['success' => false, 'errors' => 'Invalid IP address'], true, true, 400); + break; + } + + if ($ptr !== '' && ! preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/', $ptr)) { + $vf->output(['success' => false, 'errors' => 'Invalid hostname for PTR record'], true, true, 400); + break; + } + if (strlen($ptr) > 253) { + $vf->output(['success' => false, 'errors' => 'Hostname too long'], true, true, 400); + break; + } + + // Cross-check: the submitted IP must be currently assigned to this user's server. + // Fetch fresh from VirtFusion (not the stored object) to prevent stale-ownership writes + // after an IP reassignment. + $serverData = $vf->fetchServerData($serviceID); + if (! $serverData) { + $vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502); + break; + } + $assigned = IpUtil::extractIps($serverData)['addresses']; + $targetBin = @inet_pton($ip); + $owns = false; + foreach ($assigned as $a) { + if (@inet_pton($a) === $targetBin) { + $owns = true; + break; + } + } + if (! $owns) { + Log::insert('rdnsUpdate:ownership', ['serviceID' => $serviceID, 'ip' => $ip], 'IP not assigned to this service'); + $vf->output(['success' => false, 'errors' => 'This IP is not assigned to your server'], true, true, 403); + break; + } + + $result = (new PtrManager)->setPtr($ip, $ptr); + + if ($result['ok']) { + // Audit trail for successful edits — surfaces in Utilities → Logs → Module Log, + // searchable by clientId / serviceId / ip for "who changed this PTR". + Log::insert( + 'rdnsUpdate:ok', + ['clientId' => $clientId, 'serviceID' => $serviceID, 'ip' => $ip, 'reason' => $result['reason']], + ['ptr' => $ptr === '' ? '(deleted)' : $ptr], + ); + $vf->output(['success' => true, 'data' => ['reason' => $result['reason']]], true, true, 200); + break; + } + + // Map internal reasons to client-facing messages/status codes. + switch ($result['reason']) { + case 'forward-missing': + $vf->output(['success' => false, 'errors' => 'Forward DNS for "' . $ptr . '" does not resolve to ' . $ip . '. Configure the A/AAAA record with your DNS provider first, then try again.'], true, true, 400); + break; + case 'rate-limited': + $vf->output(['success' => false, 'errors' => 'Too many updates for this IP. Try again in a few seconds.'], true, true, 429); + break; + case 'no-zone': + $vf->output(['success' => false, 'errors' => 'This IP has no reverse DNS zone configured on the nameserver.'], true, true, 400); + break; + case 'disabled': + $vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400); + break; + default: + $vf->output(['success' => false, 'errors' => 'Reverse DNS update failed (' . $result['reason'] . ')'], true, true, 500); + } + break; + default: $vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400); } diff --git a/modules/servers/VirtFusionDirect/hooks.php b/modules/servers/VirtFusionDirect/hooks.php index f4be8fc..bdb1f04 100644 --- a/modules/servers/VirtFusionDirect/hooks.php +++ b/modules/servers/VirtFusionDirect/hooks.php @@ -1,14 +1,68 @@ reconcileAll(); + } + } catch (Throwable $e) { + Log::insert('PowerDns:DailyCronJob', [], $e->getMessage()); + } +}); + /** * Shopping Cart Validation Hook * diff --git a/modules/servers/VirtFusionDirect/lib/AdminHTML.php b/modules/servers/VirtFusionDirect/lib/AdminHTML.php index 3a5c321..1152284 100644 --- a/modules/servers/VirtFusionDirect/lib/AdminHTML.php +++ b/modules/servers/VirtFusionDirect/lib/AdminHTML.php @@ -4,6 +4,27 @@ namespace WHMCS\Module\Server\VirtFusionDirect; /** * Static methods that generate HTML fragments for the WHMCS admin services tab. + * + * WHY RAW HTML STRINGS INSTEAD OF TEMPLATES + * ----------------------------------------- + * WHMCS's AdminServicesTabFields hook expects an associative array of + * label => HTML-string pairs. It renders each entry as a table row with the + * label on the left and the raw HTML inserted verbatim on the right. There's + * no way to return a Smarty template reference from that hook — WHMCS doesn't + * know how to render one in that context. + * + * So we concatenate HTML here. All variable interpolation uses htmlspecialchars() + * at the PHP boundary — never trust that a value passed in is safe for HTML. + * + * ASSET INJECTION + * --------------- + * Some renderers (serverInfo, rdnsSection) embed and +EOT; + } + + /** + * Render the admin Reverse DNS section for the services tab. + * + * Ships an empty container + a Reconcile button. Data is loaded client-side via + * the admin rdnsStatus AJAX endpoint once the page opens. The JS function + * vfAdminLoadRdns (defined in templates/js/module.js) populates #vf-rdns-list + * and wires up the Reconcile button's onclick to admin.php?action=rdnsReconcile. + * + * @param string $systemUrl WHMCS system URL + * @param int $serviceId WHMCS service ID + * @return string HTML fragment for the admin services tab + */ + public static function rdnsSection($systemUrl, $serviceId) + { + $systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8'); + $serviceId = (int) $serviceId; + + return << +
+ Loading reverse DNS… +
+
+ + + +
+ + EOT; } } diff --git a/modules/servers/VirtFusionDirect/lib/Module.php b/modules/servers/VirtFusionDirect/lib/Module.php index 2a73fb7..84241d0 100644 --- a/modules/servers/VirtFusionDirect/lib/Module.php +++ b/modules/servers/VirtFusionDirect/lib/Module.php @@ -10,8 +10,49 @@ use WHMCS\Database\Capsule; * server feature methods (power, network, VNC, backup, resource modification, * self-service billing, traffic, rename, password reset). * - * Extended by ModuleFunctions (service lifecycle) and ConfigureService (order-time - * operations). Most business logic lives here; subclasses delegate to these methods. + * INHERITANCE SHAPE + * ----------------- + * Extended by: + * - ModuleFunctions — service lifecycle (create, suspend, unsuspend, terminate, change package) + * - ConfigureService — order-time operations (package/template discovery, server build init) + * + * Most business logic lives HERE, not in the subclasses. Subclasses are intentionally + * thin — they orchestrate sequences of calls to methods defined on this base, which + * lets us unit-exercise any single feature (e.g. "what happens during rename when + * the VirtFusion API returns 423?") without standing up a full WHMCS lifecycle. + * + * THE resolveServiceContext() PATTERN + * ----------------------------------- + * Almost every method follows the same preamble: look up the module table row, + * look up the WHMCS tblhosting row, resolve the control panel credentials, build + * a Curl client with the bearer token. That preamble is consolidated into + * resolveServiceContext() which returns everything as an array or false on any + * missing piece. Every feature method starts with "$ctx = $this->resolveServiceContext($id); + * if (! $ctx) return false;" and can then use $ctx['request'], $ctx['serverId'], etc. + * + * This pattern is the most important abstraction in the module — violating it + * (e.g. reading tblservers directly in a feature method) leads to drift where + * some features handle missing servers gracefully and others don't. + * + * ENDPOINT OUTPUT CONVENTION + * -------------------------- + * client.php and admin.php call $this->output() to emit JSON responses. Every + * output() call in a switch case MUST be followed by a `break` — the module + * deliberately does NOT rely on exit() inside output() for flow control because + * that couples the HTTP response format to the control-flow mechanism and makes + * refactoring fragile. + * + * SECURITY HELPERS + * ---------------- + * Five guards callers compose in front of sensitive actions: + * - isAuthenticated() — client session required + * - adminOnly() — admin session required + * - requirePost() — HTTP method gate (mutations only) + * - requireSameOrigin() — CSRF origin check + * - requireServiceStatus() — filter by tblhosting.domainstatus + * + * Each exits on failure with the appropriate HTTP status — callers treat them + * as "throw on failure" style assertions rather than having to check return values. */ class Module { @@ -73,10 +114,23 @@ class Module /** * Resolve service context: system service, WHMCS service, control panel, and curl client. - * Returns false if any lookup fails. + * + * This is the most-called method in the module. Every feature action begins + * by calling it, so think of the return value as "everything you need to + * touch VirtFusion for this service": + * + * service — row from mod_virtfusion_direct (has server_id, server_object) + * whmcsService — row from tblhosting (has server, userid, domain, etc.) + * cp — ['url', 'base_url', 'token'] for the VirtFusion API + * request — a fresh Curl instance pre-configured with the bearer token + * serverId — (int) of service.server_id — used in every URL downstream + * + * Returning false on ANY missing piece lets callers write a single + * "if (! $ctx) return false;" check at the top of each feature method + * rather than threading nullability through three separate lookups. * * @param int $serviceID - * @return array{service: object, whmcsService: object, cp: array, request: Curl}|false + * @return array{service: object, whmcsService: object, cp: array, request: Curl, serverId: int}|false */ protected function resolveServiceContext($serviceID) { @@ -328,13 +382,37 @@ class Module return false; } + // Capture old hostname + server object from stored state so we can sync rDNS + // after the rename. We read from the cached server_object rather than a fresh + // fetch; this is the hostname the PTR would be set to (if module-managed). + $oldHostname = null; + $serverObject = null; + if (! empty($ctx['service']->server_object)) { + $serverObject = json_decode($ctx['service']->server_object, true); + if (is_array($serverObject)) { + $oldHostname = PowerDns\PtrManager::extractHostname($serverObject); + } + } + $ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName])); $data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name'); Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data); $httpCode = $ctx['request']->getRequestInfo('http_code'); + $success = $httpCode == 200 || $httpCode == 204; - return $httpCode == 200 || $httpCode == 204; + if ($success && $serverObject !== null && PowerDns\Config::isEnabled()) { + // Sync PTRs: only records whose current content equals the old hostname + // will be rewritten; client-customized PTRs are preserved automatically. + // Non-blocking: rDNS failures log but never fail the rename. + try { + (new PowerDns\PtrManager)->syncServer($serverObject, $oldHostname, $newName); + } catch (\Throwable $e) { + Log::insert('PowerDns:renameServer', ['serviceID' => $serviceID], $e->getMessage()); + } + } + + return $success; } catch (\Exception $e) { Log::insert(__FUNCTION__, [], $e->getMessage()); @@ -773,6 +851,26 @@ class Module /** * Resolve a WHMCS server record into an API base URL and decrypted Bearer token. * + * OUTPUT SHAPE + * ------------ + * url — full API base like "https://vf.example.com/api/v1". Append + * path components to this for every VirtFusion call. + * base_url — scheme + host only, "https://vf.example.com". Used for SSO + * redirects where we need to hit the panel UI, not the API. + * token — decrypted bearer token. Pass to initCurl() to get an + * authenticated Curl handle. + * + * $any=true is an unusual behaviour: when a WHMCS product doesn't have a + * specific server pinned (allowed if the module is the only VF module on + * the install), we fall back to any enabled VirtFusion server. This mostly + * exists for the "Test Connection" button which doesn't know which server + * to use until after a successful connection. Normal provisioning always + * passes a real server ID. + * + * The token is stored encrypted in tblservers.password and decrypted here + * via WHMCS's global decrypt() — the same encryption key used for addon + * module password fields. + * * @param int|object $server WHMCS server ID or server object * @param bool $any When true, fall back to any available server if the given one is not found * @return array{url: string, base_url: string, token: string}|false @@ -825,6 +923,164 @@ class Module $this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401); } + /** + * Enforce POST as the HTTP method. Emits a 405 JSON response and exits otherwise. + * + * WHY THIS EXISTS + * --------------- + * The REST principle says mutations should be POST, and PHP's $_POST / $_GET + * separation means a mutation that reads from $_POST would fail quietly when + * called via GET. But "fail quietly" isn't what we want — an attacker probing + * endpoints via crafted tags shouldn't + * even reach our input-validation code. This gate kills that path with a 405 + * before any per-endpoint logic runs. + * + * Combined with requireSameOrigin() below, this closes the most common + * cross-site request forgery vectors (form POST, image GET) without needing + * explicit CSRF tokens threaded through every AJAX call. + * + * @return bool|void + */ + public function requirePost() + { + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { + return true; + } + + $this->output(['success' => false, 'errors' => 'method not allowed'], true, true, 405); + } + + /** + * Verify the request's Origin/Referer belongs to this WHMCS install. + * + * THREAT MODEL + * ------------ + * A logged-in WHMCS user visits a malicious page. That page makes a POST + * to our rDNS endpoint; because the session cookie is tied to our domain, + * the browser attaches it automatically. Without this check, the attacker + * could silently rewrite the user's PTRs. + * + * The defence: browsers attach an Origin header on cross-origin fetch/XHR + * and a Referer on cross-origin form POST. Those headers carry the + * attacker's origin, not ours — so we compare them against our own + * hostname and reject mismatches with a 403. + * + * This is NOT a full CSRF token scheme. It defends against the common + * cross-site-POST and cross-site-form-submit vectors but a same-site XSS + * that can read the user's DOM could still circumvent it. For that you'd + * need per-request tokens bound to the session — out of scope for the + * current module, but the helper stays here ready to be composed with + * a token check if one's added later. + * + * IMPLEMENTATION + * -------------- + * 1. Collect our "known good" host set from HTTP_HOST (what the browser + * connected to) plus the SystemURL host from tblconfiguration (what + * WHMCS thinks its canonical URL is). Behind a reverse proxy these + * can differ; accepting either closes the false-positive gap. + * 2. Parse HTTP_ORIGIN and HTTP_REFERER and pull out their host:port. + * 3. Require at least one of those headers to match. + * + * Fails closed: if we can't determine our own host OR if neither Origin + * nor Referer is present, we reject. A legitimate same-origin AJAX call + * from the module's own JS always sets Origin (fetch API) or Referer + * (form submit), so the "both absent" case only happens with scripted + * non-browser clients — which are exactly who we want to filter out. + * + * @return bool|void true on success; emits 403 JSON and exits otherwise + */ + public function requireSameOrigin() + { + $expected = []; + + $host = (string) ($_SERVER['HTTP_HOST'] ?? ''); + if ($host !== '') { + $expected[] = strtolower($host); + } + + $systemUrl = Database::getSystemUrl(); + if ($systemUrl) { + $parsed = parse_url($systemUrl); + if (! empty($parsed['host'])) { + $expected[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '')); + $expected[] = strtolower($parsed['host']); + } + } + $expected = array_unique(array_filter($expected)); + if (empty($expected)) { + // Can't determine our own host; fail closed rather than silently allow. + $this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403); + } + + $origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? ''); + $referer = (string) ($_SERVER['HTTP_REFERER'] ?? ''); + + $candidates = []; + foreach ([$origin, $referer] as $raw) { + if ($raw === '') { + continue; + } + $parsed = parse_url($raw); + if (! empty($parsed['host'])) { + $candidates[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '')); + $candidates[] = strtolower($parsed['host']); + } + } + + if (empty($candidates)) { + $this->output(['success' => false, 'errors' => 'cross-origin check failed (missing origin)'], true, true, 403); + } + + foreach ($candidates as $c) { + if (in_array($c, $expected, true)) { + return true; + } + } + + Log::insert('csrf:origin-mismatch', ['origin' => $origin, 'referer' => $referer, 'expected' => $expected], 'cross-origin request rejected'); + $this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403); + } + + /** + * Ensure the WHMCS service is in a status where client-initiated writes make sense. + * + * tblhosting.domainstatus can be: Active, Suspended, Terminated, Pending, + * Cancelled, Fraud. Not every action makes sense in every status: + * - Reads (rdnsList, serverData) usually allow Active + Suspended so a + * suspended user can still see their current config. + * - Writes (rdnsUpdate, power, etc.) typically require Active only — + * mutating a cancelled service's rDNS has no sensible business meaning. + * + * Pass the allowed set explicitly per endpoint rather than trying to encode + * a global policy here. Some endpoints (admin reconcile) don't call this at + * all because the admin is allowed to touch any service. + * + * Fails with 404 if the service doesn't exist, 400 otherwise — keeping the + * two conditions distinct in the response code helps client-side error + * handling (a 404 usually means "link is stale", a 400 means "not right now"). + * + * @param int $serviceID WHMCS service ID + * @param string[] $allowedStatuses Service statuses that permit the operation + * @return bool|void true on success; emits 400/404 JSON and exits otherwise + */ + public function requireServiceStatus(int $serviceID, array $allowedStatuses = ['Active']) + { + $row = Database::getWhmcsService($serviceID); + if (! $row) { + $this->output(['success' => false, 'errors' => 'service not found'], true, true, 404); + } + if (! in_array((string) $row->domainstatus, $allowedStatuses, true)) { + $this->output( + ['success' => false, 'errors' => 'service status "' . (string) $row->domainstatus . '" does not permit this action'], + true, + true, + 400, + ); + } + + return true; + } + /** * Create a pre-configured Curl instance with JSON Accept/Content-Type headers * and a Bearer token for authenticating against the VirtFusion API. diff --git a/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php b/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php index 5d8516b..093aaae 100644 --- a/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php +++ b/modules/servers/VirtFusionDirect/lib/ModuleFunctions.php @@ -5,8 +5,38 @@ namespace WHMCS\Module\Server\VirtFusionDirect; /** * Extends Module to handle the WHMCS service lifecycle for VirtFusion servers. * - * Responsibilities include: provisioning (create, suspend, unsuspend, terminate), - * package changes, usage updates, client area rendering, and admin tab fields. + * WHY A SEPARATE CLASS FROM MODULE + * -------------------------------- + * The WHMCS module interface (VirtFusionDirect.php) expects top-level functions + * like VirtFusionDirect_CreateAccount(). Those functions delegate into methods + * on this class so: + * 1. The top-level functions stay one-liners that are easy to audit. + * 2. All lifecycle logic lives in an object we can instantiate and unit-exercise + * without going through WHMCS's dispatch machinery. + * 3. The shared behaviour with Module (API calls, auth, validation) comes for + * free via inheritance — no copy-pasted curl setup or error handling. + * + * ERROR MESSAGE CONVENTION + * ------------------------ + * Every public method either returns the literal string 'success' or an error + * string that WHMCS will render to the admin in the service activity log. Do NOT + * return arrays, objects, or booleans — WHMCS treats anything other than + * 'success' as an error and displays it verbatim. + * + * EXCEPTION HANDLING + * ------------------ + * Every public method is wrapped in try/catch. Uncaught exceptions bubbling up + * to WHMCS appear as stack traces in the admin UI and leak implementation detail, + * so we catch and convert to a human error string. Log::insert() captures the + * original exception message for diagnostics in the module log. + * + * PowerDNS INTEGRATION + * -------------------- + * createAccount(), terminateAccount(), and (via parent Module) renameServer() + * call into PowerDns\PtrManager to sync rDNS. Those calls are wrapped in their + * OWN try/catch so DNS failures never bubble up to WHMCS — provisioning must + * succeed even if PowerDNS is temporarily unreachable. See cleanupPowerDnsForService() + * for the termination-time cleanup helper. */ class ModuleFunctions extends Module { @@ -163,6 +193,33 @@ class ModuleFunctions extends Module Database::systemOnServerCreate($params['serviceid'], $data); $this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data); + // Initialize reverse DNS for the newly-assigned IPs. + // + // Ordering: after Database::systemOnServerCreate() AND + // updateWhmcsServiceParamsOnServerObject() so mod_virtfusion_direct + // has the stored server_object (admin reconcile later reads it) and + // tblhosting has the primary IP (for cross-check on client edits). + // + // But BEFORE ConfigureService::initServerBuild() so rDNS is in place + // when the VPS first boots — mail servers and other services that + // check FCrDNS during early-boot see correct PTRs. + // + // Non-blocking: rDNS failures are logged but never fail provisioning. + // A broken PowerDNS or missing zone must not prevent a customer + // from getting the VPS they paid for. + try { + if (PowerDns\Config::isEnabled()) { + // syncServer with $oldHostname=null means "create mode" — see + // PtrManager::syncServer() docblock for the semantics. + $hostname = PowerDns\PtrManager::extractHostname($data); + if ($hostname !== null) { + (new PowerDns\PtrManager)->syncServer($data, null, $hostname); + } + } + } catch (\Throwable $e) { + Log::insert('PowerDns:createAccount', ['serviceid' => $params['serviceid']], $e->getMessage()); + } + // If the server is created successfully, we can initialize the server build. $cs = new ConfigureService; $vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null; @@ -304,6 +361,7 @@ class ModuleFunctions extends Module switch ($request->getRequestInfo('http_code')) { case 204: + $this->cleanupPowerDnsForService($service); Database::deleteSystemService($params['serviceid']); $this->updateWhmcsServiceParamsOnDestroy($params['serviceid']); @@ -312,6 +370,7 @@ class ModuleFunctions extends Module case 404: if (isset($data->msg)) { if ($data->msg == 'server not found') { + $this->cleanupPowerDnsForService($service); Database::deleteSystemService($params['serviceid']); return 'success'; @@ -335,6 +394,33 @@ class ModuleFunctions extends Module } } + /** + * Delete any PTR records owned by this service before the local record is erased. + * The stored server_object is the last source of the IP list; once deleted from + * the module table we'd have no way to find them again. Non-fatal — DNS failures + * never block termination. + * + * @param object|null $service Row from mod_virtfusion_direct (has server_object JSON) + */ + protected function cleanupPowerDnsForService($service): void + { + try { + if (! PowerDns\Config::isEnabled()) { + return; + } + if (! $service || empty($service->server_object)) { + return; + } + $decoded = json_decode($service->server_object, true); + if (! is_array($decoded)) { + return; + } + (new PowerDns\PtrManager)->deleteForServer($decoded); + } catch (\Throwable $e) { + Log::insert('PowerDns:terminate', ['service' => $service->service_id ?? null], $e->getMessage()); + } + } + /** * Suspend a VirtFusion server, queuing the action if another operation is in progress. * @@ -552,6 +638,9 @@ class ModuleFunctions extends Module if ($params['status'] != 'Terminated') { $fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']); + if (PowerDns\Config::isEnabled()) { + $fields['Reverse DNS'] = AdminHTML::rdnsSection($systemUrl, $params['serviceid']); + } } return $fields; @@ -659,6 +748,7 @@ class ModuleFunctions extends Module 'serviceStatus' => $params['status'], 'serverHostname' => $serverHostname, 'selfServiceMode' => (int) ($params['configoption4'] ?? 0), + 'rdnsEnabled' => PowerDns\Config::isEnabled(), ], ]; } catch (\Throwable $e) { diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php new file mode 100644 index 0000000..6c4a57e --- /dev/null +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Client.php @@ -0,0 +1,310 @@ +|null $config Optional pre-resolved config; defaults to PowerDns\Config::get() + */ + public function __construct(?array $config = null) + { + $config = $config ?? Config::get(); + $this->endpoint = rtrim((string) ($config['endpoint'] ?? ''), '/'); + $this->apiKey = (string) ($config['apiKey'] ?? ''); + $this->serverId = (string) ($config['serverId'] ?? 'localhost'); + } + + /** Base URL for the configured PowerDNS server (no trailing slash). */ + private function base(): string + { + return $this->endpoint . '/api/v1/servers/' . rawurlencode($this->serverId); + } + + /** + * Encode a zone name to its PowerDNS URL-safe id form. + * + * PowerDNS's API uses a custom URL encoding for zone names that have characters + * like "/" which would collide with path semantics. Instead of using %-encoding + * (which many HTTP frameworks would parse back out at routing time), PowerDNS + * uses "=HH" where HH is the hex code — so "/" becomes "=2F". + * + * This only matters for RFC 2317 classless-delegation zone names like + * "64/64.113.0.203.in-addr.arpa." whose zone id in the API is + * "64=2F64.113.0.203.in-addr.arpa.". Standard zones pass through unchanged + * because they contain no "/" characters. + * + * Using rawurlencode() here would produce "%2F" which PowerDNS does NOT accept. + * That's why this is a plain str_replace. + */ + private function zoneIdEncode(string $zoneName): string + { + return str_replace('/', '=2F', rtrim($zoneName, '.') . '.'); + } + + /** Fresh Curl instance with PowerDNS auth + JSON headers. */ + private function newCurl(): Curl + { + $curl = new Curl; + $curl->addOption(CURLOPT_HTTPHEADER, [ + 'Accept: application/json', + 'Content-Type: application/json; charset=utf-8', + 'X-API-Key: ' . $this->apiKey, + ]); + + return $curl; + } + + /** + * Healthcheck. Returns [ok: bool, http: int, error: ?string]. + * Used by the addon's Test Connection button and by VirtFusionDirect_TestConnection(). + * + * @return array{ok: bool, http: int, error: ?string} + */ + public function ping(): array + { + try { + $curl = $this->newCurl(); + $body = $curl->get($this->base()); + $http = (int) $curl->getRequestInfo('http_code'); + if ($http === 200) { + return ['ok' => true, 'http' => 200, 'error' => null]; + } + if ($http === 0) { + $err = (string) ($curl->getRequestInfo('curl_error') ?: 'connection failed'); + + return ['ok' => false, 'http' => 0, 'error' => $err]; + } + if ($http === 401 || $http === 403) { + return ['ok' => false, 'http' => $http, 'error' => 'authentication failed (check API key)']; + } + + return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)]; + } catch (\Throwable $e) { + Log::insert('PowerDns:ping', [], $e->getMessage()); + + return ['ok' => false, 'http' => 0, 'error' => $e->getMessage()]; + } + } + + /** + * List every zone on the configured PowerDNS server. + * + * Result is cached for the configured cacheTtl. Used as the primary zone-discovery + * strategy: PtrManager finds the containing zone for a PTR name by longest-suffix + * matching against this list rather than probing individual zones. + * + * @return string[] Zone names with trailing dot + */ + public function listZones(): array + { + $ttl = Config::cacheTtl(); + $cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId); + + $cached = Cache::get($cacheKey); + if (is_array($cached)) { + return $cached; + } + + $zones = []; + + try { + $curl = $this->newCurl(); + $body = $curl->get($this->base() . '/zones'); + $http = (int) $curl->getRequestInfo('http_code'); + + if ($http === 200) { + $decoded = json_decode((string) $body, true); + if (is_array($decoded)) { + foreach ($decoded as $z) { + if (! empty($z['name'])) { + $zones[] = rtrim((string) $z['name'], '.') . '.'; + } + } + } + } else { + Log::insert('PowerDns:listZones', ['http' => $http], substr((string) $body, 0, 500)); + } + } catch (\Throwable $e) { + Log::insert('PowerDns:listZones', [], $e->getMessage()); + } + + Cache::set($cacheKey, $zones, $ttl); + + return $zones; + } + + /** Drop any cached zone list (call after PATCHes or settings changes). */ + public function forgetZoneCache(): void + { + $cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId); + Cache::forget($cacheKey); + } + + /** + * Fetch a single zone by name. Returns decoded JSON array, or null on 404/error. + * + * @return array|null + */ + public function getZone(string $zoneName): ?array + { + try { + $zoneName = rtrim($zoneName, '.') . '.'; + $curl = $this->newCurl(); + $body = $curl->get($this->base() . '/zones/' . $this->zoneIdEncode($zoneName)); + $http = (int) $curl->getRequestInfo('http_code'); + if ($http === 200) { + $decoded = json_decode((string) $body, true); + + return is_array($decoded) ? $decoded : null; + } + if ($http !== 404) { + Log::insert('PowerDns:getZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 500)); + } + } catch (\Throwable $e) { + Log::insert('PowerDns:getZone', ['zone' => $zoneName], $e->getMessage()); + } + + return null; + } + + /** + * Apply an RRset change to a zone via PATCH. + * + * $rrset keys (per PowerDNS API): name, type, ttl?, changetype (REPLACE|DELETE|EXTEND), records[]. + * On success PowerDNS returns 204 No Content. + * + * @return array{ok: bool, http: int, body: string} + */ + public function patchRRset(string $zoneName, array $rrset): array + { + try { + $zoneName = rtrim($zoneName, '.') . '.'; + if (isset($rrset['name'])) { + $rrset['name'] = rtrim((string) $rrset['name'], '.') . '.'; + } + + $payload = ['rrsets' => [$rrset]]; + $curl = $this->newCurl(); + $curl->addOption(CURLOPT_POSTFIELDS, json_encode($payload)); + $body = $curl->patch($this->base() . '/zones/' . $this->zoneIdEncode($zoneName)); + $http = (int) $curl->getRequestInfo('http_code'); + + Log::insert( + 'PowerDns:patchRRset', + [ + 'zone' => $zoneName, + 'name' => $rrset['name'] ?? null, + 'type' => $rrset['type'] ?? null, + 'changetype' => $rrset['changetype'] ?? null, + ], + ['http' => $http, 'body' => substr((string) $body, 0, 500)], + ); + + if ($http === 204) { + // Fire-and-forget NOTIFY so slaves pick up the bumped SOA serial immediately. + // + // Background: PowerDNS auto-increments SOA on every API write when the zone + // has soa_edit_api=INCREASE (recommended; see README). Slaves normally learn + // about the new serial via polling at the refresh interval (often 15+ min) + // OR via a NOTIFY push from the master. Without our NOTIFY, rDNS changes + // made via this module would take effect on the authoritative master + // immediately but wouldn't propagate until the next scheduled poll. + // + // Only meaningful for Master-kind zones. For Native zones (no slaves) or + // Slave zones (reverse direction), PowerDNS returns a 422 or similar — + // notifyZone() logs that and returns ok=false, but we don't care here: + // the PATCH itself succeeded, which is what we report upward. + $this->notifyZone($zoneName); + } + + return ['ok' => $http === 204, 'http' => $http, 'body' => (string) $body]; + } catch (\Throwable $e) { + Log::insert('PowerDns:patchRRset', ['zone' => $zoneName], $e->getMessage()); + + return ['ok' => false, 'http' => 0, 'body' => $e->getMessage()]; + } + } + + /** + * Send a DNS NOTIFY to all slaves for this zone. Only applicable to Master-kind zones; + * PowerDNS returns 400/422 for Native/Slave kinds and that's fine — we log and continue. + * + * SOA serial bumping itself is handled by PowerDNS (soa_edit_api=INCREASE or similar + * on the zone); this call just ensures slaves learn about the new serial right away + * rather than waiting for the next scheduled refresh. + * + * @return array{ok: bool, http: int} + */ + public function notifyZone(string $zoneName): array + { + try { + $zoneName = rtrim($zoneName, '.') . '.'; + $curl = $this->newCurl(); + $body = $curl->put($this->base() . '/zones/' . $this->zoneIdEncode($zoneName) . '/notify'); + $http = (int) $curl->getRequestInfo('http_code'); + + if ($http !== 200) { + Log::insert('PowerDns:notifyZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 300)); + } + + return ['ok' => $http === 200, 'http' => $http]; + } catch (\Throwable $e) { + Log::insert('PowerDns:notifyZone', ['zone' => $zoneName], $e->getMessage()); + + return ['ok' => false, 'http' => 0]; + } + } +} diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Config.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Config.php new file mode 100644 index 0000000..7b8f14e --- /dev/null +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Config.php @@ -0,0 +1,185 @@ +|null Null = not loaded yet; an array = resolved settings */ + private static $cached = null; + + /** + * Force a reload on next get(). + * + * Primary use case: the addon's _output() page calls this before re-fetching + * config so a test-connection click after saving settings sees the saved values. + * Most other code should NOT call this — the request-scoped cache is there for + * good performance reasons. + */ + public static function reset(): void + { + self::$cached = null; + } + + /** + * Return the fully-resolved configuration array with decrypted apiKey. + * + * Keys: enabled(bool), endpoint(string), apiKey(string), serverId(string), + * defaultTtl(int), cacheTtl(int). + */ + public static function get(): array + { + if (self::$cached !== null) { + return self::$cached; + } + + $config = [ + 'enabled' => false, + 'endpoint' => '', + 'apiKey' => '', + 'serverId' => 'localhost', + 'defaultTtl' => 3600, + 'cacheTtl' => 60, + ]; + + try { + // pluck('value', 'setting') returns a Collection keyed by 'setting' with + // 'value' as the values — so $rows['enabled'] reads the row where + // setting='enabled'. Efficient: one query regardless of how many + // settings exist. + $rows = DB::table('tbladdonmodules') + ->where('module', self::MODULE_NAME) + ->pluck('value', 'setting') + ->toArray(); + + // WHMCS yesno fields store either "on"/"" or "1"/"0" depending on version + // and form handling. Accept all common truthy representations rather than + // relying on a single literal. + $enabledRaw = $rows['enabled'] ?? ''; + $config['enabled'] = in_array(strtolower((string) $enabledRaw), ['on', 'yes', '1', 'true'], true); + + // Trim trailing slash from endpoint so Client::base() can safely concatenate + // "/api/v1/..." without producing doubled slashes. + $config['endpoint'] = rtrim((string) ($rows['endpoint'] ?? ''), '/'); + $config['serverId'] = (string) ($rows['serverId'] ?? 'localhost'); + + // Floor at 60s for defaultTtl and 10s for cacheTtl. Prevents a foot-gun + // where an operator accidentally saves "0" and causes PowerDNS to treat + // PTRs as non-cacheable (which some resolvers refuse) or this module to + // hammer PowerDNS on every call. + $config['defaultTtl'] = max(60, (int) ($rows['defaultTtl'] ?? 3600)); + $config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60)); + + if (! empty($rows['apiKey'])) { + try { + // decrypt() is WHMCS's global helper — matches how the VirtFusion + // bearer token is handled in Module::getCP(). + $decrypted = decrypt($rows['apiKey']); + + // Fallback to raw value if decrypt returned empty or non-string — + // defends against the rare case where decrypt silently fails + // (wrong encryption key at rest) or the value was inserted + // manually as plaintext during development. + $config['apiKey'] = is_string($decrypted) && $decrypted !== '' ? $decrypted : (string) $rows['apiKey']; + } catch (\Throwable $e) { + // Even when decrypt throws, we try the raw value so a diagnostic + // path exists. Operator sees the decrypt error in the module log + // but isn't locked out of using the addon while they investigate. + $config['apiKey'] = (string) $rows['apiKey']; + Log::insert('PowerDns:Config', 'decrypt skipped', $e->getMessage()); + } + } + } catch (\Throwable $e) { + // Any DB-level failure (table doesn't exist, connection dropped, etc.) + // leaves $config at its safe defaults — isEnabled() returns false, + // nothing gets written to PowerDNS, and the server module continues + // to provision as if the addon weren't installed. + Log::insert('PowerDns:Config', 'load failed', $e->getMessage()); + } + + self::$cached = $config; + + return $config; + } + + /** True only when the addon is activated, configured, and has both endpoint and key. */ + public static function isEnabled(): bool + { + $c = self::get(); + + return $c['enabled'] && $c['endpoint'] !== '' && $c['apiKey'] !== ''; + } + + public static function endpoint(): string + { + return self::get()['endpoint']; + } + + public static function apiKey(): string + { + return self::get()['apiKey']; + } + + public static function serverId(): string + { + return self::get()['serverId']; + } + + public static function defaultTtl(): int + { + return self::get()['defaultTtl']; + } + + public static function cacheTtl(): int + { + return self::get()['cacheTtl']; + } +} diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php b/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php new file mode 100644 index 0000000..34fff5a --- /dev/null +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php @@ -0,0 +1,426 @@ + "20010db8000000000000000000000001" + * + * Why: PTR names under ip6.arpa use *all* 32 nibbles (no compression, no :: shorthand), + * so we need the fully-expanded form before we can reverse the nibbles. + * + * Implementation: inet_pton normalises any valid IPv6 notation to 16 raw bytes, + * and bin2hex turns that into 32 lowercase hex chars. No manual padding/splitting + * logic means we can't get ":" vs "::" compression wrong. + * + * @return string|null 32-char hex string, or null if input isn't valid IPv6 + */ + public static function expandIpv6(string $ip): ?string + { + $bin = @inet_pton($ip); + // inet_pton returns 16 bytes for v6, 4 bytes for v4. Guard on both conditions + // so a valid IPv4 like "192.0.2.1" doesn't silently pass through this v6 helper. + if ($bin === false || strlen($bin) !== 16) { + return null; + } + + return bin2hex($bin); + } + + /** + * Build the fully-qualified PTR name (trailing dot) for an IPv4 or IPv6 address. + * + * IPv4 example: 203.0.113.5 -> "5.113.0.203.in-addr.arpa." + * IPv6 example: 2001:db8::1 -> "1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa." + * + * @return string|null PTR name with trailing dot, or null if input isn't a valid IP + */ + public static function ptrNameForIp(string $ip): ?string + { + // IPv4: reverse the four octets and suffix with in-addr.arpa. + // 203.0.113.5 -> 5.113.0.203.in-addr.arpa. + if (self::isIpv4($ip)) { + $octets = array_reverse(explode('.', $ip)); + + return implode('.', $octets) . '.in-addr.arpa.'; + } + + // IPv6: expand to 32 nibbles, reverse each nibble, suffix with ip6.arpa. + // 2001:db8::1 -> 1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa. + // The nibble-level reversal (not byte-level) is important: each hex digit + // becomes its own DNS label. inet_pton/bin2hex give us the 32-char form; + // str_split with no length arg defaults to 1 so each char becomes one label. + if (self::isIpv6($ip)) { + $hex = self::expandIpv6($ip); + if ($hex === null) { + return null; + } + $nibbles = array_reverse(str_split($hex)); + + return implode('.', $nibbles) . '.ip6.arpa.'; + } + + return null; + } + + /** + * Extract every host IP address (v4 and v6) from a VirtFusion server object. + * + * Walks every interface, not just interfaces[0] (ServerResource only reads the primary). + * Handles both explicit `address` fields and `subnet`+`cidr` pairs. + * For IPv6 entries exposed only as `subnet`+`cidr`, the subnet base is used when + * the cidr is /128 (single host); otherwise the entry is skipped and reported. + * + * @param object|array $serverObject Raw VirtFusion server payload (may be wrapped in `data`) + * @return array{addresses: string[], skipped: array} Deduped IP strings + array of skipped entries with reasons + */ + public static function extractIps($serverObject): array + { + $addresses = []; + $skipped = []; + + // Normalise object-or-array input. json_decode(json_encode($x), true) is the + // cheapest defensive way to turn a stdClass tree (VirtFusion's response) or + // an already-decoded array (stored server_object blob) into a uniform array. + if (is_object($serverObject)) { + $serverObject = json_decode(json_encode($serverObject), true); + } + if (! is_array($serverObject)) { + return ['addresses' => [], 'skipped' => []]; + } + + // VirtFusion wraps the payload in a "data" key on GET responses but the stored + // server_object blob is sometimes already unwrapped. Accept both shapes. + $data = $serverObject['data'] ?? $serverObject; + $interfaces = $data['network']['interfaces'] ?? []; + if (! is_array($interfaces)) { + return ['addresses' => [], 'skipped' => []]; + } + + // Walk every interface (not just interfaces[0]). ServerResource only reads [0] + // because it's building display data for the "primary" IP; rDNS needs PTRs + // for every IP no matter which interface it lives on. + foreach ($interfaces as $iface) { + foreach (($iface['ipv4'] ?? []) as $v4) { + // Accept both "address" and "ip" field names — VirtFusion's schema + // has evolved and we want the module to survive minor shape changes. + $candidate = $v4['address'] ?? ($v4['ip'] ?? null); + if ($candidate && self::isIpv4($candidate)) { + // Use the IP as an array key for free de-duplication. If the same + // IP appears on two interfaces (unusual but possible), we write + // one PTR not two. + $addresses[$candidate] = true; + } + } + + foreach (($iface['ipv6'] ?? []) as $v6) { + // Preferred shape: a discrete host address (the normal v6 pattern). + $candidate = $v6['address'] ?? ($v6['ip'] ?? null); + if ($candidate && self::isIpv6($candidate)) { + $addresses[$candidate] = true; + + continue; + } + + // Fallback shape: VirtFusion sometimes exposes v6 only as subnet+cidr + // (common when a /64 is routed to the VPS and the OS auto-assigns + // specific host addresses). We can't set a PTR for the whole subnet, + // so we only accept /128 (single-host) entries and report the rest + // via the "skipped" channel so callers can surface a UI note. + $subnet = $v6['subnet'] ?? null; + $cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null; + if ($subnet && self::isIpv6($subnet)) { + if ($cidr === 128) { + $addresses[$subnet] = true; + } else { + $skipped[] = [ + 'subnet' => $subnet, + 'cidr' => $cidr, + 'reason' => 'ipv6-subnet-without-explicit-host-address', + ]; + } + } + } + } + + // array_keys gives us the de-duplicated list in insertion order. + return ['addresses' => array_keys($addresses), 'skipped' => $skipped]; + } + + /** + * Find the longest-suffix zone from a list of zone names that contains a given PTR name. + * Both inputs are normalised to a trailing dot before matching. + * + * @param string $ptrName Fully-qualified PTR name (with or without trailing dot) + * @param string[] $zones List of zone names from PowerDNS (with or without trailing dots) + * @return string|null Matching zone name with trailing dot, or null if no zone covers the PTR + */ + public static function findContainingZone(string $ptrName, array $zones): ?string + { + $ptrName = rtrim($ptrName, '.') . '.'; + $best = null; + $bestLen = 0; + + foreach ($zones as $zone) { + if (! is_string($zone) || $zone === '') { + continue; + } + if (strpos($zone, '/') !== false) { + // RFC 2317 classless zones can't be identified by plain suffix match: + // a PTR like "5.113.0.203.in-addr.arpa." does NOT end with + // ".64/64.113.0.203.in-addr.arpa." even when 5 is in range. Range + // matching lives in findZoneAndPtrName; this helper is kept for any + // caller that only deals with standard zones. + continue; + } + $z = rtrim($zone, '.') . '.'; + // Prefix with "." so a zone "example.com." doesn't accidentally match + // "foo.anotherexample.com." via naive substring compare. + $suffix = '.' . $z; + if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) { + // Longest match wins. For nested delegations (e.g. both + // "0.203.in-addr.arpa." and "113.0.203.in-addr.arpa." exist), + // the more specific one is the correct authoritative zone. + $len = strlen($z); + if ($len > $bestLen) { + $best = $z; + $bestLen = $len; + } + } + } + + return $best; + } + + /** + * Parse an RFC 2317 classless-delegation IPv4 reverse zone name. + * + * RFC 2317 lets a /24 owner delegate sub-ranges of that /24 to separate + * authoritative servers by creating CNAMEs in the parent zone that point + * into a named sub-zone. The sub-zone's label conventionally uses "X/Y" + * where the slash carries structural meaning, not path semantics. + * + * Two "Y" conventions exist in the wild. We accept both: + * + * (a) Y is a CIDR prefix length, Y ∈ [24, 32]. Standard per the RFC. + * "64/26.113.0.203.in-addr.arpa." — /26 → 64 addresses → covers 64..127 + * "0/25.1.168.192.in-addr.arpa." — /25 → 128 addresses → covers 0..127 + * + * (b) Y is a block size (count of addresses), Y > 32. Non-standard but + * used by some operators because the label reads naturally: + * "64/64.113.0.203.in-addr.arpa." — size 64 → covers 64..127 + * + * We disambiguate by Y's magnitude: ≤32 is a prefix length, >32 is a count. + * (Y=32 would be "a single-host delegation", valid under convention (a).) + * + * ALIGNMENT CHECK + * --------------- + * We also verify X is a multiple of the block size. Misaligned entries + * like "3/26.x.y.z" don't correspond to any real DNS delegation — a /26 + * must start at a multiple of 64 (0, 64, 128, or 192). Rejecting these + * prevents silent write-into-wrong-zone if an operator mis-names a zone. + * + * @return array{parent: string, start: int, end: int}|null + * parent: parent /24 reverse zone name with trailing dot (e.g. "113.0.203.in-addr.arpa.") + * start/end: inclusive last-octet range covered by this classless zone + */ + public static function parseClasslessZone(string $zone): ?array + { + $zone = rtrim($zone, '.') . '.'; + + // Structural gate 1: must end in .in-addr.arpa. — classless only applies to IPv4. + if (substr($zone, -strlen('.in-addr.arpa.')) !== '.in-addr.arpa.') { + return null; + } + + // Structural gate 2: must have at least 5 labels to contain both the + // classless label and a full /24 parent: "X/Y . o . o . o . in-addr . arpa . ''" + // The trailing empty label from the terminal dot bumps this to ≥ 7 in practice, + // but 5 is the minimum we need to safely slice below. + $labels = explode('.', $zone); + if (count($labels) < 5) { + return null; + } + + // Structural gate 3: the first label must contain a "/". If not, this is a + // standard zone (e.g. "113.0.203.in-addr.arpa.") — let the caller handle it. + $first = $labels[0]; + if (strpos($first, '/') === false) { + return null; + } + + // Parse "X/Y" — reject if either side isn't a non-negative integer. + $parts = explode('/', $first, 2); + if (count($parts) !== 2 || ! ctype_digit($parts[0]) || ! ctype_digit($parts[1])) { + return null; + } + $x = (int) $parts[0]; + $y = (int) $parts[1]; + + // X must fit in an octet; Y must be positive (0 and negative make no sense). + if ($x < 0 || $x > 255 || $y <= 0) { + return null; + } + + // Map Y → block size using the dual-convention rule described in the doc-block. + if ($y <= 32) { + // CIDR prefix convention. Values <24 would cross /24 boundaries (outside + // the scope of a single-/24 delegation), >32 is impossible for IPv4. + if ($y < 24 || $y > 32) { + return null; + } + // 1 << (32 - Y) gives the block size. Y=24→256 (whole /24), Y=32→1 (host). + $size = 1 << (32 - $y); + } else { + // Block-size convention. Accept any positive Y that fits the /24 range check below. + $size = $y; + } + + // Alignment: X must sit on a block boundary. For size=64, legal starts are + // 0, 64, 128, 192. Mis-alignments indicate a misconfigured zone label. + if ($x % $size !== 0) { + return null; + } + + $end = $x + $size - 1; + // The range must stay within the parent /24 (last octet 0..255). + if ($end > 255) { + return null; + } + + // The parent zone is everything after the first label, i.e. the /24 reverse zone. + // array_slice(labels, 1) drops "X/Y" and the implode reconstructs the trailing-dot form. + $parent = implode('.', array_slice($labels, 1)); + + return ['parent' => $parent, 'start' => $x, 'end' => $end]; + } + + /** + * Resolve an IP to its (zone, ptrName) pair in one shot, handling both standard + * reverse zones and RFC 2317 classless delegations. + * + * For a classless match, the returned ptrName includes the classless zone + * label (e.g. "100.64/64.113.0.203.in-addr.arpa.") — this is the actual DNS + * name the PTR record lives at in PowerDNS. Classless zones take precedence + * over any matching parent zone, because in a properly-delegated setup the + * parent only holds CNAMEs pointing into the classless sub-zone. + * + * @param string[] $zones Zone names from PowerDNS (trailing dots optional) + * @return array{zone: string, ptrName: string}|null + */ + public static function findZoneAndPtrName(string $ip, array $zones): ?array + { + $ptrName = self::ptrNameForIp($ip); + if ($ptrName === null) { + return null; + } + + $ipv4 = self::isIpv4($ip); + // Extract the last octet up front for classless range comparison. + // Only meaningful for IPv4 since RFC 2317 is IPv4-only (IPv6 delegations + // naturally align on nibble boundaries and don't need classless tricks). + $lastOctet = null; + if ($ipv4) { + $octets = explode('.', $ip); + $lastOctet = (int) $octets[3]; + } + + $bestDirect = null; + $bestDirectLen = 0; + $classlessMatch = null; + + // Single pass over the zone list, bucketing each candidate into the + // classless path or the direct-suffix-match path. + foreach ($zones as $zone) { + if (! is_string($zone) || $zone === '') { + continue; + } + $z = rtrim($zone, '.') . '.'; + + if (strpos($z, '/') !== false) { + // Classless path. Skip for IPv6 entirely. + if (! $ipv4) { + continue; + } + $parsed = self::parseClasslessZone($z); + if ($parsed === null) { + // Malformed classless zone name (misaligned, wrong TLD, etc.) — skip. + continue; + } + // The PTR still needs to suffix-match the PARENT zone; otherwise the + // classless zone lives under a different /24 and isn't relevant. + $parentSuffix = '.' . $parsed['parent']; + if (substr($ptrName, -strlen($parentSuffix)) !== $parentSuffix) { + continue; + } + // Range gate: the host octet must fall inside this classless zone's window. + if ($lastOctet < $parsed['start'] || $lastOctet > $parsed['end']) { + continue; + } + // The record name inside a classless zone prepends the full host octet + // to the classless label, e.g. PTR "100" lives at: + // "100.64/64.113.0.203.in-addr.arpa." + // (NOT "100.113.0.203.in-addr.arpa." — the classless sub-zone holds the RRset). + $classlessMatch = [ + 'zone' => $z, + 'ptrName' => $lastOctet . '.' . $z, + ]; + + continue; + } + + // Direct suffix-match path (standard reverse zones). + $suffix = '.' . $z; + if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) { + // Longest-match wins (see findContainingZone() for rationale). + if (strlen($z) > $bestDirectLen) { + $bestDirect = ['zone' => $z, 'ptrName' => $ptrName]; + $bestDirectLen = strlen($z); + } + } + } + + // PRECEDENCE: classless beats direct. In a correctly-delegated RFC 2317 setup + // the parent /24 zone only contains CNAMEs pointing into the classless sub-zone — + // it does NOT hold the PTR RRset directly. Writing to the parent would create a + // record that's shadowed by the CNAME and never consulted during resolution. + return $classlessMatch ?? $bestDirect; + } +} diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php b/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php new file mode 100644 index 0000000..0be140d --- /dev/null +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php @@ -0,0 +1,716 @@ + bool so test harnesses and + * admin UIs can tell "we did nothing because disabled" apart from "we did nothing + * because there were no IPs". + */ +class PtrManager +{ + /** @var Client */ + private $client; + + /** @var array|null> Request-scoped zone contents cache, keyed by zone name */ + private $zoneCache = []; + + /** @var string[]|null Request-scoped zone-list memo (Client handles cross-request caching) */ + private $zoneListCache = null; + + public function __construct(?Client $client = null) + { + // Dependency-inject the Client so tests can pass a mock; default to the + // Config-driven instance so production code never has to wire this up. + $this->client = $client ?? new Client; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** + * Sync PTRs for every IP on the given server object. + * + * TWO MODES OF OPERATION + * ---------------------- + * CREATE ($oldHostname = null) — provisioning path. + * Write $newHostname to every IP that doesn't + * already have a PTR. Pre-existing PTRs are + * preserved (shouldn't exist on a new server, + * but if they do they're likely left over from + * a previous owner of the IP and must not be + * silently overwritten). + * + * RENAME ($oldHostname given) — rename path. + * Only overwrite PTRs whose current content + * equals $oldHostname. Anything else was set + * by the client (custom rDNS like mail servers + * need to match HELO) and must be preserved. + * + * The forward-DNS check runs before every write. A PTR without a matching + * A/AAAA is FCrDNS-broken and actively harms deliverability, so we'd rather + * leave the PTR absent than set a broken one. + * + * ERROR SEMANTICS + * --------------- + * This method never throws. Every per-IP failure is caught, logged, and + * recorded in $summary['errors']. Lifecycle callers (createAccount, + * renameServer) wrap the call in their own try/catch as belt-and-braces, + * but the expectation is that DNS issues never bubble up to WHMCS as + * provisioning failures. + * + * @param object|array $serverObject VirtFusion server payload + * @return array Summary counts: written, preserved, forward_missing, no_zone, skipped_ipv6, errors, details[] + */ + public function syncServer($serverObject, ?string $oldHostname, string $newHostname): array + { + $summary = [ + 'enabled' => false, + 'written' => 0, + 'preserved' => 0, + 'forward_missing' => 0, + 'no_zone' => 0, + 'skipped_ipv6' => 0, + 'errors' => 0, + 'details' => [], + ]; + + if (! Config::isEnabled()) { + return $summary; + } + $summary['enabled'] = true; + + $extracted = IpUtil::extractIps($serverObject); + // Report (not write) v6 subnet-only allocations. UI can surface "IPv6 PTR + // not configured — /64 without explicit host" as guidance. + $summary['skipped_ipv6'] = count($extracted['skipped']); + + foreach ($extracted['addresses'] as $ip) { + try { + $loc = $this->locate($ip); + if ($loc === null) { + // IP isn't covered by any zone we host. Not an error — the + // operator may manage reverse DNS for this range elsewhere. + $summary['no_zone']++; + $summary['details'][] = ['ip' => $ip, 'status' => 'no-zone']; + + continue; + } + + $current = $this->readPtr($loc); + + // Rename-mode preservation check. The "current PTR equals old + // hostname" comparison is the whole safety mechanism for protecting + // client-custom rDNS across server renames — see class docblock. + // On CREATE mode ($oldHostname === null) we skip this branch, + // which means pre-existing PTRs on a new IP get overwritten; this + // is acceptable because a fresh IP shouldn't have PTRs yet. + if ($oldHostname !== null && $current !== null) { + if (self::normalizeHost($current) !== self::normalizeHost($oldHostname)) { + $summary['preserved']++; + $summary['details'][] = ['ip' => $ip, 'status' => 'preserved', 'current' => $current]; + + continue; + } + } + + if (! Resolver::resolvesTo($newHostname, $ip, Config::cacheTtl())) { + $summary['forward_missing']++; + $summary['details'][] = ['ip' => $ip, 'status' => 'forward-missing', 'desired' => $newHostname]; + Log::insert('PowerDns:syncServer', ['ip' => $ip, 'hostname' => $newHostname], 'forward DNS mismatch; PTR skipped'); + + continue; + } + + $result = $this->writePtr($loc, $newHostname); + if ($result['ok']) { + $summary['written']++; + $summary['details'][] = ['ip' => $ip, 'status' => 'written', 'content' => $newHostname]; + } else { + $summary['errors']++; + $summary['details'][] = ['ip' => $ip, 'status' => 'error', 'http' => $result['http']]; + } + } catch (\Throwable $e) { + $summary['errors']++; + Log::insert('PowerDns:syncServer', ['ip' => $ip], $e->getMessage()); + } + } + + return $summary; + } + + /** + * Delete every PTR belonging to the given server. + * + * @return array Summary counts: deleted, no_zone, errors + */ + public function deleteForServer($serverObject): array + { + $summary = ['enabled' => false, 'deleted' => 0, 'no_zone' => 0, 'errors' => 0]; + if (! Config::isEnabled()) { + return $summary; + } + $summary['enabled'] = true; + + $extracted = IpUtil::extractIps($serverObject); + foreach ($extracted['addresses'] as $ip) { + try { + $loc = $this->locate($ip); + if ($loc === null) { + $summary['no_zone']++; + + continue; + } + $result = $this->deletePtr($loc); + if ($result['ok']) { + $summary['deleted']++; + } else { + $summary['errors']++; + } + } catch (\Throwable $e) { + $summary['errors']++; + Log::insert('PowerDns:deleteForServer', ['ip' => $ip], $e->getMessage()); + } + } + + return $summary; + } + + /** + * Produce a per-IP status list suitable for client-area and admin display. + * + * Each entry: [ip, ptr, ttl, zone, status] + * Status values: ok, unverified, missing, no-zone, error, disabled. + * + * @return array> + */ + public function listPtrs($serverObject, ?string $expectedHostname = null): array + { + $out = []; + $extracted = IpUtil::extractIps($serverObject); + + if (! Config::isEnabled()) { + foreach ($extracted['addresses'] as $ip) { + $out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'disabled']; + } + + return $out; + } + + foreach ($extracted['addresses'] as $ip) { + try { + $loc = $this->locate($ip); + if ($loc === null) { + $out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'no-zone']; + + continue; + } + $rrset = $this->findPtrRRset($loc); + if ($rrset === null) { + $out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => $loc['zone'], 'status' => 'missing']; + + continue; + } + $ptr = $rrset['content']; + $status = Resolver::resolvesTo($ptr, $ip, Config::cacheTtl()) ? 'ok' : 'unverified'; + $out[] = [ + 'ip' => $ip, + 'ptr' => $ptr, + 'ttl' => $rrset['ttl'], + 'zone' => $loc['zone'], + 'status' => $status, + ]; + } catch (\Throwable $e) { + Log::insert('PowerDns:listPtrs', ['ip' => $ip], $e->getMessage()); + $out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'error']; + } + } + + return $out; + } + + /** + * Client-initiated PTR set/delete. + * + * Differences from syncServer(): + * - Only ever writes one PTR, not a whole server's worth + * - Rate-limited per IP (10s window) to stop save-button abuse + * - Forward-DNS failure is a HARD REJECT that surfaces to the user — not a + * silent skip like the automatic paths. The client wants immediate feedback + * when their A record is missing. + * - Empty content path is an explicit delete (DELETE changetype, not REPLACE-empty) + * + * IP-OWNERSHIP NOTE + * ----------------- + * This method TRUSTS that the caller has already verified the client owns $ip — + * that check lives in the calling endpoint (client.php rdnsUpdate) where it has + * access to the WHMCS session. If you call setPtr() from a new code path, you + * MUST add the ownership guard upstream of it. + * + * @return array{ok: bool, reason: string, http?: int} + * reason values: disabled, invalid-ip, rate-limited, no-zone, + * forward-missing, deleted, delete-failed, written, write-failed + */ + public function setPtr(string $ip, string $content): array + { + if (! Config::isEnabled()) { + return ['ok' => false, 'reason' => 'disabled']; + } + if (! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) { + return ['ok' => false, 'reason' => 'invalid-ip']; + } + + // Rate limit: one successful check per IP per 10s. Uses the module's + // two-tier Cache (Redis or filesystem), so the limit spans PHP processes. + // md5 of IP as the key keeps filesystem filenames short and safe. + $rateKey = 'pdns:write-lock:' . md5($ip); + if (Cache::get($rateKey) !== null) { + return ['ok' => false, 'reason' => 'rate-limited']; + } + // Set the lock BEFORE any downstream work so a parallel request racing + // through the same IP sees the lock and gets rate-limited cleanly. + Cache::set($rateKey, 1, 10); + + $loc = $this->locate($ip); + if ($loc === null) { + return ['ok' => false, 'reason' => 'no-zone']; + } + + $content = trim($content); + if ($content === '') { + $result = $this->deletePtr($loc); + + return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'deleted' : 'delete-failed', 'http' => $result['http']]; + } + + if (! Resolver::resolvesTo($content, $ip, Config::cacheTtl())) { + return ['ok' => false, 'reason' => 'forward-missing']; + } + + $result = $this->writePtr($loc, $content); + + return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'written' : 'write-failed', 'http' => $result['http']]; + } + + /** + * Admin reconciliation for a single service. + * + * The user-facing purpose: "make the PTRs match what they should be, but don't + * step on client customisations unless I explicitly ask". + * + * Uses the STORED server_object (from mod_virtfusion_direct) rather than fetching + * fresh from VirtFusion. Reasons: + * 1. Admin reconcile runs from the services tab — no live-data dependency + * 2. Cron calls this once per service; fetching fresh would mean N VirtFusion + * calls per reconcile run + * 3. The stored object is the ground truth for "what IPs/hostname did this + * service have at last sync" — if VirtFusion temporarily returns a different + * shape, we'd rather work from known-good data than retry. + * + * If the stored state is materially out of date (e.g. IPs were added in VirtFusion + * after last sync), an admin should hit "Update Server Object" first. + * + * FORCE MODE + * ---------- + * $force = true is the only code path in the entire module that overwrites a + * non-matching PTR. It's reachable exclusively via the admin "Reconcile (force + * reset)" button — never from cron, never from client writes, never from + * automatic lifecycle. This asymmetry is deliberate: forceful overrides are + * the admin's explicit choice, not a silent automation. + * + * @return array Summary counts: added, reset, preserved, forward_missing, no_zone, errors + */ + public function reconcile(int $serviceId, bool $force = false): array + { + $summary = [ + 'enabled' => false, + 'added' => 0, + 'reset' => 0, + 'preserved' => 0, + 'forward_missing' => 0, + 'no_zone' => 0, + 'errors' => 0, + ]; + if (! Config::isEnabled()) { + return $summary; + } + $summary['enabled'] = true; + + $row = Database::getSystemService($serviceId); + if (! $row || empty($row->server_object)) { + $summary['errors']++; + + return $summary; + } + $serverObject = json_decode($row->server_object, true); + if (! is_array($serverObject)) { + $summary['errors']++; + + return $summary; + } + + $hostname = self::extractHostname($serverObject); + if ($hostname === null) { + $summary['errors']++; + + return $summary; + } + + $extracted = IpUtil::extractIps($serverObject); + foreach ($extracted['addresses'] as $ip) { + try { + $loc = $this->locate($ip); + if ($loc === null) { + $summary['no_zone']++; + + continue; + } + + $current = $this->readPtr($loc); + $verified = Resolver::resolvesTo($hostname, $ip, Config::cacheTtl()); + + if ($current === null) { + if (! $verified) { + $summary['forward_missing']++; + + continue; + } + $result = $this->writePtr($loc, $hostname); + if ($result['ok']) { + $summary['added']++; + } else { + $summary['errors']++; + } + + continue; + } + + if ($force && self::normalizeHost($current) !== self::normalizeHost($hostname)) { + if (! $verified) { + $summary['forward_missing']++; + + continue; + } + $result = $this->writePtr($loc, $hostname); + if ($result['ok']) { + $summary['reset']++; + } else { + $summary['errors']++; + } + + continue; + } + + $summary['preserved']++; + } catch (\Throwable $e) { + $summary['errors']++; + Log::insert('PowerDns:reconcile', ['ip' => $ip, 'service' => $serviceId], $e->getMessage()); + } + } + + return $summary; + } + + /** + * Cron reconciliation across every managed service. + * + * Called from the DailyCronJob hook. Iterates every row in mod_virtfusion_direct + * and runs reconcile() on each with $force = false. That means: + * + * - IPs missing a PTR get one (if forward DNS resolves) + * - Existing PTRs are NEVER touched, even if they differ from the hostname + * + * This asymmetry is the safety property. A brief forward-DNS blip during the + * cron window shouldn't trigger mass-rewrites that corrupt client-custom + * records. Admins who need forceful re-alignment must run the per-service + * "Reconcile (force reset)" button explicitly. + * + * Failures on individual services are logged and counted but never abort the + * job — a misconfigured single zone or one VirtFusion-unreachable service + * should not block reconciliation for the rest of the fleet. + * + * @return array Aggregate summary across all services + */ + public function reconcileAll(): array + { + $summary = [ + 'enabled' => false, + 'services' => 0, + 'added' => 0, + 'preserved' => 0, + 'forward_missing' => 0, + 'no_zone' => 0, + 'errors' => 0, + ]; + if (! Config::isEnabled()) { + return $summary; + } + $summary['enabled'] = true; + + try { + $rows = DB::table(Database::SYSTEM_TABLE)->pluck('service_id'); + } catch (\Throwable $e) { + Log::insert('PowerDns:reconcileAll', [], $e->getMessage()); + + return $summary; + } + + foreach ($rows as $serviceId) { + $summary['services']++; + + try { + $r = $this->reconcile((int) $serviceId, false); + $summary['added'] += $r['added']; + $summary['preserved'] += $r['preserved']; + $summary['forward_missing'] += $r['forward_missing']; + $summary['no_zone'] += $r['no_zone']; + $summary['errors'] += $r['errors']; + } catch (\Throwable $e) { + $summary['errors']++; + Log::insert('PowerDns:reconcileAll:service', ['service' => $serviceId], $e->getMessage()); + } + } + + Log::insert('PowerDns:reconcileAll', [], $summary); + + return $summary; + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + /** + * Resolve an IP to the (zone, ptrName) pair using the cached zone list. + * Handles both standard and RFC 2317 classless zones (delegates to IpUtil). + * + * Memoised within this instance: the zone list is fetched once (via the Client, + * which itself caches across requests per Config::cacheTtl()) and reused for + * every IP of the current server. A server with 3 IPs in the same /24 therefore + * triggers ONE listZones call, not three. + * + * @return array{zone: string, ptrName: string}|null null means "no zone covers this IP" + */ + private function locate(string $ip): ?array + { + if ($this->zoneListCache === null) { + $this->zoneListCache = $this->client->listZones(); + } + + return IpUtil::findZoneAndPtrName($ip, $this->zoneListCache); + } + + /** @return array|null */ + private function getZoneCached(string $zoneName): ?array + { + if (array_key_exists($zoneName, $this->zoneCache)) { + return $this->zoneCache[$zoneName]; + } + $this->zoneCache[$zoneName] = $this->client->getZone($zoneName); + + return $this->zoneCache[$zoneName]; + } + + /** + * Current PTR content for a located address, or null if absent. + * + * @param array{zone: string, ptrName: string} $loc + */ + private function readPtr(array $loc): ?string + { + $rrset = $this->findPtrRRset($loc); + + return $rrset === null ? null : $rrset['content']; + } + + /** + * Find a PTR RRset at the located name. + * + * @param array{zone: string, ptrName: string} $loc + * @return array{content: string, ttl: int}|null + */ + private function findPtrRRset(array $loc): ?array + { + $zone = $this->getZoneCached($loc['zone']); + if ($zone === null || empty($zone['rrsets']) || ! is_array($zone['rrsets'])) { + return null; + } + foreach ($zone['rrsets'] as $rrset) { + if (($rrset['type'] ?? '') !== 'PTR') { + continue; + } + if (self::normalizeHost($rrset['name'] ?? '') !== self::normalizeHost($loc['ptrName'])) { + continue; + } + $records = $rrset['records'] ?? []; + foreach ($records as $record) { + if (! empty($record['disabled'])) { + continue; + } + if (! empty($record['content'])) { + return [ + 'content' => rtrim((string) $record['content'], '.'), + 'ttl' => (int) ($rrset['ttl'] ?? Config::defaultTtl()), + ]; + } + } + } + + return null; + } + + /** + * Write/replace a PTR record. + * + * Always uses REPLACE changetype rather than a create-then-update pattern — + * REPLACE is idempotent and atomic from PowerDNS's view, whereas separate + * create + update would briefly leave the record absent. + * + * Content is canonicalised to end with a trailing dot before sending (PowerDNS + * treats unqualified names as relative to the zone, which is not what we want + * for PTR content — "host.example.com" without a trailing dot would be stored + * as "host.example.com.113.0.203.in-addr.arpa."). + * + * @param array{zone: string, ptrName: string} $loc + * @return array{ok: bool, http: int} + */ + private function writePtr(array $loc, string $content): array + { + $content = rtrim(trim($content), '.') . '.'; + $ttl = Config::defaultTtl(); + + $result = $this->client->patchRRset($loc['zone'], [ + 'name' => $loc['ptrName'], + 'type' => 'PTR', + 'ttl' => $ttl, + 'changetype' => 'REPLACE', + 'records' => [['content' => $content, 'disabled' => false]], + ]); + + $this->invalidateZone($loc['zone']); + + return ['ok' => $result['ok'], 'http' => $result['http']]; + } + + /** + * Delete a PTR record. + * + * @param array{zone: string, ptrName: string} $loc + * @return array{ok: bool, http: int} + */ + private function deletePtr(array $loc): array + { + $result = $this->client->patchRRset($loc['zone'], [ + 'name' => $loc['ptrName'], + 'type' => 'PTR', + 'changetype' => 'DELETE', + ]); + + $this->invalidateZone($loc['zone']); + + return ['ok' => $result['ok'], 'http' => $result['http']]; + } + + /** + * Drop the cached zone contents so the next read re-fetches from PowerDNS. + * Called after every successful write so read-after-write in the same request + * (e.g. listPtrs right after setPtr in a test harness) observes fresh data. + */ + private function invalidateZone(string $zoneName): void + { + unset($this->zoneCache[$zoneName]); + } + + /** + * Normalise a hostname for comparison: lowercase, no trailing dot. + * + * DNS hostnames are case-insensitive and the trailing dot is syntactic, not + * semantic. PowerDNS returns content with a trailing dot ("host.example.com."); + * user input typically doesn't have one. Both forms of "FooBar.example.com." + * vs "foobar.example.com" should compare equal, which is what this produces. + */ + private static function normalizeHost(string $h): string + { + return strtolower(rtrim(trim($h), '.')); + } + + /** + * Extract the server hostname from a VirtFusion server payload. + * + * Accepts either object or array shape, wrapped or unwrapped by a `data` property. + * Falls back to `name` when `hostname` is absent or "-", matching the semantics + * of the existing ServerResource::process() behavior. + * + * Public so lifecycle call sites (createAccount, renameServer) can pull the + * hostname from a response or stored JSON blob without duplicating the logic. + * + * @param object|array $serverObject + */ + public static function extractHostname($serverObject): ?string + { + if (is_object($serverObject)) { + $serverObject = json_decode(json_encode($serverObject), true); + } + if (! is_array($serverObject)) { + return null; + } + $data = $serverObject['data'] ?? $serverObject; + if (! empty($data['hostname']) && $data['hostname'] !== '-') { + return (string) $data['hostname']; + } + if (! empty($data['name']) && $data['name'] !== '-') { + return (string) $data['name']; + } + + return null; + } +} diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php b/modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php new file mode 100644 index 0000000..1847207 --- /dev/null +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php @@ -0,0 +1,140 @@ + self::MAX_CNAME_DEPTH) { + return false; + } + + // Request both the matching forward type AND CNAME in one query so we see + // the whole picture at each hop. If the hostname is a direct A/AAAA, we + // see that and match immediately; if it's a CNAME, we see the target and + // recurse. + $type = IpUtil::isIpv6($ip) ? DNS_AAAA | DNS_CNAME : DNS_A | DNS_CNAME; + $records = []; + + try { + // @-suppress: dns_get_record emits a PHP warning on NXDOMAIN, which we'd + // rather just treat as "no match". The return value (empty array or false) + // tells us the same thing without polluting the error log. + $records = @dns_get_record($hostname, $type); + } catch (\Throwable $e) { + // Some PHP configurations throw on resolver failure instead of returning false. + // We treat those as "no match" and log once per (hostname, ip) since callers + // cache the result — we won't spam the log even for a permanently-broken name. + Log::insert('PowerDns:Resolver', ['hostname' => $hostname, 'ip' => $ip], $e->getMessage()); + + return false; + } + if (! is_array($records)) { + // dns_get_record returns false on resolver failure. Same semantics as above. + return false; + } + + // Convert target to binary once, outside the loop. inet_pton normalises + // "2001:db8::1" and "2001:0db8:0000:0000:0000:0000:0000:0001" to the same + // bytes, so we can compare regardless of how the resolver formatted its reply. + $targetBin = @inet_pton($ip); + foreach ($records as $r) { + $t = $r['type'] ?? null; + if ($t === 'CNAME') { + // CNAME hop: recurse on the target. We don't use a visited-set to + // detect cycles — MAX_CNAME_DEPTH is a simpler, sufficient guard. + $next = $r['target'] ?? null; + if ($next && self::resolveInternal(rtrim($next, '.'), $ip, $depth + 1)) { + return true; + } + + continue; + } + + // A records expose the address under 'ip', AAAA records under 'ipv6'. + // Only one of these will be set per record; the other is null. + $candidate = $r['ip'] ?? ($r['ipv6'] ?? null); + if ($candidate && $targetBin !== false && @inet_pton($candidate) === $targetBin) { + return true; + } + } + + return false; + } +} diff --git a/modules/servers/VirtFusionDirect/templates/css/module.css b/modules/servers/VirtFusionDirect/templates/css/module.css index 1e958cb..489998e 100644 --- a/modules/servers/VirtFusionDirect/templates/css/module.css +++ b/modules/servers/VirtFusionDirect/templates/css/module.css @@ -471,3 +471,77 @@ flex-wrap: wrap; } } + +/* ========================================================================= + Reverse DNS panel + ========================================================================= */ +.vf-rdns-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(0,0,0,.06); +} +.vf-rdns-row:last-child { border-bottom: none; } +.vf-rdns-ip { + font-family: monospace; + font-size: 13px; + min-width: 180px; + font-weight: 600; +} +.vf-rdns-edit { + display: flex; + flex: 1 1 auto; + gap: 6px; + align-items: center; + min-width: 240px; +} +.vf-rdns-input { + flex: 1 1 auto; + min-width: 180px; + max-width: 420px; + font-family: monospace; + font-size: 13px; +} +.vf-rdns-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .02em; + line-height: 1.4; + white-space: nowrap; +} +.vf-rdns-msg { + flex-basis: 100%; + font-size: 12px; + display: none; + padding-left: 180px; +} +.vf-rdns-admin-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0; + font-size: 13px; +} +.vf-rdns-ip-admin { + font-family: monospace; + font-weight: 600; + min-width: 180px; +} +.vf-rdns-ptr-admin { + font-family: monospace; + color: #333; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} +@media (max-width: 768px) { + .vf-rdns-row { flex-direction: column; align-items: stretch; } + .vf-rdns-edit { flex-direction: column; align-items: stretch; } + .vf-rdns-msg { padding-left: 0; } +} diff --git a/modules/servers/VirtFusionDirect/templates/js/module.js b/modules/servers/VirtFusionDirect/templates/js/module.js index 9a79f99..93e32f6 100644 --- a/modules/servers/VirtFusionDirect/templates/js/module.js +++ b/modules/servers/VirtFusionDirect/templates/js/module.js @@ -1,7 +1,82 @@ /** * VirtFusion Direct Provisioning Module - Client JavaScript * - * Handles client-side interactions for server management including: + * ======================================================================== + * ARCHITECTURE + * ======================================================================== + * + * This file is the single client-side script that powers both: + * - The client area (service overview panel, loaded on every service page) + * - The admin services tab (server info + rDNS widget) + * + * It uses vanilla JS + jQuery. jQuery is available because WHMCS's built-in + * admin UI depends on it; we inherit that dependency rather than adding a + * new one. The order form hooks (keygen.js, OS-gallery injector in hooks.php) + * use vanilla JS only because those run on pre-auth checkout pages where + * jQuery availability varies by theme. + * + * CONVENTION: every function is prefixed with "vf" to avoid collisions with + * whatever else the page loads. Internal helpers start with "_vf". + * + * ======================================================================== + * SECTIONS (roughly in order below) + * ======================================================================== + * + * Shared Helpers — vfUrl, vfShowAlert + * Progress Indicator — vfShowProgress / vfHideProgress + * Server Data Display — vfServerData, vfServerDataAdmin + * Power Management — vfPowerAction + * SSO Login — vfLoginAsServerOwner + * Password Reset — vfUserPasswordReset, vfResetServerPassword + * Server Rebuild — vfRebuildServer, vfLoadOsTemplates, vfRenderOsGallery + * Server Rename — vfRenameServer, vfShowNameDropdown + * Traffic / Backups — vfLoadTrafficStats, vfDrawTrafficChart, vfLoadBackups + * VNC Console — vfOpenVnc, vfToggleVnc + * Self-Service Billing — vfLoadSelfServiceUsage, vfAddCredit + * Reverse DNS (PowerDNS) — vfLoadRdns, vfRenderRdnsPanel, vfUpdateRdns, + * vfAdminLoadRdns, vfAdminReconcileRdns + * + * ======================================================================== + * AJAX REQUEST SHAPE + * ======================================================================== + * + * URL: {systemUrl}modules/servers/VirtFusionDirect/{endpoint}.php + * ?serviceID={id}&action={action} + * where endpoint is "client" (default) or "admin". + * + * Method: GET for reads, POST for writes (server-side requirePost() gate + * enforces this for rDNS mutations; other mutations rely on $_POST + * being empty for GET → validation fails naturally). + * + * Response: + * { success: true, data: { ... } } + * { success: false, errors: "human message" } + * + * ======================================================================== + * ERROR HANDLING + * ======================================================================== + * + * Every AJAX call handles three outcomes: + * 1. Network failure (.fail) → show a generic error in the panel's alert div + * 2. Server returned success:false → show response.errors to the user + * 3. Server returned success:true → render data into the DOM + * + * Error text ALWAYS comes from the server (we don't invent user-facing error + * copy client-side). That way a server-side change to error phrasing + * propagates everywhere without JS changes. + * + * ======================================================================== + * DOM UPDATE PATTERNS + * ======================================================================== + * + * Read actions render into named containers with id="vf-data-*". + * Status badges use CSS classes "vf-badge-*" for color coding. + * Text content is always set via .text() not .html() to prevent XSS + * from whatever the API returned. Exception: panels built entirely + * from server-trusted structured data use .append() with new jQuery + * elements, not string concatenation. + * + * Handles client-side interactions for: * - Server data display * - Power management (boot, shutdown, restart, power off) * - Control panel login (SSO) @@ -12,6 +87,7 @@ * - Backup listing * - VNC management * - Server naming + * - Reverse DNS (PowerDNS addon) */ // ========================================================================= @@ -1011,3 +1087,196 @@ function vfCopyButton(text) { }); return btn; } + +// ========================================================================= +// Reverse DNS (PowerDNS) +// ========================================================================= +// +// Feature gate: this section only activates when the VirtFusionDns addon is +// installed AND enabled. The PHP side renders the rDNS panel in overview.tpl +// only when $rdnsEnabled is true; if the panel isn't in the DOM, these +// functions are never called. +// +// Admin-side counterparts (vfAdminLoadRdns, vfAdminReconcileRdns) target +// admin.php instead of client.php and are used by the rdnsSection() admin +// widget rendered via AdminHTML::rdnsSection(). +// +// Status badge colours match what most operators expect: +// OK (green) = PTR present, forward DNS agrees (FCrDNS passes) +// unverified (amber) = PTR present but forward DNS no longer agrees +// missing (gray) = No PTR exists yet +// no-zone (red) = The IP's reverse zone isn't hosted in PowerDNS +// error (red) = PowerDNS unreachable or similar +// +// The server-side always decides the status; we just colour it. + +/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */ +var VF_RDNS_STATUS = { + "ok": { label: "OK", bg: "#28a745", fg: "#fff" }, + "unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" }, + "missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" }, + "no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" }, + "error": { label: "error", bg: "#dc3545", fg: "#fff" }, + "disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" } +}; + +function vfRdnsBadge(status) { + var s = VF_RDNS_STATUS[status] || VF_RDNS_STATUS["error"]; + var span = $(''); + span.text(s.label); + span.css({ background: s.bg, color: s.fg }); + return span; +} + +function vfLoadRdns(serviceId, systemUrl) { + var list = $("#vf-rdns-list"); + $.ajax({ + url: vfUrl(systemUrl, serviceId, "rdnsList"), + method: "GET", + dataType: "json" + }).done(function (resp) { + if (!resp || !resp.success) { + list.html('
Unable to load reverse DNS.
'); + return; + } + if (!resp.data.enabled) { + list.closest(".panel").hide(); + return; + } + vfRenderRdnsPanel(serviceId, systemUrl, resp.data.ips || []); + }).fail(function () { + list.html('
Unable to load reverse DNS.
'); + }); +} + +function vfRenderRdnsPanel(serviceId, systemUrl, ips) { + var list = $("#vf-rdns-list"); + list.empty(); + if (!ips.length) { + list.html('
No IP addresses assigned to this server yet.
'); + return; + } + ips.forEach(function (row) { + var wrap = $('
'); + var ipLabel = $('
').text(row.ip); + var badge = vfRdnsBadge(row.status); + + var input = $(''); + input.val(row.ptr || ""); + + var saveBtn = $(''); + var msg = $('
'); + + 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 = $('
').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('Unable to load PTR state.'); + return; + } + if (!resp.data.enabled) { + list.html('Reverse DNS addon is not activated.'); + return; + } + list.empty(); + if (!resp.data.ips.length) { + list.html('No IPs assigned.'); + return; + } + resp.data.ips.forEach(function (row) { + var line = $('
'); + $('').text(row.ip).appendTo(line); + $('').text(row.ptr || "(no PTR)").appendTo(line); + vfRdnsBadge(row.status).appendTo(line); + list.append(line); + }); + }).fail(function () { + list.html('Unable to load PTR state.'); + }); +} + +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"); + }); +} diff --git a/modules/servers/VirtFusionDirect/templates/overview.tpl b/modules/servers/VirtFusionDirect/templates/overview.tpl index 3bcfb00..a5a4704 100644 --- a/modules/servers/VirtFusionDirect/templates/overview.tpl +++ b/modules/servers/VirtFusionDirect/templates/overview.tpl @@ -237,6 +237,28 @@ +{if $rdnsEnabled} +{* Reverse DNS Panel *} +
+
+

Reverse DNS

+
+
+

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.

+ +
+
+
+
+ +
+
+{/if} + {* Resources Panel — populated by JS after server data loads *}