Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3239b511bd | ||
|
|
c1c579dd14 | ||
|
|
7e7f3c1c14 | ||
|
|
daaddc7c24 | ||
|
|
65f3f36569 | ||
|
|
a1406f8193 | ||
|
|
a2ffb7d53a | ||
|
|
8a88862364 | ||
|
|
ad85439dfb |
163
.github/workflows/publish-release.yml
vendored
163
.github/workflows/publish-release.yml
vendored
@@ -1,43 +1,168 @@
|
||||
name: Publish Release
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Release-notes strategy (in order of preference):
|
||||
#
|
||||
# 1. If CHANGELOG.md has a "## [X.Y.Z] - YYYY-MM-DD" section matching the
|
||||
# tag version, use that section verbatim. This is the normal path —
|
||||
# maintainers write release notes once in CHANGELOG and they flow to
|
||||
# GitHub automatically with no re-typing.
|
||||
#
|
||||
# 2. Otherwise, fall back to grouping the commits in the tag range by
|
||||
# conventional-commit prefix (feat / fix / refactor / docs / other).
|
||||
# Keeps releases useful even if the maintainer forgot the CHANGELOG.
|
||||
#
|
||||
# 3. Append a compare link (PREV_TAG...TAG) at the bottom so readers can
|
||||
# dive into the full diff in one click.
|
||||
#
|
||||
# Retag safety:
|
||||
# When a tag is force-pushed (e.g. to fix a last-minute doc error), the
|
||||
# workflow normally would overwrite any hand-edited release body. We guard
|
||||
# against that by checking the current release body BEFORE running the
|
||||
# generator — if a body is already present, we leave it alone. To
|
||||
# intentionally regenerate, clear the body first via:
|
||||
# gh release edit vX.Y.Z --notes ""
|
||||
#
|
||||
# Security note:
|
||||
# All ${{ ... }} interpolation in this file flows through `env:` blocks
|
||||
# rather than inline in `run:` commands. Shell scripts reference those
|
||||
# env vars with $VAR, which is immune to the command-injection pattern
|
||||
# that hits workflows interpolating untrusted event data directly.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Need full history for `git describe` to find the previous tag and
|
||||
# for `git log PREV..HEAD` to enumerate commits in the release range.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract tag name
|
||||
id: tag
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
- name: Derive versions
|
||||
id: version
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
TAG="${REF#refs/tags/}"
|
||||
VERSION="${TAG#v}"
|
||||
# Previous tag for compare link + commit range. Empty on first release.
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || echo "")
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag: $TAG Version: $VERSION Previous: ${PREV_TAG:-<none>}"
|
||||
|
||||
- name: Check for existing release body
|
||||
id: existing
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# If the release already has a non-empty body, skip generation so
|
||||
# hand-edits survive tag re-pushes. Fresh releases (no body) proceed.
|
||||
BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body 2>/dev/null || echo "")
|
||||
if [ -n "$(printf '%s' "$BODY" | tr -d '[:space:]')" ]; then
|
||||
echo "Existing release body detected — preserving manual edits."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No existing body (or empty) — will generate."
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
if: steps.existing.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
PREV_TAG: ${{ steps.version.outputs.prev_tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --pretty=format:"- %s" "$PREV_TAG"..HEAD)
|
||||
else
|
||||
NOTES=$(git log --pretty=format:"- %s")
|
||||
set -eo pipefail
|
||||
|
||||
# --- 1. Try extracting the section from CHANGELOG.md --------------
|
||||
# Matches "## [1.2.0] ..." exactly and prints every line up to the
|
||||
# next "## [" heading or EOF.
|
||||
CHANGELOG_SECTION=""
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
CHANGELOG_SECTION=$(awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md)
|
||||
fi
|
||||
# Write to file for the release body
|
||||
echo "$NOTES" > /tmp/release-notes.txt
|
||||
|
||||
# --- 2. Commit-based fallback ------------------------------------
|
||||
# Used only when CHANGELOG has no section for this version. Groups
|
||||
# conventional-commit prefixes into readable categories; skips
|
||||
# automated "chore(release): …" bump commits from display since
|
||||
# they're noise in a release the reader is already looking at.
|
||||
if [ -z "$(printf '%s' "$CHANGELOG_SECTION" | tr -d '[:space:]')" ]; then
|
||||
echo "::warning::CHANGELOG.md has no section for [$VERSION]; falling back to commit-log grouping."
|
||||
|
||||
if [ -n "$PREV_TAG" ]; then RANGE="$PREV_TAG..HEAD"; else RANGE=""; fi
|
||||
LOG=$(git log $RANGE --no-merges --pretty=format:'%s' \
|
||||
| grep -vE '^chore\(release\)' || true)
|
||||
|
||||
# extract <regex> — prints matching commits as "- <rest>" with the
|
||||
# conventional-commit "type(scope)?:" prefix stripped for readability.
|
||||
extract() {
|
||||
printf '%s\n' "$LOG" \
|
||||
| grep -E "^($1)(\([^)]+\))?:" \
|
||||
| sed -E "s/^($1)(\([^)]+\))?:[[:space:]]*/- /" \
|
||||
|| true
|
||||
}
|
||||
|
||||
FEATURES=$(extract 'feat')
|
||||
FIXES=$(extract 'fix')
|
||||
REFACTORS=$(extract 'refactor')
|
||||
DOCS=$(extract 'docs')
|
||||
OTHER=$(printf '%s\n' "$LOG" \
|
||||
| grep -vE '^(feat|fix|refactor|docs|chore)(\([^)]+\))?:' \
|
||||
| sed -E 's/^/- /' \
|
||||
|| true)
|
||||
|
||||
{
|
||||
[ -n "$FEATURES" ] && printf '### Features\n\n%s\n\n' "$FEATURES"
|
||||
[ -n "$FIXES" ] && printf '### Bug Fixes\n\n%s\n\n' "$FIXES"
|
||||
[ -n "$REFACTORS" ] && printf '### Changes\n\n%s\n\n' "$REFACTORS"
|
||||
[ -n "$DOCS" ] && printf '### Documentation\n\n%s\n\n' "$DOCS"
|
||||
[ -n "$OTHER" ] && printf '### Other\n\n%s\n\n' "$OTHER"
|
||||
} > /tmp/generated.md
|
||||
|
||||
CHANGELOG_SECTION=$(cat /tmp/generated.md)
|
||||
fi
|
||||
|
||||
# --- 3. Compose final body (content + compare footer) ------------
|
||||
{
|
||||
printf '%s\n' "$CHANGELOG_SECTION"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
printf '\n---\n\n**Full Changelog:** [%s...%s](https://github.com/%s/compare/%s...%s)\n' \
|
||||
"$PREV_TAG" "$TAG" "$REPO" "$PREV_TAG" "$TAG"
|
||||
fi
|
||||
} > /tmp/release-notes.md
|
||||
|
||||
echo "--- release notes ($(wc -c < /tmp/release-notes.md) bytes) ---"
|
||||
head -20 /tmp/release-notes.md
|
||||
echo "---"
|
||||
|
||||
- name: Create release
|
||||
if: steps.existing.outputs.skip != 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.version }}
|
||||
name: ${{ steps.tag.outputs.version }}
|
||||
body_path: /tmp/release-notes.txt
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
body_path: /tmp/release-notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -2,6 +2,44 @@
|
||||
|
||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||
|
||||
## [1.3.0] - 2026-04-17
|
||||
|
||||
### Bug Fixes
|
||||
- **Critical: decrypt() corruption of plaintext addon API keys.** `Config::get()` was calling WHMCS's `decrypt()` on the raw `tbladdonmodules.value` for the PowerDNS API key and accepting whatever non-empty result came back. WHMCS addon password-type fields are actually stored **plaintext** (unlike `tblservers.password` which is encrypted), and `decrypt()` on plaintext input returns ~4 bytes of binary garbage instead of empty. That garbage was ending up in the `X-API-Key:` header, producing a baffling 401 from PowerDNS and an empty zone list — which then surfaced as **"no zone"** for every IP in the client-area rDNS panel. Fix: only use `decrypt()`'s output when it's printable ASCII; fall back to raw otherwise. Also `trim()` the chosen value so a stray paste-newline can't corrupt the header.
|
||||
|
||||
### Features
|
||||
- **IPv6 subnet visibility + custom-host PTR flow.** VirtFusion allocates v6 as whole subnets (e.g. a /64 routed to the VPS) rather than discrete host addresses. The module previously filtered these silently; now subnets appear as first-class rows in the client rDNS panel with a collapsible "Add host PTR" form. Ownership verification uses **subnet containment** (`IpUtil::ipv6InSubnet()` via `inet_pton` + bit masking) so any address inside one of the VPS's allocated subnets is writeable, while addresses outside them are rejected. FCrDNS / rate-limit / CSRF guards all still apply.
|
||||
- **Diagnose-an-IP tool** on the VirtFusion DNS addon admin page. Takes an IP input and runs the full PtrManager pipeline inline: config snapshot, fresh zone list (cache-bypassed), computed PTR name, matched zone, current PTR content. Every common failure mode (wrong key, wrong serverId, forgotten zone, mis-aligned RFC 2317 label, stale cache) produces a distinctive shape in that output, turning "support ticket" into "screenshot the diagnosis".
|
||||
- **Actionable auth-error messages.** `Client::ping()` now returns structured guidance on 401/403 (check API key, `api-allow-from`, whitespace) and 404 (check `serverId`, it should be the literal `localhost`), replacing the previous "authentication failed (check API key)" / "unexpected HTTP 404" which gave no clue which of several causes was actually biting.
|
||||
|
||||
## [1.2.0] - 2026-04-17
|
||||
|
||||
### Features
|
||||
- **PowerDNS reverse DNS (PTR) integration** — opt-in via companion `VirtFusionDns` addon module:
|
||||
- Automatic PTR sync on server create, rename, and terminate
|
||||
- Client-area "Reverse DNS" panel with one editable PTR per assigned IP and per-row status badges
|
||||
- Admin services-tab widget with Reconcile (additive) and Reconcile (force reset) buttons
|
||||
- Daily cron additive reconciliation (never overwrites existing PTRs)
|
||||
- Forward-confirmed reverse DNS (FCrDNS) enforcement — PTR writes rejected if forward A/AAAA doesn't resolve to the target IP
|
||||
- IPv4 + IPv6 support with full nibble-reversal for `ip6.arpa`
|
||||
- RFC 2317 classless delegation support (both CIDR-prefix `0/26` and block-size `64/64` conventions)
|
||||
- Automatic NOTIFY after every successful PATCH so slaves pick up SOA bumps immediately
|
||||
- PowerDNS zone ID `=2F` URL-encoding for zones containing `/`
|
||||
- **Security hardening helpers** on the Module base class:
|
||||
- `requirePost()` — 405 on non-POST mutations
|
||||
- `requireSameOrigin()` — CSRF Origin/Referer check against WHMCS host
|
||||
- `requireServiceStatus()` — filter endpoints by `tblhosting.domainstatus`
|
||||
- Applied to all rDNS endpoints with successful-write audit logging
|
||||
- Merged Test Connection — when the DNS addon is active the admin button verifies both VirtFusion AND PowerDNS in a single check
|
||||
|
||||
### Bug Fixes
|
||||
- `IpUtil::parseClasslessZone` now rejects misaligned start addresses (e.g., `3/26.x.y.z` — /26 ranges must begin at a multiple of 64). Prevents silent write-into-wrong-zone on misconfigured zone names.
|
||||
|
||||
### Documentation
|
||||
- Detailed design-rationale commentary added across the module for future-developer onboarding (Cache, Curl, Log, Database, ServerResource, ConfigureService) and throughout the new PowerDNS subsystem
|
||||
- README updated with an extensive "Reverse DNS Addon (PowerDNS)" section covering activation, configuration, behaviour, and security posture
|
||||
- CLAUDE.md updated with architecture notes and PowerDNS API compatibility details
|
||||
|
||||
## [1.0.0] - 2026-03-19
|
||||
|
||||
### Features
|
||||
|
||||
61
CLAUDE.md
61
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 |
|
||||
|
||||
107
README.md
107
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,27 +107,48 @@ 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
|
||||
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
|
||||
WHMCS=/path/to/whmcs
|
||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
|
||||
&& rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||
&& rm -rf /tmp/vf
|
||||
```
|
||||
|
||||
Replace `/path/to/whmcs` with your actual WHMCS installation root. The database table, schema migrations, and custom fields are all created automatically on first load.
|
||||
Set `WHMCS` once at the top — it's reused in every path below. The database table, schema migrations, and custom fields are all created automatically on first load.
|
||||
|
||||
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
|
||||
WHMCS=/path/to/whmcs
|
||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
|
||||
&& rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||
&& rsync -ahP --delete /tmp/vf/modules/addons/VirtFusionDns/ "$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 +230,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 +319,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 +335,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 +435,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 +570,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 +581,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 +595,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
|
||||
|
||||
367
modules/addons/VirtFusionDns/VirtFusionDns.php
Normal file
367
modules/addons/VirtFusionDns/VirtFusionDns.php
Normal file
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config;
|
||||
|
||||
/**
|
||||
* VirtFusion DNS — companion WHMCS addon module that holds PowerDNS settings for
|
||||
* the VirtFusionDirect server module. Keeps the server module decoupled from the
|
||||
* addon: the server module reads settings from tbladdonmodules and never loads
|
||||
* addon code at runtime.
|
||||
*
|
||||
* Activation: WHMCS Admin -> System Settings -> Addon Modules -> Activate -> Configure.
|
||||
*
|
||||
* API key handling: WHMCS encrypts password-type addon fields in tbladdonmodules;
|
||||
* the server module calls decrypt() on read (see lib/PowerDns/Config.php).
|
||||
*/
|
||||
if (! defined('WHMCS')) {
|
||||
exit('This file cannot be accessed directly');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the server module's PowerDNS classes on demand. Done inside functions rather
|
||||
* than at file scope so the WHMCS addon list still works if the server module is
|
||||
* absent (e.g., uninstalled while the addon is still activated). Returns true when
|
||||
* the classes are available.
|
||||
*/
|
||||
function virtfusiondns_load_server_libs(): bool
|
||||
{
|
||||
$base = __DIR__ . '/../../servers/VirtFusionDirect/lib/';
|
||||
$files = [
|
||||
'Curl.php',
|
||||
'Log.php',
|
||||
'Cache.php',
|
||||
'PowerDns/Config.php',
|
||||
'PowerDns/IpUtil.php',
|
||||
'PowerDns/Client.php',
|
||||
];
|
||||
foreach ($files as $f) {
|
||||
if (! is_file($base . $f)) {
|
||||
return false;
|
||||
}
|
||||
require_once $base . $f;
|
||||
}
|
||||
// PtrManager + IpUtil are only needed for the diagnostic tool below; load them
|
||||
// if present but don't require them for the basic status page to work.
|
||||
foreach (['PowerDns/Resolver.php', 'PowerDns/PtrManager.php'] as $optional) {
|
||||
if (is_file($base . $optional)) {
|
||||
require_once $base . $optional;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHMCS addon metadata.
|
||||
*/
|
||||
function VirtFusionDns_config()
|
||||
{
|
||||
return [
|
||||
'name' => 'VirtFusion DNS',
|
||||
'description' => 'Adds reverse DNS (PTR) management to the VirtFusionDirect server module using a PowerDNS HTTP API. Zones must already exist in PowerDNS; the addon never creates zones. Requires the VirtFusionDirect server module.',
|
||||
'version' => '1.0',
|
||||
'author' => 'VirtFusionDirect',
|
||||
'language' => 'english',
|
||||
'fields' => [
|
||||
'enabled' => [
|
||||
'FriendlyName' => 'Enable rDNS Sync',
|
||||
'Type' => 'yesno',
|
||||
'Description' => 'Master switch. When off, the server module skips every PowerDNS call.',
|
||||
],
|
||||
'endpoint' => [
|
||||
'FriendlyName' => 'PowerDNS API Endpoint',
|
||||
'Type' => 'text',
|
||||
'Size' => '60',
|
||||
'Default' => 'http://ns1.example.com:8081',
|
||||
'Description' => 'Scheme + host + port (no path). The /api/v1/... path is appended automatically.',
|
||||
],
|
||||
'apiKey' => [
|
||||
'FriendlyName' => 'PowerDNS API Key',
|
||||
'Type' => 'password',
|
||||
'Size' => '60',
|
||||
'Description' => 'X-API-Key. Stored encrypted by WHMCS; decrypted only server-side when PowerDNS is called.',
|
||||
],
|
||||
'serverId' => [
|
||||
'FriendlyName' => 'PowerDNS Server ID',
|
||||
'Type' => 'text',
|
||||
'Size' => '20',
|
||||
'Default' => 'localhost',
|
||||
'Description' => 'Almost always "localhost" (the PowerDNS API server identifier, not a hostname).',
|
||||
],
|
||||
'defaultTtl' => [
|
||||
'FriendlyName' => 'Default PTR TTL (seconds)',
|
||||
'Type' => 'text',
|
||||
'Size' => '10',
|
||||
'Default' => '3600',
|
||||
'Description' => 'TTL applied to PTR records created by the module.',
|
||||
],
|
||||
'cacheTtl' => [
|
||||
'FriendlyName' => 'Cache TTL (seconds)',
|
||||
'Type' => 'text',
|
||||
'Size' => '10',
|
||||
'Default' => '60',
|
||||
'Description' => 'How long zone lists and DNS-resolution results are cached. Minimum 10s.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the addon is activated. No schema to create — settings live in tbladdonmodules.
|
||||
*/
|
||||
function VirtFusionDns_activate()
|
||||
{
|
||||
return [
|
||||
'status' => 'success',
|
||||
'description' => 'VirtFusion DNS activated. Fill in the endpoint + API key in the addon configuration, then use the Test Connection button on the addon page.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the addon is deactivated. Settings preserved (re-activating restores them).
|
||||
*/
|
||||
function VirtFusionDns_deactivate()
|
||||
{
|
||||
return [
|
||||
'status' => 'success',
|
||||
'description' => 'VirtFusion DNS deactivated. Server lifecycle PowerDNS calls will now be skipped. Settings are preserved.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin status page — rendered by WHMCS when the addon is clicked from the Addons menu.
|
||||
*
|
||||
* Shows a settings summary, a Test Connection button (calls PowerDNS ping), the current
|
||||
* zone count, and a recent log extract filtered to PowerDNS-related entries.
|
||||
*/
|
||||
function VirtFusionDns_output($vars)
|
||||
{
|
||||
if (! virtfusiondns_load_server_libs()) {
|
||||
echo '<div style="max-width:900px;padding:16px;border-radius:4px;background:#f8d7da;color:#721c24">';
|
||||
echo '<strong>VirtFusionDirect server module not found.</strong> ';
|
||||
echo 'This addon requires the VirtFusionDirect server module at <code>modules/servers/VirtFusionDirect/</code>. ';
|
||||
echo 'Install or restore that module and reload this page.';
|
||||
echo '</div>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Config::reset();
|
||||
$config = Config::get();
|
||||
|
||||
$pingResult = null;
|
||||
$zoneCount = null;
|
||||
$zoneSample = [];
|
||||
|
||||
if (! empty($_GET['vfdns_test'])) {
|
||||
if (Config::isEnabled()) {
|
||||
$client = new Client;
|
||||
$pingResult = $client->ping();
|
||||
if ($pingResult['ok']) {
|
||||
$client->forgetZoneCache();
|
||||
$zones = $client->listZones();
|
||||
$zoneCount = count($zones);
|
||||
$zoneSample = array_slice($zones, 0, 8);
|
||||
}
|
||||
} else {
|
||||
$pingResult = ['ok' => false, 'http' => 0, 'error' => 'Not enabled or missing endpoint/apiKey.'];
|
||||
}
|
||||
}
|
||||
|
||||
$modulelink = htmlspecialchars($vars['modulelink'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$endpoint = htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8');
|
||||
$serverId = htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8');
|
||||
$ttl = (int) $config['defaultTtl'];
|
||||
$cacheTtl = (int) $config['cacheTtl'];
|
||||
$enabledBadge = $config['enabled']
|
||||
? '<span style="color:#28a745;font-weight:bold">enabled</span>'
|
||||
: '<span style="color:#dc3545;font-weight:bold">disabled</span>';
|
||||
$keyBadge = $config['apiKey'] !== '' ? '<span style="color:#28a745">set</span>' : '<span style="color:#dc3545">missing</span>';
|
||||
|
||||
echo '<div style="max-width:900px">';
|
||||
echo '<h2 style="margin-top:0">VirtFusion DNS</h2>';
|
||||
echo '<p>Reverse DNS management for the VirtFusionDirect server module. All PTR writes happen through the VirtFusion server lifecycle (create, rename, terminate) and through the client-area Reverse DNS panel. Forward DNS (A/AAAA) is verified before every PTR write; mismatches are skipped and logged.</p>';
|
||||
|
||||
echo '<h3>Current settings</h3>';
|
||||
echo '<table class="table table-sm" style="max-width:700px"><tbody>';
|
||||
echo '<tr><th style="text-align:left;width:180px">Status</th><td>' . $enabledBadge . '</td></tr>';
|
||||
echo '<tr><th style="text-align:left">Endpoint</th><td><code>' . ($endpoint ?: '<em>not set</em>') . '</code></td></tr>';
|
||||
echo '<tr><th style="text-align:left">API Key</th><td>' . $keyBadge . '</td></tr>';
|
||||
echo '<tr><th style="text-align:left">Server ID</th><td><code>' . $serverId . '</code></td></tr>';
|
||||
echo '<tr><th style="text-align:left">Default PTR TTL</th><td>' . $ttl . 's</td></tr>';
|
||||
echo '<tr><th style="text-align:left">Cache TTL</th><td>' . $cacheTtl . 's</td></tr>';
|
||||
echo '</tbody></table>';
|
||||
|
||||
echo '<h3>Test Connection</h3>';
|
||||
echo '<p>Calls <code>GET /api/v1/servers/' . $serverId . '</code> and, on success, lists available zones.</p>';
|
||||
echo '<a href="' . $modulelink . '&vfdns_test=1" class="btn btn-primary btn-sm">Run Test</a>';
|
||||
|
||||
if ($pingResult !== null) {
|
||||
echo '<div style="margin-top:12px;padding:10px;border-radius:4px;background:' . ($pingResult['ok'] ? '#d4edda' : '#f8d7da') . ';color:' . ($pingResult['ok'] ? '#155724' : '#721c24') . '">';
|
||||
if ($pingResult['ok']) {
|
||||
echo '<strong>OK.</strong> PowerDNS reachable and authenticated. ';
|
||||
if ($zoneCount !== null) {
|
||||
echo $zoneCount . ' zone(s) visible.';
|
||||
if (! empty($zoneSample)) {
|
||||
echo '<div style="margin-top:8px;font-family:monospace;font-size:12px">';
|
||||
foreach ($zoneSample as $z) {
|
||||
echo htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . '<br>';
|
||||
}
|
||||
if ($zoneCount > count($zoneSample)) {
|
||||
echo '<em>... and ' . ($zoneCount - count($zoneSample)) . ' more</em>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo '<strong>Failed.</strong> HTTP ' . (int) $pingResult['http'] . ': ' . htmlspecialchars((string) ($pingResult['error'] ?? 'unknown error'), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
echo '<h3 style="margin-top:24px">Operation</h3>';
|
||||
echo '<ul>';
|
||||
echo '<li><strong>On server creation:</strong> a PTR is created for each assigned IP, set to the server hostname, <em>only if the forward DNS already resolves to that IP</em>.</li>';
|
||||
echo '<li><strong>On server rename:</strong> PTRs whose current content matches the <em>previous</em> hostname are updated to the new hostname; custom PTRs set by the client are preserved.</li>';
|
||||
echo '<li><strong>On server termination:</strong> every PTR for the server\'s IPs is deleted from PowerDNS.</li>';
|
||||
echo '<li><strong>Clients:</strong> may set a custom PTR per IP via the Reverse DNS panel on the service overview page. Forward DNS must resolve to the IP; mismatch rejects the write.</li>';
|
||||
echo '<li><strong>Reconcile cron:</strong> runs daily, additive-only — creates PTRs where none exist, never overwrites.</li>';
|
||||
echo '<li><strong>Reconcile (admin):</strong> a button on the admin services tab triggers an explicit reconcile with optional <em>force</em> to reset client-custom PTRs back to the server hostname.</li>';
|
||||
echo '</ul>';
|
||||
|
||||
echo '<h3>Requirements</h3>';
|
||||
echo '<ul>';
|
||||
echo '<li>PowerDNS Authoritative with HTTP API enabled (<code>webserver=yes</code>, <code>api=yes</code>).</li>';
|
||||
echo '<li>Reverse zones (<code>*.in-addr.arpa</code> / <code>*.ip6.arpa</code>) for your IP ranges must exist in PowerDNS already — the addon never creates zones.</li>';
|
||||
echo '<li><code>api-allow-from</code> must include the WHMCS host\'s IP.</li>';
|
||||
echo '</ul>';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Diagnostic: "What does the module see for IP X?"
|
||||
//
|
||||
// Runs the full pipeline an admin would otherwise have to trace through
|
||||
// multiple log lines to reproduce:
|
||||
// 1. Current config (what values is Config::get() actually returning?)
|
||||
// 2. Zone list (what does Client::listZones() return right now, post-cache?)
|
||||
// 3. Zone match for an input IP (is findZoneAndPtrName selecting the right zone?)
|
||||
// 4. Current PTR content at the located (zone, ptrName) pair
|
||||
//
|
||||
// Catches every common failure mode: wrong API key (empty zones, auth error),
|
||||
// wrong server ID (404), forgotten zone (no match), stale cache (mismatched
|
||||
// zones), and typos in the RFC 2317 zone name (parseClasslessZone rejection).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
echo '<h3 style="margin-top:24px">Diagnose an IP</h3>';
|
||||
echo '<p>Runs the exact same pipeline the client-area rDNS panel uses. Useful when a specific IP shows "no zone" in the UI and you need to see <em>why</em>.</p>';
|
||||
|
||||
$diagIp = isset($_GET['diag_ip']) ? trim((string) $_GET['diag_ip']) : '';
|
||||
echo '<form method="get" action="" style="display:flex;gap:8px;align-items:center;margin-bottom:12px">';
|
||||
// WHMCS passes the module slug via ?module=... — preserve any existing query params
|
||||
// by re-emitting the current GET state as hidden fields (except diag_ip itself).
|
||||
foreach ($_GET as $k => $v) {
|
||||
if ($k === 'diag_ip') {
|
||||
continue;
|
||||
}
|
||||
echo '<input type="hidden" name="' . htmlspecialchars((string) $k, ENT_QUOTES, 'UTF-8') . '" value="' . htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8') . '">';
|
||||
}
|
||||
echo '<input type="text" name="diag_ip" placeholder="IP address (e.g. 198.51.100.42 or 2001:db8::1)" value="' . htmlspecialchars($diagIp, ENT_QUOTES, 'UTF-8') . '" class="form-control form-control-sm" style="max-width:320px;font-family:monospace">';
|
||||
echo '<button type="submit" class="btn btn-primary btn-sm">Diagnose</button>';
|
||||
echo '</form>';
|
||||
|
||||
if ($diagIp !== '') {
|
||||
echo '<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;padding:12px;font-family:monospace;font-size:13px;white-space:pre-wrap;word-break:break-all">';
|
||||
|
||||
if (filter_var($diagIp, FILTER_VALIDATE_IP) === false) {
|
||||
echo '<span style="color:#dc3545">Invalid IP address.</span>';
|
||||
} elseif (! Config::isEnabled()) {
|
||||
echo '<span style="color:#dc3545">Addon disabled or missing endpoint/API key. Diagnosis skipped.</span>';
|
||||
} else {
|
||||
$client = new Client;
|
||||
|
||||
echo '<strong>Config snapshot:</strong>' . "\n";
|
||||
echo ' endpoint = ' . htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||
echo ' serverId = ' . htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||
echo ' cacheTtl = ' . $cacheTtl . 's' . "\n";
|
||||
echo ' apiKey = ' . ($config['apiKey'] !== '' ? '(set, ' . strlen($config['apiKey']) . ' chars)' : '(MISSING)') . "\n\n";
|
||||
|
||||
// Always forget cache before diagnose so we see the LIVE state, not a
|
||||
// potentially-stale cached list from an earlier misconfigured call.
|
||||
$client->forgetZoneCache();
|
||||
$zones = $client->listZones();
|
||||
|
||||
echo '<strong>Live zone list (cache purged, ' . count($zones) . ' zones):</strong>' . "\n";
|
||||
if (empty($zones)) {
|
||||
echo ' <span style="color:#dc3545">NO ZONES RETURNED.</span>' . "\n";
|
||||
echo ' Likely causes: wrong API key (PowerDNS returned 401/403), wrong Server ID' . "\n";
|
||||
echo ' (PowerDNS returned 404), or api-allow-from blocking the WHMCS host IP.' . "\n";
|
||||
echo ' Run the Test Connection button above to see the exact HTTP error.' . "\n\n";
|
||||
} else {
|
||||
foreach (array_slice($zones, 0, 15) as $z) {
|
||||
echo ' ' . htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . "\n";
|
||||
}
|
||||
if (count($zones) > 15) {
|
||||
echo ' ... and ' . (count($zones) - 15) . ' more' . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
$ptrName = IpUtil::ptrNameForIp($diagIp);
|
||||
echo '<strong>Computed PTR name for ' . htmlspecialchars($diagIp, ENT_QUOTES, 'UTF-8') . ':</strong>' . "\n";
|
||||
echo ' ' . htmlspecialchars((string) $ptrName, ENT_QUOTES, 'UTF-8') . "\n\n";
|
||||
|
||||
$loc = IpUtil::findZoneAndPtrName($diagIp, $zones);
|
||||
echo '<strong>Zone match (IpUtil::findZoneAndPtrName):</strong>' . "\n";
|
||||
if ($loc === null) {
|
||||
echo ' <span style="color:#dc3545">NO MATCH.</span>' . "\n";
|
||||
echo ' The IP does not fall within any zone returned above.' . "\n";
|
||||
if (IpUtil::isIpv4($diagIp)) {
|
||||
$oct = (int) explode('.', $diagIp)[3];
|
||||
echo " For IPv4: confirm a standard reverse zone exists (one of the listed\n";
|
||||
echo " zones should end with the first-three-octets-reversed of $diagIp), OR\n";
|
||||
echo " that an RFC 2317 classless zone exists whose range covers octet $oct.\n";
|
||||
}
|
||||
if (IpUtil::isIpv6($diagIp)) {
|
||||
echo " For IPv6: confirm a reverse zone exists ending in .ip6.arpa. whose\n";
|
||||
echo " nibble prefix matches the high-order bits of $diagIp.\n";
|
||||
}
|
||||
echo "\n";
|
||||
} else {
|
||||
echo ' zone = ' . htmlspecialchars($loc['zone'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||
echo ' ptrName = ' . htmlspecialchars($loc['ptrName'], ENT_QUOTES, 'UTF-8') . "\n\n";
|
||||
|
||||
// Actual current PTR content, if any.
|
||||
echo '<strong>Current PTR record in PowerDNS:</strong>' . "\n";
|
||||
$zoneData = $client->getZone($loc['zone']);
|
||||
if ($zoneData === null) {
|
||||
echo ' <span style="color:#dc3545">Unable to fetch zone contents (HTTP error or not found).</span>' . "\n";
|
||||
} else {
|
||||
$found = null;
|
||||
foreach ($zoneData['rrsets'] ?? [] as $rr) {
|
||||
if (($rr['type'] ?? '') === 'PTR' && rtrim($rr['name'], '.') === rtrim($loc['ptrName'], '.')) {
|
||||
foreach ($rr['records'] ?? [] as $rec) {
|
||||
if (empty($rec['disabled']) && ! empty($rec['content'])) {
|
||||
$found = [
|
||||
'content' => $rec['content'],
|
||||
'ttl' => (int) ($rr['ttl'] ?? 0),
|
||||
];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($found === null) {
|
||||
echo ' (no PTR record present at ' . htmlspecialchars($loc['ptrName'], ENT_QUOTES, 'UTF-8') . ')' . "\n";
|
||||
} else {
|
||||
echo ' content = ' . htmlspecialchars($found['content'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||
echo ' ttl = ' . $found['ttl'] . 's' . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* VirtFusion Direct Provisioning Module — WHMCS server module entry point.
|
||||
*
|
||||
* This file contains the non-namespaced functions WHMCS calls via its reflection-
|
||||
* based module dispatcher. They follow the naming convention:
|
||||
*
|
||||
* {ModuleDirectoryName}_{FunctionName}(...)
|
||||
*
|
||||
* WHMCS looks for these on every relevant event (provisioning, UI rendering,
|
||||
* daily cron, test connection, etc.). Every function here is a thin shim that
|
||||
* instantiates ModuleFunctions (or Module) and delegates to a method — keeping
|
||||
* the dispatch surface small and the business logic in unit-exercisable classes.
|
||||
*
|
||||
* DO NOT add significant logic directly in these shims. If you need a new
|
||||
* lifecycle behaviour, add it as a method on ModuleFunctions and point the
|
||||
* shim at it. This makes the module predictable: one public function, one method.
|
||||
*
|
||||
* RESERVED NAMES — DO NOT CHANGE
|
||||
* ------------------------------
|
||||
* WHMCS looks up these specific function names by convention; renaming them
|
||||
* disables the corresponding feature in WHMCS silently:
|
||||
* VirtFusionDirect_MetaData → Displayed name + API version
|
||||
* VirtFusionDirect_ConfigOptions → Product-level settings fields
|
||||
* VirtFusionDirect_TestConnection → Admin "Test Connection" button
|
||||
* VirtFusionDirect_CreateAccount → Provisioning on order-activation
|
||||
* VirtFusionDirect_SuspendAccount → Suspension
|
||||
* VirtFusionDirect_UnsuspendAccount → Unsuspension
|
||||
* VirtFusionDirect_TerminateAccount → Termination
|
||||
* VirtFusionDirect_ChangePackage → Package change on upgrade/downgrade
|
||||
* VirtFusionDirect_AdminServicesTabFields → Admin services tab renderer
|
||||
* VirtFusionDirect_AdminServicesTabFieldsSave → Admin services tab save handler
|
||||
* VirtFusionDirect_ClientArea → Client-area template + vars
|
||||
* VirtFusionDirect_ServiceSingleSignOn → SSO button handler
|
||||
* VirtFusionDirect_AdminCustomButtonArray → Custom admin action buttons
|
||||
* VirtFusionDirect_UsageUpdate → Daily cron bandwidth/disk usage sync
|
||||
*/
|
||||
if (! defined('WHMCS')) {
|
||||
exit('This file cannot be accessed directly');
|
||||
}
|
||||
@@ -9,6 +45,8 @@ use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client as PowerDnsClient;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
|
||||
/**
|
||||
* Returns module metadata consumed by WHMCS.
|
||||
@@ -97,6 +135,20 @@ function VirtFusionDirect_TestConnection(array $params)
|
||||
$httpCode = $request->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' => ''];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,167 @@ 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;
|
||||
}
|
||||
$extracted = IpUtil::extractIps($serverData);
|
||||
$targetBin = @inet_pton($ip);
|
||||
$owns = false;
|
||||
|
||||
// Stage 1: exact-IP match. Covers every v4 case and any v6 host address
|
||||
// VirtFusion exposes directly (per-host records or /128 subnet entries).
|
||||
foreach ($extracted['addresses'] as $a) {
|
||||
if (@inet_pton($a) === $targetBin) {
|
||||
$owns = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: v6 subnet containment. If the exact match failed and this is
|
||||
// a v6 address, check whether it falls inside any of the server's
|
||||
// allocated v6 subnets. This is the path for "my VirtFusion VPS has a
|
||||
// /64 routed to it and I want a PTR for mail.example.com on one of the
|
||||
// host addresses inside that /64" — we don't know which host addresses
|
||||
// are actually in use, but we can prove this one lies within a range
|
||||
// the customer is authorised for.
|
||||
if (! $owns && IpUtil::isIpv6($ip)) {
|
||||
foreach ($extracted['subnets'] as $s) {
|
||||
if (IpUtil::ipv6InSubnet($ip, $s['subnet'], (int) $s['cidr'])) {
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* WHMCS hooks for the VirtFusion module.
|
||||
*
|
||||
* HOW HOOKS WORK IN WHMCS
|
||||
* -----------------------
|
||||
* add_hook('EventName', $priority, $callback) registers $callback to fire on
|
||||
* the named event. WHMCS discovers hook files by walking modules/servers/*
|
||||
* /hooks.php and modules/addons/* /hooks.php on every page load, then invokes
|
||||
* every registered hook for the current event.
|
||||
*
|
||||
* Hooks run IN-REQUEST — there's no queue or background worker. Anything
|
||||
* expensive in a hook (like an external API call) blocks the user's page
|
||||
* load. For that reason we only do:
|
||||
* - Fast in-process work (building DOM snippets, validating session state)
|
||||
* - Scheduled work on DailyCronJob where "in-request" means the cron worker,
|
||||
* not a user session
|
||||
*
|
||||
* HOOKS REGISTERED HERE
|
||||
* ---------------------
|
||||
* DailyCronJob — PowerDNS reconciliation across all services
|
||||
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
|
||||
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
|
||||
*
|
||||
* FAILURE SEMANTICS
|
||||
* -----------------
|
||||
* Every hook wraps its body in try/catch and silently absorbs any exception.
|
||||
* A hook that throws would potentially break the entire WHMCS request for
|
||||
* all users, not just this module — so we log and swallow, preferring
|
||||
* degraded functionality over site-wide breakage.
|
||||
*/
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
||||
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;
|
||||
|
||||
if (! defined('WHMCS')) {
|
||||
exit('This file cannot be accessed directly');
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily PowerDNS reconciliation.
|
||||
*
|
||||
* Walks every managed service and creates any missing PTRs (never overwrites existing
|
||||
* values — cron is additive-only). Requires the VirtFusion DNS addon to be activated
|
||||
* and enabled; otherwise short-circuits immediately.
|
||||
*
|
||||
* All error handling lives inside reconcileAll(); this wrapper just logs any escape
|
||||
* without disturbing the rest of the daily cron run.
|
||||
*/
|
||||
add_hook('DailyCronJob', 1, function ($vars) {
|
||||
try {
|
||||
if (PowerDnsConfig::isEnabled()) {
|
||||
(new PtrManager)->reconcileAll();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::insert('PowerDns:DailyCronJob', [], $e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Shopping Cart Validation Hook
|
||||
*
|
||||
|
||||
@@ -4,6 +4,27 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
|
||||
/**
|
||||
* Static methods that generate HTML fragments for the WHMCS admin services tab.
|
||||
*
|
||||
* WHY RAW HTML STRINGS INSTEAD OF TEMPLATES
|
||||
* -----------------------------------------
|
||||
* WHMCS's AdminServicesTabFields hook expects an associative array of
|
||||
* label => HTML-string pairs. It renders each entry as a table row with the
|
||||
* label on the left and the raw HTML inserted verbatim on the right. There's
|
||||
* no way to return a Smarty template reference from that hook — WHMCS doesn't
|
||||
* know how to render one in that context.
|
||||
*
|
||||
* So we concatenate HTML here. All variable interpolation uses htmlspecialchars()
|
||||
* at the PHP boundary — never trust that a value passed in is safe for HTML.
|
||||
*
|
||||
* ASSET INJECTION
|
||||
* ---------------
|
||||
* Some renderers (serverInfo, rdnsSection) embed <link> and <script> tags so
|
||||
* the admin services tab picks up our CSS and JS without a separate loader
|
||||
* hook. This is safe because WHMCS's admin CSP allows same-origin resources
|
||||
* and the admin page is already inside an authenticated admin session.
|
||||
*
|
||||
* Cache-busting uses time() as a query string — fine for an admin-only surface
|
||||
* where we'd rather pay for the extra fetch than let stale JS cause bugs.
|
||||
*/
|
||||
class AdminHTML
|
||||
{
|
||||
@@ -147,6 +168,38 @@ EOT;
|
||||
</div>
|
||||
</div>
|
||||
<script>vfServerDataAdmin("${serviceId}","${systemUrl}");</script>
|
||||
EOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin Reverse DNS section for the services tab.
|
||||
*
|
||||
* Ships an empty container + a Reconcile button. Data is loaded client-side via
|
||||
* the admin rdnsStatus AJAX endpoint once the page opens. The JS function
|
||||
* vfAdminLoadRdns (defined in templates/js/module.js) populates #vf-rdns-list
|
||||
* and wires up the Reconcile button's onclick to admin.php?action=rdnsReconcile.
|
||||
*
|
||||
* @param string $systemUrl WHMCS system URL
|
||||
* @param int $serviceId WHMCS service ID
|
||||
* @return string HTML fragment for the admin services tab
|
||||
*/
|
||||
public static function rdnsSection($systemUrl, $serviceId)
|
||||
{
|
||||
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
||||
$serviceId = (int) $serviceId;
|
||||
|
||||
return <<<EOT
|
||||
<div id="vf-rdns-admin-wrap">
|
||||
<div id="vf-rdns-list" class="vf-rdns-list">
|
||||
<em class="text-muted">Loading reverse DNS…</em>
|
||||
</div>
|
||||
<div class="vf-rdns-actions" style="margin-top:10px">
|
||||
<button type="button" class="btn btn-default btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', false)">Reconcile (additive)</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', true)">Reconcile (force reset)</button>
|
||||
<span id="vf-rdns-report" style="margin-left:10px"></span>
|
||||
</div>
|
||||
</div>
|
||||
<script>if(typeof vfAdminLoadRdns==='function'){vfAdminLoadRdns(${serviceId},"${systemUrl}");}</script>
|
||||
EOT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,47 @@
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
|
||||
/**
|
||||
* Two-tier cache: uses Redis when the ext-redis extension is available, with an atomic
|
||||
* filesystem fallback stored in the system temp directory.
|
||||
* Two-tier cache: Redis when ext-redis is available, atomic filesystem fallback otherwise.
|
||||
*
|
||||
* WHY TWO TIERS
|
||||
* -------------
|
||||
* The module is deployed to every kind of WHMCS install — shared hosting, dedicated
|
||||
* VPS, bare-metal. Requiring Redis would exclude the long tail of smaller operators
|
||||
* who never installed the extension. But operators who DO have Redis get a huge
|
||||
* performance win for cross-request caching (PowerDNS zone lists, OS template
|
||||
* listings, traffic stats), so we opportunistically use it when present.
|
||||
*
|
||||
* The fallback is filesystem-based, using the OS temp directory. Writes are atomic
|
||||
* via the classic tmp-file + rename pattern so a process crash mid-write can never
|
||||
* corrupt an existing cache entry for another concurrent reader.
|
||||
*
|
||||
* EXPIRY SEMANTICS
|
||||
* ----------------
|
||||
* Redis: native SETEX — the key auto-expires on the server side.
|
||||
* Filesystem: we store a JSON envelope {expires, data} and check expiry on read,
|
||||
* deleting stale entries lazily. This means a cache with lots of expired entries
|
||||
* will slowly accumulate files until accessed — acceptable for the module's scale
|
||||
* (tens-to-hundreds of keys per install) but worth noting if someone ports this
|
||||
* to a higher-volume context.
|
||||
*
|
||||
* NAMESPACE
|
||||
* ---------
|
||||
* Every key is prefixed with "vfd:" to avoid collisions with anything else that
|
||||
* shares the Redis instance. Nested keys add their own sub-prefix (e.g.
|
||||
* "pdns:zones:<hash>" for PowerDNS zone lists) for semantic clarity in the logs.
|
||||
*
|
||||
* FAILURE MODES
|
||||
* -------------
|
||||
* Redis unreachable: we set $redisAvailable = false on first failure, which
|
||||
* permanently disables Redis for the rest of this PHP process (subsequent calls
|
||||
* skip straight to the file cache). Prevents paying reconnect overhead on every
|
||||
* miss when Redis is down.
|
||||
* File cache write fails: silently skipped. Cache is best-effort; a failed SET
|
||||
* just means the next GET will re-fetch from the authoritative source.
|
||||
*/
|
||||
class Cache
|
||||
{
|
||||
/** Module-global key prefix — keeps us out of Redis key collisions on shared installs. */
|
||||
const PREFIX = 'vfd:';
|
||||
|
||||
/** @var \Redis|null */
|
||||
@@ -150,12 +186,18 @@ class Cache
|
||||
}
|
||||
}
|
||||
|
||||
// File cache fallback with atomic write (race condition safe)
|
||||
// File cache fallback with atomic write.
|
||||
// Writing to a temp file + rename ensures that readers either see the
|
||||
// complete previous entry or the complete new entry — never a truncated
|
||||
// or partially-written file. getmypid() suffix lets concurrent PHP
|
||||
// processes write to the same key without stomping each other's temp files.
|
||||
$path = self::filePath($key);
|
||||
$tmp = $path . '.' . getmypid() . '.tmp';
|
||||
$entry = json_encode(['expires' => time() + $ttl, 'data' => $value]);
|
||||
|
||||
if (@file_put_contents($tmp, $entry, LOCK_EX) !== false) {
|
||||
// rename() is atomic on POSIX when source and target are on the same
|
||||
// filesystem (which they always are here — both in sys_get_temp_dir).
|
||||
@rename($tmp, $path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,37 @@ use WHMCS\User\User;
|
||||
/**
|
||||
* Handles order-time and provisioning-time operations for VirtFusion servers.
|
||||
*
|
||||
* Extends Module to provide package discovery, OS template fetching, server build
|
||||
* initialization, and SSH key retrieval/creation. Used during WHMCS checkout and
|
||||
* account creation flows rather than ongoing service management.
|
||||
* WHY A SIBLING OF ModuleFunctions RATHER THAN METHODS ON IT
|
||||
* ----------------------------------------------------------
|
||||
* ModuleFunctions handles the WHMCS LIFECYCLE (create, suspend, terminate, etc.)
|
||||
* — operations driven by WHMCS service-state transitions.
|
||||
*
|
||||
* ConfigureService handles ORDER-TIME logic — package lookups, template fetching,
|
||||
* SSH key creation, initial build triggering. These run during checkout (via the
|
||||
* ClientAreaFooterOutput hook that populates dropdowns on the order form) and
|
||||
* immediately after account creation (initServerBuild is called from
|
||||
* ModuleFunctions::createAccount once the VirtFusion server exists).
|
||||
*
|
||||
* Splitting the concerns keeps ModuleFunctions focused on lifecycle state machines
|
||||
* and ConfigureService focused on catalogue/discovery calls. They share the base
|
||||
* Module's API plumbing via inheritance.
|
||||
*
|
||||
* CACHING
|
||||
* -------
|
||||
* Package/template lookups use the module's Cache class with 10-minute TTLs.
|
||||
* These values change rarely (a package list is typically edited once per
|
||||
* month at most) but the endpoints are on the checkout hot path, so aggressive
|
||||
* caching matters for page-load performance.
|
||||
*
|
||||
* CP RESOLVED IN CONSTRUCTOR
|
||||
* --------------------------
|
||||
* Unlike ModuleFunctions which resolves the control panel per-request via the
|
||||
* service ID, ConfigureService resolves it ONCE in the constructor via
|
||||
* getCP(false, true) — "any available VirtFusion server". Order-time operations
|
||||
* happen BEFORE a WHMCS service exists, so we can't dereference a specific
|
||||
* server through mod_virtfusion_direct. "Any enabled server" is the pragmatic
|
||||
* default for catalogue operations that typically return the same data
|
||||
* regardless of which panel you hit.
|
||||
*/
|
||||
class ConfigureService extends Module
|
||||
{
|
||||
|
||||
@@ -17,7 +17,20 @@ class Curl
|
||||
/** @var array User-supplied cURL options that override defaults */
|
||||
private $customOptions = [];
|
||||
|
||||
/** @var array Default cURL options applied to every request */
|
||||
/**
|
||||
* @var array Default cURL options applied to every request.
|
||||
*
|
||||
* Rationale:
|
||||
* VERIFYPEER/VERIFYHOST: Full TLS chain + hostname validation. Disabling
|
||||
* either is a common source of MITM bugs, so we never do it silently.
|
||||
* RETURNTRANSFER: We always want the response body back as a string.
|
||||
* HEADER off: Callers almost never need headers. Saves a parse cycle.
|
||||
* NOBODY off: Default to GET-style body-returning requests.
|
||||
* TIMEOUT 30s: Covers slow API endpoints without letting a hung connection
|
||||
* block a whole WHMCS request indefinitely.
|
||||
* CONNECTTIMEOUT 10s: Separate from the total timeout so a failed TCP
|
||||
* handshake (firewall black-hole) fails fast rather than burning 30s.
|
||||
*/
|
||||
private $defaultOptions = [
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
|
||||
@@ -7,12 +7,45 @@ use WHMCS\Database\Capsule as DB;
|
||||
/**
|
||||
* Handles all database operations for the module's custom table (mod_virtfusion_direct)
|
||||
* and queries against core WHMCS tables (tblhosting, tblclients, tblservers, etc.).
|
||||
*
|
||||
* SCHEMA AUTO-MIGRATION
|
||||
* ---------------------
|
||||
* schema() runs on every Module construction — the first call per request creates
|
||||
* or migrates the module table and ensures all required custom fields exist on
|
||||
* every VirtFusionDirect product. Subsequent calls within the same request hit
|
||||
* the $fieldsChecked idempotency flag and short-circuit, so the overhead is
|
||||
* one SHOW-columns query per request.
|
||||
*
|
||||
* This design means operators never need to run a separate install script —
|
||||
* dropping the module files into place and hitting any admin page triggers the
|
||||
* migration. The trade-off is small per-request overhead; we take it because
|
||||
* WHMCS modules historically had fragile install/uninstall hooks.
|
||||
*
|
||||
* SCHEMA VERSIONING
|
||||
* -----------------
|
||||
* No explicit version table. Migrations are expressed as "create if missing"
|
||||
* checks — hasTable(), hasColumn() — which makes forward migration additive
|
||||
* and safe to re-run. Deletions would require a proper versioning scheme, but
|
||||
* we have none so far; every column added has been non-breaking.
|
||||
*
|
||||
* WHMCS TABLE ACCESS
|
||||
* ------------------
|
||||
* Reads from tblhosting / tblclients / tblconfiguration are done via Capsule's
|
||||
* fluent query builder, not raw SQL, to inherit WHMCS's database abstraction
|
||||
* (connection pooling, character set, prepared statement handling).
|
||||
*/
|
||||
class Database
|
||||
{
|
||||
/** Module's own per-service state table. Created on first Module instantiation. */
|
||||
const SYSTEM_TABLE = 'mod_virtfusion_direct';
|
||||
|
||||
/** @var bool Tracks whether custom field existence has already been verified this request. */
|
||||
/**
|
||||
* @var bool Tracks whether custom field existence has already been verified this request.
|
||||
*
|
||||
* Custom-field creation is idempotent (updateOrInsert) but touching every
|
||||
* product on every request is wasteful. This flag ensures it runs exactly
|
||||
* once per PHP request.
|
||||
*/
|
||||
private static $fieldsChecked = false;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,18 +3,46 @@
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the WHMCS logModuleCall() function for module-level logging.
|
||||
* Thin wrapper around the WHMCS logModuleCall() function.
|
||||
*
|
||||
* WHY A WRAPPER
|
||||
* -------------
|
||||
* Consolidating log writes lets us:
|
||||
* - Pin the module name in one place (the LOG_MODULE constant). All entries
|
||||
* go under "VirtFusionDirect" regardless of which caller inserted them,
|
||||
* which keeps WHMCS Admin → Utilities → Logs → Module Log filterable.
|
||||
* - Get a stable import path for every file that logs (Log::insert).
|
||||
* - Add cross-cutting policy later (e.g. redaction, sampling) without
|
||||
* touching every call site.
|
||||
*
|
||||
* OUTPUT SURFACE
|
||||
* --------------
|
||||
* Entries appear in WHMCS Admin → Utilities → Logs → Module Log. The request
|
||||
* and response parameters accept strings OR arrays — WHMCS serialises arrays
|
||||
* to readable form automatically. Pass structured data (["zone" => $z, "ip" => $ip])
|
||||
* rather than string-concatenated messages; the UI renders arrays as key/value
|
||||
* pairs which makes filtering and debugging much easier.
|
||||
*
|
||||
* REDACTION EXPECTATION
|
||||
* ---------------------
|
||||
* Callers are responsible for not passing secrets into logs. In particular:
|
||||
* - Never log Authorization/X-API-Key headers
|
||||
* - Never log full request_header info from the Curl class
|
||||
* - Never log the decrypted VirtFusion bearer token or PowerDNS API key
|
||||
* The Curl class deliberately defaults CURLOPT_HEADER to off so header capture
|
||||
* doesn't accidentally populate a field that callers might log.
|
||||
*/
|
||||
class Log
|
||||
{
|
||||
/** Keep this in sync with the WHMCS server module name, so filters work. */
|
||||
const LOG_MODULE = 'VirtFusionDirect';
|
||||
|
||||
/**
|
||||
* Write an entry to the WHMCS module log.
|
||||
*
|
||||
* @param string $action Name of the action being logged (e.g. 'CreateAccount')
|
||||
* @param string|array $requestString Request data sent to the API
|
||||
* @param string|array $responseData Response data received from the API
|
||||
* @param string $action Short tag identifying the operation (used as the "Function" column in the log UI)
|
||||
* @param string|array $requestString Outbound payload or context data. Arrays preferred — rendered as key/value pairs.
|
||||
* @param string|array $responseData Inbound response or result. Same conventions as $requestString.
|
||||
*/
|
||||
public static function insert($action, $requestString, $responseData)
|
||||
{
|
||||
|
||||
@@ -10,8 +10,49 @@ use WHMCS\Database\Capsule;
|
||||
* server feature methods (power, network, VNC, backup, resource modification,
|
||||
* self-service billing, traffic, rename, password reset).
|
||||
*
|
||||
* Extended by ModuleFunctions (service lifecycle) and ConfigureService (order-time
|
||||
* operations). Most business logic lives here; subclasses delegate to these methods.
|
||||
* INHERITANCE SHAPE
|
||||
* -----------------
|
||||
* Extended by:
|
||||
* - ModuleFunctions — service lifecycle (create, suspend, unsuspend, terminate, change package)
|
||||
* - ConfigureService — order-time operations (package/template discovery, server build init)
|
||||
*
|
||||
* Most business logic lives HERE, not in the subclasses. Subclasses are intentionally
|
||||
* thin — they orchestrate sequences of calls to methods defined on this base, which
|
||||
* lets us unit-exercise any single feature (e.g. "what happens during rename when
|
||||
* the VirtFusion API returns 423?") without standing up a full WHMCS lifecycle.
|
||||
*
|
||||
* THE resolveServiceContext() PATTERN
|
||||
* -----------------------------------
|
||||
* Almost every method follows the same preamble: look up the module table row,
|
||||
* look up the WHMCS tblhosting row, resolve the control panel credentials, build
|
||||
* a Curl client with the bearer token. That preamble is consolidated into
|
||||
* resolveServiceContext() which returns everything as an array or false on any
|
||||
* missing piece. Every feature method starts with "$ctx = $this->resolveServiceContext($id);
|
||||
* if (! $ctx) return false;" and can then use $ctx['request'], $ctx['serverId'], etc.
|
||||
*
|
||||
* This pattern is the most important abstraction in the module — violating it
|
||||
* (e.g. reading tblservers directly in a feature method) leads to drift where
|
||||
* some features handle missing servers gracefully and others don't.
|
||||
*
|
||||
* ENDPOINT OUTPUT CONVENTION
|
||||
* --------------------------
|
||||
* client.php and admin.php call $this->output() to emit JSON responses. Every
|
||||
* output() call in a switch case MUST be followed by a `break` — the module
|
||||
* deliberately does NOT rely on exit() inside output() for flow control because
|
||||
* that couples the HTTP response format to the control-flow mechanism and makes
|
||||
* refactoring fragile.
|
||||
*
|
||||
* SECURITY HELPERS
|
||||
* ----------------
|
||||
* Five guards callers compose in front of sensitive actions:
|
||||
* - isAuthenticated() — client session required
|
||||
* - adminOnly() — admin session required
|
||||
* - requirePost() — HTTP method gate (mutations only)
|
||||
* - requireSameOrigin() — CSRF origin check
|
||||
* - requireServiceStatus() — filter by tblhosting.domainstatus
|
||||
*
|
||||
* Each exits on failure with the appropriate HTTP status — callers treat them
|
||||
* as "throw on failure" style assertions rather than having to check return values.
|
||||
*/
|
||||
class Module
|
||||
{
|
||||
@@ -73,10 +114,23 @@ class Module
|
||||
|
||||
/**
|
||||
* Resolve service context: system service, WHMCS service, control panel, and curl client.
|
||||
* Returns false if any lookup fails.
|
||||
*
|
||||
* This is the most-called method in the module. Every feature action begins
|
||||
* by calling it, so think of the return value as "everything you need to
|
||||
* touch VirtFusion for this service":
|
||||
*
|
||||
* service — row from mod_virtfusion_direct (has server_id, server_object)
|
||||
* whmcsService — row from tblhosting (has server, userid, domain, etc.)
|
||||
* cp — ['url', 'base_url', 'token'] for the VirtFusion API
|
||||
* request — a fresh Curl instance pre-configured with the bearer token
|
||||
* serverId — (int) of service.server_id — used in every URL downstream
|
||||
*
|
||||
* Returning false on ANY missing piece lets callers write a single
|
||||
* "if (! $ctx) return false;" check at the top of each feature method
|
||||
* rather than threading nullability through three separate lookups.
|
||||
*
|
||||
* @param int $serviceID
|
||||
* @return array{service: object, whmcsService: object, cp: array, request: Curl}|false
|
||||
* @return array{service: object, whmcsService: object, cp: array, request: Curl, serverId: int}|false
|
||||
*/
|
||||
protected function resolveServiceContext($serviceID)
|
||||
{
|
||||
@@ -328,13 +382,37 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
// Capture old hostname + server object from stored state so we can sync rDNS
|
||||
// after the rename. We read from the cached server_object rather than a fresh
|
||||
// fetch; this is the hostname the PTR would be set to (if module-managed).
|
||||
$oldHostname = null;
|
||||
$serverObject = null;
|
||||
if (! empty($ctx['service']->server_object)) {
|
||||
$serverObject = json_decode($ctx['service']->server_object, true);
|
||||
if (is_array($serverObject)) {
|
||||
$oldHostname = PowerDns\PtrManager::extractHostname($serverObject);
|
||||
}
|
||||
}
|
||||
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
|
||||
$data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||||
$success = $httpCode == 200 || $httpCode == 204;
|
||||
|
||||
return $httpCode == 200 || $httpCode == 204;
|
||||
if ($success && $serverObject !== null && PowerDns\Config::isEnabled()) {
|
||||
// Sync PTRs: only records whose current content equals the old hostname
|
||||
// will be rewritten; client-customized PTRs are preserved automatically.
|
||||
// Non-blocking: rDNS failures log but never fail the rename.
|
||||
try {
|
||||
(new PowerDns\PtrManager)->syncServer($serverObject, $oldHostname, $newName);
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:renameServer', ['serviceID' => $serviceID], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
} catch (\Exception $e) {
|
||||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||
|
||||
@@ -773,6 +851,26 @@ class Module
|
||||
/**
|
||||
* Resolve a WHMCS server record into an API base URL and decrypted Bearer token.
|
||||
*
|
||||
* OUTPUT SHAPE
|
||||
* ------------
|
||||
* url — full API base like "https://vf.example.com/api/v1". Append
|
||||
* path components to this for every VirtFusion call.
|
||||
* base_url — scheme + host only, "https://vf.example.com". Used for SSO
|
||||
* redirects where we need to hit the panel UI, not the API.
|
||||
* token — decrypted bearer token. Pass to initCurl() to get an
|
||||
* authenticated Curl handle.
|
||||
*
|
||||
* $any=true is an unusual behaviour: when a WHMCS product doesn't have a
|
||||
* specific server pinned (allowed if the module is the only VF module on
|
||||
* the install), we fall back to any enabled VirtFusion server. This mostly
|
||||
* exists for the "Test Connection" button which doesn't know which server
|
||||
* to use until after a successful connection. Normal provisioning always
|
||||
* passes a real server ID.
|
||||
*
|
||||
* The token is stored encrypted in tblservers.password and decrypted here
|
||||
* via WHMCS's global decrypt() — the same encryption key used for addon
|
||||
* module password fields.
|
||||
*
|
||||
* @param int|object $server WHMCS server ID or server object
|
||||
* @param bool $any When true, fall back to any available server if the given one is not found
|
||||
* @return array{url: string, base_url: string, token: string}|false
|
||||
@@ -825,6 +923,164 @@ class Module
|
||||
$this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce POST as the HTTP method. Emits a 405 JSON response and exits otherwise.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* The REST principle says mutations should be POST, and PHP's $_POST / $_GET
|
||||
* separation means a mutation that reads from $_POST would fail quietly when
|
||||
* called via GET. But "fail quietly" isn't what we want — an attacker probing
|
||||
* endpoints via crafted <img src="?action=...&ip=...&ptr=..."> tags shouldn't
|
||||
* even reach our input-validation code. This gate kills that path with a 405
|
||||
* before any per-endpoint logic runs.
|
||||
*
|
||||
* Combined with requireSameOrigin() below, this closes the most common
|
||||
* cross-site request forgery vectors (form POST, image GET) without needing
|
||||
* explicit CSRF tokens threaded through every AJAX call.
|
||||
*
|
||||
* @return bool|void
|
||||
*/
|
||||
public function requirePost()
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->output(['success' => false, 'errors' => 'method not allowed'], true, true, 405);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the request's Origin/Referer belongs to this WHMCS install.
|
||||
*
|
||||
* THREAT MODEL
|
||||
* ------------
|
||||
* A logged-in WHMCS user visits a malicious page. That page makes a POST
|
||||
* to our rDNS endpoint; because the session cookie is tied to our domain,
|
||||
* the browser attaches it automatically. Without this check, the attacker
|
||||
* could silently rewrite the user's PTRs.
|
||||
*
|
||||
* The defence: browsers attach an Origin header on cross-origin fetch/XHR
|
||||
* and a Referer on cross-origin form POST. Those headers carry the
|
||||
* attacker's origin, not ours — so we compare them against our own
|
||||
* hostname and reject mismatches with a 403.
|
||||
*
|
||||
* This is NOT a full CSRF token scheme. It defends against the common
|
||||
* cross-site-POST and cross-site-form-submit vectors but a same-site XSS
|
||||
* that can read the user's DOM could still circumvent it. For that you'd
|
||||
* need per-request tokens bound to the session — out of scope for the
|
||||
* current module, but the helper stays here ready to be composed with
|
||||
* a token check if one's added later.
|
||||
*
|
||||
* IMPLEMENTATION
|
||||
* --------------
|
||||
* 1. Collect our "known good" host set from HTTP_HOST (what the browser
|
||||
* connected to) plus the SystemURL host from tblconfiguration (what
|
||||
* WHMCS thinks its canonical URL is). Behind a reverse proxy these
|
||||
* can differ; accepting either closes the false-positive gap.
|
||||
* 2. Parse HTTP_ORIGIN and HTTP_REFERER and pull out their host:port.
|
||||
* 3. Require at least one of those headers to match.
|
||||
*
|
||||
* Fails closed: if we can't determine our own host OR if neither Origin
|
||||
* nor Referer is present, we reject. A legitimate same-origin AJAX call
|
||||
* from the module's own JS always sets Origin (fetch API) or Referer
|
||||
* (form submit), so the "both absent" case only happens with scripted
|
||||
* non-browser clients — which are exactly who we want to filter out.
|
||||
*
|
||||
* @return bool|void true on success; emits 403 JSON and exits otherwise
|
||||
*/
|
||||
public function requireSameOrigin()
|
||||
{
|
||||
$expected = [];
|
||||
|
||||
$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
|
||||
if ($host !== '') {
|
||||
$expected[] = strtolower($host);
|
||||
}
|
||||
|
||||
$systemUrl = Database::getSystemUrl();
|
||||
if ($systemUrl) {
|
||||
$parsed = parse_url($systemUrl);
|
||||
if (! empty($parsed['host'])) {
|
||||
$expected[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
|
||||
$expected[] = strtolower($parsed['host']);
|
||||
}
|
||||
}
|
||||
$expected = array_unique(array_filter($expected));
|
||||
if (empty($expected)) {
|
||||
// Can't determine our own host; fail closed rather than silently allow.
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||||
}
|
||||
|
||||
$origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
|
||||
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
|
||||
|
||||
$candidates = [];
|
||||
foreach ([$origin, $referer] as $raw) {
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$parsed = parse_url($raw);
|
||||
if (! empty($parsed['host'])) {
|
||||
$candidates[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
|
||||
$candidates[] = strtolower($parsed['host']);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed (missing origin)'], true, true, 403);
|
||||
}
|
||||
|
||||
foreach ($candidates as $c) {
|
||||
if (in_array($c, $expected, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Log::insert('csrf:origin-mismatch', ['origin' => $origin, 'referer' => $referer, 'expected' => $expected], 'cross-origin request rejected');
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WHMCS service is in a status where client-initiated writes make sense.
|
||||
*
|
||||
* tblhosting.domainstatus can be: Active, Suspended, Terminated, Pending,
|
||||
* Cancelled, Fraud. Not every action makes sense in every status:
|
||||
* - Reads (rdnsList, serverData) usually allow Active + Suspended so a
|
||||
* suspended user can still see their current config.
|
||||
* - Writes (rdnsUpdate, power, etc.) typically require Active only —
|
||||
* mutating a cancelled service's rDNS has no sensible business meaning.
|
||||
*
|
||||
* Pass the allowed set explicitly per endpoint rather than trying to encode
|
||||
* a global policy here. Some endpoints (admin reconcile) don't call this at
|
||||
* all because the admin is allowed to touch any service.
|
||||
*
|
||||
* Fails with 404 if the service doesn't exist, 400 otherwise — keeping the
|
||||
* two conditions distinct in the response code helps client-side error
|
||||
* handling (a 404 usually means "link is stale", a 400 means "not right now").
|
||||
*
|
||||
* @param int $serviceID WHMCS service ID
|
||||
* @param string[] $allowedStatuses Service statuses that permit the operation
|
||||
* @return bool|void true on success; emits 400/404 JSON and exits otherwise
|
||||
*/
|
||||
public function requireServiceStatus(int $serviceID, array $allowedStatuses = ['Active'])
|
||||
{
|
||||
$row = Database::getWhmcsService($serviceID);
|
||||
if (! $row) {
|
||||
$this->output(['success' => false, 'errors' => 'service not found'], true, true, 404);
|
||||
}
|
||||
if (! in_array((string) $row->domainstatus, $allowedStatuses, true)) {
|
||||
$this->output(
|
||||
['success' => false, 'errors' => 'service status "' . (string) $row->domainstatus . '" does not permit this action'],
|
||||
true,
|
||||
true,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-configured Curl instance with JSON Accept/Content-Type headers
|
||||
* and a Bearer token for authenticating against the VirtFusion API.
|
||||
|
||||
@@ -5,8 +5,38 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
/**
|
||||
* Extends Module to handle the WHMCS service lifecycle for VirtFusion servers.
|
||||
*
|
||||
* Responsibilities include: provisioning (create, suspend, unsuspend, terminate),
|
||||
* package changes, usage updates, client area rendering, and admin tab fields.
|
||||
* WHY A SEPARATE CLASS FROM MODULE
|
||||
* --------------------------------
|
||||
* The WHMCS module interface (VirtFusionDirect.php) expects top-level functions
|
||||
* like VirtFusionDirect_CreateAccount(). Those functions delegate into methods
|
||||
* on this class so:
|
||||
* 1. The top-level functions stay one-liners that are easy to audit.
|
||||
* 2. All lifecycle logic lives in an object we can instantiate and unit-exercise
|
||||
* without going through WHMCS's dispatch machinery.
|
||||
* 3. The shared behaviour with Module (API calls, auth, validation) comes for
|
||||
* free via inheritance — no copy-pasted curl setup or error handling.
|
||||
*
|
||||
* ERROR MESSAGE CONVENTION
|
||||
* ------------------------
|
||||
* Every public method either returns the literal string 'success' or an error
|
||||
* string that WHMCS will render to the admin in the service activity log. Do NOT
|
||||
* return arrays, objects, or booleans — WHMCS treats anything other than
|
||||
* 'success' as an error and displays it verbatim.
|
||||
*
|
||||
* EXCEPTION HANDLING
|
||||
* ------------------
|
||||
* Every public method is wrapped in try/catch. Uncaught exceptions bubbling up
|
||||
* to WHMCS appear as stack traces in the admin UI and leak implementation detail,
|
||||
* so we catch and convert to a human error string. Log::insert() captures the
|
||||
* original exception message for diagnostics in the module log.
|
||||
*
|
||||
* PowerDNS INTEGRATION
|
||||
* --------------------
|
||||
* createAccount(), terminateAccount(), and (via parent Module) renameServer()
|
||||
* call into PowerDns\PtrManager to sync rDNS. Those calls are wrapped in their
|
||||
* OWN try/catch so DNS failures never bubble up to WHMCS — provisioning must
|
||||
* succeed even if PowerDNS is temporarily unreachable. See cleanupPowerDnsForService()
|
||||
* for the termination-time cleanup helper.
|
||||
*/
|
||||
class ModuleFunctions extends Module
|
||||
{
|
||||
@@ -163,6 +193,33 @@ class ModuleFunctions extends Module
|
||||
Database::systemOnServerCreate($params['serviceid'], $data);
|
||||
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
|
||||
|
||||
// Initialize reverse DNS for the newly-assigned IPs.
|
||||
//
|
||||
// Ordering: after Database::systemOnServerCreate() AND
|
||||
// updateWhmcsServiceParamsOnServerObject() so mod_virtfusion_direct
|
||||
// has the stored server_object (admin reconcile later reads it) and
|
||||
// tblhosting has the primary IP (for cross-check on client edits).
|
||||
//
|
||||
// But BEFORE ConfigureService::initServerBuild() so rDNS is in place
|
||||
// when the VPS first boots — mail servers and other services that
|
||||
// check FCrDNS during early-boot see correct PTRs.
|
||||
//
|
||||
// Non-blocking: rDNS failures are logged but never fail provisioning.
|
||||
// A broken PowerDNS or missing zone must not prevent a customer
|
||||
// from getting the VPS they paid for.
|
||||
try {
|
||||
if (PowerDns\Config::isEnabled()) {
|
||||
// syncServer with $oldHostname=null means "create mode" — see
|
||||
// PtrManager::syncServer() docblock for the semantics.
|
||||
$hostname = PowerDns\PtrManager::extractHostname($data);
|
||||
if ($hostname !== null) {
|
||||
(new PowerDns\PtrManager)->syncServer($data, null, $hostname);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:createAccount', ['serviceid' => $params['serviceid']], $e->getMessage());
|
||||
}
|
||||
|
||||
// If the server is created successfully, we can initialize the server build.
|
||||
$cs = new ConfigureService;
|
||||
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
|
||||
@@ -304,6 +361,7 @@ class ModuleFunctions extends Module
|
||||
switch ($request->getRequestInfo('http_code')) {
|
||||
|
||||
case 204:
|
||||
$this->cleanupPowerDnsForService($service);
|
||||
Database::deleteSystemService($params['serviceid']);
|
||||
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
|
||||
|
||||
@@ -312,6 +370,7 @@ class ModuleFunctions extends Module
|
||||
case 404:
|
||||
if (isset($data->msg)) {
|
||||
if ($data->msg == 'server not found') {
|
||||
$this->cleanupPowerDnsForService($service);
|
||||
Database::deleteSystemService($params['serviceid']);
|
||||
|
||||
return 'success';
|
||||
@@ -335,6 +394,33 @@ class ModuleFunctions extends Module
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete any PTR records owned by this service before the local record is erased.
|
||||
* The stored server_object is the last source of the IP list; once deleted from
|
||||
* the module table we'd have no way to find them again. Non-fatal — DNS failures
|
||||
* never block termination.
|
||||
*
|
||||
* @param object|null $service Row from mod_virtfusion_direct (has server_object JSON)
|
||||
*/
|
||||
protected function cleanupPowerDnsForService($service): void
|
||||
{
|
||||
try {
|
||||
if (! PowerDns\Config::isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (! $service || empty($service->server_object)) {
|
||||
return;
|
||||
}
|
||||
$decoded = json_decode($service->server_object, true);
|
||||
if (! is_array($decoded)) {
|
||||
return;
|
||||
}
|
||||
(new PowerDns\PtrManager)->deleteForServer($decoded);
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:terminate', ['service' => $service->service_id ?? null], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a VirtFusion server, queuing the action if another operation is in progress.
|
||||
*
|
||||
@@ -552,6 +638,9 @@ class ModuleFunctions extends Module
|
||||
|
||||
if ($params['status'] != 'Terminated') {
|
||||
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
|
||||
if (PowerDns\Config::isEnabled()) {
|
||||
$fields['Reverse DNS'] = AdminHTML::rdnsSection($systemUrl, $params['serviceid']);
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
@@ -659,6 +748,7 @@ class ModuleFunctions extends Module
|
||||
'serviceStatus' => $params['status'],
|
||||
'serverHostname' => $serverHostname,
|
||||
'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
|
||||
'rdnsEnabled' => PowerDns\Config::isEnabled(),
|
||||
],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
341
modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
Normal file
341
modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Curl;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Thin HTTP wrapper around the PowerDNS Authoritative HTTP API.
|
||||
*
|
||||
* WHY A SEPARATE CLIENT INSTEAD OF REUSING MODULE::INITCURL()
|
||||
* -----------------------------------------------------------
|
||||
* Module::initCurl() is hardcoded to Bearer auth for VirtFusion. PowerDNS uses
|
||||
* X-API-Key, and mixing the two authorization styles inside one factory method
|
||||
* would either require a new flag (leaky abstraction) or accidental leakage of
|
||||
* the VirtFusion token into a PowerDNS request. A dedicated wrapper keeps the
|
||||
* two credential flows completely isolated — a bug in PowerDNS handling can
|
||||
* never leak a VirtFusion token, and vice versa.
|
||||
*
|
||||
* LOGGING RULES
|
||||
* -------------
|
||||
* We NEVER pass the API key or any header containing it to Log::insert().
|
||||
* PATCH/NOTIFY calls log the zone+operation+HTTP code, successes log minimally,
|
||||
* errors include up to 500 bytes of response body (PowerDNS error responses are
|
||||
* small JSON fragments, not customer data). The Curl class doesn't capture
|
||||
* request headers by default (CURLOPT_HEADER is off), so even the internal
|
||||
* request_header field doesn't contain the API key.
|
||||
*
|
||||
* CACHING
|
||||
* -------
|
||||
* listZones() caches the zone list via the module's Cache class (Redis/filesystem)
|
||||
* for Config::cacheTtl() seconds. Zone lists rarely change — the TTL balances
|
||||
* "pick up a newly-created zone soon" against "don't hammer PowerDNS for every
|
||||
* listZones call across unrelated lifecycle events".
|
||||
*
|
||||
* getZone() and patchRRset() are NOT cached here; per-request memoisation of
|
||||
* getZone results lives in PtrManager::getZoneCached so it can invalidate on
|
||||
* write from within the same request.
|
||||
*
|
||||
* SINGLE-USE CURL INSTANCES
|
||||
* -------------------------
|
||||
* newCurl() returns a fresh Curl for every HTTP call. That's how the existing
|
||||
* module's Curl class is designed — reusing a handle across requests produces
|
||||
* undefined behaviour because options from the first call bleed into the second.
|
||||
* It's cheap (curl_init is microseconds).
|
||||
*/
|
||||
class Client
|
||||
{
|
||||
/** @var string */
|
||||
private $endpoint;
|
||||
|
||||
/** @var string */
|
||||
private $apiKey;
|
||||
|
||||
/** @var string */
|
||||
private $serverId;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $config Optional pre-resolved config; defaults to PowerDns\Config::get()
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config = $config ?? Config::get();
|
||||
$this->endpoint = rtrim((string) ($config['endpoint'] ?? ''), '/');
|
||||
$this->apiKey = (string) ($config['apiKey'] ?? '');
|
||||
$this->serverId = (string) ($config['serverId'] ?? 'localhost');
|
||||
}
|
||||
|
||||
/** Base URL for the configured PowerDNS server (no trailing slash). */
|
||||
private function base(): string
|
||||
{
|
||||
return $this->endpoint . '/api/v1/servers/' . rawurlencode($this->serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a zone name to its PowerDNS URL-safe id form.
|
||||
*
|
||||
* PowerDNS's API uses a custom URL encoding for zone names that have characters
|
||||
* like "/" which would collide with path semantics. Instead of using %-encoding
|
||||
* (which many HTTP frameworks would parse back out at routing time), PowerDNS
|
||||
* uses "=HH" where HH is the hex code — so "/" becomes "=2F".
|
||||
*
|
||||
* This only matters for RFC 2317 classless-delegation zone names like
|
||||
* "64/64.113.0.203.in-addr.arpa." whose zone id in the API is
|
||||
* "64=2F64.113.0.203.in-addr.arpa.". Standard zones pass through unchanged
|
||||
* because they contain no "/" characters.
|
||||
*
|
||||
* Using rawurlencode() here would produce "%2F" which PowerDNS does NOT accept.
|
||||
* That's why this is a plain str_replace.
|
||||
*/
|
||||
private function zoneIdEncode(string $zoneName): string
|
||||
{
|
||||
return str_replace('/', '=2F', rtrim($zoneName, '.') . '.');
|
||||
}
|
||||
|
||||
/** Fresh Curl instance with PowerDNS auth + JSON headers. */
|
||||
private function newCurl(): Curl
|
||||
{
|
||||
$curl = new Curl;
|
||||
$curl->addOption(CURLOPT_HTTPHEADER, [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'X-API-Key: ' . $this->apiKey,
|
||||
]);
|
||||
|
||||
return $curl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Healthcheck. Returns [ok: bool, http: int, error: ?string].
|
||||
* Used by the addon's Test Connection button and by VirtFusionDirect_TestConnection().
|
||||
*
|
||||
* @return array{ok: bool, http: int, error: ?string}
|
||||
*/
|
||||
public function ping(): array
|
||||
{
|
||||
try {
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->get($this->base());
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
if ($http === 200) {
|
||||
return ['ok' => true, 'http' => 200, 'error' => null];
|
||||
}
|
||||
if ($http === 0) {
|
||||
$err = (string) ($curl->getRequestInfo('curl_error') ?: 'connection failed');
|
||||
|
||||
return ['ok' => false, 'http' => 0, 'error' => $err];
|
||||
}
|
||||
if ($http === 401 || $http === 403) {
|
||||
// Three distinct causes all produce 401/403 here:
|
||||
// (a) Actual wrong API key — the #1 obvious cause.
|
||||
// (b) `api-allow-from` in PowerDNS config excludes the WHMCS
|
||||
// host's IP. PowerDNS rejects pre-auth in some configs,
|
||||
// producing 401/403 even with a valid key.
|
||||
// (c) Invisible whitespace in the stored key (fixed in Config
|
||||
// via trim(), but a pre-upgrade install might still have
|
||||
// a cached request dating from before the fix).
|
||||
// Listing all three gives the operator a concrete checklist.
|
||||
return [
|
||||
'ok' => false,
|
||||
'http' => $http,
|
||||
'error' => 'HTTP ' . $http . ' — PowerDNS rejected authentication. Check: ' .
|
||||
'(1) the X-API-Key matches the `api-key=` in PowerDNS config, ' .
|
||||
'(2) `api-allow-from=` includes this WHMCS host\'s IP, and ' .
|
||||
'(3) the key has no trailing whitespace/newlines (re-paste it if unsure).',
|
||||
];
|
||||
}
|
||||
if ($http === 404) {
|
||||
// The endpoint reached PowerDNS (no 0/connection-refused) but the
|
||||
// server ID path segment isn't known. By far the most common cause
|
||||
// is an addon misconfiguration where someone entered the nameserver
|
||||
// FQDN instead of the literal string "localhost" into the Server ID
|
||||
// field. Surface that hypothesis directly — it's the single highest-
|
||||
// probability fix and turns a mystery into an actionable error.
|
||||
return [
|
||||
'ok' => false,
|
||||
'http' => 404,
|
||||
'error' => 'HTTP 404 — PowerDNS does not recognise server id "' . $this->serverId .
|
||||
'". This field should almost always be the literal string "localhost" ' .
|
||||
'(the PowerDNS API server identifier, NOT your nameserver hostname).',
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:ping', [], $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'http' => 0, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List every zone on the configured PowerDNS server.
|
||||
*
|
||||
* Result is cached for the configured cacheTtl. Used as the primary zone-discovery
|
||||
* strategy: PtrManager finds the containing zone for a PTR name by longest-suffix
|
||||
* matching against this list rather than probing individual zones.
|
||||
*
|
||||
* @return string[] Zone names with trailing dot
|
||||
*/
|
||||
public function listZones(): array
|
||||
{
|
||||
$ttl = Config::cacheTtl();
|
||||
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$zones = [];
|
||||
|
||||
try {
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->get($this->base() . '/zones');
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
|
||||
if ($http === 200) {
|
||||
$decoded = json_decode((string) $body, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $z) {
|
||||
if (! empty($z['name'])) {
|
||||
$zones[] = rtrim((string) $z['name'], '.') . '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log::insert('PowerDns:listZones', ['http' => $http], substr((string) $body, 0, 500));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:listZones', [], $e->getMessage());
|
||||
}
|
||||
|
||||
Cache::set($cacheKey, $zones, $ttl);
|
||||
|
||||
return $zones;
|
||||
}
|
||||
|
||||
/** Drop any cached zone list (call after PATCHes or settings changes). */
|
||||
public function forgetZoneCache(): void
|
||||
{
|
||||
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single zone by name. Returns decoded JSON array, or null on 404/error.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getZone(string $zoneName): ?array
|
||||
{
|
||||
try {
|
||||
$zoneName = rtrim($zoneName, '.') . '.';
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->get($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
if ($http === 200) {
|
||||
$decoded = json_decode((string) $body, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
if ($http !== 404) {
|
||||
Log::insert('PowerDns:getZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 500));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:getZone', ['zone' => $zoneName], $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an RRset change to a zone via PATCH.
|
||||
*
|
||||
* $rrset keys (per PowerDNS API): name, type, ttl?, changetype (REPLACE|DELETE|EXTEND), records[].
|
||||
* On success PowerDNS returns 204 No Content.
|
||||
*
|
||||
* @return array{ok: bool, http: int, body: string}
|
||||
*/
|
||||
public function patchRRset(string $zoneName, array $rrset): array
|
||||
{
|
||||
try {
|
||||
$zoneName = rtrim($zoneName, '.') . '.';
|
||||
if (isset($rrset['name'])) {
|
||||
$rrset['name'] = rtrim((string) $rrset['name'], '.') . '.';
|
||||
}
|
||||
|
||||
$payload = ['rrsets' => [$rrset]];
|
||||
$curl = $this->newCurl();
|
||||
$curl->addOption(CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
$body = $curl->patch($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
|
||||
Log::insert(
|
||||
'PowerDns:patchRRset',
|
||||
[
|
||||
'zone' => $zoneName,
|
||||
'name' => $rrset['name'] ?? null,
|
||||
'type' => $rrset['type'] ?? null,
|
||||
'changetype' => $rrset['changetype'] ?? null,
|
||||
],
|
||||
['http' => $http, 'body' => substr((string) $body, 0, 500)],
|
||||
);
|
||||
|
||||
if ($http === 204) {
|
||||
// Fire-and-forget NOTIFY so slaves pick up the bumped SOA serial immediately.
|
||||
//
|
||||
// Background: PowerDNS auto-increments SOA on every API write when the zone
|
||||
// has soa_edit_api=INCREASE (recommended; see README). Slaves normally learn
|
||||
// about the new serial via polling at the refresh interval (often 15+ min)
|
||||
// OR via a NOTIFY push from the master. Without our NOTIFY, rDNS changes
|
||||
// made via this module would take effect on the authoritative master
|
||||
// immediately but wouldn't propagate until the next scheduled poll.
|
||||
//
|
||||
// Only meaningful for Master-kind zones. For Native zones (no slaves) or
|
||||
// Slave zones (reverse direction), PowerDNS returns a 422 or similar —
|
||||
// notifyZone() logs that and returns ok=false, but we don't care here:
|
||||
// the PATCH itself succeeded, which is what we report upward.
|
||||
$this->notifyZone($zoneName);
|
||||
}
|
||||
|
||||
return ['ok' => $http === 204, 'http' => $http, 'body' => (string) $body];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:patchRRset', ['zone' => $zoneName], $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'http' => 0, 'body' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DNS NOTIFY to all slaves for this zone. Only applicable to Master-kind zones;
|
||||
* PowerDNS returns 400/422 for Native/Slave kinds and that's fine — we log and continue.
|
||||
*
|
||||
* SOA serial bumping itself is handled by PowerDNS (soa_edit_api=INCREASE or similar
|
||||
* on the zone); this call just ensures slaves learn about the new serial right away
|
||||
* rather than waiting for the next scheduled refresh.
|
||||
*
|
||||
* @return array{ok: bool, http: int}
|
||||
*/
|
||||
public function notifyZone(string $zoneName): array
|
||||
{
|
||||
try {
|
||||
$zoneName = rtrim($zoneName, '.') . '.';
|
||||
$curl = $this->newCurl();
|
||||
$body = $curl->put($this->base() . '/zones/' . $this->zoneIdEncode($zoneName) . '/notify');
|
||||
$http = (int) $curl->getRequestInfo('http_code');
|
||||
|
||||
if ($http !== 200) {
|
||||
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 300));
|
||||
}
|
||||
|
||||
return ['ok' => $http === 200, 'http' => $http];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName], $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'http' => 0];
|
||||
}
|
||||
}
|
||||
}
|
||||
212
modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
Normal file
212
modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Database\Capsule as DB;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Loads PowerDNS addon settings from tbladdonmodules (module="virtfusiondns") and
|
||||
* decrypts the API key using WHMCS's native decrypt() helper.
|
||||
*
|
||||
* WHY "LOOSE COUPLING" VIA TBLADDONMODULES
|
||||
* ----------------------------------------
|
||||
* WHMCS lets an operator activate/deactivate addon modules independently of server
|
||||
* modules. If the server module required addon PHP code at load time (e.g. via
|
||||
* require_once on the addon's files), deactivating the addon would fatal-error every
|
||||
* checkout and service page.
|
||||
*
|
||||
* Instead, the server module reads raw rows from tbladdonmodules. If the addon is
|
||||
* missing OR deactivated OR "enabled" is set to No, isEnabled() returns false and
|
||||
* every PtrManager call site short-circuits. The server module never dereferences
|
||||
* addon code directly; it just asks the DB "what are the PowerDNS settings?" and
|
||||
* does nothing with them if they're absent.
|
||||
*
|
||||
* REQUEST-SCOPED CACHE
|
||||
* --------------------
|
||||
* get() caches the resolved config in a static property for the remainder of the
|
||||
* PHP request. Without that, every PtrManager call would re-query tbladdonmodules
|
||||
* and re-decrypt the API key — wasteful on the provisioning path where we touch
|
||||
* PowerDNS 1-5 times per server. reset() is exposed for scenarios where settings
|
||||
* change mid-request (the addon's _output() page after a vfdns_test click).
|
||||
*
|
||||
* API KEY HANDLING
|
||||
* ----------------
|
||||
* WHMCS stores password-type addon config fields encrypted in tbladdonmodules.value.
|
||||
* We call decrypt() — the same helper the server-module uses for the VirtFusion
|
||||
* bearer token — to get plaintext. If decryption fails (e.g. the WHMCS encryption
|
||||
* key changed or the value was inserted manually as plaintext), we fall back to
|
||||
* using the raw value. This is defensive; logs note the failure so an operator
|
||||
* can diagnose.
|
||||
*
|
||||
* The decrypted key exists only in memory inside this process's request lifetime.
|
||||
* It's passed to PowerDns\Client via the get() array and used for the X-API-Key
|
||||
* header; it's never written to disk, logged, or sent anywhere except to the
|
||||
* configured PowerDNS endpoint.
|
||||
*/
|
||||
class Config
|
||||
{
|
||||
/**
|
||||
* Name used for this addon in modules/addons/ AND stored in tbladdonmodules.module.
|
||||
* These two MUST match — WHMCS auto-lowercases the module directory name when
|
||||
* writing to the DB, so "VirtFusionDns" (directory) becomes "virtfusiondns" here.
|
||||
*/
|
||||
public const MODULE_NAME = 'virtfusiondns';
|
||||
|
||||
/** @var array<string,mixed>|null Null = not loaded yet; an array = resolved settings */
|
||||
private static $cached = null;
|
||||
|
||||
/**
|
||||
* Force a reload on next get().
|
||||
*
|
||||
* Primary use case: the addon's _output() page calls this before re-fetching
|
||||
* config so a test-connection click after saving settings sees the saved values.
|
||||
* Most other code should NOT call this — the request-scoped cache is there for
|
||||
* good performance reasons.
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fully-resolved configuration array with decrypted apiKey.
|
||||
*
|
||||
* Keys: enabled(bool), endpoint(string), apiKey(string), serverId(string),
|
||||
* defaultTtl(int), cacheTtl(int).
|
||||
*/
|
||||
public static function get(): array
|
||||
{
|
||||
if (self::$cached !== null) {
|
||||
return self::$cached;
|
||||
}
|
||||
|
||||
$config = [
|
||||
'enabled' => false,
|
||||
'endpoint' => '',
|
||||
'apiKey' => '',
|
||||
'serverId' => 'localhost',
|
||||
'defaultTtl' => 3600,
|
||||
'cacheTtl' => 60,
|
||||
];
|
||||
|
||||
try {
|
||||
// pluck('value', 'setting') returns a Collection keyed by 'setting' with
|
||||
// 'value' as the values — so $rows['enabled'] reads the row where
|
||||
// setting='enabled'. Efficient: one query regardless of how many
|
||||
// settings exist.
|
||||
$rows = DB::table('tbladdonmodules')
|
||||
->where('module', self::MODULE_NAME)
|
||||
->pluck('value', 'setting')
|
||||
->toArray();
|
||||
|
||||
// WHMCS yesno fields store either "on"/"" or "1"/"0" depending on version
|
||||
// and form handling. Accept all common truthy representations rather than
|
||||
// relying on a single literal.
|
||||
$enabledRaw = $rows['enabled'] ?? '';
|
||||
$config['enabled'] = in_array(strtolower((string) $enabledRaw), ['on', 'yes', '1', 'true'], true);
|
||||
|
||||
// Trim trailing slash from endpoint so Client::base() can safely concatenate
|
||||
// "/api/v1/..." without producing doubled slashes.
|
||||
$config['endpoint'] = rtrim((string) ($rows['endpoint'] ?? ''), '/');
|
||||
$config['serverId'] = (string) ($rows['serverId'] ?? 'localhost');
|
||||
|
||||
// Floor at 60s for defaultTtl and 10s for cacheTtl. Prevents a foot-gun
|
||||
// where an operator accidentally saves "0" and causes PowerDNS to treat
|
||||
// PTRs as non-cacheable (which some resolvers refuse) or this module to
|
||||
// hammer PowerDNS on every call.
|
||||
$config['defaultTtl'] = max(60, (int) ($rows['defaultTtl'] ?? 3600));
|
||||
$config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60));
|
||||
|
||||
if (! empty($rows['apiKey'])) {
|
||||
$raw = (string) $rows['apiKey'];
|
||||
$decrypted = '';
|
||||
|
||||
try {
|
||||
// decrypt() is WHMCS's global helper — matches how the VirtFusion
|
||||
// bearer token is handled in Module::getCP().
|
||||
$decrypted = (string) decrypt($raw);
|
||||
} 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.
|
||||
Log::insert('PowerDns:Config', 'decrypt threw', $e->getMessage());
|
||||
}
|
||||
|
||||
// WHMCS addon module password-type fields are stored PLAINTEXT in
|
||||
// tbladdonmodules.value (unlike tblservers.password which IS encrypted).
|
||||
// When fed a plaintext input, WHMCS's decrypt() doesn't return empty
|
||||
// or unchanged — it returns a short binary garbage string. If we used
|
||||
// that as the API key we'd produce a baffling 401 from PowerDNS.
|
||||
//
|
||||
// Heuristic: an API key is printable ASCII by definition. If
|
||||
// decrypt() produced non-printable output, we know it mangled a
|
||||
// plaintext value and we should stick with raw. If decrypt()
|
||||
// produced a different-but-printable string, it's a genuine
|
||||
// decryption of an actually-encrypted value (unusual for addons,
|
||||
// but some third-party setups do encrypt at rest).
|
||||
//
|
||||
// trim() handles another common foot-gun: admin UIs silently
|
||||
// appending a newline on paste, which would land in the
|
||||
// X-API-Key: header verbatim and also produce a 401.
|
||||
$candidate = $raw;
|
||||
if ($decrypted !== '' && $decrypted !== $raw && ctype_print($decrypted)) {
|
||||
$candidate = $decrypted;
|
||||
} elseif ($decrypted !== '' && $decrypted !== $raw) {
|
||||
// Decrypt output wasn't printable — it's garbage from mangling
|
||||
// a plaintext input. Log once so the diagnostic trail is clear
|
||||
// but don't expose key material.
|
||||
Log::insert(
|
||||
'PowerDns:Config',
|
||||
'decrypt produced non-printable output; using raw',
|
||||
['raw_len' => strlen($raw), 'dec_len' => strlen($decrypted)],
|
||||
);
|
||||
}
|
||||
$config['apiKey'] = trim($candidate);
|
||||
}
|
||||
} 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'];
|
||||
}
|
||||
}
|
||||
499
modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
Normal file
499
modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
Normal file
@@ -0,0 +1,499 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
/**
|
||||
* Pure static helpers for IP address manipulation and PTR-name construction.
|
||||
*
|
||||
* DESIGN NOTES
|
||||
* ------------
|
||||
* Everything here is pure — no I/O, no globals, no state. That matters for two reasons:
|
||||
* 1. PtrManager can compose these helpers freely without worrying about test isolation.
|
||||
* 2. They are safe to call inside tight loops (e.g. iterating every zone in PowerDNS
|
||||
* and testing it against a PTR name) without triggering hidden network or DB hits.
|
||||
*
|
||||
* Naming conventions used here:
|
||||
* - "PTR name" = the fully-qualified record name the PTR lives at,
|
||||
* e.g. "5.113.0.203.in-addr.arpa." (trailing dot always).
|
||||
* - "zone name" = the zone the record belongs to,
|
||||
* e.g. "113.0.203.in-addr.arpa." (trailing dot always).
|
||||
* - "nibble" = a single hex digit representing 4 bits, used in IPv6 reverse names.
|
||||
* - "classless" = an RFC 2317 sub-zone like "64/64.113.0.203.in-addr.arpa." —
|
||||
* a delegation of a sub-range of a /24, covered in parseClasslessZone().
|
||||
*
|
||||
* All zone/PTR strings are normalised with a trailing dot because PowerDNS's canonical
|
||||
* form always carries one, and mixing dotted/undotted forms makes string comparison
|
||||
* unreliable (".example.com." ≠ ".example.com").
|
||||
*/
|
||||
class IpUtil
|
||||
{
|
||||
/** Strict IPv4 validation (rejects "1", "::1", and other ambiguous forms). */
|
||||
public static function isIpv4(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
||||
}
|
||||
|
||||
/** Strict IPv6 validation (rejects IPv4-mapped, etc. — only pure v6 addresses). */
|
||||
public static function isIpv6(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-expand an IPv6 address to 32 lowercase hex characters (no colons).
|
||||
* e.g. 2001:db8::1 -> "20010db8000000000000000000000001"
|
||||
*
|
||||
* Why: PTR names under ip6.arpa use *all* 32 nibbles (no compression, no :: shorthand),
|
||||
* so we need the fully-expanded form before we can reverse the nibbles.
|
||||
*
|
||||
* Implementation: inet_pton normalises any valid IPv6 notation to 16 raw bytes,
|
||||
* and bin2hex turns that into 32 lowercase hex chars. No manual padding/splitting
|
||||
* logic means we can't get ":" vs "::" compression wrong.
|
||||
*
|
||||
* @return string|null 32-char hex string, or null if input isn't valid IPv6
|
||||
*/
|
||||
public static function expandIpv6(string $ip): ?string
|
||||
{
|
||||
$bin = @inet_pton($ip);
|
||||
// inet_pton returns 16 bytes for v6, 4 bytes for v4. Guard on both conditions
|
||||
// so a valid IPv4 like "192.0.2.1" doesn't silently pass through this v6 helper.
|
||||
if ($bin === false || strlen($bin) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bin2hex($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fully-qualified PTR name (trailing dot) for an IPv4 or IPv6 address.
|
||||
*
|
||||
* IPv4 example: 203.0.113.5 -> "5.113.0.203.in-addr.arpa."
|
||||
* IPv6 example: 2001:db8::1 -> "1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa."
|
||||
*
|
||||
* @return string|null PTR name with trailing dot, or null if input isn't a valid IP
|
||||
*/
|
||||
public static function ptrNameForIp(string $ip): ?string
|
||||
{
|
||||
// IPv4: reverse the four octets and suffix with in-addr.arpa.
|
||||
// 203.0.113.5 -> 5.113.0.203.in-addr.arpa.
|
||||
if (self::isIpv4($ip)) {
|
||||
$octets = array_reverse(explode('.', $ip));
|
||||
|
||||
return implode('.', $octets) . '.in-addr.arpa.';
|
||||
}
|
||||
|
||||
// IPv6: expand to 32 nibbles, reverse each nibble, suffix with ip6.arpa.
|
||||
// 2001:db8::1 -> 1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa.
|
||||
// The nibble-level reversal (not byte-level) is important: each hex digit
|
||||
// becomes its own DNS label. inet_pton/bin2hex give us the 32-char form;
|
||||
// str_split with no length arg defaults to 1 so each char becomes one label.
|
||||
if (self::isIpv6($ip)) {
|
||||
$hex = self::expandIpv6($ip);
|
||||
if ($hex === null) {
|
||||
return null;
|
||||
}
|
||||
$nibbles = array_reverse(str_split($hex));
|
||||
|
||||
return implode('.', $nibbles) . '.ip6.arpa.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every IP address and IPv6 subnet from a VirtFusion server object.
|
||||
*
|
||||
* Walks every interface, not just interfaces[0] (ServerResource only reads the primary).
|
||||
* Returns three buckets:
|
||||
*
|
||||
* addresses — discrete host IPs (v4 always, v6 when the API exposes per-host records
|
||||
* or a /128 subnet entry). Each entry is a plain IP string.
|
||||
*
|
||||
* subnets — IPv6 subnet allocations (e.g. 2001:db8:0:5d::/64) where the module
|
||||
* cannot auto-discover individual host addresses. These are surfaced
|
||||
* so the client UI can show "here's your /64" and offer an "Add host PTR"
|
||||
* path where the customer types a specific address inside the subnet.
|
||||
* Each entry: ['subnet' => '2001:db8:0:5d::', 'cidr' => 64].
|
||||
*
|
||||
* skipped — malformed / unusable entries (non-IP, missing cidr, etc.) kept for
|
||||
* logging so we can diagnose schema drift in the VirtFusion API.
|
||||
*
|
||||
* @param object|array $serverObject Raw VirtFusion server payload (may be wrapped in `data`)
|
||||
* @return array{addresses: string[], subnets: array<int, array{subnet: string, cidr: int}>, skipped: array}
|
||||
*/
|
||||
public static function extractIps($serverObject): array
|
||||
{
|
||||
$addresses = [];
|
||||
$subnets = [];
|
||||
$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' => [], 'subnets' => [], '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' => [], 'subnets' => [], '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;
|
||||
}
|
||||
|
||||
// Subnet-with-cidr shape. VirtFusion's common v6 allocation model is
|
||||
// to route a whole /64 to the VPS and let the OS auto-assign specific
|
||||
// host addresses. The module can't know which host the customer
|
||||
// actually uses, so we surface the subnet as a first-class entry and
|
||||
// let the client UI offer an "Add host PTR" path with containment
|
||||
// ownership verification.
|
||||
$subnet = $v6['subnet'] ?? null;
|
||||
$cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null;
|
||||
if ($subnet && self::isIpv6($subnet) && $cidr !== null) {
|
||||
if ($cidr === 128) {
|
||||
// Single-host "subnet" — treat as a discrete address.
|
||||
$addresses[$subnet] = true;
|
||||
} elseif ($cidr > 0 && $cidr < 128) {
|
||||
// Genuine subnet allocation. Dedupe by (subnet, cidr) pair.
|
||||
$key = $subnet . '/' . $cidr;
|
||||
if (! isset($subnets[$key])) {
|
||||
$subnets[$key] = ['subnet' => $subnet, 'cidr' => $cidr];
|
||||
}
|
||||
} else {
|
||||
$skipped[] = ['subnet' => $subnet, 'cidr' => $cidr, 'reason' => 'invalid-cidr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'addresses' => array_keys($addresses),
|
||||
'subnets' => array_values($subnets),
|
||||
'skipped' => $skipped,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if $ip falls inside the subnet $prefix/$cidrBits.
|
||||
*
|
||||
* Used for subnet-containment ownership checks when the customer wants to set
|
||||
* a PTR for a specific host address inside an IPv6 subnet allocated to their
|
||||
* VPS — we can't enumerate their assigned hosts, but we CAN prove the address
|
||||
* they're claiming lies within one of their subnets.
|
||||
*
|
||||
* Works on the binary (inet_pton) representation so v6 notation differences
|
||||
* (compression, case) don't affect the comparison.
|
||||
*
|
||||
* ALGORITHM
|
||||
* ---------
|
||||
* 1. Convert both IPs to 16 raw bytes via inet_pton (or 4 for v4).
|
||||
* 2. Compare the first floor(cidr/8) bytes byte-wise (full-byte prefix).
|
||||
* 3. If cidr isn't a multiple of 8, mask the next byte and compare bits.
|
||||
*
|
||||
* Example: 2001:db8::5 vs 2001:db8::/32
|
||||
* fullBytes = 32/8 = 4; first 4 bytes of both are 20:01:0d:b8 → match
|
||||
* remBits = 0 → no partial byte to compare
|
||||
* → true
|
||||
*/
|
||||
public static function ipv6InSubnet(string $ip, string $subnetPrefix, int $cidrBits): bool
|
||||
{
|
||||
if (! self::isIpv6($ip) || ! self::isIpv6($subnetPrefix)) {
|
||||
return false;
|
||||
}
|
||||
if ($cidrBits < 0 || $cidrBits > 128) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = @inet_pton($ip);
|
||||
$subBin = @inet_pton($subnetPrefix);
|
||||
if ($ipBin === false || $subBin === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fullBytes = intdiv($cidrBits, 8);
|
||||
$remBits = $cidrBits % 8;
|
||||
|
||||
// Compare whole-byte prefix with a single substr compare.
|
||||
if ($fullBytes > 0 && substr($ipBin, 0, $fullBytes) !== substr($subBin, 0, $fullBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare the partial byte at the cidr boundary, if any.
|
||||
if ($remBits > 0) {
|
||||
$mask = (0xFF << (8 - $remBits)) & 0xFF;
|
||||
if ((ord($ipBin[$fullBytes]) & $mask) !== (ord($subBin[$fullBytes]) & $mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
732
modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
Normal file
732
modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
Normal file
@@ -0,0 +1,732 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Database\Capsule as DB;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Orchestrates PTR lifecycle against PowerDNS for VirtFusion servers.
|
||||
*
|
||||
* RESPONSIBILITIES
|
||||
* ----------------
|
||||
* - Compute zone membership for a given IP by matching against PowerDNS's zone list
|
||||
* - Verify forward DNS (A/AAAA) before writing any PTR; never write a PTR whose
|
||||
* hostname doesn't already resolve to the target IP
|
||||
* - Preserve client-customised PTRs during server renames (only overwrite PTRs
|
||||
* whose current content equals the previous hostname)
|
||||
* - Provide read-through views for client-area and admin panels with status flags
|
||||
* - Support an explicit admin reconcile (optionally forceful) and an additive-only
|
||||
* cron reconciliation that never overwrites existing values
|
||||
*
|
||||
* CACHING MODEL
|
||||
* -------------
|
||||
* Two tiers, both serving different purposes:
|
||||
*
|
||||
* $zoneListCache — the list of every zone PowerDNS knows about. Populated once
|
||||
* per PtrManager instance via locate(). The underlying Client
|
||||
* caches the HTTP response for Config::cacheTtl() seconds across
|
||||
* requests; this instance field just memoises the lookup within
|
||||
* one request so multiple IPs on the same server don't each
|
||||
* call Client::listZones().
|
||||
*
|
||||
* $zoneCache — decoded RRset contents of individual zones, keyed by zone
|
||||
* name. Populated lazily as findPtrRRset() looks up each IP's
|
||||
* zone. IMPORTANT: request-scoped only — we must invalidate on
|
||||
* writes (see invalidateZone) so a read-after-write within the
|
||||
* same request sees fresh data. This is why deletePtr/writePtr
|
||||
* call invalidateZone before returning.
|
||||
*
|
||||
* Neither cache is shared between PtrManager instances (new PtrManager per WHMCS
|
||||
* request is cheap). The Client's HTTP-response cache IS shared across requests via
|
||||
* the module's Cache class (Redis or filesystem), which is where cross-request
|
||||
* amortisation happens.
|
||||
*
|
||||
* SHORT-CIRCUIT BEHAVIOUR
|
||||
* -----------------------
|
||||
* Every public method checks Config::isEnabled() and returns an empty/no-op summary
|
||||
* when the addon is inactive. This means unrelated calling code (createAccount,
|
||||
* terminateAccount, renameServer, the client panel endpoint, cron) can always
|
||||
* invoke PtrManager without a feature flag — the gate lives here.
|
||||
*
|
||||
* The summary arrays deliberately include 'enabled' => bool so test harnesses and
|
||||
* admin UIs can tell "we did nothing because disabled" apart from "we did nothing
|
||||
* because there were no IPs".
|
||||
*/
|
||||
class PtrManager
|
||||
{
|
||||
/** @var Client */
|
||||
private $client;
|
||||
|
||||
/** @var array<string, array<string,mixed>|null> Request-scoped zone contents cache, keyed by zone name */
|
||||
private $zoneCache = [];
|
||||
|
||||
/** @var string[]|null Request-scoped zone-list memo (Client handles cross-request caching) */
|
||||
private $zoneListCache = null;
|
||||
|
||||
public function __construct(?Client $client = null)
|
||||
{
|
||||
// Dependency-inject the Client so tests can pass a mock; default to the
|
||||
// Config-driven instance so production code never has to wire this up.
|
||||
$this->client = $client ?? new Client;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync PTRs for every IP on the given server object.
|
||||
*
|
||||
* TWO MODES OF OPERATION
|
||||
* ----------------------
|
||||
* CREATE ($oldHostname = null) — provisioning path.
|
||||
* Write $newHostname to every IP that doesn't
|
||||
* already have a PTR. Pre-existing PTRs are
|
||||
* preserved (shouldn't exist on a new server,
|
||||
* but if they do they're likely left over from
|
||||
* a previous owner of the IP and must not be
|
||||
* silently overwritten).
|
||||
*
|
||||
* RENAME ($oldHostname given) — rename path.
|
||||
* Only overwrite PTRs whose current content
|
||||
* equals $oldHostname. Anything else was set
|
||||
* by the client (custom rDNS like mail servers
|
||||
* need to match HELO) and must be preserved.
|
||||
*
|
||||
* The forward-DNS check runs before every write. A PTR without a matching
|
||||
* A/AAAA is FCrDNS-broken and actively harms deliverability, so we'd rather
|
||||
* leave the PTR absent than set a broken one.
|
||||
*
|
||||
* ERROR SEMANTICS
|
||||
* ---------------
|
||||
* This method never throws. Every per-IP failure is caught, logged, and
|
||||
* recorded in $summary['errors']. Lifecycle callers (createAccount,
|
||||
* renameServer) wrap the call in their own try/catch as belt-and-braces,
|
||||
* but the expectation is that DNS issues never bubble up to WHMCS as
|
||||
* provisioning failures.
|
||||
*
|
||||
* @param object|array $serverObject VirtFusion server payload
|
||||
* @return array Summary counts: written, preserved, forward_missing, no_zone, skipped_ipv6, errors, details[]
|
||||
*/
|
||||
public function syncServer($serverObject, ?string $oldHostname, string $newHostname): array
|
||||
{
|
||||
$summary = [
|
||||
'enabled' => false,
|
||||
'written' => 0,
|
||||
'preserved' => 0,
|
||||
'forward_missing' => 0,
|
||||
'no_zone' => 0,
|
||||
'skipped_ipv6' => 0,
|
||||
'errors' => 0,
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
// Report (not write) v6 subnet-only allocations. UI can surface "IPv6 PTR
|
||||
// not configured — /64 without explicit host" as guidance.
|
||||
$summary['skipped_ipv6'] = count($extracted['skipped']);
|
||||
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
// IP isn't covered by any zone we host. Not an error — the
|
||||
// operator may manage reverse DNS for this range elsewhere.
|
||||
$summary['no_zone']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'no-zone'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $this->readPtr($loc);
|
||||
|
||||
// Rename-mode preservation check. The "current PTR equals old
|
||||
// hostname" comparison is the whole safety mechanism for protecting
|
||||
// client-custom rDNS across server renames — see class docblock.
|
||||
// On CREATE mode ($oldHostname === null) we skip this branch,
|
||||
// which means pre-existing PTRs on a new IP get overwritten; this
|
||||
// is acceptable because a fresh IP shouldn't have PTRs yet.
|
||||
if ($oldHostname !== null && $current !== null) {
|
||||
if (self::normalizeHost($current) !== self::normalizeHost($oldHostname)) {
|
||||
$summary['preserved']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'preserved', 'current' => $current];
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! Resolver::resolvesTo($newHostname, $ip, Config::cacheTtl())) {
|
||||
$summary['forward_missing']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'forward-missing', 'desired' => $newHostname];
|
||||
Log::insert('PowerDns:syncServer', ['ip' => $ip, 'hostname' => $newHostname], 'forward DNS mismatch; PTR skipped');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->writePtr($loc, $newHostname);
|
||||
if ($result['ok']) {
|
||||
$summary['written']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'written', 'content' => $newHostname];
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
$summary['details'][] = ['ip' => $ip, 'status' => 'error', 'http' => $result['http']];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:syncServer', ['ip' => $ip], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every PTR belonging to the given server.
|
||||
*
|
||||
* @return array Summary counts: deleted, no_zone, errors
|
||||
*/
|
||||
public function deleteForServer($serverObject): array
|
||||
{
|
||||
$summary = ['enabled' => false, 'deleted' => 0, 'no_zone' => 0, 'errors' => 0];
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
$summary['no_zone']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$result = $this->deletePtr($loc);
|
||||
if ($result['ok']) {
|
||||
$summary['deleted']++;
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:deleteForServer', ['ip' => $ip], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a per-IP status list suitable for client-area and admin display.
|
||||
*
|
||||
* Each entry: [ip, ptr, ttl, zone, status]
|
||||
* Status values: ok, unverified, missing, no-zone, error, disabled.
|
||||
*
|
||||
* @return array<int, array<string,mixed>>
|
||||
*/
|
||||
public function listPtrs($serverObject, ?string $expectedHostname = null): array
|
||||
{
|
||||
$out = [];
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
|
||||
if (! Config::isEnabled()) {
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'disabled'];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
// Subnet-only rows come first so the client UI can render "you have a /64,
|
||||
// here's how to add a host PTR inside it" above the discrete-IP list.
|
||||
// These carry no PTR content themselves — they're informational anchors
|
||||
// plus the "Add custom host" entry point.
|
||||
foreach ($extracted['subnets'] as $s) {
|
||||
$out[] = [
|
||||
'ip' => null,
|
||||
'subnet' => $s['subnet'],
|
||||
'cidr' => $s['cidr'],
|
||||
'ptr' => null,
|
||||
'ttl' => null,
|
||||
'zone' => null,
|
||||
'status' => 'subnet-only',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'no-zone'];
|
||||
|
||||
continue;
|
||||
}
|
||||
$rrset = $this->findPtrRRset($loc);
|
||||
if ($rrset === null) {
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => $loc['zone'], 'status' => 'missing'];
|
||||
|
||||
continue;
|
||||
}
|
||||
$ptr = $rrset['content'];
|
||||
$status = Resolver::resolvesTo($ptr, $ip, Config::cacheTtl()) ? 'ok' : 'unverified';
|
||||
$out[] = [
|
||||
'ip' => $ip,
|
||||
'ptr' => $ptr,
|
||||
'ttl' => $rrset['ttl'],
|
||||
'zone' => $loc['zone'],
|
||||
'status' => $status,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:listPtrs', ['ip' => $ip], $e->getMessage());
|
||||
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'error'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-initiated PTR set/delete.
|
||||
*
|
||||
* Differences from syncServer():
|
||||
* - Only ever writes one PTR, not a whole server's worth
|
||||
* - Rate-limited per IP (10s window) to stop save-button abuse
|
||||
* - Forward-DNS failure is a HARD REJECT that surfaces to the user — not a
|
||||
* silent skip like the automatic paths. The client wants immediate feedback
|
||||
* when their A record is missing.
|
||||
* - Empty content path is an explicit delete (DELETE changetype, not REPLACE-empty)
|
||||
*
|
||||
* IP-OWNERSHIP NOTE
|
||||
* -----------------
|
||||
* This method TRUSTS that the caller has already verified the client owns $ip —
|
||||
* that check lives in the calling endpoint (client.php rdnsUpdate) where it has
|
||||
* access to the WHMCS session. If you call setPtr() from a new code path, you
|
||||
* MUST add the ownership guard upstream of it.
|
||||
*
|
||||
* @return array{ok: bool, reason: string, http?: int}
|
||||
* reason values: disabled, invalid-ip, rate-limited, no-zone,
|
||||
* forward-missing, deleted, delete-failed, written, write-failed
|
||||
*/
|
||||
public function setPtr(string $ip, string $content): array
|
||||
{
|
||||
if (! Config::isEnabled()) {
|
||||
return ['ok' => false, 'reason' => 'disabled'];
|
||||
}
|
||||
if (! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
|
||||
return ['ok' => false, 'reason' => 'invalid-ip'];
|
||||
}
|
||||
|
||||
// Rate limit: one successful check per IP per 10s. Uses the module's
|
||||
// two-tier Cache (Redis or filesystem), so the limit spans PHP processes.
|
||||
// md5 of IP as the key keeps filesystem filenames short and safe.
|
||||
$rateKey = 'pdns:write-lock:' . md5($ip);
|
||||
if (Cache::get($rateKey) !== null) {
|
||||
return ['ok' => false, 'reason' => 'rate-limited'];
|
||||
}
|
||||
// Set the lock BEFORE any downstream work so a parallel request racing
|
||||
// through the same IP sees the lock and gets rate-limited cleanly.
|
||||
Cache::set($rateKey, 1, 10);
|
||||
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
return ['ok' => false, 'reason' => 'no-zone'];
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
$result = $this->deletePtr($loc);
|
||||
|
||||
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'deleted' : 'delete-failed', 'http' => $result['http']];
|
||||
}
|
||||
|
||||
if (! Resolver::resolvesTo($content, $ip, Config::cacheTtl())) {
|
||||
return ['ok' => false, 'reason' => 'forward-missing'];
|
||||
}
|
||||
|
||||
$result = $this->writePtr($loc, $content);
|
||||
|
||||
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'written' : 'write-failed', 'http' => $result['http']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reconciliation for a single service.
|
||||
*
|
||||
* The user-facing purpose: "make the PTRs match what they should be, but don't
|
||||
* step on client customisations unless I explicitly ask".
|
||||
*
|
||||
* Uses the STORED server_object (from mod_virtfusion_direct) rather than fetching
|
||||
* fresh from VirtFusion. Reasons:
|
||||
* 1. Admin reconcile runs from the services tab — no live-data dependency
|
||||
* 2. Cron calls this once per service; fetching fresh would mean N VirtFusion
|
||||
* calls per reconcile run
|
||||
* 3. The stored object is the ground truth for "what IPs/hostname did this
|
||||
* service have at last sync" — if VirtFusion temporarily returns a different
|
||||
* shape, we'd rather work from known-good data than retry.
|
||||
*
|
||||
* If the stored state is materially out of date (e.g. IPs were added in VirtFusion
|
||||
* after last sync), an admin should hit "Update Server Object" first.
|
||||
*
|
||||
* FORCE MODE
|
||||
* ----------
|
||||
* $force = true is the only code path in the entire module that overwrites a
|
||||
* non-matching PTR. It's reachable exclusively via the admin "Reconcile (force
|
||||
* reset)" button — never from cron, never from client writes, never from
|
||||
* automatic lifecycle. This asymmetry is deliberate: forceful overrides are
|
||||
* the admin's explicit choice, not a silent automation.
|
||||
*
|
||||
* @return array Summary counts: added, reset, preserved, forward_missing, no_zone, errors
|
||||
*/
|
||||
public function reconcile(int $serviceId, bool $force = false): array
|
||||
{
|
||||
$summary = [
|
||||
'enabled' => false,
|
||||
'added' => 0,
|
||||
'reset' => 0,
|
||||
'preserved' => 0,
|
||||
'forward_missing' => 0,
|
||||
'no_zone' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
$row = Database::getSystemService($serviceId);
|
||||
if (! $row || empty($row->server_object)) {
|
||||
$summary['errors']++;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
$serverObject = json_decode($row->server_object, true);
|
||||
if (! is_array($serverObject)) {
|
||||
$summary['errors']++;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$hostname = self::extractHostname($serverObject);
|
||||
if ($hostname === null) {
|
||||
$summary['errors']++;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$extracted = IpUtil::extractIps($serverObject);
|
||||
foreach ($extracted['addresses'] as $ip) {
|
||||
try {
|
||||
$loc = $this->locate($ip);
|
||||
if ($loc === null) {
|
||||
$summary['no_zone']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $this->readPtr($loc);
|
||||
$verified = Resolver::resolvesTo($hostname, $ip, Config::cacheTtl());
|
||||
|
||||
if ($current === null) {
|
||||
if (! $verified) {
|
||||
$summary['forward_missing']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$result = $this->writePtr($loc, $hostname);
|
||||
if ($result['ok']) {
|
||||
$summary['added']++;
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($force && self::normalizeHost($current) !== self::normalizeHost($hostname)) {
|
||||
if (! $verified) {
|
||||
$summary['forward_missing']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$result = $this->writePtr($loc, $hostname);
|
||||
if ($result['ok']) {
|
||||
$summary['reset']++;
|
||||
} else {
|
||||
$summary['errors']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['preserved']++;
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:reconcile', ['ip' => $ip, 'service' => $serviceId], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron reconciliation across every managed service.
|
||||
*
|
||||
* Called from the DailyCronJob hook. Iterates every row in mod_virtfusion_direct
|
||||
* and runs reconcile() on each with $force = false. That means:
|
||||
*
|
||||
* - IPs missing a PTR get one (if forward DNS resolves)
|
||||
* - Existing PTRs are NEVER touched, even if they differ from the hostname
|
||||
*
|
||||
* This asymmetry is the safety property. A brief forward-DNS blip during the
|
||||
* cron window shouldn't trigger mass-rewrites that corrupt client-custom
|
||||
* records. Admins who need forceful re-alignment must run the per-service
|
||||
* "Reconcile (force reset)" button explicitly.
|
||||
*
|
||||
* Failures on individual services are logged and counted but never abort the
|
||||
* job — a misconfigured single zone or one VirtFusion-unreachable service
|
||||
* should not block reconciliation for the rest of the fleet.
|
||||
*
|
||||
* @return array Aggregate summary across all services
|
||||
*/
|
||||
public function reconcileAll(): array
|
||||
{
|
||||
$summary = [
|
||||
'enabled' => false,
|
||||
'services' => 0,
|
||||
'added' => 0,
|
||||
'preserved' => 0,
|
||||
'forward_missing' => 0,
|
||||
'no_zone' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
if (! Config::isEnabled()) {
|
||||
return $summary;
|
||||
}
|
||||
$summary['enabled'] = true;
|
||||
|
||||
try {
|
||||
$rows = DB::table(Database::SYSTEM_TABLE)->pluck('service_id');
|
||||
} catch (\Throwable $e) {
|
||||
Log::insert('PowerDns:reconcileAll', [], $e->getMessage());
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
foreach ($rows as $serviceId) {
|
||||
$summary['services']++;
|
||||
|
||||
try {
|
||||
$r = $this->reconcile((int) $serviceId, false);
|
||||
$summary['added'] += $r['added'];
|
||||
$summary['preserved'] += $r['preserved'];
|
||||
$summary['forward_missing'] += $r['forward_missing'];
|
||||
$summary['no_zone'] += $r['no_zone'];
|
||||
$summary['errors'] += $r['errors'];
|
||||
} catch (\Throwable $e) {
|
||||
$summary['errors']++;
|
||||
Log::insert('PowerDns:reconcileAll:service', ['service' => $serviceId], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Log::insert('PowerDns:reconcileAll', [], $summary);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve an IP to the (zone, ptrName) pair using the cached zone list.
|
||||
* Handles both standard and RFC 2317 classless zones (delegates to IpUtil).
|
||||
*
|
||||
* Memoised within this instance: the zone list is fetched once (via the Client,
|
||||
* which itself caches across requests per Config::cacheTtl()) and reused for
|
||||
* every IP of the current server. A server with 3 IPs in the same /24 therefore
|
||||
* triggers ONE listZones call, not three.
|
||||
*
|
||||
* @return array{zone: string, ptrName: string}|null null means "no zone covers this IP"
|
||||
*/
|
||||
private function locate(string $ip): ?array
|
||||
{
|
||||
if ($this->zoneListCache === null) {
|
||||
$this->zoneListCache = $this->client->listZones();
|
||||
}
|
||||
|
||||
return IpUtil::findZoneAndPtrName($ip, $this->zoneListCache);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function getZoneCached(string $zoneName): ?array
|
||||
{
|
||||
if (array_key_exists($zoneName, $this->zoneCache)) {
|
||||
return $this->zoneCache[$zoneName];
|
||||
}
|
||||
$this->zoneCache[$zoneName] = $this->client->getZone($zoneName);
|
||||
|
||||
return $this->zoneCache[$zoneName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Current PTR content for a located address, or null if absent.
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
*/
|
||||
private function readPtr(array $loc): ?string
|
||||
{
|
||||
$rrset = $this->findPtrRRset($loc);
|
||||
|
||||
return $rrset === null ? null : $rrset['content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a PTR RRset at the located name.
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
* @return array{content: string, ttl: int}|null
|
||||
*/
|
||||
private function findPtrRRset(array $loc): ?array
|
||||
{
|
||||
$zone = $this->getZoneCached($loc['zone']);
|
||||
if ($zone === null || empty($zone['rrsets']) || ! is_array($zone['rrsets'])) {
|
||||
return null;
|
||||
}
|
||||
foreach ($zone['rrsets'] as $rrset) {
|
||||
if (($rrset['type'] ?? '') !== 'PTR') {
|
||||
continue;
|
||||
}
|
||||
if (self::normalizeHost($rrset['name'] ?? '') !== self::normalizeHost($loc['ptrName'])) {
|
||||
continue;
|
||||
}
|
||||
$records = $rrset['records'] ?? [];
|
||||
foreach ($records as $record) {
|
||||
if (! empty($record['disabled'])) {
|
||||
continue;
|
||||
}
|
||||
if (! empty($record['content'])) {
|
||||
return [
|
||||
'content' => rtrim((string) $record['content'], '.'),
|
||||
'ttl' => (int) ($rrset['ttl'] ?? Config::defaultTtl()),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write/replace a PTR record.
|
||||
*
|
||||
* Always uses REPLACE changetype rather than a create-then-update pattern —
|
||||
* REPLACE is idempotent and atomic from PowerDNS's view, whereas separate
|
||||
* create + update would briefly leave the record absent.
|
||||
*
|
||||
* Content is canonicalised to end with a trailing dot before sending (PowerDNS
|
||||
* treats unqualified names as relative to the zone, which is not what we want
|
||||
* for PTR content — "host.example.com" without a trailing dot would be stored
|
||||
* as "host.example.com.113.0.203.in-addr.arpa.").
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
* @return array{ok: bool, http: int}
|
||||
*/
|
||||
private function writePtr(array $loc, string $content): array
|
||||
{
|
||||
$content = rtrim(trim($content), '.') . '.';
|
||||
$ttl = Config::defaultTtl();
|
||||
|
||||
$result = $this->client->patchRRset($loc['zone'], [
|
||||
'name' => $loc['ptrName'],
|
||||
'type' => 'PTR',
|
||||
'ttl' => $ttl,
|
||||
'changetype' => 'REPLACE',
|
||||
'records' => [['content' => $content, 'disabled' => false]],
|
||||
]);
|
||||
|
||||
$this->invalidateZone($loc['zone']);
|
||||
|
||||
return ['ok' => $result['ok'], 'http' => $result['http']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a PTR record.
|
||||
*
|
||||
* @param array{zone: string, ptrName: string} $loc
|
||||
* @return array{ok: bool, http: int}
|
||||
*/
|
||||
private function deletePtr(array $loc): array
|
||||
{
|
||||
$result = $this->client->patchRRset($loc['zone'], [
|
||||
'name' => $loc['ptrName'],
|
||||
'type' => 'PTR',
|
||||
'changetype' => 'DELETE',
|
||||
]);
|
||||
|
||||
$this->invalidateZone($loc['zone']);
|
||||
|
||||
return ['ok' => $result['ok'], 'http' => $result['http']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached zone contents so the next read re-fetches from PowerDNS.
|
||||
* Called after every successful write so read-after-write in the same request
|
||||
* (e.g. listPtrs right after setPtr in a test harness) observes fresh data.
|
||||
*/
|
||||
private function invalidateZone(string $zoneName): void
|
||||
{
|
||||
unset($this->zoneCache[$zoneName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a hostname for comparison: lowercase, no trailing dot.
|
||||
*
|
||||
* DNS hostnames are case-insensitive and the trailing dot is syntactic, not
|
||||
* semantic. PowerDNS returns content with a trailing dot ("host.example.com.");
|
||||
* user input typically doesn't have one. Both forms of "FooBar.example.com."
|
||||
* vs "foobar.example.com" should compare equal, which is what this produces.
|
||||
*/
|
||||
private static function normalizeHost(string $h): string
|
||||
{
|
||||
return strtolower(rtrim(trim($h), '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the server hostname from a VirtFusion server payload.
|
||||
*
|
||||
* Accepts either object or array shape, wrapped or unwrapped by a `data` property.
|
||||
* Falls back to `name` when `hostname` is absent or "-", matching the semantics
|
||||
* of the existing ServerResource::process() behavior.
|
||||
*
|
||||
* Public so lifecycle call sites (createAccount, renameServer) can pull the
|
||||
* hostname from a response or stored JSON blob without duplicating the logic.
|
||||
*
|
||||
* @param object|array $serverObject
|
||||
*/
|
||||
public static function extractHostname($serverObject): ?string
|
||||
{
|
||||
if (is_object($serverObject)) {
|
||||
$serverObject = json_decode(json_encode($serverObject), true);
|
||||
}
|
||||
if (! is_array($serverObject)) {
|
||||
return null;
|
||||
}
|
||||
$data = $serverObject['data'] ?? $serverObject;
|
||||
if (! empty($data['hostname']) && $data['hostname'] !== '-') {
|
||||
return (string) $data['hostname'];
|
||||
}
|
||||
if (! empty($data['name']) && $data['name'] !== '-') {
|
||||
return (string) $data['name'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
140
modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
Normal file
140
modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
|
||||
/**
|
||||
* Public-DNS verification helper used for forward-confirmed reverse DNS (FCrDNS) checks.
|
||||
*
|
||||
* WHAT FCrDNS IS AND WHY IT MATTERS HERE
|
||||
* --------------------------------------
|
||||
* A PTR record by itself is easy to lie about — anyone who controls a reverse zone
|
||||
* can say "this IP is mail.example.com". Receivers defend against that by looking
|
||||
* UP the hostname the PTR claims and checking that its A/AAAA records point back
|
||||
* at the IP. That "two-way agreement" is FCrDNS.
|
||||
*
|
||||
* For mail deliverability in particular, a PTR without matching forward DNS is
|
||||
* worse than no PTR at all — some filters treat it as evidence of a compromised
|
||||
* host. The module enforces FCrDNS before every PTR write: if the user asks us
|
||||
* to set "mail.example.com" as the PTR for 1.2.3.4 but mail.example.com resolves
|
||||
* to something other than 1.2.3.4, we refuse.
|
||||
*
|
||||
* USES PUBLIC DNS, NOT POWERDNS
|
||||
* -----------------------------
|
||||
* This calls dns_get_record(), which hits the system's configured recursive
|
||||
* resolver. That's deliberate: the hostname in a PTR may live in a zone hosted
|
||||
* anywhere (client's own domain, another DNS provider, etc.) — not necessarily
|
||||
* in the PowerDNS instance we're managing. Using the recursive public view means
|
||||
* our verification matches what mail servers and other FCrDNS checkers actually
|
||||
* see downstream.
|
||||
*
|
||||
* CNAME FOLLOWING
|
||||
* ---------------
|
||||
* If the hostname is itself a CNAME, dns_get_record returns the CNAME record
|
||||
* (with DNS_CNAME flag) rather than auto-resolving to the ultimate A/AAAA. We
|
||||
* follow up to MAX_CNAME_DEPTH hops before giving up. The depth cap prevents
|
||||
* accidental infinite loops from misconfigured zones and bounds work per check.
|
||||
*
|
||||
* CACHING
|
||||
* -------
|
||||
* Keyed by md5(hostname|ip). A bad-A-record result lives in the cache just like
|
||||
* a good one, which means a client who fixes their forward DNS must wait up to
|
||||
* cacheTtl seconds before a retry succeeds. Documented in the admin settings
|
||||
* tooltip as the tradeoff for not hammering authoritative resolvers when a
|
||||
* user mashes the Save button while debugging.
|
||||
*/
|
||||
class Resolver
|
||||
{
|
||||
private const CACHE_PREFIX = 'pdns:resolve:';
|
||||
|
||||
/**
|
||||
* Maximum hops through a CNAME chain before we give up.
|
||||
* Real-world chains are usually 0-2 hops; 5 is generous headroom without
|
||||
* letting a loop run unbounded.
|
||||
*/
|
||||
private const MAX_CNAME_DEPTH = 5;
|
||||
|
||||
/**
|
||||
* Does the public DNS A/AAAA of $hostname resolve to $ip?
|
||||
* Follows up to 5 CNAME hops. Cached for $ttl seconds on the initial call.
|
||||
*/
|
||||
public static function resolvesTo(string $hostname, string $ip, int $ttl = 60): bool
|
||||
{
|
||||
$hostname = rtrim(trim($hostname), '.');
|
||||
if ($hostname === '' || ! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_PREFIX . md5($hostname . '|' . $ip);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return (bool) $cached;
|
||||
}
|
||||
|
||||
$match = self::resolveInternal($hostname, $ip, 0);
|
||||
Cache::set($cacheKey, $match ? 1 : 0, $ttl);
|
||||
|
||||
return $match;
|
||||
}
|
||||
|
||||
private static function resolveInternal(string $hostname, string $ip, int $depth): bool
|
||||
{
|
||||
if ($depth > self::MAX_CNAME_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Request both the matching forward type AND CNAME in one query so we see
|
||||
// the whole picture at each hop. If the hostname is a direct A/AAAA, we
|
||||
// see that and match immediately; if it's a CNAME, we see the target and
|
||||
// recurse.
|
||||
$type = IpUtil::isIpv6($ip) ? DNS_AAAA | DNS_CNAME : DNS_A | DNS_CNAME;
|
||||
$records = [];
|
||||
|
||||
try {
|
||||
// @-suppress: dns_get_record emits a PHP warning on NXDOMAIN, which we'd
|
||||
// rather just treat as "no match". The return value (empty array or false)
|
||||
// tells us the same thing without polluting the error log.
|
||||
$records = @dns_get_record($hostname, $type);
|
||||
} catch (\Throwable $e) {
|
||||
// Some PHP configurations throw on resolver failure instead of returning false.
|
||||
// We treat those as "no match" and log once per (hostname, ip) since callers
|
||||
// cache the result — we won't spam the log even for a permanently-broken name.
|
||||
Log::insert('PowerDns:Resolver', ['hostname' => $hostname, 'ip' => $ip], $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
if (! is_array($records)) {
|
||||
// dns_get_record returns false on resolver failure. Same semantics as above.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert target to binary once, outside the loop. inet_pton normalises
|
||||
// "2001:db8::1" and "2001:0db8:0000:0000:0000:0000:0000:0001" to the same
|
||||
// bytes, so we can compare regardless of how the resolver formatted its reply.
|
||||
$targetBin = @inet_pton($ip);
|
||||
foreach ($records as $r) {
|
||||
$t = $r['type'] ?? null;
|
||||
if ($t === 'CNAME') {
|
||||
// CNAME hop: recurse on the target. We don't use a visited-set to
|
||||
// detect cycles — MAX_CNAME_DEPTH is a simpler, sufficient guard.
|
||||
$next = $r['target'] ?? null;
|
||||
if ($next && self::resolveInternal(rtrim($next, '.'), $ip, $depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A records expose the address under 'ip', AAAA records under 'ipv6'.
|
||||
// Only one of these will be set per record; the other is null.
|
||||
$candidate = $r['ip'] ?? ($r['ipv6'] ?? null);
|
||||
if ($candidate && $targetBin !== false && @inet_pton($candidate) === $targetBin) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,47 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
|
||||
/**
|
||||
* Transforms a VirtFusion API server response into a flat key-value array for Smarty templates and admin display.
|
||||
*
|
||||
* WHY A FLAT ARRAY
|
||||
* ----------------
|
||||
* Smarty templates can traverse nested structures (`{$data.network.interfaces[0].ipv4[0].address}`)
|
||||
* but that leaks the API shape into the template layer. A flat array ("hostname",
|
||||
* "primaryNetwork.ipv4[]", "memoryRaw", etc.) decouples the template from the upstream
|
||||
* schema: if VirtFusion renames `network.interfaces` tomorrow, only this file needs
|
||||
* to change.
|
||||
*
|
||||
* PRIMARY-INTERFACE-ONLY DESIGN
|
||||
* -----------------------------
|
||||
* process() only reads interfaces[0]. That's the primary network — the one the
|
||||
* client-area "Overview" card displays. Servers with multiple interfaces (common
|
||||
* for dedicated IPMI networks, storage networks, etc.) still work for display
|
||||
* because the primary interface holds the customer-facing IP.
|
||||
*
|
||||
* The reverse-DNS subsystem (PowerDns\IpUtil::extractIps) walks ALL interfaces
|
||||
* explicitly because PTRs matter for every IP no matter which NIC it's on.
|
||||
* If you add a feature that needs secondary-interface data for display, do NOT
|
||||
* generalise this class — add a new one or a helper that doesn't disturb the
|
||||
* well-tested primary-interface behaviour.
|
||||
*
|
||||
* UNIT CONVERSIONS
|
||||
* ----------------
|
||||
* VirtFusion stores:
|
||||
* - traffic as bytes (usage) or GB (limits)
|
||||
* - storage as GB (limits) or bytes (usage)
|
||||
* - memory as MB
|
||||
* WHMCS expects MB for storage/traffic in tblhosting. This class produces two
|
||||
* pairs of values per resource: a human-readable string with unit suffix
|
||||
* (e.g. "200 GB") AND a raw integer without the unit (for slider UIs and
|
||||
* arithmetic). Keep both — removing one breaks a UI consumer somewhere.
|
||||
*
|
||||
* "-" SENTINELS
|
||||
* -------------
|
||||
* Fields that are missing or empty are rendered as "-" rather than empty strings.
|
||||
* That makes the client-area card always have content (a dash is a valid visual
|
||||
* placeholder) and distinguishes "missing data" from "empty string returned by
|
||||
* the API". Consumers who need boolean presence checks should test against "-",
|
||||
* not "" / null — and upstream (e.g. updateWhmcsServiceParamsOnServerObject)
|
||||
* already does.
|
||||
*/
|
||||
class ServerResource
|
||||
{
|
||||
|
||||
@@ -471,3 +471,106 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
/* Subnet-only rows (IPv6 /64 allocations). Distinct visual treatment so
|
||||
customers see "this is a subnet, not a host" without reading the badge. */
|
||||
.vf-rdns-subnet-row {
|
||||
background: rgba(23, 162, 184, 0.04);
|
||||
border-left: 3px solid #17a2b8;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.vf-rdns-subnet-form {
|
||||
flex-basis: 100%;
|
||||
padding: 10px 0 0 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.vf-rdns-subnet-inputs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vf-rdns-subnet-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.vf-rdns-subnet-form { padding-left: 0; }
|
||||
.vf-rdns-subnet-inputs { flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -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,268 @@ 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" },
|
||||
"subnet-only": { label: "subnet", bg: "#17a2b8", fg: "#fff" }
|
||||
};
|
||||
|
||||
function vfRdnsBadge(status) {
|
||||
var s = VF_RDNS_STATUS[status] || VF_RDNS_STATUS["error"];
|
||||
var span = $('<span class="vf-rdns-badge"></span>');
|
||||
span.text(s.label);
|
||||
span.css({ background: s.bg, color: s.fg });
|
||||
return span;
|
||||
}
|
||||
|
||||
function vfLoadRdns(serviceId, systemUrl) {
|
||||
var list = $("#vf-rdns-list");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsList"),
|
||||
method: "GET",
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (!resp || !resp.success) {
|
||||
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
|
||||
return;
|
||||
}
|
||||
if (!resp.data.enabled) {
|
||||
list.closest(".panel").hide();
|
||||
return;
|
||||
}
|
||||
vfRenderRdnsPanel(serviceId, systemUrl, resp.data.ips || []);
|
||||
}).fail(function () {
|
||||
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
|
||||
});
|
||||
}
|
||||
|
||||
function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
|
||||
var list = $("#vf-rdns-list");
|
||||
list.empty();
|
||||
if (!ips.length) {
|
||||
list.html('<div class="text-muted">No IP addresses assigned to this server yet.</div>');
|
||||
return;
|
||||
}
|
||||
ips.forEach(function (row) {
|
||||
// Subnet-only rows (IPv6 /64 allocations) render as a distinct informational
|
||||
// anchor with an expandable "Add host PTR" form — the customer types a
|
||||
// specific address inside the subnet + hostname, backend verifies containment.
|
||||
if (row.status === "subnet-only") {
|
||||
list.append(vfRenderSubnetRow(serviceId, systemUrl, row));
|
||||
return;
|
||||
}
|
||||
list.append(vfRenderIpRow(serviceId, systemUrl, row));
|
||||
});
|
||||
}
|
||||
|
||||
/** Standard per-IP row with inline PTR editor. Used for v4 addresses + discrete v6 hosts. */
|
||||
function vfRenderIpRow(serviceId, systemUrl, row) {
|
||||
var wrap = $('<div class="vf-rdns-row"></div>');
|
||||
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
||||
var badge = vfRdnsBadge(row.status);
|
||||
|
||||
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="host.example.com (blank to delete)">');
|
||||
input.val(row.ptr || "");
|
||||
|
||||
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
|
||||
var msg = $('<div class="vf-rdns-msg"></div>');
|
||||
|
||||
saveBtn.on("click", function () {
|
||||
vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge);
|
||||
});
|
||||
input.on("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
|
||||
});
|
||||
|
||||
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
|
||||
return wrap.append(ipLabel).append(editor).append(badge).append(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subnet-only row: shows "2602:2f3:0:5d::/64" with a collapsible "Add host PTR" form.
|
||||
*
|
||||
* Why collapsed by default: most customers won't set custom v6 PTRs, so burying
|
||||
* the form until explicitly requested keeps the panel uncluttered for the common
|
||||
* case. Adding a host PTR is a power-user operation (needs a pre-existing AAAA
|
||||
* record) so surfacing it as a secondary action is UX-appropriate.
|
||||
*/
|
||||
function vfRenderSubnetRow(serviceId, systemUrl, row) {
|
||||
var wrap = $('<div class="vf-rdns-row vf-rdns-subnet-row"></div>');
|
||||
var label = $('<div class="vf-rdns-ip"></div>').text(row.subnet + "/" + row.cidr);
|
||||
var badge = vfRdnsBadge(row.status);
|
||||
|
||||
var toggleBtn = $('<button type="button" class="btn btn-sm btn-outline-secondary">+ Add host PTR</button>');
|
||||
var form = $('<div class="vf-rdns-subnet-form" style="display:none;"></div>');
|
||||
|
||||
var ipInput = $('<input type="text" class="form-control form-control-sm vf-rdns-input" placeholder="Host IPv6 address inside this subnet (e.g. 2602:2f3:0:5d::10)">');
|
||||
var ptrInput = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="Hostname for PTR (e.g. mail.example.com)">');
|
||||
var addBtn = $('<button type="button" class="btn btn-sm btn-primary">Add PTR</button>');
|
||||
var cancelBtn = $('<button type="button" class="btn btn-sm btn-link">Cancel</button>');
|
||||
var msg = $('<div class="vf-rdns-msg"></div>');
|
||||
|
||||
toggleBtn.on("click", function () {
|
||||
form.toggle();
|
||||
toggleBtn.text(form.is(":visible") ? "− Hide" : "+ Add host PTR");
|
||||
});
|
||||
cancelBtn.on("click", function () {
|
||||
form.hide();
|
||||
toggleBtn.text("+ Add host PTR");
|
||||
ipInput.val(""); ptrInput.val(""); msg.hide();
|
||||
});
|
||||
|
||||
addBtn.on("click", function () {
|
||||
var ip = (ipInput.val() || "").trim();
|
||||
var ptr = (ptrInput.val() || "").trim();
|
||||
if (!ip) { msg.text("Enter a host IPv6 address.").css("color", "#dc3545").show(); return; }
|
||||
if (!ptr) { msg.text("Enter a hostname for the PTR.").css("color", "#dc3545").show(); return; }
|
||||
// Same server-side validation guards apply; we reuse the normal update flow.
|
||||
vfUpdateRdns(serviceId, systemUrl, ip, ptrInput, addBtn, msg, null, function () {
|
||||
// On success, refresh the whole panel so the new host PTR shows up as its own row
|
||||
// alongside the subnet it came from.
|
||||
setTimeout(function () { vfLoadRdns(serviceId, systemUrl); }, 1500);
|
||||
});
|
||||
});
|
||||
ipInput.on("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); ptrInput.focus(); } });
|
||||
ptrInput.on("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); addBtn.click(); } });
|
||||
|
||||
var inputsRow = $('<div class="vf-rdns-subnet-inputs"></div>').append(ipInput).append(ptrInput);
|
||||
var actionsRow = $('<div class="vf-rdns-subnet-actions"></div>').append(addBtn).append(cancelBtn);
|
||||
form.append(inputsRow).append(actionsRow).append(msg);
|
||||
|
||||
var editorWrap = $('<div class="vf-rdns-edit"></div>').append(toggleBtn);
|
||||
return wrap.append(label).append(editorWrap).append(badge).append(form);
|
||||
}
|
||||
|
||||
function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge, onSuccess) {
|
||||
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);
|
||||
// Badge may be null (e.g. when called from the subnet row's Add-PTR form
|
||||
// which has no per-row badge to update). Guard rather than crash.
|
||||
if (badge) {
|
||||
// Optimistically update the badge; a background refresh will correct it.
|
||||
if (ptr === "") {
|
||||
badge.replaceWith(vfRdnsBadge("missing"));
|
||||
} else {
|
||||
badge.replaceWith(vfRdnsBadge("ok"));
|
||||
}
|
||||
}
|
||||
if (typeof onSuccess === "function") { onSuccess(); }
|
||||
} else {
|
||||
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
|
||||
msg.text(err).css("color", "#dc3545").show();
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
saveBtn.prop("disabled", false);
|
||||
var err = "Save failed.";
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
if (r && r.errors) err = r.errors;
|
||||
} catch (e) {}
|
||||
msg.text(err).css("color", "#dc3545").show();
|
||||
});
|
||||
}
|
||||
|
||||
// Admin-side wrappers — different endpoint ("admin"), no ownership check on server side.
|
||||
|
||||
function vfAdminLoadRdns(serviceId, systemUrl) {
|
||||
var list = $("#vf-rdns-list");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsStatus", "admin"),
|
||||
method: "GET",
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (!resp || !resp.success) {
|
||||
list.html('<em class="text-muted">Unable to load PTR state.</em>');
|
||||
return;
|
||||
}
|
||||
if (!resp.data.enabled) {
|
||||
list.html('<em class="text-muted">Reverse DNS addon is not activated.</em>');
|
||||
return;
|
||||
}
|
||||
list.empty();
|
||||
if (!resp.data.ips.length) {
|
||||
list.html('<em class="text-muted">No IPs assigned.</em>');
|
||||
return;
|
||||
}
|
||||
resp.data.ips.forEach(function (row) {
|
||||
var line = $('<div class="vf-rdns-admin-row"></div>');
|
||||
$('<span class="vf-rdns-ip-admin"></span>').text(row.ip).appendTo(line);
|
||||
$('<span class="vf-rdns-ptr-admin"></span>').text(row.ptr || "(no PTR)").appendTo(line);
|
||||
vfRdnsBadge(row.status).appendTo(line);
|
||||
list.append(line);
|
||||
});
|
||||
}).fail(function () {
|
||||
list.html('<em class="text-muted">Unable to load PTR state.</em>');
|
||||
});
|
||||
}
|
||||
|
||||
function vfAdminReconcileRdns(serviceId, systemUrl, force) {
|
||||
var out = $("#vf-rdns-report");
|
||||
out.text("Reconciling…").css("color", "#555");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsReconcile", "admin"),
|
||||
method: "POST",
|
||||
data: { force: force ? 1 : 0 },
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (resp && resp.success) {
|
||||
var s = resp.data;
|
||||
var parts = [];
|
||||
["added", "reset", "preserved", "forward_missing", "no_zone", "errors"].forEach(function (k) {
|
||||
if (s[k] > 0) parts.push(k + "=" + s[k]);
|
||||
});
|
||||
out.text(parts.length ? parts.join(" ") : "no changes needed").css("color", "#28a745");
|
||||
vfAdminLoadRdns(serviceId, systemUrl);
|
||||
} else {
|
||||
out.text((resp && resp.errors) ? resp.errors : "Reconcile failed").css("color", "#dc3545");
|
||||
}
|
||||
}).fail(function () {
|
||||
out.text("Reconcile failed").css("color", "#dc3545");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,6 +237,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if $rdnsEnabled}
|
||||
{* Reverse DNS Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Reverse DNS</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<p class="vf-small text-muted mb-3">Set a custom PTR record for each assigned IP. Forward DNS (A/AAAA) for the hostname must already resolve to the IP before the PTR can be saved.</p>
|
||||
<div id="vf-rdns-alert" class="alert" style="display:none;"></div>
|
||||
<div id="vf-rdns-list">
|
||||
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||
</div>
|
||||
<script>
|
||||
if (typeof vfLoadRdns === 'function') {
|
||||
vfLoadRdns('{$serviceid}', '{$systemURL}');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{* Resources Panel — populated by JS after server data loads *}
|
||||
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div class="panel-heading card-header">
|
||||
|
||||
Reference in New Issue
Block a user