From 7e7f3c1c146d65cfa654649f1d15950d91a941a7 Mon Sep 17 00:00:00 2001 From: Prophet731 Date: Fri, 17 Apr 2026 22:04:23 -0400 Subject: [PATCH] feat(ipv6): surface /64 subnet allocations with custom-host PTR flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VirtFusion's IPv6 allocation model routes a whole /64 to the VPS rather than exposing discrete host addresses via the API. The previous module silently filtered these entries — the client saw v4 IPs in the rDNS panel but no v6 at all, with no indication why, and no way to set a PTR for a specific address they were using inside the /64. This commit surfaces subnets as first-class entries throughout: - IpUtil::extractIps() now returns {addresses, subnets, skipped}. The subnets bucket carries {subnet, cidr} pairs for any v6 allocation with cidr != 128; /128 entries continue to be treated as discrete addresses, and genuinely malformed entries still go to skipped. - IpUtil::ipv6InSubnet($ip, $prefix, $cidrBits) — new helper that does binary-prefix subnet containment via inet_pton + bit masking. Used for v6 ownership verification (see below). - PtrManager::listPtrs() emits subnet-only rows ahead of per-IP rows, so the client UI can render the /64 as an informational anchor with an entry point for the custom-host flow. - client.php::rdnsUpdate adds a second ownership-check stage: if the submitted IP is v6 AND doesn't match any discrete address, check whether it falls inside one of the server's allocated subnets. This preserves "only your own IPs" while unlocking the feature. - Client-side (module.js / module.css) renders subnet rows with a collapsible "Add host PTR" form (IP + hostname inputs) that posts to the same rdnsUpdate endpoint. Subnet rows get a distinct cyan accent so they visually differ from per-host rows. The usual guards still apply to v6 custom-host writes: forward-DNS (FCrDNS) verification, PTR regex, per-IP rate limit, same-origin / POST-method gates. Nothing about the security envelope changes — only what input is accepted as "you own this IP". Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/servers/VirtFusionDirect/client.php | 24 +++- .../VirtFusionDirect/lib/PowerDns/IpUtil.php | 113 ++++++++++++--- .../lib/PowerDns/PtrManager.php | 16 +++ .../VirtFusionDirect/templates/css/module.css | 29 ++++ .../VirtFusionDirect/templates/js/module.js | 136 +++++++++++++----- 5 files changed, 264 insertions(+), 54 deletions(-) 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();