Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3239b511bd | ||
|
|
c1c579dd14 | ||
|
|
7e7f3c1c14 | ||
|
|
daaddc7c24 | ||
|
|
65f3f36569 |
163
.github/workflows/publish-release.yml
vendored
163
.github/workflows/publish-release.yml
vendored
@@ -1,43 +1,168 @@
|
|||||||
name: Publish Release
|
name: Publish Release
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Release-notes strategy (in order of preference):
|
||||||
|
#
|
||||||
|
# 1. If CHANGELOG.md has a "## [X.Y.Z] - YYYY-MM-DD" section matching the
|
||||||
|
# tag version, use that section verbatim. This is the normal path —
|
||||||
|
# maintainers write release notes once in CHANGELOG and they flow to
|
||||||
|
# GitHub automatically with no re-typing.
|
||||||
|
#
|
||||||
|
# 2. Otherwise, fall back to grouping the commits in the tag range by
|
||||||
|
# conventional-commit prefix (feat / fix / refactor / docs / other).
|
||||||
|
# Keeps releases useful even if the maintainer forgot the CHANGELOG.
|
||||||
|
#
|
||||||
|
# 3. Append a compare link (PREV_TAG...TAG) at the bottom so readers can
|
||||||
|
# dive into the full diff in one click.
|
||||||
|
#
|
||||||
|
# Retag safety:
|
||||||
|
# When a tag is force-pushed (e.g. to fix a last-minute doc error), the
|
||||||
|
# workflow normally would overwrite any hand-edited release body. We guard
|
||||||
|
# against that by checking the current release body BEFORE running the
|
||||||
|
# generator — if a body is already present, we leave it alone. To
|
||||||
|
# intentionally regenerate, clear the body first via:
|
||||||
|
# gh release edit vX.Y.Z --notes ""
|
||||||
|
#
|
||||||
|
# Security note:
|
||||||
|
# All ${{ ... }} interpolation in this file flows through `env:` blocks
|
||||||
|
# rather than inline in `run:` commands. Shell scripts reference those
|
||||||
|
# env vars with $VAR, which is immune to the command-injection pattern
|
||||||
|
# that hits workflows interpolating untrusted event data directly.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Need full history for `git describe` to find the previous tag and
|
||||||
|
# for `git log PREV..HEAD` to enumerate commits in the release range.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Derive versions
|
||||||
id: tag
|
id: version
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
env:
|
||||||
|
REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
TAG="${REF#refs/tags/}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
# Previous tag for compare link + commit range. Empty on first release.
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || echo "")
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Tag: $TAG Version: $VERSION Previous: ${PREV_TAG:-<none>}"
|
||||||
|
|
||||||
|
- name: Check for existing release body
|
||||||
|
id: existing
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
# If the release already has a non-empty body, skip generation so
|
||||||
|
# hand-edits survive tag re-pushes. Fresh releases (no body) proceed.
|
||||||
|
BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body 2>/dev/null || echo "")
|
||||||
|
if [ -n "$(printf '%s' "$BODY" | tr -d '[:space:]')" ]; then
|
||||||
|
echo "Existing release body detected — preserving manual edits."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "No existing body (or empty) — will generate."
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
id: notes
|
if: steps.existing.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
PREV_TAG: ${{ steps.version.outputs.prev_tag }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
# Get previous tag
|
set -eo pipefail
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
|
||||||
if [ -n "$PREV_TAG" ]; then
|
# --- 1. Try extracting the section from CHANGELOG.md --------------
|
||||||
NOTES=$(git log --pretty=format:"- %s" "$PREV_TAG"..HEAD)
|
# Matches "## [1.2.0] ..." exactly and prints every line up to the
|
||||||
else
|
# next "## [" heading or EOF.
|
||||||
NOTES=$(git log --pretty=format:"- %s")
|
CHANGELOG_SECTION=""
|
||||||
|
if [ -f CHANGELOG.md ]; then
|
||||||
|
CHANGELOG_SECTION=$(awk -v ver="$VERSION" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" { found=1; next }
|
||||||
|
found && /^## \[/ { exit }
|
||||||
|
found { print }
|
||||||
|
' CHANGELOG.md)
|
||||||
fi
|
fi
|
||||||
# Write to file for the release body
|
|
||||||
echo "$NOTES" > /tmp/release-notes.txt
|
# --- 2. Commit-based fallback ------------------------------------
|
||||||
|
# Used only when CHANGELOG has no section for this version. Groups
|
||||||
|
# conventional-commit prefixes into readable categories; skips
|
||||||
|
# automated "chore(release): …" bump commits from display since
|
||||||
|
# they're noise in a release the reader is already looking at.
|
||||||
|
if [ -z "$(printf '%s' "$CHANGELOG_SECTION" | tr -d '[:space:]')" ]; then
|
||||||
|
echo "::warning::CHANGELOG.md has no section for [$VERSION]; falling back to commit-log grouping."
|
||||||
|
|
||||||
|
if [ -n "$PREV_TAG" ]; then RANGE="$PREV_TAG..HEAD"; else RANGE=""; fi
|
||||||
|
LOG=$(git log $RANGE --no-merges --pretty=format:'%s' \
|
||||||
|
| grep -vE '^chore\(release\)' || true)
|
||||||
|
|
||||||
|
# extract <regex> — prints matching commits as "- <rest>" with the
|
||||||
|
# conventional-commit "type(scope)?:" prefix stripped for readability.
|
||||||
|
extract() {
|
||||||
|
printf '%s\n' "$LOG" \
|
||||||
|
| grep -E "^($1)(\([^)]+\))?:" \
|
||||||
|
| sed -E "s/^($1)(\([^)]+\))?:[[:space:]]*/- /" \
|
||||||
|
|| true
|
||||||
|
}
|
||||||
|
|
||||||
|
FEATURES=$(extract 'feat')
|
||||||
|
FIXES=$(extract 'fix')
|
||||||
|
REFACTORS=$(extract 'refactor')
|
||||||
|
DOCS=$(extract 'docs')
|
||||||
|
OTHER=$(printf '%s\n' "$LOG" \
|
||||||
|
| grep -vE '^(feat|fix|refactor|docs|chore)(\([^)]+\))?:' \
|
||||||
|
| sed -E 's/^/- /' \
|
||||||
|
|| true)
|
||||||
|
|
||||||
|
{
|
||||||
|
[ -n "$FEATURES" ] && printf '### Features\n\n%s\n\n' "$FEATURES"
|
||||||
|
[ -n "$FIXES" ] && printf '### Bug Fixes\n\n%s\n\n' "$FIXES"
|
||||||
|
[ -n "$REFACTORS" ] && printf '### Changes\n\n%s\n\n' "$REFACTORS"
|
||||||
|
[ -n "$DOCS" ] && printf '### Documentation\n\n%s\n\n' "$DOCS"
|
||||||
|
[ -n "$OTHER" ] && printf '### Other\n\n%s\n\n' "$OTHER"
|
||||||
|
} > /tmp/generated.md
|
||||||
|
|
||||||
|
CHANGELOG_SECTION=$(cat /tmp/generated.md)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Compose final body (content + compare footer) ------------
|
||||||
|
{
|
||||||
|
printf '%s\n' "$CHANGELOG_SECTION"
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
printf '\n---\n\n**Full Changelog:** [%s...%s](https://github.com/%s/compare/%s...%s)\n' \
|
||||||
|
"$PREV_TAG" "$TAG" "$REPO" "$PREV_TAG" "$TAG"
|
||||||
|
fi
|
||||||
|
} > /tmp/release-notes.md
|
||||||
|
|
||||||
|
echo "--- release notes ($(wc -c < /tmp/release-notes.md) bytes) ---"
|
||||||
|
head -20 /tmp/release-notes.md
|
||||||
|
echo "---"
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
|
if: steps.existing.outputs.skip != 'true'
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.tag.outputs.version }}
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
name: ${{ steps.tag.outputs.version }}
|
name: ${{ steps.version.outputs.tag }}
|
||||||
body_path: /tmp/release-notes.txt
|
body_path: /tmp/release-notes.md
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-04-17
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Critical: decrypt() corruption of plaintext addon API keys.** `Config::get()` was calling WHMCS's `decrypt()` on the raw `tbladdonmodules.value` for the PowerDNS API key and accepting whatever non-empty result came back. WHMCS addon password-type fields are actually stored **plaintext** (unlike `tblservers.password` which is encrypted), and `decrypt()` on plaintext input returns ~4 bytes of binary garbage instead of empty. That garbage was ending up in the `X-API-Key:` header, producing a baffling 401 from PowerDNS and an empty zone list — which then surfaced as **"no zone"** for every IP in the client-area rDNS panel. Fix: only use `decrypt()`'s output when it's printable ASCII; fall back to raw otherwise. Also `trim()` the chosen value so a stray paste-newline can't corrupt the header.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **IPv6 subnet visibility + custom-host PTR flow.** VirtFusion allocates v6 as whole subnets (e.g. a /64 routed to the VPS) rather than discrete host addresses. The module previously filtered these silently; now subnets appear as first-class rows in the client rDNS panel with a collapsible "Add host PTR" form. Ownership verification uses **subnet containment** (`IpUtil::ipv6InSubnet()` via `inet_pton` + bit masking) so any address inside one of the VPS's allocated subnets is writeable, while addresses outside them are rejected. FCrDNS / rate-limit / CSRF guards all still apply.
|
||||||
|
- **Diagnose-an-IP tool** on the VirtFusion DNS addon admin page. Takes an IP input and runs the full PtrManager pipeline inline: config snapshot, fresh zone list (cache-bypassed), computed PTR name, matched zone, current PTR content. Every common failure mode (wrong key, wrong serverId, forgotten zone, mis-aligned RFC 2317 label, stale cache) produces a distinctive shape in that output, turning "support ticket" into "screenshot the diagnosis".
|
||||||
|
- **Actionable auth-error messages.** `Client::ping()` now returns structured guidance on 401/403 (check API key, `api-allow-from`, whitespace) and 404 (check `serverId`, it should be the literal `localhost`), replacing the previous "authentication failed (check API key)" / "unexpected HTTP 404" which gave no clue which of several causes was actually biting.
|
||||||
|
|
||||||
## [1.2.0] - 2026-04-17
|
## [1.2.0] - 2026-04-17
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ function virtfusiondns_load_server_libs(): bool
|
|||||||
}
|
}
|
||||||
require_once $base . $f;
|
require_once $base . $f;
|
||||||
}
|
}
|
||||||
|
// PtrManager + IpUtil are only needed for the diagnostic tool below; load them
|
||||||
|
// if present but don't require them for the basic status page to work.
|
||||||
|
foreach (['PowerDns/Resolver.php', 'PowerDns/PtrManager.php'] as $optional) {
|
||||||
|
if (is_file($base . $optional)) {
|
||||||
|
require_once $base . $optional;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -230,5 +237,131 @@ function VirtFusionDns_output($vars)
|
|||||||
echo '<li><code>api-allow-from</code> must include the WHMCS host\'s IP.</li>';
|
echo '<li><code>api-allow-from</code> must include the WHMCS host\'s IP.</li>';
|
||||||
echo '</ul>';
|
echo '</ul>';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Diagnostic: "What does the module see for IP X?"
|
||||||
|
//
|
||||||
|
// Runs the full pipeline an admin would otherwise have to trace through
|
||||||
|
// multiple log lines to reproduce:
|
||||||
|
// 1. Current config (what values is Config::get() actually returning?)
|
||||||
|
// 2. Zone list (what does Client::listZones() return right now, post-cache?)
|
||||||
|
// 3. Zone match for an input IP (is findZoneAndPtrName selecting the right zone?)
|
||||||
|
// 4. Current PTR content at the located (zone, ptrName) pair
|
||||||
|
//
|
||||||
|
// Catches every common failure mode: wrong API key (empty zones, auth error),
|
||||||
|
// wrong server ID (404), forgotten zone (no match), stale cache (mismatched
|
||||||
|
// zones), and typos in the RFC 2317 zone name (parseClasslessZone rejection).
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo '<h3 style="margin-top:24px">Diagnose an IP</h3>';
|
||||||
|
echo '<p>Runs the exact same pipeline the client-area rDNS panel uses. Useful when a specific IP shows "no zone" in the UI and you need to see <em>why</em>.</p>';
|
||||||
|
|
||||||
|
$diagIp = isset($_GET['diag_ip']) ? trim((string) $_GET['diag_ip']) : '';
|
||||||
|
echo '<form method="get" action="" style="display:flex;gap:8px;align-items:center;margin-bottom:12px">';
|
||||||
|
// WHMCS passes the module slug via ?module=... — preserve any existing query params
|
||||||
|
// by re-emitting the current GET state as hidden fields (except diag_ip itself).
|
||||||
|
foreach ($_GET as $k => $v) {
|
||||||
|
if ($k === 'diag_ip') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo '<input type="hidden" name="' . htmlspecialchars((string) $k, ENT_QUOTES, 'UTF-8') . '" value="' . htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8') . '">';
|
||||||
|
}
|
||||||
|
echo '<input type="text" name="diag_ip" placeholder="IP address (e.g. 198.51.100.42 or 2001:db8::1)" value="' . htmlspecialchars($diagIp, ENT_QUOTES, 'UTF-8') . '" class="form-control form-control-sm" style="max-width:320px;font-family:monospace">';
|
||||||
|
echo '<button type="submit" class="btn btn-primary btn-sm">Diagnose</button>';
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
if ($diagIp !== '') {
|
||||||
|
echo '<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;padding:12px;font-family:monospace;font-size:13px;white-space:pre-wrap;word-break:break-all">';
|
||||||
|
|
||||||
|
if (filter_var($diagIp, FILTER_VALIDATE_IP) === false) {
|
||||||
|
echo '<span style="color:#dc3545">Invalid IP address.</span>';
|
||||||
|
} elseif (! Config::isEnabled()) {
|
||||||
|
echo '<span style="color:#dc3545">Addon disabled or missing endpoint/API key. Diagnosis skipped.</span>';
|
||||||
|
} else {
|
||||||
|
$client = new Client;
|
||||||
|
|
||||||
|
echo '<strong>Config snapshot:</strong>' . "\n";
|
||||||
|
echo ' endpoint = ' . htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||||
|
echo ' serverId = ' . htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||||
|
echo ' cacheTtl = ' . $cacheTtl . 's' . "\n";
|
||||||
|
echo ' apiKey = ' . ($config['apiKey'] !== '' ? '(set, ' . strlen($config['apiKey']) . ' chars)' : '(MISSING)') . "\n\n";
|
||||||
|
|
||||||
|
// Always forget cache before diagnose so we see the LIVE state, not a
|
||||||
|
// potentially-stale cached list from an earlier misconfigured call.
|
||||||
|
$client->forgetZoneCache();
|
||||||
|
$zones = $client->listZones();
|
||||||
|
|
||||||
|
echo '<strong>Live zone list (cache purged, ' . count($zones) . ' zones):</strong>' . "\n";
|
||||||
|
if (empty($zones)) {
|
||||||
|
echo ' <span style="color:#dc3545">NO ZONES RETURNED.</span>' . "\n";
|
||||||
|
echo ' Likely causes: wrong API key (PowerDNS returned 401/403), wrong Server ID' . "\n";
|
||||||
|
echo ' (PowerDNS returned 404), or api-allow-from blocking the WHMCS host IP.' . "\n";
|
||||||
|
echo ' Run the Test Connection button above to see the exact HTTP error.' . "\n\n";
|
||||||
|
} else {
|
||||||
|
foreach (array_slice($zones, 0, 15) as $z) {
|
||||||
|
echo ' ' . htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . "\n";
|
||||||
|
}
|
||||||
|
if (count($zones) > 15) {
|
||||||
|
echo ' ... and ' . (count($zones) - 15) . ' more' . "\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$ptrName = IpUtil::ptrNameForIp($diagIp);
|
||||||
|
echo '<strong>Computed PTR name for ' . htmlspecialchars($diagIp, ENT_QUOTES, 'UTF-8') . ':</strong>' . "\n";
|
||||||
|
echo ' ' . htmlspecialchars((string) $ptrName, ENT_QUOTES, 'UTF-8') . "\n\n";
|
||||||
|
|
||||||
|
$loc = IpUtil::findZoneAndPtrName($diagIp, $zones);
|
||||||
|
echo '<strong>Zone match (IpUtil::findZoneAndPtrName):</strong>' . "\n";
|
||||||
|
if ($loc === null) {
|
||||||
|
echo ' <span style="color:#dc3545">NO MATCH.</span>' . "\n";
|
||||||
|
echo ' The IP does not fall within any zone returned above.' . "\n";
|
||||||
|
if (IpUtil::isIpv4($diagIp)) {
|
||||||
|
$oct = (int) explode('.', $diagIp)[3];
|
||||||
|
echo " For IPv4: confirm a standard reverse zone exists (one of the listed\n";
|
||||||
|
echo " zones should end with the first-three-octets-reversed of $diagIp), OR\n";
|
||||||
|
echo " that an RFC 2317 classless zone exists whose range covers octet $oct.\n";
|
||||||
|
}
|
||||||
|
if (IpUtil::isIpv6($diagIp)) {
|
||||||
|
echo " For IPv6: confirm a reverse zone exists ending in .ip6.arpa. whose\n";
|
||||||
|
echo " nibble prefix matches the high-order bits of $diagIp.\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
} else {
|
||||||
|
echo ' zone = ' . htmlspecialchars($loc['zone'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||||
|
echo ' ptrName = ' . htmlspecialchars($loc['ptrName'], ENT_QUOTES, 'UTF-8') . "\n\n";
|
||||||
|
|
||||||
|
// Actual current PTR content, if any.
|
||||||
|
echo '<strong>Current PTR record in PowerDNS:</strong>' . "\n";
|
||||||
|
$zoneData = $client->getZone($loc['zone']);
|
||||||
|
if ($zoneData === null) {
|
||||||
|
echo ' <span style="color:#dc3545">Unable to fetch zone contents (HTTP error or not found).</span>' . "\n";
|
||||||
|
} else {
|
||||||
|
$found = null;
|
||||||
|
foreach ($zoneData['rrsets'] ?? [] as $rr) {
|
||||||
|
if (($rr['type'] ?? '') === 'PTR' && rtrim($rr['name'], '.') === rtrim($loc['ptrName'], '.')) {
|
||||||
|
foreach ($rr['records'] ?? [] as $rec) {
|
||||||
|
if (empty($rec['disabled']) && ! empty($rec['content'])) {
|
||||||
|
$found = [
|
||||||
|
'content' => $rec['content'],
|
||||||
|
'ttl' => (int) ($rr['ttl'] ?? 0),
|
||||||
|
];
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($found === null) {
|
||||||
|
echo ' (no PTR record present at ' . htmlspecialchars($loc['ptrName'], ENT_QUOTES, 'UTF-8') . ')' . "\n";
|
||||||
|
} else {
|
||||||
|
echo ' content = ' . htmlspecialchars($found['content'], ENT_QUOTES, 'UTF-8') . "\n";
|
||||||
|
echo ' ttl = ' . $found['ttl'] . 's' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -128,7 +128,38 @@ class Client
|
|||||||
return ['ok' => false, 'http' => 0, 'error' => $err];
|
return ['ok' => false, 'http' => 0, 'error' => $err];
|
||||||
}
|
}
|
||||||
if ($http === 401 || $http === 403) {
|
if ($http === 401 || $http === 403) {
|
||||||
return ['ok' => false, 'http' => $http, 'error' => 'authentication failed (check API key)'];
|
// Three distinct causes all produce 401/403 here:
|
||||||
|
// (a) Actual wrong API key — the #1 obvious cause.
|
||||||
|
// (b) `api-allow-from` in PowerDNS config excludes the WHMCS
|
||||||
|
// host's IP. PowerDNS rejects pre-auth in some configs,
|
||||||
|
// producing 401/403 even with a valid key.
|
||||||
|
// (c) Invisible whitespace in the stored key (fixed in Config
|
||||||
|
// via trim(), but a pre-upgrade install might still have
|
||||||
|
// a cached request dating from before the fix).
|
||||||
|
// Listing all three gives the operator a concrete checklist.
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http' => $http,
|
||||||
|
'error' => 'HTTP ' . $http . ' — PowerDNS rejected authentication. Check: ' .
|
||||||
|
'(1) the X-API-Key matches the `api-key=` in PowerDNS config, ' .
|
||||||
|
'(2) `api-allow-from=` includes this WHMCS host\'s IP, and ' .
|
||||||
|
'(3) the key has no trailing whitespace/newlines (re-paste it if unsure).',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($http === 404) {
|
||||||
|
// The endpoint reached PowerDNS (no 0/connection-refused) but the
|
||||||
|
// server ID path segment isn't known. By far the most common cause
|
||||||
|
// is an addon misconfiguration where someone entered the nameserver
|
||||||
|
// FQDN instead of the literal string "localhost" into the Server ID
|
||||||
|
// field. Surface that hypothesis directly — it's the single highest-
|
||||||
|
// probability fix and turns a mystery into an actionable error.
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http' => 404,
|
||||||
|
'error' => 'HTTP 404 — PowerDNS does not recognise server id "' . $this->serverId .
|
||||||
|
'". This field should almost always be the literal string "localhost" ' .
|
||||||
|
'(the PowerDNS API server identifier, NOT your nameserver hostname).',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)];
|
return ['ok' => false, 'http' => $http, 'error' => 'unexpected HTTP ' . $http . ': ' . substr((string) $body, 0, 200)];
|
||||||
|
|||||||
@@ -119,23 +119,50 @@ class Config
|
|||||||
$config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60));
|
$config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60));
|
||||||
|
|
||||||
if (! empty($rows['apiKey'])) {
|
if (! empty($rows['apiKey'])) {
|
||||||
|
$raw = (string) $rows['apiKey'];
|
||||||
|
$decrypted = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// decrypt() is WHMCS's global helper — matches how the VirtFusion
|
// decrypt() is WHMCS's global helper — matches how the VirtFusion
|
||||||
// bearer token is handled in Module::getCP().
|
// bearer token is handled in Module::getCP().
|
||||||
$decrypted = decrypt($rows['apiKey']);
|
$decrypted = (string) decrypt($raw);
|
||||||
|
|
||||||
// Fallback to raw value if decrypt returned empty or non-string —
|
|
||||||
// defends against the rare case where decrypt silently fails
|
|
||||||
// (wrong encryption key at rest) or the value was inserted
|
|
||||||
// manually as plaintext during development.
|
|
||||||
$config['apiKey'] = is_string($decrypted) && $decrypted !== '' ? $decrypted : (string) $rows['apiKey'];
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Even when decrypt throws, we try the raw value so a diagnostic
|
// Even when decrypt throws, we try the raw value so a diagnostic
|
||||||
// path exists. Operator sees the decrypt error in the module log
|
// path exists. Operator sees the decrypt error in the module log
|
||||||
// but isn't locked out of using the addon while they investigate.
|
// but isn't locked out of using the addon while they investigate.
|
||||||
$config['apiKey'] = (string) $rows['apiKey'];
|
Log::insert('PowerDns:Config', 'decrypt threw', $e->getMessage());
|
||||||
Log::insert('PowerDns:Config', 'decrypt skipped', $e->getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WHMCS addon module password-type fields are stored PLAINTEXT in
|
||||||
|
// tbladdonmodules.value (unlike tblservers.password which IS encrypted).
|
||||||
|
// When fed a plaintext input, WHMCS's decrypt() doesn't return empty
|
||||||
|
// or unchanged — it returns a short binary garbage string. If we used
|
||||||
|
// that as the API key we'd produce a baffling 401 from PowerDNS.
|
||||||
|
//
|
||||||
|
// Heuristic: an API key is printable ASCII by definition. If
|
||||||
|
// decrypt() produced non-printable output, we know it mangled a
|
||||||
|
// plaintext value and we should stick with raw. If decrypt()
|
||||||
|
// produced a different-but-printable string, it's a genuine
|
||||||
|
// decryption of an actually-encrypted value (unusual for addons,
|
||||||
|
// but some third-party setups do encrypt at rest).
|
||||||
|
//
|
||||||
|
// trim() handles another common foot-gun: admin UIs silently
|
||||||
|
// appending a newline on paste, which would land in the
|
||||||
|
// X-API-Key: header verbatim and also produce a 401.
|
||||||
|
$candidate = $raw;
|
||||||
|
if ($decrypted !== '' && $decrypted !== $raw && ctype_print($decrypted)) {
|
||||||
|
$candidate = $decrypted;
|
||||||
|
} elseif ($decrypted !== '' && $decrypted !== $raw) {
|
||||||
|
// Decrypt output wasn't printable — it's garbage from mangling
|
||||||
|
// a plaintext input. Log once so the diagnostic trail is clear
|
||||||
|
// but don't expose key material.
|
||||||
|
Log::insert(
|
||||||
|
'PowerDns:Config',
|
||||||
|
'decrypt produced non-printable output; using raw',
|
||||||
|
['raw_len' => strlen($raw), 'dec_len' => strlen($decrypted)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$config['apiKey'] = trim($candidate);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Any DB-level failure (table doesn't exist, connection dropped, etc.)
|
// Any DB-level failure (table doesn't exist, connection dropped, etc.)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1112,12 +1112,13 @@ function vfCopyButton(text) {
|
|||||||
|
|
||||||
/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */
|
/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */
|
||||||
var VF_RDNS_STATUS = {
|
var VF_RDNS_STATUS = {
|
||||||
"ok": { label: "OK", bg: "#28a745", fg: "#fff" },
|
"ok": { label: "OK", bg: "#28a745", fg: "#fff" },
|
||||||
"unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" },
|
"unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" },
|
||||||
"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,30 +1158,96 @@ function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ips.forEach(function (row) {
|
ips.forEach(function (row) {
|
||||||
var wrap = $('<div class="vf-rdns-row"></div>');
|
// Subnet-only rows (IPv6 /64 allocations) render as a distinct informational
|
||||||
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
// anchor with an expandable "Add host PTR" form — the customer types a
|
||||||
var badge = vfRdnsBadge(row.status);
|
// specific address inside the subnet + hostname, backend verifies containment.
|
||||||
|
if (row.status === "subnet-only") {
|
||||||
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="host.example.com (blank to delete)">');
|
list.append(vfRenderSubnetRow(serviceId, systemUrl, row));
|
||||||
input.val(row.ptr || "");
|
return;
|
||||||
|
}
|
||||||
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
|
list.append(vfRenderIpRow(serviceId, systemUrl, row));
|
||||||
var msg = $('<div class="vf-rdns-msg"></div>');
|
|
||||||
|
|
||||||
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 = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
|
|
||||||
wrap.append(ipLabel).append(editor).append(badge).append(msg);
|
|
||||||
list.append(wrap);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = $('<div class="vf-rdns-row"></div>');
|
||||||
|
var ipLabel = $('<div class="vf-rdns-ip"></div>').text(row.ip);
|
||||||
|
var badge = vfRdnsBadge(row.status);
|
||||||
|
|
||||||
|
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input" maxlength="253" placeholder="host.example.com (blank to delete)">');
|
||||||
|
input.val(row.ptr || "");
|
||||||
|
|
||||||
|
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
|
||||||
|
var msg = $('<div class="vf-rdns-msg"></div>');
|
||||||
|
|
||||||
|
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 = $('<div class="vf-rdns-edit"></div>').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 = $('<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);
|
||||||
// Optimistically update the badge; a background refresh will correct it.
|
// Badge may be null (e.g. when called from the subnet row's Add-PTR form
|
||||||
if (ptr === "") {
|
// which has no per-row badge to update). Guard rather than crash.
|
||||||
badge.replaceWith(vfRdnsBadge("missing"));
|
if (badge) {
|
||||||
} else {
|
// Optimistically update the badge; a background refresh will correct it.
|
||||||
badge.replaceWith(vfRdnsBadge("ok"));
|
if (ptr === "") {
|
||||||
|
badge.replaceWith(vfRdnsBadge("missing"));
|
||||||
|
} else {
|
||||||
|
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