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:
@@ -544,15 +544,35 @@ try {
|
|||||||
$vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502);
|
$vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$assigned = IpUtil::extractIps($serverData)['addresses'];
|
$extracted = IpUtil::extractIps($serverData);
|
||||||
$targetBin = @inet_pton($ip);
|
$targetBin = @inet_pton($ip);
|
||||||
$owns = false;
|
$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) {
|
if (@inet_pton($a) === $targetBin) {
|
||||||
$owns = true;
|
$owns = true;
|
||||||
break;
|
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) {
|
if (! $owns) {
|
||||||
Log::insert('rdnsUpdate:ownership', ['serviceID' => $serviceID, 'ip' => $ip], 'IP not assigned to this service');
|
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);
|
$vf->output(['success' => false, 'errors' => 'This IP is not assigned to your server'], true, true, 403);
|
||||||
|
|||||||
@@ -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).
|
* Walks every interface, not just interfaces[0] (ServerResource only reads the primary).
|
||||||
* Handles both explicit `address` fields and `subnet`+`cidr` pairs.
|
* Returns three buckets:
|
||||||
* 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.
|
* 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`)
|
* @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<int, array{subnet: string, cidr: int}>, skipped: array}
|
||||||
*/
|
*/
|
||||||
public static function extractIps($serverObject): array
|
public static function extractIps($serverObject): array
|
||||||
{
|
{
|
||||||
$addresses = [];
|
$addresses = [];
|
||||||
|
$subnets = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
|
|
||||||
// Normalise object-or-array input. json_decode(json_encode($x), true) is the
|
// 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);
|
$serverObject = json_decode(json_encode($serverObject), true);
|
||||||
}
|
}
|
||||||
if (! is_array($serverObject)) {
|
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
|
// VirtFusion wraps the payload in a "data" key on GET responses but the stored
|
||||||
@@ -131,7 +142,7 @@ class IpUtil
|
|||||||
$data = $serverObject['data'] ?? $serverObject;
|
$data = $serverObject['data'] ?? $serverObject;
|
||||||
$interfaces = $data['network']['interfaces'] ?? [];
|
$interfaces = $data['network']['interfaces'] ?? [];
|
||||||
if (! is_array($interfaces)) {
|
if (! is_array($interfaces)) {
|
||||||
return ['addresses' => [], 'skipped' => []];
|
return ['addresses' => [], 'subnets' => [], 'skipped' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk every interface (not just interfaces[0]). ServerResource only reads [0]
|
// Walk every interface (not just interfaces[0]). ServerResource only reads [0]
|
||||||
@@ -159,29 +170,91 @@ class IpUtil
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback shape: VirtFusion sometimes exposes v6 only as subnet+cidr
|
// Subnet-with-cidr shape. VirtFusion's common v6 allocation model is
|
||||||
// (common when a /64 is routed to the VPS and the OS auto-assigns
|
// to route a whole /64 to the VPS and let the OS auto-assign specific
|
||||||
// specific host addresses). We can't set a PTR for the whole subnet,
|
// host addresses. The module can't know which host the customer
|
||||||
// so we only accept /128 (single-host) entries and report the rest
|
// actually uses, so we surface the subnet as a first-class entry and
|
||||||
// via the "skipped" channel so callers can surface a UI note.
|
// let the client UI offer an "Add host PTR" path with containment
|
||||||
|
// ownership verification.
|
||||||
$subnet = $v6['subnet'] ?? null;
|
$subnet = $v6['subnet'] ?? null;
|
||||||
$cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null;
|
$cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null;
|
||||||
if ($subnet && self::isIpv6($subnet)) {
|
if ($subnet && self::isIpv6($subnet) && $cidr !== null) {
|
||||||
if ($cidr === 128) {
|
if ($cidr === 128) {
|
||||||
|
// Single-host "subnet" — treat as a discrete address.
|
||||||
$addresses[$subnet] = true;
|
$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 {
|
} else {
|
||||||
$skipped[] = [
|
$skipped[] = ['subnet' => $subnet, 'cidr' => $cidr, 'reason' => 'invalid-cidr'];
|
||||||
'subnet' => $subnet,
|
|
||||||
'cidr' => $cidr,
|
|
||||||
'reason' => 'ipv6-subnet-without-explicit-host-address',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// array_keys gives us the de-duplicated list in insertion order.
|
return [
|
||||||
return ['addresses' => array_keys($addresses), 'skipped' => $skipped];
|
'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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -246,6 +246,22 @@ class PtrManager
|
|||||||
return $out;
|
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) {
|
foreach ($extracted['addresses'] as $ip) {
|
||||||
try {
|
try {
|
||||||
$loc = $this->locate($ip);
|
$loc = $this->locate($ip);
|
||||||
|
|||||||
@@ -545,3 +545,32 @@
|
|||||||
.vf-rdns-edit { flex-direction: column; align-items: stretch; }
|
.vf-rdns-edit { flex-direction: column; align-items: stretch; }
|
||||||
.vf-rdns-msg { padding-left: 0; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1117,7 +1117,8 @@ var VF_RDNS_STATUS = {
|
|||||||
"missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" },
|
"missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" },
|
||||||
"no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" },
|
"no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" },
|
||||||
"error": { label: "error", bg: "#dc3545", fg: "#fff" },
|
"error": { label: "error", bg: "#dc3545", fg: "#fff" },
|
||||||
"disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" }
|
"disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" },
|
||||||
|
"subnet-only": { label: "subnet", bg: "#17a2b8", fg: "#fff" }
|
||||||
};
|
};
|
||||||
|
|
||||||
function vfRdnsBadge(status) {
|
function vfRdnsBadge(status) {
|
||||||
@@ -1157,6 +1158,19 @@ function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ips.forEach(function (row) {
|
ips.forEach(function (row) {
|
||||||
|
// 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard per-IP row with inline PTR editor. Used for v4 addresses + discrete v6 hosts. */
|
||||||
|
function vfRenderIpRow(serviceId, systemUrl, row) {
|
||||||
var wrap = $('<div class="vf-rdns-row"></div>');
|
var wrap = $('<div class="vf-rdns-row"></div>');
|
||||||
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
||||||
var badge = vfRdnsBadge(row.status);
|
var badge = vfRdnsBadge(row.status);
|
||||||
@@ -1175,12 +1189,65 @@ function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
|
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
|
||||||
wrap.append(ipLabel).append(editor).append(badge).append(msg);
|
return wrap.append(ipLabel).append(editor).append(badge).append(msg);
|
||||||
list.append(wrap);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge) {
|
/**
|
||||||
|
* 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 = $('<div class="vf-rdns-row vf-rdns-subnet-row"></div>');
|
||||||
|
var label = $('<div class="vf-rdns-ip"></div>').text(row.subnet + "/" + row.cidr);
|
||||||
|
var badge = vfRdnsBadge(row.status);
|
||||||
|
|
||||||
|
var toggleBtn = $('<button type="button" class="btn btn-sm btn-outline-secondary">+ Add host PTR</button>');
|
||||||
|
var form = $('<div class="vf-rdns-subnet-form" style="display:none;"></div>');
|
||||||
|
|
||||||
|
var ipInput = $('<input type="text" class="form-control form-control-sm vf-rdns-input" placeholder="Host IPv6 address inside this subnet (e.g. 2602:2f3:0:5d::10)">');
|
||||||
|
var ptrInput = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="Hostname for PTR (e.g. mail.example.com)">');
|
||||||
|
var addBtn = $('<button type="button" class="btn btn-sm btn-primary">Add PTR</button>');
|
||||||
|
var cancelBtn = $('<button type="button" class="btn btn-sm btn-link">Cancel</button>');
|
||||||
|
var msg = $('<div class="vf-rdns-msg"></div>');
|
||||||
|
|
||||||
|
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 = $('<div class="vf-rdns-subnet-inputs"></div>').append(ipInput).append(ptrInput);
|
||||||
|
var actionsRow = $('<div class="vf-rdns-subnet-actions"></div>').append(addBtn).append(cancelBtn);
|
||||||
|
form.append(inputsRow).append(actionsRow).append(msg);
|
||||||
|
|
||||||
|
var editorWrap = $('<div class="vf-rdns-edit"></div>').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();
|
var ptr = (input.val() || "").trim();
|
||||||
// Light client-side regex mirrors the server-side one — strict enforcement is on the server.
|
// 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)) {
|
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";
|
var verb = (ptr === "") ? "deleted" : "saved";
|
||||||
msg.text("rDNS " + verb + ".").css("color", "#28a745").show();
|
msg.text("rDNS " + verb + ".").css("color", "#28a745").show();
|
||||||
setTimeout(function () { msg.fadeOut(); }, 2500);
|
setTimeout(function () { msg.fadeOut(); }, 2500);
|
||||||
|
// 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.
|
// Optimistically update the badge; a background refresh will correct it.
|
||||||
if (ptr === "") {
|
if (ptr === "") {
|
||||||
badge.replaceWith(vfRdnsBadge("missing"));
|
badge.replaceWith(vfRdnsBadge("missing"));
|
||||||
} else {
|
} else {
|
||||||
badge.replaceWith(vfRdnsBadge("ok"));
|
badge.replaceWith(vfRdnsBadge("ok"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (typeof onSuccess === "function") { onSuccess(); }
|
||||||
} else {
|
} else {
|
||||||
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
|
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
|
||||||
msg.text(err).css("color", "#dc3545").show();
|
msg.text(err).css("color", "#dc3545").show();
|
||||||
|
|||||||
Reference in New Issue
Block a user