5 Commits

Author SHA1 Message Date
Prophet731
3239b511bd chore(release): 1.3.0
All checks were successful
Publish Release / release (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:05:05 -04:00
Prophet731
c1c579dd14 feat(addon): Diagnose-an-IP tool + actionable auth-error messages
Two complementary improvements for operators debugging a misconfigured
addon — both motivated by a live production incident where "every IP
shows no zone" took several hypotheses (wrong serverId, wrong key,
stale cache) before landing on the real cause.

1. Diagnose-an-IP panel on the addon admin page (VirtFusionDns.php
   _output()). Takes an IP in a text input and runs the full pipeline
   inline: prints the current config snapshot, forces a fresh zone
   list from PowerDNS (bypassing cache), shows the computed PTR name,
   shows what IpUtil::findZoneAndPtrName selects, and fetches the
   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.

2. More actionable error messages in PowerDns\Client::ping():

   - On 401/403: now spells out the three real causes (API key
     mismatch, api-allow-from excluding the WHMCS IP, whitespace in
     the stored key) as a checklist, so the operator doesn't have to
     guess which they're hitting.

   - On 404: explicitly names serverId as the field to check and
     reminds that "localhost" is the PowerDNS API server identifier,
     NOT the nameserver's hostname (a surprisingly common misreading
     of the field label).

The addon helper virtfusiondns_load_server_libs() now also pulls in
Resolver + PtrManager lazily since the diagnostic pane needs IpUtil's
pipeline-level output. They're optional — missing files don't break
the basic status page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:04:42 -04:00
Prophet731
7e7f3c1c14 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>
2026-04-17 22:04:23 -04:00
Prophet731
daaddc7c24 fix(powerdns): prevent decrypt() garbage from corrupting plaintext API keys
WHMCS's addon-module password-type fields are stored plaintext in
tbladdonmodules.value — unlike tblservers.password which IS encrypted
at rest. Config::get() was blindly calling decrypt() on the raw value
and then preferring its output over raw when the two differed.

Unfortunately, when decrypt() is fed a plaintext string, it doesn't
return empty or unchanged — it returns a short binary-garbage string
(observed: 4 bytes of \xEF\xBF\xBD unicode-replacement noise for a
32-char plaintext). That garbage then went into the X-API-Key header,
PowerDNS responded 401, and every rDNS read returned an empty zone list,
surfacing as "no zone" for every IP in the client UI.

Fix: only accept decrypt()'s output when it's printable ASCII. Real
API keys are always printable; decrypted ciphertext that looks like
binary is a mangled-plaintext signal, so we fall back to raw. Also
trim() the chosen value to defeat a second foot-gun — admin UIs can
silently append a newline on paste, which would land in the header
verbatim and produce the same 401.

Diagnosed via direct WHMCS tbladdonmodules inspection on a user's
affected install; confirmed the fix end-to-end with a live ping()
returning HTTP 200 post-deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:04:04 -04:00
Prophet731
65f3f36569 ci: generate proper release notes from CHANGELOG with commit-log fallback
Previous workflow dumped every commit subject since the last tag as raw
bullets — no grouping, no structure, and it overwrote hand-edited release
bodies on every re-push.

New strategy, in order of preference:

  1. Extract the "## [X.Y.Z]" section from CHANGELOG.md and use it as the
     release body. Maintainers already write structured notes there
     (Features / Bug Fixes / Documentation per Keep-a-Changelog); this
     flows them to GitHub with zero re-typing.

  2. If CHANGELOG.md has no matching section, fall back to grouping the
     commit range by conventional-commit prefix:
       feat:     → Features
       fix:      → Bug Fixes
       refactor: → Changes
       docs:     → Documentation
       other    → Other
     Automated "chore(release):" bumps are filtered out (they're noise in
     a release the reader is already viewing).

  3. Append a "Full Changelog" compare link at the bottom when a previous
     tag exists.

Retag safety: the workflow now checks the current release body before
regenerating. If a body is already present (manual edit), it's preserved
instead of being clobbered by a force-pushed tag. To intentionally
regenerate: `gh release edit vX.Y.Z --notes ""` then re-push the tag.

Security: all ${{ ... }} interpolation flows through `env:` blocks rather
than inline into `run:` commands. Shell scripts reference those env vars
with $VAR, which is immune to the command-injection pattern documented at
https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/

Also switched to fetch-depth: 0 on checkout so `git describe --tags` can
find the previous tag (default fetch-depth: 1 has no tag history).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:36:46 -04:00
10 changed files with 619 additions and 83 deletions

View File

@@ -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 }}

View File

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

View File

@@ -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>';
} }

View File

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

View File

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

View File

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

View File

@@ -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;
} }
/** /**

View File

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

View File

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

View File

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