feat: add PowerDNS reverse DNS (PTR) integration

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

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

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

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

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

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

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

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

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

View File

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