diff --git a/modules/servers/VirtFusionDirect/client.php b/modules/servers/VirtFusionDirect/client.php index 9e4ec8d..7ca8507 100644 --- a/modules/servers/VirtFusionDirect/client.php +++ b/modules/servers/VirtFusionDirect/client.php @@ -544,15 +544,35 @@ try { $vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502); break; } - $assigned = IpUtil::extractIps($serverData)['addresses']; + $extracted = IpUtil::extractIps($serverData); $targetBin = @inet_pton($ip); $owns = false; - foreach ($assigned as $a) { + + // 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); diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php b/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php index 34fff5a..130eb9c 100644 --- a/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php @@ -101,19 +101,30 @@ class IpUtil } /** - * Extract every host IP address (v4 and v6) from a VirtFusion server object. + * Extract every IP address and IPv6 subnet 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. + * 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[], skipped: array} Deduped IP strings + array of skipped entries with reasons + * @return array{addresses: string[], subnets: array, skipped: array} */ public static function extractIps($serverObject): array { $addresses = []; + $subnets = []; $skipped = []; // Normalise object-or-array input. json_decode(json_encode($x), true) is the @@ -123,7 +134,7 @@ class IpUtil $serverObject = json_decode(json_encode($serverObject), true); } if (! is_array($serverObject)) { - return ['addresses' => [], 'skipped' => []]; + return ['addresses' => [], 'subnets' => [], 'skipped' => []]; } // VirtFusion wraps the payload in a "data" key on GET responses but the stored @@ -131,7 +142,7 @@ class IpUtil $data = $serverObject['data'] ?? $serverObject; $interfaces = $data['network']['interfaces'] ?? []; if (! is_array($interfaces)) { - return ['addresses' => [], 'skipped' => []]; + return ['addresses' => [], 'subnets' => [], 'skipped' => []]; } // Walk every interface (not just interfaces[0]). ServerResource only reads [0] @@ -159,29 +170,91 @@ class IpUtil 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-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)) { + 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' => 'ipv6-subnet-without-explicit-host-address', - ]; + $skipped[] = ['subnet' => $subnet, 'cidr' => $cidr, 'reason' => 'invalid-cidr']; } } } } - // array_keys gives us the de-duplicated list in insertion order. - return ['addresses' => array_keys($addresses), 'skipped' => $skipped]; + 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; } /** diff --git a/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php b/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php index 0be140d..7eec808 100644 --- a/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php +++ b/modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php @@ -246,6 +246,22 @@ class PtrManager 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); diff --git a/modules/servers/VirtFusionDirect/templates/css/module.css b/modules/servers/VirtFusionDirect/templates/css/module.css index 489998e..626593f 100644 --- a/modules/servers/VirtFusionDirect/templates/css/module.css +++ b/modules/servers/VirtFusionDirect/templates/css/module.css @@ -545,3 +545,32 @@ .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; } +} diff --git a/modules/servers/VirtFusionDirect/templates/js/module.js b/modules/servers/VirtFusionDirect/templates/js/module.js index 93e32f6..33e05aa 100644 --- a/modules/servers/VirtFusionDirect/templates/js/module.js +++ b/modules/servers/VirtFusionDirect/templates/js/module.js @@ -1112,12 +1112,13 @@ function vfCopyButton(text) { /** 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" } + "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) { @@ -1157,30 +1158,96 @@ function vfRenderRdnsPanel(serviceId, systemUrl, ips) { return; } ips.forEach(function (row) { - var wrap = $('
'); - var ipLabel = $('
').text(row.ip); - var badge = vfRdnsBadge(row.status); - - var input = $(''); - input.val(row.ptr || ""); - - var saveBtn = $(''); - var msg = $('
'); - - saveBtn.on("click", function () { - vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge); - }); - input.on("keydown", function (e) { - if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); } - }); - - var editor = $('
').append(input).append(saveBtn); - wrap.append(ipLabel).append(editor).append(badge).append(msg); - list.append(wrap); + // 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)); }); } -function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge) { +/** Standard per-IP row with inline PTR editor. Used for v4 addresses + discrete v6 hosts. */ +function vfRenderIpRow(serviceId, systemUrl, row) { + var wrap = $('
'); + var ipLabel = $('
').text(row.ip); + var badge = vfRdnsBadge(row.status); + + var input = $(''); + input.val(row.ptr || ""); + + var saveBtn = $(''); + var msg = $('
'); + + saveBtn.on("click", function () { + vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge); + }); + input.on("keydown", function (e) { + if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); } + }); + + var editor = $('
').append(input).append(saveBtn); + 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 = $('
'); + var label = $('
').text(row.subnet + "/" + row.cidr); + var badge = vfRdnsBadge(row.status); + + var toggleBtn = $(''); + var form = $(''); + + var ipInput = $(''); + var ptrInput = $(''); + var addBtn = $(''); + var cancelBtn = $(''); + var msg = $('
'); + + 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 = $('
').append(ipInput).append(ptrInput); + var actionsRow = $('
').append(addBtn).append(cancelBtn); + form.append(inputsRow).append(actionsRow).append(msg); + + var editorWrap = $('
').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)) { @@ -1201,12 +1268,17 @@ function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge) { 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")); + // 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();