feat(ipv6): surface /64 subnet allocations with custom-host PTR flow

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) <noreply@anthropic.com>
This commit is contained in:
Prophet731
2026-04-17 22:04:23 -04:00
parent daaddc7c24
commit 7e7f3c1c14
5 changed files with 264 additions and 54 deletions

View File

@@ -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);