feat: add PowerDNS reverse DNS (PTR) integration
Introduces an opt-in reverse DNS management subsystem backed by a PowerDNS
Authoritative HTTP API. Runs via a companion WHMCS addon module
(modules/addons/VirtFusionDns) that holds settings and a Test Connection
page; the server module reads those settings from tbladdonmodules and
short-circuits when the addon is absent or disabled, so provisioning is
unaffected for operators who don't use the feature.
Lifecycle hooks:
- createAccount creates PTRs for every assigned IP (forward DNS must
already resolve to the IP — FCrDNS enforcement)
- renameServer updates only PTRs whose content matched the old hostname,
preserving client-custom records
- terminateAccount deletes all PTRs before the local state is purged
- TestConnection merges PowerDNS health check with the existing VirtFusion
check
- A DailyCronJob hook reconciles missing PTRs additive-only (never
overwrites)
Client UI: new "Reverse DNS" panel on the service overview with one
editable PTR input per assigned IP, per-row status badges, and
forward-DNS rejection on save. Admin services tab gets a parallel
widget with Reconcile (additive) and Reconcile (force reset) buttons.
New subsystem at lib/PowerDns/:
- Client.php PowerDNS API wrapper (X-API-Key, listZones/getZone/
patchRRset/notifyZone), auto-NOTIFY on successful PATCH
- Config.php Loads + decrypts addon settings from tbladdonmodules
- IpUtil.php PTR-name generation (IPv4 + IPv6), zone matching,
RFC 2317 classless parsing
- Resolver.php FCrDNS verification via dns_get_record with CNAME-chain
following and per-(hostname,ip) caching
- PtrManager.php Orchestrator: syncServer, deleteForServer, listPtrs,
setPtr, reconcile, reconcileAll
Security hardening helpers added to Module and applied to the rDNS
endpoints:
- requirePost() HTTP method gate (405 on non-POST mutations)
- requireSameOrigin() Origin/Referer check against WHMCS host (CSRF
defence against cross-site form POST)
- requireServiceStatus() tblhosting.domainstatus filter (Active for
writes, Active+Suspended for reads)
RFC 2317 classless delegations (e.g. 64/64.113.0.203.in-addr.arpa.)
supported with alignment validation: rejects misaligned start addresses
that don't correspond to any real delegation boundary.
PowerDNS zone IDs containing '/' are URL-encoded as '=2F' per the
PowerDNS API convention. PATCH success triggers PUT /zones/{id}/notify
so slaves pick up the SOA-bumped serial immediately.
Includes IPv4 + IPv6 support, per-IP write rate limit (10s), fresh
IP-ownership re-verification on every client write (defends against
stale-ownership after IP reassignment), and audit logging of every
successful edit to the WHMCS module log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -471,3 +471,77 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Reverse DNS panel
|
||||
========================================================================= */
|
||||
.vf-rdns-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,.06);
|
||||
}
|
||||
.vf-rdns-row:last-child { border-bottom: none; }
|
||||
.vf-rdns-ip {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
min-width: 180px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vf-rdns-edit {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 240px;
|
||||
}
|
||||
.vf-rdns-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 180px;
|
||||
max-width: 420px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vf-rdns-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .02em;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vf-rdns-msg {
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
padding-left: 180px;
|
||||
}
|
||||
.vf-rdns-admin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vf-rdns-ip-admin {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
min-width: 180px;
|
||||
}
|
||||
.vf-rdns-ptr-admin {
|
||||
font-family: monospace;
|
||||
color: #333;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.vf-rdns-row { flex-direction: column; align-items: stretch; }
|
||||
.vf-rdns-edit { flex-direction: column; align-items: stretch; }
|
||||
.vf-rdns-msg { padding-left: 0; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,82 @@
|
||||
/**
|
||||
* VirtFusion Direct Provisioning Module - Client JavaScript
|
||||
*
|
||||
* Handles client-side interactions for server management including:
|
||||
* ========================================================================
|
||||
* ARCHITECTURE
|
||||
* ========================================================================
|
||||
*
|
||||
* This file is the single client-side script that powers both:
|
||||
* - The client area (service overview panel, loaded on every service page)
|
||||
* - The admin services tab (server info + rDNS widget)
|
||||
*
|
||||
* It uses vanilla JS + jQuery. jQuery is available because WHMCS's built-in
|
||||
* admin UI depends on it; we inherit that dependency rather than adding a
|
||||
* new one. The order form hooks (keygen.js, OS-gallery injector in hooks.php)
|
||||
* use vanilla JS only because those run on pre-auth checkout pages where
|
||||
* jQuery availability varies by theme.
|
||||
*
|
||||
* CONVENTION: every function is prefixed with "vf" to avoid collisions with
|
||||
* whatever else the page loads. Internal helpers start with "_vf".
|
||||
*
|
||||
* ========================================================================
|
||||
* SECTIONS (roughly in order below)
|
||||
* ========================================================================
|
||||
*
|
||||
* Shared Helpers — vfUrl, vfShowAlert
|
||||
* Progress Indicator — vfShowProgress / vfHideProgress
|
||||
* Server Data Display — vfServerData, vfServerDataAdmin
|
||||
* Power Management — vfPowerAction
|
||||
* SSO Login — vfLoginAsServerOwner
|
||||
* Password Reset — vfUserPasswordReset, vfResetServerPassword
|
||||
* Server Rebuild — vfRebuildServer, vfLoadOsTemplates, vfRenderOsGallery
|
||||
* Server Rename — vfRenameServer, vfShowNameDropdown
|
||||
* Traffic / Backups — vfLoadTrafficStats, vfDrawTrafficChart, vfLoadBackups
|
||||
* VNC Console — vfOpenVnc, vfToggleVnc
|
||||
* Self-Service Billing — vfLoadSelfServiceUsage, vfAddCredit
|
||||
* Reverse DNS (PowerDNS) — vfLoadRdns, vfRenderRdnsPanel, vfUpdateRdns,
|
||||
* vfAdminLoadRdns, vfAdminReconcileRdns
|
||||
*
|
||||
* ========================================================================
|
||||
* AJAX REQUEST SHAPE
|
||||
* ========================================================================
|
||||
*
|
||||
* URL: {systemUrl}modules/servers/VirtFusionDirect/{endpoint}.php
|
||||
* ?serviceID={id}&action={action}
|
||||
* where endpoint is "client" (default) or "admin".
|
||||
*
|
||||
* Method: GET for reads, POST for writes (server-side requirePost() gate
|
||||
* enforces this for rDNS mutations; other mutations rely on $_POST
|
||||
* being empty for GET → validation fails naturally).
|
||||
*
|
||||
* Response:
|
||||
* { success: true, data: { ... } }
|
||||
* { success: false, errors: "human message" }
|
||||
*
|
||||
* ========================================================================
|
||||
* ERROR HANDLING
|
||||
* ========================================================================
|
||||
*
|
||||
* Every AJAX call handles three outcomes:
|
||||
* 1. Network failure (.fail) → show a generic error in the panel's alert div
|
||||
* 2. Server returned success:false → show response.errors to the user
|
||||
* 3. Server returned success:true → render data into the DOM
|
||||
*
|
||||
* Error text ALWAYS comes from the server (we don't invent user-facing error
|
||||
* copy client-side). That way a server-side change to error phrasing
|
||||
* propagates everywhere without JS changes.
|
||||
*
|
||||
* ========================================================================
|
||||
* DOM UPDATE PATTERNS
|
||||
* ========================================================================
|
||||
*
|
||||
* Read actions render into named containers with id="vf-data-*".
|
||||
* Status badges use CSS classes "vf-badge-*" for color coding.
|
||||
* Text content is always set via .text() not .html() to prevent XSS
|
||||
* from whatever the API returned. Exception: panels built entirely
|
||||
* from server-trusted structured data use .append() with new jQuery
|
||||
* elements, not string concatenation.
|
||||
*
|
||||
* Handles client-side interactions for:
|
||||
* - Server data display
|
||||
* - Power management (boot, shutdown, restart, power off)
|
||||
* - Control panel login (SSO)
|
||||
@@ -12,6 +87,7 @@
|
||||
* - Backup listing
|
||||
* - VNC management
|
||||
* - Server naming
|
||||
* - Reverse DNS (PowerDNS addon)
|
||||
*/
|
||||
|
||||
// =========================================================================
|
||||
@@ -1011,3 +1087,196 @@ function vfCopyButton(text) {
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Reverse DNS (PowerDNS)
|
||||
// =========================================================================
|
||||
//
|
||||
// Feature gate: this section only activates when the VirtFusionDns addon is
|
||||
// installed AND enabled. The PHP side renders the rDNS panel in overview.tpl
|
||||
// only when $rdnsEnabled is true; if the panel isn't in the DOM, these
|
||||
// functions are never called.
|
||||
//
|
||||
// Admin-side counterparts (vfAdminLoadRdns, vfAdminReconcileRdns) target
|
||||
// admin.php instead of client.php and are used by the rdnsSection() admin
|
||||
// widget rendered via AdminHTML::rdnsSection().
|
||||
//
|
||||
// Status badge colours match what most operators expect:
|
||||
// OK (green) = PTR present, forward DNS agrees (FCrDNS passes)
|
||||
// unverified (amber) = PTR present but forward DNS no longer agrees
|
||||
// missing (gray) = No PTR exists yet
|
||||
// no-zone (red) = The IP's reverse zone isn't hosted in PowerDNS
|
||||
// error (red) = PowerDNS unreachable or similar
|
||||
//
|
||||
// The server-side always decides the status; we just colour it.
|
||||
|
||||
/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */
|
||||
var VF_RDNS_STATUS = {
|
||||
"ok": { label: "OK", bg: "#28a745", fg: "#fff" },
|
||||
"unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" },
|
||||
"missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" },
|
||||
"no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" },
|
||||
"error": { label: "error", bg: "#dc3545", fg: "#fff" },
|
||||
"disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" }
|
||||
};
|
||||
|
||||
function vfRdnsBadge(status) {
|
||||
var s = VF_RDNS_STATUS[status] || VF_RDNS_STATUS["error"];
|
||||
var span = $('<span class="vf-rdns-badge"></span>');
|
||||
span.text(s.label);
|
||||
span.css({ background: s.bg, color: s.fg });
|
||||
return span;
|
||||
}
|
||||
|
||||
function vfLoadRdns(serviceId, systemUrl) {
|
||||
var list = $("#vf-rdns-list");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsList"),
|
||||
method: "GET",
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (!resp || !resp.success) {
|
||||
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
|
||||
return;
|
||||
}
|
||||
if (!resp.data.enabled) {
|
||||
list.closest(".panel").hide();
|
||||
return;
|
||||
}
|
||||
vfRenderRdnsPanel(serviceId, systemUrl, resp.data.ips || []);
|
||||
}).fail(function () {
|
||||
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
|
||||
});
|
||||
}
|
||||
|
||||
function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
|
||||
var list = $("#vf-rdns-list");
|
||||
list.empty();
|
||||
if (!ips.length) {
|
||||
list.html('<div class="text-muted">No IP addresses assigned to this server yet.</div>');
|
||||
return;
|
||||
}
|
||||
ips.forEach(function (row) {
|
||||
var wrap = $('<div class="vf-rdns-row"></div>');
|
||||
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
||||
var badge = vfRdnsBadge(row.status);
|
||||
|
||||
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="host.example.com (blank to delete)">');
|
||||
input.val(row.ptr || "");
|
||||
|
||||
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
|
||||
var msg = $('<div class="vf-rdns-msg"></div>');
|
||||
|
||||
saveBtn.on("click", function () {
|
||||
vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge);
|
||||
});
|
||||
input.on("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
|
||||
});
|
||||
|
||||
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
|
||||
wrap.append(ipLabel).append(editor).append(badge).append(msg);
|
||||
list.append(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge) {
|
||||
var ptr = (input.val() || "").trim();
|
||||
// Light client-side regex mirrors the server-side one — strict enforcement is on the server.
|
||||
if (ptr !== "" && !/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/.test(ptr)) {
|
||||
msg.text("Invalid hostname.").css("color", "#dc3545").show();
|
||||
return;
|
||||
}
|
||||
saveBtn.prop("disabled", true);
|
||||
msg.hide();
|
||||
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsUpdate"),
|
||||
method: "POST",
|
||||
data: { ip: ip, ptr: ptr },
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
saveBtn.prop("disabled", false);
|
||||
if (resp && resp.success) {
|
||||
var verb = (ptr === "") ? "deleted" : "saved";
|
||||
msg.text("rDNS " + verb + ".").css("color", "#28a745").show();
|
||||
setTimeout(function () { msg.fadeOut(); }, 2500);
|
||||
// Optimistically update the badge; a background refresh will correct it.
|
||||
if (ptr === "") {
|
||||
badge.replaceWith(vfRdnsBadge("missing"));
|
||||
} else {
|
||||
badge.replaceWith(vfRdnsBadge("ok"));
|
||||
}
|
||||
} else {
|
||||
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
|
||||
msg.text(err).css("color", "#dc3545").show();
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
saveBtn.prop("disabled", false);
|
||||
var err = "Save failed.";
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
if (r && r.errors) err = r.errors;
|
||||
} catch (e) {}
|
||||
msg.text(err).css("color", "#dc3545").show();
|
||||
});
|
||||
}
|
||||
|
||||
// Admin-side wrappers — different endpoint ("admin"), no ownership check on server side.
|
||||
|
||||
function vfAdminLoadRdns(serviceId, systemUrl) {
|
||||
var list = $("#vf-rdns-list");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsStatus", "admin"),
|
||||
method: "GET",
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (!resp || !resp.success) {
|
||||
list.html('<em class="text-muted">Unable to load PTR state.</em>');
|
||||
return;
|
||||
}
|
||||
if (!resp.data.enabled) {
|
||||
list.html('<em class="text-muted">Reverse DNS addon is not activated.</em>');
|
||||
return;
|
||||
}
|
||||
list.empty();
|
||||
if (!resp.data.ips.length) {
|
||||
list.html('<em class="text-muted">No IPs assigned.</em>');
|
||||
return;
|
||||
}
|
||||
resp.data.ips.forEach(function (row) {
|
||||
var line = $('<div class="vf-rdns-admin-row"></div>');
|
||||
$('<span class="vf-rdns-ip-admin"></span>').text(row.ip).appendTo(line);
|
||||
$('<span class="vf-rdns-ptr-admin"></span>').text(row.ptr || "(no PTR)").appendTo(line);
|
||||
vfRdnsBadge(row.status).appendTo(line);
|
||||
list.append(line);
|
||||
});
|
||||
}).fail(function () {
|
||||
list.html('<em class="text-muted">Unable to load PTR state.</em>');
|
||||
});
|
||||
}
|
||||
|
||||
function vfAdminReconcileRdns(serviceId, systemUrl, force) {
|
||||
var out = $("#vf-rdns-report");
|
||||
out.text("Reconciling…").css("color", "#555");
|
||||
$.ajax({
|
||||
url: vfUrl(systemUrl, serviceId, "rdnsReconcile", "admin"),
|
||||
method: "POST",
|
||||
data: { force: force ? 1 : 0 },
|
||||
dataType: "json"
|
||||
}).done(function (resp) {
|
||||
if (resp && resp.success) {
|
||||
var s = resp.data;
|
||||
var parts = [];
|
||||
["added", "reset", "preserved", "forward_missing", "no_zone", "errors"].forEach(function (k) {
|
||||
if (s[k] > 0) parts.push(k + "=" + s[k]);
|
||||
});
|
||||
out.text(parts.length ? parts.join(" ") : "no changes needed").css("color", "#28a745");
|
||||
vfAdminLoadRdns(serviceId, systemUrl);
|
||||
} else {
|
||||
out.text((resp && resp.errors) ? resp.errors : "Reconcile failed").css("color", "#dc3545");
|
||||
}
|
||||
}).fail(function () {
|
||||
out.text("Reconcile failed").css("color", "#dc3545");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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