Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7825f6be80 | ||
|
|
1e0a1308bf | ||
|
|
8caf8c0c01 | ||
|
|
589442e59c | ||
|
|
c90cbd7399 | ||
|
|
bb12cae954 | ||
|
|
5249d6bc19 | ||
|
|
3ea21dfb60 | ||
|
|
fecbf701b7 | ||
|
|
02e059274b | ||
|
|
e9772ed29f | ||
|
|
a3c4154fb2 | ||
|
|
cece1f5ae0 | ||
|
|
f4d6b06203 | ||
|
|
1f09671fee | ||
|
|
6ae3ab55a9 | ||
|
|
0c913110cc | ||
|
|
3239b511bd | ||
|
|
c1c579dd14 | ||
|
|
7e7f3c1c14 | ||
|
|
daaddc7c24 | ||
|
|
65f3f36569 | ||
|
|
a1406f8193 | ||
|
|
a2ffb7d53a | ||
|
|
8a88862364 | ||
|
|
ad85439dfb | ||
|
|
d253bd44e6 | ||
|
|
1ab2ef42a5 | ||
|
|
3ca9eb60c3 | ||
|
|
504d2926a4 | ||
|
|
64dcce3d0e | ||
|
|
6694a5e44d | ||
|
|
6528c8a53a | ||
|
|
d3d75b4752 | ||
|
|
3d3df6e2dc | ||
|
|
0ade74dd4e | ||
|
|
a9565ff6f9 | ||
|
|
9cd737c5d5 | ||
|
|
90a97c4afb |
200
.github/workflows/publish-release.yml
vendored
200
.github/workflows/publish-release.yml
vendored
@@ -1,41 +1,189 @@
|
|||||||
# .github/workflows/semantic-versioning-release.yml
|
name: Publish Release
|
||||||
name: Automated Semantic Versioning 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:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write # for creating tags and releases
|
|
||||||
issues: write # for commenting on issues
|
|
||||||
pull-requests: write # for commenting on PRs
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# This is required to analyze the full commit history
|
# 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
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Automated Semantic Release
|
- name: Derive versions
|
||||||
# This action wraps the popular semantic-release tool
|
id: version
|
||||||
uses: cycjimmy/semantic-release-action@v4
|
|
||||||
with:
|
|
||||||
# You can specify the branches to release from
|
|
||||||
branch: main
|
|
||||||
extra_plugins: |
|
|
||||||
@semantic-release/changelog
|
|
||||||
@semantic-release/git
|
|
||||||
env:
|
env:
|
||||||
# GITHUB_TOKEN is required for authentication
|
REF: ${{ github.ref }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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>}"
|
||||||
|
|
||||||
# To make this work, you must follow the Conventional Commits specification.
|
- name: Check for existing release body
|
||||||
# Examples:
|
id: existing
|
||||||
# - fix: correct a typo in the documentation
|
env:
|
||||||
# - feat: add a new user authentication endpoint
|
GH_TOKEN: ${{ github.token }}
|
||||||
# - feat(api): add rate limiting
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
# BREAKING CHANGE: The API now returns 429 when rate limit is exceeded.
|
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
|
||||||
|
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: |
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
# --- 1. Try extracting the section from CHANGELOG.md --------------
|
||||||
|
# Matches "## [1.2.0] ..." exactly and prints every line up to the
|
||||||
|
# next "## [" heading or EOF.
|
||||||
|
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
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
if: steps.existing.outputs.skip != 'true'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
|
name: ${{ steps.version.outputs.tag }}
|
||||||
|
body_path: /tmp/release-notes.md
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
make_latest: 'true'
|
||||||
|
|
||||||
|
# Belt-and-suspenders: action-gh-release@v2 has a long-standing
|
||||||
|
# intermittent bug where it creates the release as a draft and silently
|
||||||
|
# fails to flip the draft→published step, even though it reports success.
|
||||||
|
# When that happens the install script + README snippets resolve "latest"
|
||||||
|
# to whatever was last properly published, so users would get an old
|
||||||
|
# version. We explicitly flip to published + latest here as a safety net;
|
||||||
|
# if the action already did it correctly, this is a no-op.
|
||||||
|
#
|
||||||
|
# Security note: TAG and REPO are sourced from earlier `env:` blocks (not
|
||||||
|
# interpolated inline into the run command), matching the same pattern
|
||||||
|
# used elsewhere in this workflow.
|
||||||
|
- name: Force-publish release
|
||||||
|
if: steps.existing.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
|
/.superpowers/
|
||||||
|
/vendor/
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"branches": ["main"],
|
|
||||||
"plugins": [
|
|
||||||
"@semantic-release/commit-analyzer",
|
|
||||||
"@semantic-release/release-notes-generator",
|
|
||||||
["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
|
|
||||||
"@semantic-release/github",
|
|
||||||
["@semantic-release/git", {
|
|
||||||
"assets": ["CHANGELOG.md"],
|
|
||||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
|
||||||
}]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
145
CHANGELOG.md
145
CHANGELOG.md
@@ -1,23 +1,134 @@
|
|||||||
# 1.0.0 (2026-02-07)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* add null/false guards, proper error handling, and VNC popup fix ([49fdd9e](https://github.com/EZSCALE/virtfusion-whmcs-module/commit/49fdd9e49ba87bfb4b72dd741e15f790c1050033))
|
|
||||||
* TestConnection for unsaved servers, traffic display, and cache-busting ([e8d2eb0](https://github.com/EZSCALE/virtfusion-whmcs-module/commit/e8d2eb0aa1f173f13bb0b8d7dfca0acebb821ac7))
|
|
||||||
* XSS escaping, null guards, JS bug fixes, and documentation updates ([6c7cdc6](https://github.com/EZSCALE/virtfusion-whmcs-module/commit/6c7cdc6421678390746adcee4877a7ade8f2a061))
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add client-side SSH Ed25519 key generator on order page ([209e01d](https://github.com/EZSCALE/virtfusion-whmcs-module/commit/209e01deb6832dce76a307410fbab28b1e420093))
|
|
||||||
* add VNC check, SSH key paste, resources panel, sliders, and self-service billing ([1e471af](https://github.com/EZSCALE/virtfusion-whmcs-module/commit/1e471affd0ae9a68358afa5704523bce9bb413d0))
|
|
||||||
* streamline network panel, conditional self-service, remove IP add endpoints ([e73e85c](https://github.com/EZSCALE/virtfusion-whmcs-module/commit/e73e85c5a9faa79b50e4949328c1d2a3cbc49ddf))
|
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||||
|
|
||||||
|
## [1.4.4] - 2026-04-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **`install.sh`: "TMP: unbound variable" error at script exit, plus exit code 1 even on successful installs.** The cleanup `trap 'rm -rf "$TMP"' EXIT` referenced a `local TMP` from inside `cmd_sync()`. The EXIT trap doesn't fire until the *shell* exits — by which time the function-scoped local is out of scope — and `set -u` then exploded the trap body, masking the real exit code with `1`. Fix: drop `local` so `TMP` persists at script scope until cleanup runs, and switch the trap body to `${TMP:-}` so any future regression that tightens TMP's scope still survives the trap. Cosmetic in practice (the install/upgrade work itself completed before the trap ran), but the non-zero exit broke automated wrappers and cron-driven invocations that check `$?`.
|
||||||
|
|
||||||
|
## [1.4.3] - 2026-04-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **`install.sh` helper script with `install` / `upgrade` / `check` subcommands.** Single-file POSIX bash script that handles both first-time installation and upgrades, auto-detects the WHMCS web user from the parent directory's ownership and applies it to new files via rsync `--chown`, optionally syncs the PowerDNS reverse-DNS addon (`--with-addon`), accepts a pinned version (`--version v1.4.1`, default: latest published release), preserves any custom `config/ConfigOptionMapping.php` across the rsync `--delete`, and writes a `.installed-version` marker so the `check` subcommand can report installed-vs-latest without making changes. Pipeable via curl or wget. Exit codes for `check` (0=current, 1=outdated, 2=not installed) make it usable as a cron-driven update monitor. Closes the long-standing pitfall where rsyncing as root left files owned by `root:root` and the web server couldn't read them — the classic "module installed but invisible in WHMCS" symptom.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Release workflow now force-publishes new releases to non-draft and marks them `--latest`.** `softprops/action-gh-release@v2` has a long-standing intermittent bug where it creates a release as a draft and silently fails to flip it to published, despite reporting success. v1.4.0, v1.4.1, and v1.4.2 all shipped as drafts because of this — meaning the GitHub `releases/latest` API returned v1.3.0, the install snippets and the new `install.sh` would all download v1.3.0, and users would never get the storage-type-code fix even after running the documented upgrade. Added a `make_latest: 'true'` input to the action and a follow-up `gh release edit --draft=false --latest` step that runs unconditionally as a safety net. v1.4.0/1/2 were manually re-published as a one-off cleanup.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- README install/upgrade sections rewritten to feature the `install.sh` script as the primary path (with both `curl` and `wget` examples), with the manual rsync recipe preserved in collapsible `<details>` blocks for users who prefer not to pipe scripts to bash. The manual recipe also gained a `stat -c '%U:%G'` ownership probe and `--chown="$OWNER"` flag, fixing the same root-owned-file pitfall the script handles automatically.
|
||||||
|
|
||||||
|
## [1.4.2] - 2026-04-25
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Install/upgrade snippets now pull tagged releases instead of cloning `main`.** The previous `git clone` flow always pulled HEAD, which could include in-flight commits between releases — the same trap the v1.4.1 storage-type-code bug fell into for anyone who installed during the v1.4.0 release window. The new snippets default to the latest published release (queried live from the GitHub API at install time) and accept a `VERSION=vX.Y.Z` override for pinned installs and rollbacks. Pure POSIX — only requires `curl`, `sed`, `tar`, and `rsync`, all standard on any WHMCS host. The `archive/refs/tags/<TAG>.tar.gz` endpoint is public and cacheable, so only the version lookup hits the GitHub API (well under the 60/hr unauthenticated rate limit).
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-04-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Critical: stock control returned qty=0 fleet-wide for packages with a `primaryStorageProfile`.** `StockControl::capForStorage()` was comparing the package's `primaryStorageProfile` against `otherStorage[].id`, but the VirtFusion API exposes that field as a **storage type code** (mirrors `server_packages.storage_type`) — a filter that should match `otherStorage[].storageType`. Pool ids are unique per hypervisor (e.g. 23/28/30 for the same logical mountpoint on three nodes) and almost never collide with the type-code domain (0=local, 4=mountpoint, etc.), so the check returned 0 for every hypervisor and silently zeroed inventory for any product that opted into stock control with a non-default storage profile. Symptoms: every stock-controlled VPS product showed qty=0 in WHMCS despite abundant memory/CPU/IPv4 capacity; only workarounds were disabling stock control or removing `primaryStorageProfile` from the package, both of which defeat the gating. Fix: match `pool.storageType` instead of `pool.id`; walk all pools that match (a hypervisor may carry multiple pools of the same type) and pick the one that fits the most VMs; treat a disabled pool as skip-and-continue rather than a hard zero, so an enabled peer of the same type still contributes. Also renamed the internal `$profileId` parameter to `$storageTypeId` so future readers don't fall into the same naming trap. Verified on a 3-hypervisor cluster: qty went from 0/0/0/0/0/0/0/0 to 66/32/15/7/3/1/32/15 across the VPS-1 through VPS-32 products with no other config change.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-04-24
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Dynamic VPS stock control driven by live hypervisor capacity.** Opt-in per product via WHMCS's native `tblproducts.stockcontrol` toggle; when enabled, the module overwrites `tblproducts.qty` with the real number of VPSes the panel can still provision and WHMCS handles the "Out of Stock" badge, Add-to-Cart gating, and checkout refusal natively — no template work required. qty is derived by combining two authoritative sources:
|
||||||
|
- `GET /packages/{packageId}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`)
|
||||||
|
- `GET /compute/hypervisors/groups/{id}/resources` for live per-hypervisor free/allocated data
|
||||||
|
|
||||||
|
Algorithm sums `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) for every group the product can be placed in (default `configoption1` plus every numeric value of a `Location` configurable option), capped by the group-level IPv4 pool taken as `max()` within a group to avoid double-counting. Storage matching is strict against `package.primaryStorageProfile`; hypervisors without the named pool contribute 0. Confirmed-missing conditions (HTTP 404 on `/packages/{id}`, `package.enabled=false`) force qty=0; transient failures leave `qty` UNTOUCHED to avoid false out-of-stock during API blips.
|
||||||
|
|
||||||
|
- **Event-driven stock recalculation hooks:**
|
||||||
|
- `AfterModuleCreate` — refreshes qty after every VirtFusion provision (capacity just decreased). Bursts of parallel provisions coalesce via a 30 s shared rate-limit.
|
||||||
|
- `AfterModuleTerminate` — refreshes qty after every VirtFusion termination (capacity just increased). Shares the 30 s rate-limit with create.
|
||||||
|
- `AfterCronJob` — every-2-hour safety net that catches capacity changes made directly in the VirtFusion panel without going through WHMCS. Interval tunable via `STOCK_CRON_INTERVAL_SECONDS` in `hooks.php`.
|
||||||
|
- `ClientAreaPageCart` — opportunistic per-product refresh during the order flow, rate-limited to once per product per 60 s.
|
||||||
|
|
||||||
|
- **Order auto-accept after successful provision.** `AfterModuleCreate` calls WHMCS `AcceptOrder` (with `autosetup=false` so there's no double-provision) when the parent order is still in Pending status. Closes the gap for installs that rely on pending-order workflows for non-VF products but want VirtFusion provisions to auto-advance. Idempotent — already-accepted orders are skipped.
|
||||||
|
|
||||||
|
- **Admin-triggered full recalculation.** New `admin.php?action=stockRecalculate` action (POST + same-origin required) runs `StockControl::recalculateAll()` on demand and returns a JSON `{productId: qty}` map; the module log gets a compact summary (`{total, updated, zeroed, skipped}`) so it stays readable on stores with hundreds of products.
|
||||||
|
|
||||||
|
- **Per-product safety buffer.** New `stockSafetyBufferPct` config option (configoption7, default 10) reserves X% of each resource's `max` during stock calculation. Applied only to capped resources (unlimited resources with `max=0` skip the buffer). Admins can override per product in the module settings; blank falls back to 10% so existing products get sensible headroom without any config change.
|
||||||
|
|
||||||
|
- **Test Connection now probes `/compute/hypervisors/groups`.** A VirtFusion API token scoped only to `/servers` would pass the existing `/connect` check but silently break nightly stock updates. The admin's Test Connection button now surfaces missing `/compute` read scope at config time with a specific error rather than as unexplained nightly silence.
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
- New cache keys: `pkg:{packageId}` (10 min TTL, package definitions rarely change) and `grpres:{groupId}` (120 s TTL, resources change minute-to-minute under load). Confirmed 404 responses are cached for 60 s so an admin re-creating a deleted package/group takes effect quickly.
|
||||||
|
|
||||||
|
### Safety Properties
|
||||||
|
- `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during transient API blips, or show inventory for deleted packages.
|
||||||
|
- `\Throwable` catches on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract.
|
||||||
|
- Stock-control is gated by `tblproducts.stockcontrol=1` — products that opt out are never touched, even by the safety-net cron.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **PowerDNS reverse DNS (PTR) integration** — opt-in via companion `VirtFusionDns` addon module:
|
||||||
|
- Automatic PTR sync on server create, rename, and terminate
|
||||||
|
- Client-area "Reverse DNS" panel with one editable PTR per assigned IP and per-row status badges
|
||||||
|
- Admin services-tab widget with Reconcile (additive) and Reconcile (force reset) buttons
|
||||||
|
- Daily cron additive reconciliation (never overwrites existing PTRs)
|
||||||
|
- Forward-confirmed reverse DNS (FCrDNS) enforcement — PTR writes rejected if forward A/AAAA doesn't resolve to the target IP
|
||||||
|
- IPv4 + IPv6 support with full nibble-reversal for `ip6.arpa`
|
||||||
|
- RFC 2317 classless delegation support (both CIDR-prefix `0/26` and block-size `64/64` conventions)
|
||||||
|
- Automatic NOTIFY after every successful PATCH so slaves pick up SOA bumps immediately
|
||||||
|
- PowerDNS zone ID `=2F` URL-encoding for zones containing `/`
|
||||||
|
- **Security hardening helpers** on the Module base class:
|
||||||
|
- `requirePost()` — 405 on non-POST mutations
|
||||||
|
- `requireSameOrigin()` — CSRF Origin/Referer check against WHMCS host
|
||||||
|
- `requireServiceStatus()` — filter endpoints by `tblhosting.domainstatus`
|
||||||
|
- Applied to all rDNS endpoints with successful-write audit logging
|
||||||
|
- Merged Test Connection — when the DNS addon is active the admin button verifies both VirtFusion AND PowerDNS in a single check
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- `IpUtil::parseClasslessZone` now rejects misaligned start addresses (e.g., `3/26.x.y.z` — /26 ranges must begin at a multiple of 64). Prevents silent write-into-wrong-zone on misconfigured zone names.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Detailed design-rationale commentary added across the module for future-developer onboarding (Cache, Curl, Log, Database, ServerResource, ConfigureService) and throughout the new PowerDNS subsystem
|
||||||
|
- README updated with an extensive "Reverse DNS Addon (PowerDNS)" section covering activation, configuration, behaviour, and security posture
|
||||||
|
- CLAUDE.md updated with architecture notes and PowerDNS API compatibility details
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-03-19
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- OS template tile gallery with accordion categories, brand icons, and search
|
||||||
|
- Inline server rename with friendly name generator
|
||||||
|
- Traffic statistics canvas chart in resources panel
|
||||||
|
- Backup listing timeline in manage panel
|
||||||
|
- VNC enable/disable toggle with connection details and password copy
|
||||||
|
- Server root password reset with auto-clipboard copy
|
||||||
|
- Redis-backed API response caching with filesystem fallback
|
||||||
|
- Skeleton loading, action cooldowns, progress indicators
|
||||||
|
- Copy-to-clipboard buttons for IP addresses
|
||||||
|
- Client-side SSH Ed25519 key generator on checkout page
|
||||||
|
- VNC console support, resources panel, self-service billing
|
||||||
|
- Configurable option sliders on checkout page
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- XSS escaping, null guards, and proper error handling
|
||||||
|
- All state-mutating operations use POST instead of GET
|
||||||
|
- Explicit break after all output() calls in client.php
|
||||||
|
- Server-side regex validation on rename endpoint
|
||||||
|
- Error messages sanitized (no raw API errors exposed to clients)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Client IP removal capability (IPs managed by VirtFusion)
|
||||||
|
- IP add buttons (managed by VirtFusion during provisioning)
|
||||||
|
- Firewall panel (non-functional; managed in VirtFusion admin)
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- Tag-based release workflow (compatible with Gitea and GitHub)
|
||||||
|
- Codebase consolidation: resolveServiceContext(), groupOsTemplates(), vfUrl(), vfShowAlert()
|
||||||
|
|
||||||
## [0.0.18] - 2025-10-01
|
## [0.0.18] - 2025-10-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -74,6 +185,6 @@ All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
|||||||
- Admin services tab with server ID management
|
- Admin services tab with server ID management
|
||||||
- Package change (upgrade/downgrade) support
|
- Package change (upgrade/downgrade) support
|
||||||
- Configurable option mapping for dynamic resource allocation
|
- Configurable option mapping for dynamic resource allocation
|
||||||
- GitHub Actions CI/CD with semantic-release
|
- GitHub Actions CI/CD
|
||||||
- Security policy (SECURITY.md)
|
- Security policy (SECURITY.md)
|
||||||
- License (GPL v3)
|
- License (GPL v3)
|
||||||
|
|||||||
142
CLAUDE.md
142
CLAUDE.md
@@ -15,12 +15,27 @@ There is no automated test suite, linter, or build step. Testing is manual:
|
|||||||
- **Module logging:** WHMCS Admin → Utilities → Logs → Module Log captures all API calls and responses
|
- **Module logging:** WHMCS Admin → Utilities → Logs → Module Log captures all API calls and responses
|
||||||
- **Server object viewer:** Admin services tab shows full JSON response from VirtFusion API
|
- **Server object viewer:** Admin services tab shows full JSON response from VirtFusion API
|
||||||
|
|
||||||
|
## Development Rules
|
||||||
|
|
||||||
|
- **Error handling:** Always use try...catch blocks around API calls, database operations, and any code that may throw exceptions. Never let exceptions bubble up unhandled to the user. Log caught exceptions via `Log::insert()`.
|
||||||
|
- **Ownership validation:** Every client-facing action MUST verify service ownership via `validateUserOwnsService()` before performing any operation. Server IDs must be cross-referenced against the authenticated client to prevent cross-customer data access.
|
||||||
|
- **Security:** All input must be validated server-side. Never trust client-side validation alone. Cast IDs to `(int)`, validate strings with regex, escape output with `htmlspecialchars()`.
|
||||||
|
- **Control flow:** Every `$vf->output()` call in switch cases must be followed by `break`. Do not rely on `exit()` inside `output()` for flow control.
|
||||||
|
- **HTTP methods:** Read-only operations use GET. State-mutating operations (power, rebuild, rename, password reset, credit, VNC toggle) use POST with data in the request body.
|
||||||
|
- **Caching:** Use the `Cache` class for slow-changing API responses. Never cache real-time data (server status, VNC sessions, login tokens) or mutation responses.
|
||||||
|
|
||||||
## Release Process
|
## Release Process
|
||||||
|
|
||||||
Releases are automated via GitHub Actions using semantic-release on pushes to `main`. Use **conventional commits**:
|
Releases are triggered by pushing a git tag:
|
||||||
- `fix:` → patch release
|
```bash
|
||||||
- `feat:` → minor release
|
git tag v1.1.0
|
||||||
- `BREAKING CHANGE:` in commit body → major release
|
git push origin v1.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-generated notes from the commit log. Use **conventional commits** for clear changelogs:
|
||||||
|
- `fix:` → patch-level change
|
||||||
|
- `feat:` → feature addition
|
||||||
|
- `refactor:` → code improvement without behavior change
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -30,39 +45,47 @@ Releases are automated via GitHub Actions using semantic-release on pushes to `m
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes |
|
| `modules/servers/VirtFusionDirect/VirtFusionDirect.php` | WHMCS module interface — non-namespaced functions (`VirtFusionDirect_CreateAccount()`, etc.) that delegate to library classes |
|
||||||
| `client.php` | Client-facing AJAX API — authenticated by WHMCS session + service ownership validation |
|
| `modules/servers/VirtFusionDirect/client.php` | Client-facing AJAX API — authenticated by WHMCS session + service ownership validation. POST for mutations, GET for reads. |
|
||||||
| `admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication |
|
| `modules/servers/VirtFusionDirect/admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication |
|
||||||
| `hooks.php` | WHMCS hooks — checkout validation (OS selection), dynamic dropdown/slider injection, SSH key paste |
|
| `modules/servers/VirtFusionDirect/hooks.php` | WHMCS hooks — checkout validation (OS selection), OS gallery + SSH key UI injection, slider UI for configurable options, daily PowerDNS reconciliation |
|
||||||
|
| `modules/addons/VirtFusionDns/VirtFusionDns.php` | Optional companion addon — holds PowerDNS settings and provides a Test Connection admin page. See "Reverse DNS (PowerDNS)" below. |
|
||||||
|
|
||||||
### Core Classes (in `lib/`)
|
### Core Classes (in `lib/`)
|
||||||
|
|
||||||
| Class | Role |
|
| Class | Role |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `Module` | Base class with API integration, auth checks, power/network/VNC/backup/resource/self-service methods. All client/admin actions route through here. |
|
| `Module` | Base class with API integration, auth checks, and all feature methods (power, network, VNC, backup, resource, self-service, traffic, rename, password reset). Contains `resolveServiceContext()` for DRY service lookups and `groupOsTemplates()` for shared OS category logic. |
|
||||||
| `ModuleFunctions` | Extends `Module`. Service lifecycle: create, suspend, unsuspend, terminate, change package, usage updates, client area rendering. |
|
| `ModuleFunctions` | Extends `Module`. Service lifecycle: create, suspend, unsuspend, terminate, change package, usage updates, client area rendering. |
|
||||||
| `ConfigureService` | Extends `Module`. Order-time operations: package discovery, OS template fetching, server build initialization, SSH key retrieval and creation. |
|
| `ConfigureService` | Extends `Module`. Order-time operations: package discovery, OS template fetching, server build initialization, SSH key retrieval and creation. |
|
||||||
| `Database` | Static methods for `mod_virtfusion_direct` table operations and WHMCS DB queries. Auto-creates/migrates schema on first use. |
|
| `Database` | Static methods for `mod_virtfusion_direct` table operations and WHMCS DB queries. Auto-creates/migrates schema on first use. |
|
||||||
| `Curl` | HTTP client wrapper with Bearer token auth, SSL verification, 30s timeout. Methods: `get`, `post`, `put`, `patch`, `delete`. |
|
| `Curl` | HTTP client wrapper with Bearer token auth, SSL verification, 30s timeout. Methods: `get`, `post`, `put`, `patch`, `delete`. Single-use — each instance makes one request. |
|
||||||
| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. |
|
| `Cache` | Two-tier caching: Redis (if `ext-redis` available) with atomic filesystem fallback. TTLs: OS templates 10min, traffic/backups 2min, packages 10min. |
|
||||||
| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons). |
|
| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. Only reads `interfaces[0]`; for rDNS use `PowerDns\IpUtil::extractIps()` which walks all interfaces. |
|
||||||
|
| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons, `rdnsSection()` widget). |
|
||||||
| `Log` | Thin wrapper around WHMCS module logging. |
|
| `Log` | Thin wrapper around WHMCS module logging. |
|
||||||
|
| `PowerDns\Client` | PowerDNS HTTP API wrapper (`X-API-Key` auth): `ping`, `listZones`, `getZone`, `patchRRset`, `notifyZone`. PATCH success triggers an automatic NOTIFY so slaves pick up the SOA bump immediately. |
|
||||||
|
| `PowerDns\Config` | Loads settings from `tbladdonmodules` (module="virtfusiondns") and decrypts `apiKey` via WHMCS `decrypt()`. `isEnabled()` gates every PowerDNS call site. |
|
||||||
|
| `PowerDns\IpUtil` | Pure helpers: `ptrNameForIp` (v4/v6 nibble reversal), `expandIpv6`, `extractIps` (all interfaces), `findZoneAndPtrName` (standard + RFC 2317 classless), `parseClasslessZone`. |
|
||||||
|
| `PowerDns\Resolver` | Forward-DNS verification via `dns_get_record()` with up-to-5-hop CNAME following. Cached per (hostname, ip) pair. |
|
||||||
|
| `PowerDns\PtrManager` | Orchestrator: `syncServer`, `deleteForServer`, `listPtrs`, `setPtr`, `reconcile`, `reconcileAll`. Per-request zone cache. 10s per-IP write rate limit. Enforces FCrDNS before writes. |
|
||||||
|
| `StockControl` | Orchestrator for dynamic inventory. `recalculateForProduct()` and `recalculateAll()` compute per-product qty from live `/packages/{id}` + `/compute/hypervisors/groups/{id}/resources` data and write to `tblproducts.qty`. Fail-safe: null return = qty untouched. |
|
||||||
|
|
||||||
### Class Hierarchy
|
### Class Hierarchy
|
||||||
|
|
||||||
`ModuleFunctions` and `ConfigureService` both extend `Module`. Most business logic lives in `Module` — it handles API calls, auth, validation, and all feature-specific operations (power, network, VNC, backup, resource modification). `ModuleFunctions` orchestrates the WHMCS service lifecycle (provisioning flow, suspension, termination).
|
`ModuleFunctions` and `ConfigureService` both extend `Module`. Most business logic lives in `Module` — it handles API calls, auth, validation, and all feature-specific operations. The `resolveServiceContext()` method provides a standardized way to look up service → WHMCS service → control panel → curl client in a single call, eliminating boilerplate across all API methods.
|
||||||
|
|
||||||
### Client-Side
|
### Client-Side
|
||||||
|
|
||||||
- **`templates/overview.tpl`** — Smarty template for client area (server info, power, network, rebuild, resources, VNC, self-service billing, billing overview)
|
- **`templates/overview.tpl`** — Smarty template for client area (server info, power, network, rebuild with OS gallery, resources with traffic chart, VNC toggle, self-service billing, billing overview, backups timeline, server rename, password reset)
|
||||||
- **`templates/js/module.js`** — Vanilla JS (1000+ lines) handling AJAX calls to `client.php`, DOM updates, status badges, power actions, all management UIs
|
- **`templates/js/module.js`** — Vanilla JS + jQuery handling AJAX calls, DOM updates, status badges, power actions, all management UIs. Key helpers: `vfUrl()` (URL builder), `vfShowAlert()` (alert display), `vfRenderOsGallery()` (accordion gallery), `vfDrawTrafficChart()` (canvas chart)
|
||||||
- **`templates/js/keygen.js`** — Client-side SSH Ed25519 key generator using Web Crypto API (loaded on checkout page)
|
- **`templates/js/keygen.js`** — Client-side SSH Ed25519 key generator using Web Crypto API (loaded on checkout page)
|
||||||
- **`templates/css/module.css`** — Cross-theme styles with Bootstrap 3/4/5 dual class support (`panel card`, `panel-body card-body`)
|
- **`templates/css/module.css`** — Cross-theme styles with Bootstrap 3/4/5 dual class support (`panel card`, `panel-body card-body`)
|
||||||
|
|
||||||
### Removed Features
|
### Removed Features
|
||||||
|
|
||||||
- **Firewall** — Removed (non-functional; rulesets must be created in VirtFusion admin panel)
|
- **Firewall** — Removed (non-functional; rulesets must be created in VirtFusion admin panel)
|
||||||
- **IP add buttons** — Removed (`addIPv4`, `addIPv6` endpoints and UI); IPs are managed by VirtFusion during provisioning
|
- **IP add/remove buttons** — Removed; IPs are managed by VirtFusion during provisioning
|
||||||
- **Upgrade/Downgrade link** — Removed from resources panel
|
- **Upgrade/Downgrade link** — Removed from resources panel
|
||||||
|
|
||||||
### Data Flow: Server Creation
|
### Data Flow: Server Creation
|
||||||
@@ -73,20 +96,86 @@ Releases are automated via GitHub Actions using semantic-release on pushes to `m
|
|||||||
4. Dry-run validation → actual API POST to `/servers`
|
4. Dry-run validation → actual API POST to `/servers`
|
||||||
5. Stores server ID in `mod_virtfusion_direct` table
|
5. Stores server ID in `mod_virtfusion_direct` table
|
||||||
6. Updates WHMCS hosting record (IP, username, password, domain)
|
6. Updates WHMCS hosting record (IP, username, password, domain)
|
||||||
7. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key
|
7. If the PowerDNS addon is enabled, calls `PowerDns\PtrManager::syncServer()` to write PTRs (non-blocking; failures log but never fail provisioning)
|
||||||
|
8. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key
|
||||||
|
|
||||||
|
Custom fields (`Initial Operating System`, `Initial SSH Key`) are auto-created by `Database::ensureCustomFields()` on module load for all products using this module. No manual SQL setup required.
|
||||||
|
|
||||||
|
### Reverse DNS (PowerDNS)
|
||||||
|
|
||||||
|
Opt-in integration via the companion `VirtFusionDns` addon module. Loose-coupled: the server module never requires addon code at runtime; it queries the addon's `tbladdonmodules` row and short-circuits when `enabled=0` or the addon isn't activated. Activate via WHMCS Admin → Addon Modules → VirtFusion DNS.
|
||||||
|
|
||||||
|
**Settings** (`tbladdonmodules`, module="virtfusiondns"): `enabled` (yesno), `endpoint` (e.g. `https://ns1.example.com:8081`), `apiKey` (encrypted by WHMCS), `serverId` (usually `localhost`), `defaultTtl` (3600), `cacheTtl` (60).
|
||||||
|
|
||||||
|
**Lifecycle hooks:**
|
||||||
|
- `createAccount` → sync PTRs to server hostname (forward DNS must match before each write)
|
||||||
|
- `renameServer` → update only PTRs whose current content equals the old hostname (preserves client-custom PTRs)
|
||||||
|
- `terminateAccount` → delete every PTR before `Database::deleteSystemService()`
|
||||||
|
- `VirtFusionDirect_TestConnection` → merged VirtFusion + PowerDNS health check
|
||||||
|
- `DailyCronJob` → `PtrManager::reconcileAll()` — additive-only (never overwrites)
|
||||||
|
|
||||||
|
**Client-facing actions** (`client.php`): `rdnsList`, `rdnsUpdate`. Admin (`admin.php`): `rdnsStatus`, `rdnsReconcile` (accepts `force=1` for explicit reset).
|
||||||
|
|
||||||
|
**Client UI:** Reverse DNS panel in `templates/overview.tpl` (rendered by `vfLoadRdns()` / `vfRenderRdnsPanel()` / `vfUpdateRdns()` in `module.js`). Admin services tab gets a status widget via `AdminHTML::rdnsSection()`.
|
||||||
|
|
||||||
|
**FCrDNS rule:** Every PTR write (auto or client-initiated) requires the hostname's forward DNS (A/AAAA) to already resolve to the target IP. On mismatch, auto-sync logs and skips; client edits return a 400 with guidance.
|
||||||
|
|
||||||
|
**Zone handling:** Zones are operator-managed — the module never creates zones. Zone discovery uses `GET /zones` (cached for `cacheTtl`) + longest-suffix match. RFC 2317 classless delegations (`X/Y.octet.octet.octet.in-addr.arpa.`) are supported: both CIDR-prefix (`0/26`) and block-size (`64/64`) conventions are parsed, and PTRs are written with the classless sub-zone label in the record name.
|
||||||
|
|
||||||
|
**SOA / NOTIFY:** PowerDNS auto-bumps SOA serials when `soa_edit_api=INCREASE` is set on the zone. After every successful PATCH the module issues an explicit `PUT /zones/{id}/notify` so slaves refresh immediately rather than waiting for the next scheduled poll.
|
||||||
|
|
||||||
|
**Safety properties:**
|
||||||
|
- PowerDNS failures never block VirtFusion operations (try/catch at every call site)
|
||||||
|
- Cron is additive-only — never auto-overwrites a PTR
|
||||||
|
- Admin Reconcile button supports `force=1` for explicit reset to hostname
|
||||||
|
- Client edits are IP-ownership-checked against a *fresh* VirtFusion fetch (not cached `server_object`), defending against reassigned-IP stale-ownership
|
||||||
|
- Per-IP write rate limit (10s, via `Cache`) prevents save-button abuse
|
||||||
|
|
||||||
### Configurable Option Mapping
|
### Configurable Option Mapping
|
||||||
|
|
||||||
Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`.
|
Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`.
|
||||||
|
|
||||||
|
### Inventory / Stock Control
|
||||||
|
|
||||||
|
Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockcontrol=1`). When enabled, the module overwrites `tblproducts.qty` with the real number of VPSes that can still be provisioned — WHMCS then handles the "Out of Stock" badge, Add-to-Cart gating, and checkout refusal natively. No templates or JS required.
|
||||||
|
|
||||||
|
**Data sources (authoritative):**
|
||||||
|
- `GET /packages/{id}` — per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`)
|
||||||
|
- `GET /compute/hypervisors/groups/{id}/resources` — live free/allocated per hypervisor with per-metric quotas, storage pools (filtered by `pool.storageType` against the package's `primaryStorageProfile` *type code* — see Safety properties), and a group-level IPv4 pool
|
||||||
|
|
||||||
|
**Algorithm:** for every group the product can be placed in (default `configoption1` plus every numeric value of the `Location` configurable option), sum `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) and cap by the group-level IPv4 pool (`max` across hypervisors, not summed — IPv4 is a single group-wide pool). Sum across groups → qty.
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- `AfterModuleCreate` — post-provision refresh; bursts rate-limited to one recalc per 30 s via `stockrefresh:event` cache key.
|
||||||
|
- `AfterModuleTerminate` — post-termination refresh; shares the same 30 s rate-limit key.
|
||||||
|
- `AfterCronJob` — every-2-hour safety net (captures out-of-band VirtFusion panel changes). Tunable via `STOCK_CRON_INTERVAL_SECONDS` constant in `hooks.php`.
|
||||||
|
- `ClientAreaPageCart` — opportunistic per-product refresh on cart/order pages with a 60 s rate-limit key (`stockrefresh:{pid}`). The `grpres:{id}` cache (120 s TTL) naturally coalesces bursts.
|
||||||
|
- `admin.php?action=stockRecalculate` — admin-triggered full recalc (POST + same-origin required); returns JSON `{productId: qty}` map.
|
||||||
|
|
||||||
|
**Order auto-accept:** `AfterModuleCreate` additionally calls WHMCS `AcceptOrder` with `autosetup=false` when the service's parent order is still Pending. Closes the loop for installs that rely on pending-order workflows for non-VF products but want VF provisions to auto-advance.
|
||||||
|
|
||||||
|
**Caching:** `pkg:{id}` 600 s (package definitions rarely change), `grpres:{id}` 120 s (resources change under load). Confirmed 404s cached 60 s so re-creating a deleted package/group takes effect quickly.
|
||||||
|
|
||||||
|
**Safety properties:**
|
||||||
|
- Transient API failures (null from `fetchPackage` / `fetchGroupResources`) leave `qty` UNTOUCHED — never silently takes the catalogue offline.
|
||||||
|
- Confirmed-missing conditions (HTTP 404 on package, `package.enabled=false`) return qty=0 — the product genuinely cannot be provisioned.
|
||||||
|
- IPv4 cap is max-within-group (not summed across hypervisors) to avoid double-counting the shared pool.
|
||||||
|
- Storage matching uses the package's `primaryStorageProfile` as a **storage type code** (it mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id). The hypervisor must expose at least one `otherStorage[]` pool whose `storageType` equals that code; if multiple match (e.g. several mountpoint pools on the same hypervisor) the one that fits the most VMs wins. A disabled pool is skipped, not fatal — an enabled peer of the same type still contributes. Hypervisors with no pool of the matching type contribute 0. Falls back to `localStorage` only when the package has no profile set (`primaryStorageProfile <= 0`).
|
||||||
|
- Stock control is gated by `tblproducts.stockcontrol=1` per product — the module never touches qty on products that opt out.
|
||||||
|
|
||||||
|
**Per-product setting:** `stockSafetyBufferPct` (configoption7, default 10). Reserves X% of each resource's `max` before computing fits; ignored for unlimited resources (`max=0`) and for IPv4 (no per-hypervisor `max` in the response). Admins can override per product in the module settings; blank falls back to 10%.
|
||||||
|
|
||||||
|
**API scope required:** the VirtFusion API token must have read access to both `/packages` and `/compute/hypervisors/groups`. The Test Connection button probes the compute endpoint and shows a clear error if scope is missing.
|
||||||
|
|
||||||
## Security Patterns
|
## Security Patterns
|
||||||
|
|
||||||
- All PHP files start with `if (!defined("WHMCS")) die()` to prevent direct access
|
- All PHP files start with `if (!defined("WHMCS")) die()` to prevent direct access (except entry points using `init.php`)
|
||||||
- Client endpoints validate WHMCS session AND service ownership before any operation
|
- Client endpoints validate WHMCS session AND service ownership before any operation
|
||||||
- API tokens stored encrypted in WHMCS server password field (decrypted via `localAPI('DecryptPassword')`)
|
- API tokens stored encrypted in WHMCS server password field (decrypted via `localAPI('DecryptPassword')`)
|
||||||
- Input validation: type casting, regex filtering, `filter_var()` for IP addresses
|
- Input validation: type casting (`(int)`), regex filtering, `filter_var()` for IP addresses
|
||||||
- Output escaping: `htmlspecialchars()` in Smarty, `encodeURIComponent()` / `.text()` in JS
|
- Output escaping: `htmlspecialchars()` in PHP, `$('<span>').text()` in jQuery, `{escape:'htmlall'}` in Smarty
|
||||||
- SSL verification enabled on all API calls (`CURLOPT_SSL_VERIFYPEER` + `CURLOPT_SSL_VERIFYHOST = 2`)
|
- SSL verification enabled on all API calls (`CURLOPT_SSL_VERIFYPEER` + `CURLOPT_SSL_VERIFYHOST = 2`)
|
||||||
|
- Server rename validated both client-side and server-side with RFC 1123 regex
|
||||||
|
|
||||||
## VirtFusion API Compatibility
|
## VirtFusion API Compatibility
|
||||||
|
|
||||||
@@ -95,6 +184,17 @@ Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from
|
|||||||
- **VNC console:** v6.1.0+
|
- **VNC console:** v6.1.0+
|
||||||
- **Resource modification:** v6.2.0+
|
- **Resource modification:** v6.2.0+
|
||||||
- **Self-service billing:** Requires self-service feature enabled in VirtFusion
|
- **Self-service billing:** Requires self-service feature enabled in VirtFusion
|
||||||
|
- **OS icon path:** `{baseUrl}/img/logo/{icon_filename}` (public, no auth required)
|
||||||
|
|
||||||
|
## PowerDNS API Compatibility
|
||||||
|
|
||||||
|
- **API reference:** https://doc.powerdns.com/authoritative/http-api/
|
||||||
|
- **Tested against:** PowerDNS Authoritative 4.8+
|
||||||
|
- **Auth:** `X-API-Key` header (not Bearer)
|
||||||
|
- **Required endpoints:** `GET /servers/{id}`, `GET /servers/{id}/zones`, `GET /servers/{id}/zones/{zone}`, `PATCH /servers/{id}/zones/{zone}`, `PUT /servers/{id}/zones/{zone}/notify`
|
||||||
|
- **Zone ID URL encoding:** `/` in zone names (RFC 2317) must be encoded as `=2F` not `%2F` — handled by `Client::zoneIdEncode()`
|
||||||
|
- **`api-allow-from`:** must include the WHMCS host's IP (PowerDNS's own ACL)
|
||||||
|
- **Recommended zone config:** `soa_edit_api: INCREASE` for automatic serial bumping on API-driven changes
|
||||||
|
|
||||||
## Product Config Options
|
## Product Config Options
|
||||||
|
|
||||||
@@ -106,9 +206,11 @@ Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from
|
|||||||
| configoption4 | Self-Service Mode | 0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both | 0 |
|
| configoption4 | Self-Service Mode | 0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both | 0 |
|
||||||
| configoption5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers | 0 |
|
| configoption5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers | 0 |
|
||||||
| configoption6 | Auto Top-Off Amount | Credit amount to add on auto top-off | 100 |
|
| configoption6 | Auto Top-Off Amount | Credit amount to add on auto top-off | 100 |
|
||||||
|
| configoption7 | Stock Safety Buffer (%) | Headroom reserved per resource during stock calculation (0-100). Only effective with WHMCS stock control enabled. Blank falls back to the default. | 10 |
|
||||||
|
|
||||||
## WHMCS Compatibility
|
## WHMCS Compatibility
|
||||||
|
|
||||||
- WHMCS 8.x+ (tested 8.0–8.10)
|
- WHMCS 8.x+ (tested 8.0–8.10)
|
||||||
- PHP 8.0+ with cURL extension
|
- PHP 8.0+ with cURL extension
|
||||||
|
- Redis extension optional (improves caching performance, falls back to filesystem)
|
||||||
- All WHMCS themes supported (Six, Twenty-One, Lagom, custom) via Bootstrap 3/4/5 dual classes
|
- All WHMCS themes supported (Six, Twenty-One, Lagom, custom) via Bootstrap 3/4/5 dual classes
|
||||||
|
|||||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
support@ezscale.cloud.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
323
README.md
323
README.md
@@ -20,6 +20,8 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co
|
|||||||
- [Module Configuration Options](#module-configuration-options)
|
- [Module Configuration Options](#module-configuration-options)
|
||||||
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
|
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
|
||||||
- [Custom Option Name Mapping](#custom-option-name-mapping)
|
- [Custom Option Name Mapping](#custom-option-name-mapping)
|
||||||
|
- [Stock Control (Dynamic Inventory)](#stock-control-dynamic-inventory)
|
||||||
|
- [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
|
||||||
- [Client Area Features](#client-area-features)
|
- [Client Area Features](#client-area-features)
|
||||||
- [Admin Area Features](#admin-area-features)
|
- [Admin Area Features](#admin-area-features)
|
||||||
- [Theme Compatibility](#theme-compatibility)
|
- [Theme Compatibility](#theme-compatibility)
|
||||||
@@ -62,7 +64,7 @@ You also need a VirtFusion API token with the following permissions:
|
|||||||
- **Control Panel SSO** - One-click login to VirtFusion panel
|
- **Control Panel SSO** - One-click login to VirtFusion panel
|
||||||
- **Server Rebuild** - Reinstall with any available OS template
|
- **Server Rebuild** - Reinstall with any available OS template
|
||||||
- **Password Reset** - Reset VirtFusion panel login credentials
|
- **Password Reset** - Reset VirtFusion panel login credentials
|
||||||
- **Network Management** - View and remove IPv4 addresses; view IPv6 subnets
|
- **Network Management** - View IPv4 addresses and IPv6 subnets with copy-to-clipboard
|
||||||
- **Resources Panel** - Current memory, CPU, storage, traffic allocation with usage bars
|
- **Resources Panel** - Current memory, CPU, storage, traffic allocation with usage bars
|
||||||
- **VNC Console** - Browser-based console access (panel auto-hides when VNC is disabled on the server)
|
- **VNC Console** - Browser-based console access (panel auto-hides when VNC is disabled on the server)
|
||||||
- **Self-Service Billing** - Credit balance display, usage breakdown, and credit top-up (when enabled)
|
- **Self-Service Billing** - Credit balance display, usage breakdown, and credit top-up (when enabled)
|
||||||
@@ -79,12 +81,21 @@ You also need a VirtFusion API token with the following permissions:
|
|||||||
- **Update Server Object** - Refresh cached server data from VirtFusion
|
- **Update Server Object** - Refresh cached server data from VirtFusion
|
||||||
|
|
||||||
### Ordering Process
|
### Ordering Process
|
||||||
- Dynamic OS template dropdown populated from VirtFusion API
|
- OS template tile gallery with accordion categories, search, and brand icons
|
||||||
- SSH key selection dropdown for users with saved keys, with option to paste a new public key
|
- SSH key selection dropdown for users with saved keys, with option to paste a new public key
|
||||||
- **SSH Ed25519 key generator** — Client-side keypair generation using Web Crypto API
|
- **SSH Ed25519 key generator** — Client-side keypair generation using Web Crypto API
|
||||||
- Checkout validation ensuring OS selection before order placement
|
- Checkout validation ensuring OS selection before order placement
|
||||||
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
|
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
|
||||||
- Compatible with all WHMCS order form templates
|
- Compatible with all WHMCS order form templates
|
||||||
|
- **Order auto-accept after provision** — when a paid order's VirtFusion service provisions successfully, the module calls WHMCS `AcceptOrder` (with `autosetup=false` so there's no double-provision) to flip the order from Pending → Active automatically. Idempotent; already-accepted orders are untouched.
|
||||||
|
|
||||||
|
### Stock Control (Dynamic Inventory)
|
||||||
|
- **Out-of-stock badges driven by real hypervisor capacity** — opt-in per product via WHMCS's native Stock Control toggle. When enabled, the module keeps `tblproducts.qty` synced to the number of VPSes the panel can still actually provision, and WHMCS renders the "Out of Stock" badge, disables Add-to-Cart, and refuses checkout natively. No templates or JavaScript required.
|
||||||
|
- **Live-capacity math** — combines `/packages/{id}` (per-VPS resource footprint) with `/compute/hypervisors/groups/{id}/resources` (live per-hypervisor free/allocated) to compute qty across every group the product can be placed in. Storage matching is by **type code** (`pool.storageType`), so a package targeting e.g. mountpoint storage qualifies on every hypervisor that exposes a mountpoint pool — and picks the largest-fit pool when several share the same type. Group-level IPv4 pool accounted for without double-counting.
|
||||||
|
- **Event-driven refresh** — qty recalculates after every successful provision (`AfterModuleCreate`), termination (`AfterModuleTerminate`), and on cart/order page views for individual products. A 2-hour safety-net cron catches capacity changes made directly in the VirtFusion panel.
|
||||||
|
- **Per-product safety buffer** — `stockSafetyBufferPct` config option (default 10%) reserves headroom so the storefront stops selling before a hypervisor is literally at 100%.
|
||||||
|
- **Fail-safe under API outages** — transient VirtFusion API failures leave `qty` UNCHANGED instead of zeroing it, so a brief network blip doesn't take the catalogue offline.
|
||||||
|
- **Admin recalc on demand** — POST `admin.php?action=stockRecalculate` forces a full re-sweep.
|
||||||
|
|
||||||
### Usage Tracking
|
### Usage Tracking
|
||||||
- **Automated bandwidth sync** - WHMCS daily cron pulls traffic usage from VirtFusion
|
- **Automated bandwidth sync** - WHMCS daily cron pulls traffic usage from VirtFusion
|
||||||
@@ -106,97 +117,102 @@ You also need a VirtFusion API token with the following permissions:
|
|||||||
- Auto top-off via WHMCS cron when credit falls below threshold
|
- Auto top-off via WHMCS cron when credit falls below threshold
|
||||||
- Self-service mode configurable per product (Hourly, Resource Packs, or Both)
|
- Self-service mode configurable per product (Hourly, Resource Packs, or Both)
|
||||||
|
|
||||||
|
### Reverse DNS (Optional PowerDNS Addon)
|
||||||
|
- **Automatic PTR sync** on server create, rename, and terminate
|
||||||
|
- **Client-editable rDNS** panel in the service overview — one input per assigned IP
|
||||||
|
- **Forward-confirmed reverse DNS (FCrDNS)** — every PTR write requires the hostname's A/AAAA to already resolve to the IP; mismatches are rejected with a clear error
|
||||||
|
- **IPv4 + IPv6** support out of the box (IPv6 nibble-reversal, `.ip6.arpa` zones)
|
||||||
|
- **RFC 2317 classless delegation** — supports both CIDR-prefix (`0/26`) and block-size (`64/64`) zone naming conventions
|
||||||
|
- **Admin reconciliation** — a "Reconcile" button on the services tab and an additive-only daily cron that creates any missing PTRs
|
||||||
|
- **Client-custom PTRs preserved across renames** — only PTRs whose content matches the previous hostname get rewritten
|
||||||
|
- **Auto NOTIFY + SOA bump** so slaves pick up changes immediately (when `soa_edit_api=INCREASE` is set on the zone)
|
||||||
|
- **Opt-in** via a companion WHMCS addon module — no impact on existing provisioning if not activated
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Step 1: Download & Install
|
The fastest path is the install script. It auto-detects the WHMCS web user from your `modules/servers` directory ownership and applies it to the new files — without that, rsyncing as root would leave files owned by `root:root` and the web server couldn't read them ("module installed but invisible in WHMCS").
|
||||||
|
|
||||||
Download the latest release from the [releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases) page, or install directly via the command line:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /tmp
|
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git
|
| sudo bash -s -- install /path/to/whmcs
|
||||||
rsync -ahP --delete /tmp/virtfusion-whmcs-module/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/
|
|
||||||
rm -rf /tmp/virtfusion-whmcs-module
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `/path/to/whmcs` with your actual WHMCS installation root.
|
Same thing with `wget`:
|
||||||
|
```bash
|
||||||
The resulting file structure should be:
|
wget -qO- https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
| sudo bash -s -- install /path/to/whmcs
|
||||||
```
|
|
||||||
modules/servers/VirtFusionDirect/
|
|
||||||
VirtFusionDirect.php # Main module file
|
|
||||||
client.php # Client AJAX API
|
|
||||||
admin.php # Admin AJAX API
|
|
||||||
hooks.php # WHMCS hooks
|
|
||||||
modify.sql # Custom field setup SQL
|
|
||||||
lib/
|
|
||||||
Module.php # Core module class
|
|
||||||
ModuleFunctions.php # Provisioning functions
|
|
||||||
ConfigureService.php # OS/SSH config service
|
|
||||||
Database.php # Database operations
|
|
||||||
Curl.php # HTTP client
|
|
||||||
ServerResource.php # Data transformer
|
|
||||||
AdminHTML.php # Admin interface HTML
|
|
||||||
Log.php # Logging
|
|
||||||
templates/
|
|
||||||
overview.tpl # Client area template
|
|
||||||
error.tpl # Error template
|
|
||||||
css/module.css # Styles
|
|
||||||
js/module.js # Client JavaScript
|
|
||||||
js/keygen.js # SSH Ed25519 key generator
|
|
||||||
config/
|
|
||||||
ConfigOptionMapping-example.php # Config mapping example
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Set Up Server in WHMCS
|
Flags:
|
||||||
|
- `--with-addon` — also install the PowerDNS reverse-DNS addon (`modules/addons/VirtFusionDns/`).
|
||||||
|
- `--version v1.4.1` — pin a specific release tag (default: latest published release; any tag from [Releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases)).
|
||||||
|
|
||||||
1. Go to **Configuration > System Settings > Servers**
|
The database table, schema migrations, and custom fields are all created automatically on first load.
|
||||||
2. Click **Add New Server**
|
|
||||||
3. Fill in:
|
|
||||||
- **Name**: Anything descriptive (e.g., "VirtFusion Production")
|
|
||||||
- **Hostname**: Your VirtFusion panel hostname (e.g., `cp.example.com`)
|
|
||||||
- **Type**: VirtFusion Direct Provisioning
|
|
||||||
- **Password/Access Hash**: Your VirtFusion API token
|
|
||||||
4. Click **Test Connection** to verify
|
|
||||||
5. Click **Save Changes**
|
|
||||||
|
|
||||||
### Step 3: Create Product
|
<details>
|
||||||
|
<summary><b>Manual install</b> (if you'd rather not pipe a script to bash)</summary>
|
||||||
|
|
||||||
1. Go to **Configuration > System Settings > Products/Services**
|
```bash
|
||||||
2. Create a new product or edit an existing one
|
WHMCS=/path/to/whmcs
|
||||||
3. On the **Module Settings** tab:
|
VERSION=${VERSION:-$(curl -fsSL https://api.github.com/repos/EZSCALE/virtfusion-whmcs-module/releases/latest \
|
||||||
- Set **Module Name** to "VirtFusion Direct Provisioning"
|
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')}
|
||||||
- Select your VirtFusion server
|
OWNER=$(stat -c '%U:%G' "$WHMCS/modules/servers")
|
||||||
- Set **Hypervisor Group ID**, **Package ID**, and **Default IPv4** count
|
curl -fsSL "https://github.com/EZSCALE/virtfusion-whmcs-module/archive/refs/tags/${VERSION}.tar.gz" -o /tmp/vf.tar.gz \
|
||||||
4. Save the product
|
&& mkdir -p /tmp/vf && tar -xzf /tmp/vf.tar.gz -C /tmp/vf --strip-components=1 \
|
||||||
|
&& rsync -ahP --delete --chown="$OWNER" /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||||
|
&& rm -rf /tmp/vf /tmp/vf.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
### Step 4: Set Up Custom Fields
|
`--chown="$OWNER"` ensures the new files match your WHMCS web user (`www-data`, `apache`, etc.) instead of `root:root`. Requires rsync 3.1+ and root (or already running as the matching user). To pin a version, prepend `VERSION=v1.4.1` before the command.
|
||||||
|
|
||||||
See [Custom Fields](#custom-fields) section below.
|
</details>
|
||||||
|
|
||||||
### Step 5: Activate Hooks
|
Then configure in WHMCS Admin:
|
||||||
|
|
||||||
The hooks file (`hooks.php`) is automatically detected by WHMCS when the module is active. If you add the module files to an existing installation, you may need to re-save the product settings or clear the WHMCS template cache for hooks to take effect.
|
1. **Add Server** — Configuration > System Settings > Servers > Add New Server. Set hostname to your VirtFusion panel (e.g. `cp.example.com`), type to "VirtFusion Direct Provisioning", and paste your API token in the Password field. Click **Test Connection** to verify.
|
||||||
|
2. **Create Product** — Configuration > System Settings > Products/Services. On the Module Settings tab, select "VirtFusion Direct Provisioning", choose your server, and set the Hypervisor Group ID, Package ID, and Default IPv4 count.
|
||||||
|
3. *(Optional)* **Install the Reverse DNS Addon** — also sync the `modules/addons/VirtFusionDns/` directory if you want PowerDNS-backed rDNS management. See [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns) below for activation and configuration.
|
||||||
|
|
||||||
|
That's it. Hooks activate automatically and custom fields are created on module load.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
1. Back up your existing `modules/servers/VirtFusionDirect/` directory
|
|
||||||
2. Back up `config/ConfigOptionMapping.php` if you have a custom mapping
|
|
||||||
3. Download and deploy the new version:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /tmp
|
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git
|
| sudo bash -s -- upgrade /path/to/whmcs
|
||||||
rsync -ahP --delete /tmp/virtfusion-whmcs-module/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/
|
|
||||||
rm -rf /tmp/virtfusion-whmcs-module
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Restore your custom `config/ConfigOptionMapping.php` if applicable
|
Add `--with-addon` if you also use the PowerDNS addon. Pin a version with `--version v1.4.1` for controlled rollouts or rollbacks. Addon settings live in `tbladdonmodules` and survive file updates. The script automatically backs up and restores any custom `config/ConfigOptionMapping.php` across the rsync `--delete`.
|
||||||
5. If you have theme-overridden templates, review them for any new template variables
|
|
||||||
6. Clear the WHMCS template cache: **Configuration > System Settings > General Settings > clear template cache**
|
|
||||||
|
|
||||||
The module database table (`mod_virtfusion_direct`) is automatically migrated on first load.
|
To check whether you're current without making any changes:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
| bash -s -- check /path/to/whmcs
|
||||||
|
```
|
||||||
|
Exit codes: `0` = up-to-date, `1` = outdated (or version unknown), `2` = not installed. Useful in cron-driven monitoring.
|
||||||
|
|
||||||
|
If you use theme-overridden templates, review them for any new template variables. Clear the WHMCS template cache after upgrading: **Configuration > System Settings > General Settings > clear template cache**.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Manual upgrade</b> (if you'd rather not pipe a script to bash)</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WHMCS=/path/to/whmcs
|
||||||
|
VERSION=${VERSION:-$(curl -fsSL https://api.github.com/repos/EZSCALE/virtfusion-whmcs-module/releases/latest \
|
||||||
|
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')}
|
||||||
|
OWNER=$(stat -c '%U:%G' "$WHMCS/modules/servers")
|
||||||
|
curl -fsSL "https://github.com/EZSCALE/virtfusion-whmcs-module/archive/refs/tags/${VERSION}.tar.gz" -o /tmp/vf.tar.gz \
|
||||||
|
&& mkdir -p /tmp/vf && tar -xzf /tmp/vf.tar.gz -C /tmp/vf --strip-components=1 \
|
||||||
|
&& rsync -ahP --delete --chown="$OWNER" /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||||
|
&& rsync -ahP --delete --chown="$OWNER" /tmp/vf/modules/addons/VirtFusionDns/ "$WHMCS/modules/addons/VirtFusionDns/" \
|
||||||
|
&& rm -rf /tmp/vf /tmp/vf.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
The second `rsync` line is only needed if you use the Reverse DNS addon; skip it otherwise.
|
||||||
|
|
||||||
|
> **Note:** If you have a custom `config/ConfigOptionMapping.php`, back it up first — `--delete` will remove it. Restore it after. The helper script does this automatically.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -222,24 +238,13 @@ Each WHMCS product using this module needs:
|
|||||||
|
|
||||||
### Custom Fields
|
### Custom Fields
|
||||||
|
|
||||||
You **must** create two custom fields on each product that uses this module:
|
The module requires two custom fields per product: **Initial Operating System** and **Initial SSH Key**. These are **automatically created** when the module loads — no manual setup required.
|
||||||
|
|
||||||
| Field Name | Field Type | Show on Order Form | Admin Only | Required |
|
The fields are hidden text boxes that are dynamically replaced by dropdown selects via JavaScript hooks on the order form. They are created for every product with the module type set to "VirtFusion Direct Provisioning".
|
||||||
|---|---|---|---|---|
|
|
||||||
| Initial Operating System | Text Box | Yes | No | No |
|
|
||||||
| Initial SSH Key | Text Box | Yes | No | No |
|
|
||||||
|
|
||||||
These fields are hidden text boxes that are dynamically replaced by dropdown selects via JavaScript hooks on the order form.
|
|
||||||
|
|
||||||
**Automated setup**: Run the SQL from [modify.sql](modify.sql) to auto-create these fields for all VirtFusion products:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -u whmcs_user -p whmcs_database < modules/servers/VirtFusionDirect/modify.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Module Configuration Options
|
### Module Configuration Options
|
||||||
|
|
||||||
Each product has three module-specific settings:
|
Each product has these module-specific settings:
|
||||||
|
|
||||||
| Option | Name | Description | Default |
|
| Option | Name | Description | Default |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -249,6 +254,7 @@ Each product has three module-specific settings:
|
|||||||
| Config Option 4 | Self-Service Mode | Enable VirtFusion self-service billing (0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both) | 0 |
|
| Config Option 4 | Self-Service Mode | Enable VirtFusion self-service billing (0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both) | 0 |
|
||||||
| Config Option 5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers during cron (0=disabled) | 0 |
|
| Config Option 5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers during cron (0=disabled) | 0 |
|
||||||
| Config Option 6 | Auto Top-Off Amount | Credit amount to add when auto top-off triggers | 100 |
|
| Config Option 6 | Auto Top-Off Amount | Credit amount to add when auto top-off triggers | 100 |
|
||||||
|
| Config Option 7 | Stock Safety Buffer (%) | Headroom reserved per resource during stock calculation (0-100). Only effective with WHMCS Stock Control enabled on the product; blank falls back to the default. | 10 |
|
||||||
|
|
||||||
You can find your Hypervisor Group IDs and Package IDs in the VirtFusion admin panel.
|
You can find your Hypervisor Group IDs and Package IDs in the VirtFusion admin panel.
|
||||||
|
|
||||||
@@ -286,6 +292,102 @@ return [
|
|||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Stock Control (Dynamic Inventory)
|
||||||
|
|
||||||
|
Optional but recommended once the catalogue is backed by real hypervisor capacity. When enabled on a product, the module keeps `tblproducts.qty` synced with the number of VPSes the panel can still actually provision — then WHMCS renders "Out of Stock" badges, disables Add-to-Cart, and refuses checkout entirely on its own.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- The VirtFusion API token on the WHMCS server must have read access to both `/packages` and `/compute/hypervisors/groups`. The **Test Connection** button (Admin → System Settings → Servers) now probes the compute endpoint explicitly — if the token is missing that scope you'll see a clear error at config time instead of nightly silence.
|
||||||
|
- No addon to activate. Stock control is enabled per product via WHMCS's native toggle.
|
||||||
|
|
||||||
|
**Enabling it on a product:**
|
||||||
|
|
||||||
|
1. WHMCS Admin → **System Settings → Products/Services → Products/Services** → edit the product.
|
||||||
|
2. Under the **Details** tab, tick **Stock Control** and save. (Leave *Quantity* at 0 — the module will populate it on the next recalc.)
|
||||||
|
3. Optionally tune **Config Option 7 — Stock Safety Buffer (%)** in the **Module Settings** tab. Default 10% means the module reserves 10% of each resource's max before counting fits, so you stop selling before a hypervisor is at 100%. Set to 0 for no buffer, higher for more headroom.
|
||||||
|
4. Either wait for the next recalc event (within 2 hours) or force one immediately: POST to `modules/servers/VirtFusionDirect/admin.php?action=stockRecalculate` from an authenticated admin session.
|
||||||
|
|
||||||
|
**How qty is computed:**
|
||||||
|
|
||||||
|
For every stock-controlled VirtFusion product:
|
||||||
|
|
||||||
|
1. Resolve the set of hypervisor groups the product can be placed in — the default group (Config Option 1) plus every numeric value of the `Location` configurable option if one is attached.
|
||||||
|
2. Fetch the product's package via `GET /packages/{id}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`).
|
||||||
|
3. For each eligible group, fetch live resources via `GET /compute/hypervisors/groups/{id}/resources`.
|
||||||
|
4. For each hypervisor in the group that passes eligibility (`enabled` AND `commissioned` AND `!prohibit`), compute `min(memory, cpu, storage)` fits — with the per-product buffer applied — against the matched storage pool. `package.primaryStorageProfile` is a **storage type code** (mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id), matched against each `otherStorage[].storageType`. If multiple pools on the same hypervisor share that type (e.g. several mountpoint pools), the one with the largest fit wins; disabled peers are skipped, not fatal. Falls back to `localStorage` only when the package has no profile set.
|
||||||
|
5. Sum across hypervisors in each group, cap by the group-level IPv4 pool (`max()` within a group to avoid double-counting the shared pool), then sum across groups → `qty`.
|
||||||
|
|
||||||
|
**Refresh triggers:**
|
||||||
|
|
||||||
|
| Event | Trigger | Rate limit |
|
||||||
|
|---|---|---|
|
||||||
|
| New provision | `AfterModuleCreate` hook | 30 s shared with termination |
|
||||||
|
| VPS termination | `AfterModuleTerminate` hook | 30 s shared with create |
|
||||||
|
| Cart / order page view | `ClientAreaPageCart` hook | 60 s per product |
|
||||||
|
| Out-of-band panel change safety net | `AfterCronJob` hook | 2 hours (tunable via `STOCK_CRON_INTERVAL_SECONDS` in `hooks.php`) |
|
||||||
|
| Admin manual recalc | `admin.php?action=stockRecalculate` (POST + same-origin) | On demand |
|
||||||
|
|
||||||
|
**Safety properties:**
|
||||||
|
- **Transient API failures leave `qty` UNCHANGED.** `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during API blips or show inventory for deleted packages.
|
||||||
|
- **Confirmed-missing → qty=0.** HTTP 404 on the package or `package.enabled=false` forces qty=0, because the product genuinely cannot be provisioned.
|
||||||
|
- **Storage type mismatch → 0 for that hypervisor.** If the package targets storage type code `4` (mountpoint) but the hypervisor only exposes pools of type `0` (local default), that hypervisor contributes zero capacity — not a guess at "maybe placement will work out." This is a filter on `pool.storageType`, not on `pool.id`; identical type codes across different hypervisors all qualify, which is what makes multi-hypervisor mountpoint/datastore placement work.
|
||||||
|
- **Stock Control gate is absolute.** Products without `tblproducts.stockcontrol=1` are never touched, even by the cron safety net.
|
||||||
|
- **`\Throwable` catches** on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract.
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- `pkg:{packageId}` — 10 min TTL (package definitions rarely change)
|
||||||
|
- `grpres:{groupId}` — 120 s TTL (resources change minute-to-minute under load; shared across products that target the same group)
|
||||||
|
- Confirmed 404 responses cached 60 s so re-creating a deleted package/group takes effect quickly.
|
||||||
|
|
||||||
|
**Order auto-accept:** the `AfterModuleCreate` hook additionally calls WHMCS `AcceptOrder` with `autosetup=false` when the service's parent order is still in Pending status. This closes the loop for installs that rely on a pending-order workflow for non-VF products but want VirtFusion provisions to advance to Active automatically. Idempotent — already-accepted orders are skipped.
|
||||||
|
|
||||||
|
### Reverse DNS Addon (PowerDNS)
|
||||||
|
|
||||||
|
Optional. Activate the `VirtFusionDns` addon module to let the provisioning module manage PTR records in a PowerDNS instance automatically (and expose an rDNS editor to clients).
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- PowerDNS Authoritative 4.x with the HTTP API enabled (`webserver=yes`, `api=yes`, and an `api-key=...` set)
|
||||||
|
- `api-allow-from=` must include the IP of your WHMCS host
|
||||||
|
- **All reverse zones you intend to use must already exist in PowerDNS.** The addon never creates zones; it only PATCHes PTR RRsets into zones that are already delegated to your nameservers.
|
||||||
|
- Zones should have `soa_edit_api=INCREASE` (or similar) so PowerDNS auto-bumps the SOA serial on API writes. The addon additionally calls `PUT /zones/{id}/notify` after every PATCH to push changes to slaves immediately.
|
||||||
|
|
||||||
|
**Activation:**
|
||||||
|
|
||||||
|
1. Copy the addon into your WHMCS install (see the Installation section for the `rsync` command).
|
||||||
|
2. In WHMCS Admin → **System Settings → Addon Modules**, find **VirtFusion DNS** and click **Activate**. Grant admin role access as needed.
|
||||||
|
3. Click **Configure** and fill in:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| **Enable rDNS Sync** | Master switch. When off, every PowerDNS call short-circuits — the provisioning module behaves exactly as before the addon. |
|
||||||
|
| **PowerDNS API Endpoint** | Scheme + host + port, no path (e.g. `https://ns1.example.com:8081` or `http://10.0.0.5:8081`). The module appends `/api/v1/…` itself. |
|
||||||
|
| **PowerDNS API Key** | Password-type field. Encrypted at rest by WHMCS; decrypted server-side only when PowerDNS is called. |
|
||||||
|
| **PowerDNS Server ID** | Almost always `localhost` — the PowerDNS API server identifier, not a hostname. |
|
||||||
|
| **Default PTR TTL** | Applied to every PTR record the module creates. Default 3600. |
|
||||||
|
| **Cache TTL** | How long zone listings and DNS-resolution lookups are cached. Default 60, minimum 10. |
|
||||||
|
|
||||||
|
4. Click **Save Changes**.
|
||||||
|
5. Open the addon's admin page (same menu, usually **Addons → VirtFusion DNS**) and click **Run Test**. You should see "OK — PowerDNS reachable and authenticated" followed by a list of visible zones. If you don't see your expected reverse zones here, the module won't find them either — fix PowerDNS first.
|
||||||
|
|
||||||
|
**How it behaves:**
|
||||||
|
|
||||||
|
| Event | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Server provisioning | Creates a PTR for every assigned IP pointing to the VirtFusion hostname — but only if that hostname's A/AAAA already resolves to the IP. Forward-missing IPs are logged and skipped (provisioning still succeeds). |
|
||||||
|
| Server rename (via client or admin) | Rewrites only PTRs whose current content equals the previous hostname. Client-customised PTRs are preserved. |
|
||||||
|
| Server termination | Deletes every PTR belonging to the server before the local record is purged. |
|
||||||
|
| Client edits PTR in the Reverse DNS panel | Validates IP ownership (cross-checked against a fresh VirtFusion fetch), PTR regex, per-IP 10-second rate limit, and forward-DNS match. Empty value deletes. |
|
||||||
|
| Daily cron | Creates PTRs for IPs that don't have one yet (and whose forward DNS resolves correctly). **Additive-only — never overwrites.** |
|
||||||
|
| Admin "Reconcile (force reset)" button | The only code path that overwrites a non-matching PTR — explicit admin action. |
|
||||||
|
|
||||||
|
**RFC 2317 classless delegations** are supported: the module parses zones like `64/64.38.186.66.in-addr.arpa.` (both CIDR-prefix and block-size conventions), matches IPs by range rather than suffix, and writes PTRs with the correct classless RRset name. The PowerDNS URL-safe zone ID encoding (`/` → `=2F`) is handled transparently.
|
||||||
|
|
||||||
|
**Security posture:**
|
||||||
|
- PowerDNS integration is **opt-in** — if the addon is deactivated or `Enable rDNS Sync` is off, the provisioning module behaves exactly as before.
|
||||||
|
- Every client-facing rDNS endpoint validates service ownership and re-verifies the IP is currently assigned to the requesting user's server (defends against stale-ownership after IP reassignment).
|
||||||
|
- The API key is stored encrypted in `tbladdonmodules` by WHMCS; it is never logged.
|
||||||
|
- DNS write failures never block VirtFusion operations — provisioning, rename, and termination all succeed regardless of PowerDNS state, and errors are recorded in the WHMCS Module Log for review.
|
||||||
|
|
||||||
## Client Area Features
|
## Client Area Features
|
||||||
|
|
||||||
### Server Overview
|
### Server Overview
|
||||||
@@ -305,7 +407,7 @@ Four power control buttons:
|
|||||||
|
|
||||||
### Network Management
|
### Network Management
|
||||||
- View all IPv4 addresses and IPv6 subnets assigned to the server
|
- View all IPv4 addresses and IPv6 subnets assigned to the server
|
||||||
- Remove secondary IPv4 addresses (primary cannot be removed)
|
- Copy IP addresses to clipboard with one click
|
||||||
|
|
||||||
### VNC Console
|
### VNC Console
|
||||||
- Opens a browser-based VNC console to the server
|
- Opens a browser-based VNC console to the server
|
||||||
@@ -328,6 +430,14 @@ Four power control buttons:
|
|||||||
- Registration and next due dates
|
- Registration and next due dates
|
||||||
- Payment method
|
- Payment method
|
||||||
|
|
||||||
|
### Reverse DNS *(requires the VirtFusion DNS addon)*
|
||||||
|
A panel listing every IP assigned to the server with an inline editor for the PTR record:
|
||||||
|
- One input per IP — populate to set a custom PTR, leave blank to delete
|
||||||
|
- Per-row status badge (OK / unverified / no PTR / no zone / error)
|
||||||
|
- Saves are rate-limited to one write per IP per 10 seconds
|
||||||
|
- Forward DNS must already resolve to the IP; mismatches show an inline error guiding the client to fix their A/AAAA first
|
||||||
|
- Hidden entirely when the addon is not activated
|
||||||
|
|
||||||
## Admin Area Features
|
## Admin Area Features
|
||||||
|
|
||||||
### Admin Services Tab
|
### Admin Services Tab
|
||||||
@@ -336,6 +446,7 @@ When viewing a service in WHMCS admin, the module adds:
|
|||||||
- **Server Info** - Button to load live data from VirtFusion API
|
- **Server Info** - Button to load live data from VirtFusion API
|
||||||
- **Server Object** - Full JSON response viewer
|
- **Server Object** - Full JSON response viewer
|
||||||
- **Options** - Admin impersonation link
|
- **Options** - Admin impersonation link
|
||||||
|
- **Reverse DNS** *(when the VirtFusion DNS addon is activated)* - Live per-IP PTR status plus **Reconcile (additive)** and **Reconcile (force reset)** buttons
|
||||||
|
|
||||||
### Module Commands (Admin Buttons)
|
### Module Commands (Admin Buttons)
|
||||||
- **Create** - Provision a new server
|
- **Create** - Provision a new server
|
||||||
@@ -407,12 +518,6 @@ WHMCS automatically loads theme-specific templates when they exist. Copy the ori
|
|||||||
| `GET` | `/media/templates/fromServerPackageSpec/{id}` | OS templates |
|
| `GET` | `/media/templates/fromServerPackageSpec/{id}` | OS templates |
|
||||||
| `GET` | `/ssh_keys/user/{id}` | SSH key listing |
|
| `GET` | `/ssh_keys/user/{id}` | SSH key listing |
|
||||||
|
|
||||||
### Network
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `DELETE` | `/servers/{id}/ipv4` | Remove IPv4 address |
|
|
||||||
|
|
||||||
### SSH Keys
|
### SSH Keys
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
@@ -426,7 +531,10 @@ WHMCS automatically loads theme-specific templates when they exist. Copy the ori
|
|||||||
| `GET` | `/selfService/usage/byUserExtRelationId/{id}` | Usage data by WHMCS client ID |
|
| `GET` | `/selfService/usage/byUserExtRelationId/{id}` | Usage data by WHMCS client ID |
|
||||||
| `GET` | `/selfService/report/byUserExtRelationId/{id}` | Billing report by WHMCS client ID |
|
| `GET` | `/selfService/report/byUserExtRelationId/{id}` | Billing report by WHMCS client ID |
|
||||||
| `POST` | `/selfService/credit/byUserExtRelationId/{id}` | Add credit by WHMCS client ID |
|
| `POST` | `/selfService/credit/byUserExtRelationId/{id}` | Add credit by WHMCS client ID |
|
||||||
| `GET` | `/selfService/currencies` | Available self-service currencies |
|
| `GET` | `/servers/{id}/traffic` | Traffic statistics |
|
||||||
|
| `GET` | `/backups/server/{id}` | Backup listing |
|
||||||
|
| `POST` | `/servers/{id}/vnc` | Toggle VNC on/off |
|
||||||
|
| `POST` | `/servers/{id}/resetPassword` | Reset server root password |
|
||||||
|
|
||||||
### Advanced
|
### Advanced
|
||||||
|
|
||||||
@@ -438,6 +546,18 @@ WHMCS automatically loads theme-specific templates when they exist. Copy the ori
|
|||||||
| `PUT` | `/servers/{id}/modify/traffic` | Modify traffic (v6.0.0+) |
|
| `PUT` | `/servers/{id}/modify/traffic` | Modify traffic (v6.0.0+) |
|
||||||
| `POST/DELETE` | `/servers/{id}/backup/plan` | Backup plan management (v4.3.0+) |
|
| `POST/DELETE` | `/servers/{id}/backup/plan` | Backup plan management (v4.3.0+) |
|
||||||
|
|
||||||
|
### PowerDNS (Reverse DNS addon, PowerDNS Authoritative 4.x+)
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/v1/servers/{id}` | Health check (Test Connection button) |
|
||||||
|
| `GET` | `/api/v1/servers/{id}/zones` | Zone discovery (cached per `cacheTtl`) |
|
||||||
|
| `GET` | `/api/v1/servers/{id}/zones/{zone}` | Fetch current RRsets for status + reads |
|
||||||
|
| `PATCH` | `/api/v1/servers/{id}/zones/{zone}` | Create / replace / delete PTR RRsets |
|
||||||
|
| `PUT` | `/api/v1/servers/{id}/zones/{zone}/notify` | NOTIFY slaves after every successful PATCH |
|
||||||
|
|
||||||
|
Authentication is via the `X-API-Key` header (configured in the addon). Zone IDs containing `/` (RFC 2317 classless) are URL-encoded as `=2F` per PowerDNS convention.
|
||||||
|
|
||||||
## Usage Update (Cron)
|
## Usage Update (Cron)
|
||||||
|
|
||||||
The module implements the `UsageUpdate` function that is called by the WHMCS daily cron. It automatically syncs:
|
The module implements the `UsageUpdate` function that is called by the WHMCS daily cron. It automatically syncs:
|
||||||
@@ -533,9 +653,7 @@ This data appears in the WHMCS client area and admin product details.
|
|||||||
|
|
||||||
7. **Concurrent API Calls** - The module makes individual API calls for each feature panel on the client area page. If the VirtFusion API is slow, the page may take longer to fully load. All panels load asynchronously to minimize perceived delay.
|
7. **Concurrent API Calls** - The module makes individual API calls for each feature panel on the client area page. If the VirtFusion API is slow, the page may take longer to fully load. All panels load asynchronously to minimize perceived delay.
|
||||||
|
|
||||||
8. **Primary IPv4 Protection** - The first IPv4 address cannot be removed through the client area interface. This is by design to prevent users from accidentally removing their primary IP address.
|
8. **Self-Signed SSL Certificates** - SSL verification is enforced by default. VirtFusion panels using self-signed certificates will cause connection failures. Use a valid SSL certificate (e.g., Let's Encrypt) on your VirtFusion panel.
|
||||||
|
|
||||||
9. **Self-Signed SSL Certificates** - SSL verification is enforced by default. VirtFusion panels using self-signed certificates will cause connection failures. Use a valid SSL certificate (e.g., Let's Encrypt) on your VirtFusion panel.
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -563,17 +681,23 @@ modules/servers/VirtFusionDirect/
|
|||||||
VirtFusionDirect.php # WHMCS module entry point (MetaData, ConfigOptions, all module functions)
|
VirtFusionDirect.php # WHMCS module entry point (MetaData, ConfigOptions, all module functions)
|
||||||
client.php # Client-facing AJAX API (authenticated, ownership-validated)
|
client.php # Client-facing AJAX API (authenticated, ownership-validated)
|
||||||
admin.php # Admin-facing AJAX API (admin authentication required)
|
admin.php # Admin-facing AJAX API (admin authentication required)
|
||||||
hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation)
|
hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation, daily rDNS cron)
|
||||||
modify.sql # SQL for creating custom fields
|
|
||||||
lib/
|
lib/
|
||||||
Module.php # Base class: API communication, power, network, VNC, rebuild
|
Module.php # Base class: API communication, power, network, VNC, rebuild
|
||||||
ModuleFunctions.php # Provisioning: create, suspend, unsuspend, terminate, change package
|
ModuleFunctions.php # Provisioning: create, suspend, unsuspend, terminate, change package
|
||||||
ConfigureService.php # Order configuration: OS templates, SSH keys, server build init
|
ConfigureService.php # Order configuration: OS templates, SSH keys, server build init
|
||||||
Database.php # Database operations: custom table, WHMCS table queries
|
Database.php # Database operations: custom table, WHMCS table queries
|
||||||
|
Cache.php # Two-tier cache: Redis with filesystem fallback
|
||||||
Curl.php # HTTP client: GET, POST, PUT, PATCH, DELETE with SSL verification
|
Curl.php # HTTP client: GET, POST, PUT, PATCH, DELETE with SSL verification
|
||||||
ServerResource.php # Data transformer: VirtFusion API response -> display format
|
ServerResource.php # Data transformer: VirtFusion API response -> display format
|
||||||
AdminHTML.php # Admin interface: HTML generation for admin services tab
|
AdminHTML.php # Admin interface: HTML generation for admin services tab
|
||||||
Log.php # Logging: WHMCS module log integration
|
Log.php # Logging: WHMCS module log integration
|
||||||
|
PowerDns/
|
||||||
|
Client.php # PowerDNS HTTP API wrapper (X-API-Key, ping, listZones, getZone, patchRRset, notifyZone)
|
||||||
|
Config.php # Loads + decrypts addon settings from tbladdonmodules
|
||||||
|
IpUtil.php # PTR-name generation, IP extraction, RFC 2317 parsing, zone matching
|
||||||
|
Resolver.php # Forward-DNS verification (dns_get_record + CNAME chain, cached)
|
||||||
|
PtrManager.php # Orchestrator: syncServer, deleteForServer, listPtrs, setPtr, reconcile, reconcileAll
|
||||||
templates/
|
templates/
|
||||||
overview.tpl # Client area Smarty template (all management panels)
|
overview.tpl # Client area Smarty template (all management panels)
|
||||||
error.tpl # Error display template
|
error.tpl # Error display template
|
||||||
@@ -582,6 +706,9 @@ modules/servers/VirtFusionDirect/
|
|||||||
js/keygen.js # SSH Ed25519 key generator (Web Crypto API)
|
js/keygen.js # SSH Ed25519 key generator (Web Crypto API)
|
||||||
config/
|
config/
|
||||||
ConfigOptionMapping-example.php # Example custom option name mapping
|
ConfigOptionMapping-example.php # Example custom option name mapping
|
||||||
|
|
||||||
|
modules/addons/VirtFusionDns/ # Optional — only needed for reverse DNS support
|
||||||
|
VirtFusionDns.php # Addon entry point: _config(), _activate(), _deactivate(), _output() (Test Connection page)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
19
composer.json
Normal file
19
composer.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "ezscale/virtfusion-whmcs-module",
|
||||||
|
"description": "VirtFusion Direct Provisioning Module for WHMCS",
|
||||||
|
"type": "whmcs-module",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-install-cmd": [
|
||||||
|
"cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit"
|
||||||
|
],
|
||||||
|
"lint": "pint",
|
||||||
|
"lint-test": "pint --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
composer.lock
generated
Normal file
87
composer.lock
generated
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "f6be98eb2bded4b127a92bc0f1e19d93",
|
||||||
|
"packages": [],
|
||||||
|
"packages-dev": [
|
||||||
|
{
|
||||||
|
"name": "laravel/pint",
|
||||||
|
"version": "v1.29.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/pint.git",
|
||||||
|
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
|
||||||
|
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-tokenizer": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"php": "^8.2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.94.2",
|
||||||
|
"illuminate/view": "^12.54.1",
|
||||||
|
"larastan/larastan": "^3.9.3",
|
||||||
|
"laravel-zero/framework": "^12.0.5",
|
||||||
|
"mockery/mockery": "^1.6.12",
|
||||||
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
|
"pestphp/pest": "^3.8.6",
|
||||||
|
"shipfastlabs/agent-detector": "^1.1.0"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"builds/pint"
|
||||||
|
],
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/",
|
||||||
|
"Database\\Factories\\": "database/factories/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nuno Maduro",
|
||||||
|
"email": "enunomaduro@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An opinionated code formatter for PHP.",
|
||||||
|
"homepage": "https://laravel.com",
|
||||||
|
"keywords": [
|
||||||
|
"dev",
|
||||||
|
"format",
|
||||||
|
"formatter",
|
||||||
|
"lint",
|
||||||
|
"linter",
|
||||||
|
"php"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/pint/issues",
|
||||||
|
"source": "https://github.com/laravel/pint"
|
||||||
|
},
|
||||||
|
"time": "2026-03-12T15:51:39+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.9.0"
|
||||||
|
}
|
||||||
26
hooks/pre-commit
Executable file
26
hooks/pre-commit
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Run Pint on staged PHP files before committing.
|
||||||
|
# Fixes formatting in-place and re-stages the corrected files.
|
||||||
|
|
||||||
|
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
|
||||||
|
|
||||||
|
if [ -z "$STAGED_PHP" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that Pint is installed
|
||||||
|
if [ ! -x "./vendor/bin/pint" ]; then
|
||||||
|
echo "Error: laravel/pint is not installed. Run 'composer install' first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running Pint on staged PHP files..."
|
||||||
|
./vendor/bin/pint $STAGED_PHP
|
||||||
|
|
||||||
|
# Re-stage any files that Pint modified
|
||||||
|
for FILE in $STAGED_PHP; do
|
||||||
|
if [ -f "$FILE" ]; then
|
||||||
|
git add "$FILE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
196
install.sh
Executable file
196
install.sh
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# install.sh — Manage the VirtFusion Direct WHMCS module.
|
||||||
|
#
|
||||||
|
# Subcommands:
|
||||||
|
# install First-time install. Refuses if already present (use upgrade).
|
||||||
|
# upgrade Refresh an existing install. Refuses if nothing is installed.
|
||||||
|
# check Report installed version vs latest available. No changes.
|
||||||
|
#
|
||||||
|
# Flags (install/upgrade only):
|
||||||
|
# --with-addon, -a Also sync the PowerDNS rDNS addon.
|
||||||
|
# --version, -v vX.Y.Z Pin a specific release tag (default: latest).
|
||||||
|
#
|
||||||
|
# Exit codes for `check`:
|
||||||
|
# 0 installed and up-to-date
|
||||||
|
# 1 installed but outdated (or installed-version unknown)
|
||||||
|
# 2 not installed
|
||||||
|
#
|
||||||
|
# Pipeable:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
# | sudo bash -s -- install /path/to/whmcs
|
||||||
|
#
|
||||||
|
# wget -qO- https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
# | sudo bash -s -- upgrade --with-addon /path/to/whmcs
|
||||||
|
#
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
# | bash -s -- check /path/to/whmcs
|
||||||
|
#
|
||||||
|
# Why a script? rsync into a directory owned by the WHMCS web user (e.g.
|
||||||
|
# www-data, apache) lands files as root:root by default, which the web server
|
||||||
|
# can't read — the classic "module installed but invisible in WHMCS" symptom.
|
||||||
|
# This script reads the parent directory's owner and applies it via --chown, so
|
||||||
|
# a `sudo bash` install ends up with correct ownership. It also preserves any
|
||||||
|
# custom config/ConfigOptionMapping.php across --delete.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="EZSCALE/virtfusion-whmcs-module"
|
||||||
|
MARKER=".installed-version"
|
||||||
|
|
||||||
|
err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; }
|
||||||
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
|
info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage:
|
||||||
|
install.sh install [--with-addon] [--version vX.Y.Z] /path/to/whmcs
|
||||||
|
install.sh upgrade [--with-addon] [--version vX.Y.Z] /path/to/whmcs
|
||||||
|
install.sh check /path/to/whmcs
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh \\
|
||||||
|
| sudo bash -s -- install /path/to/whmcs
|
||||||
|
|
||||||
|
wget -qO- https://raw.githubusercontent.com/$REPO/main/install.sh \\
|
||||||
|
| sudo bash -s -- upgrade --with-addon /path/to/whmcs
|
||||||
|
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh \\
|
||||||
|
| bash -s -- check /path/to/whmcs
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
|
||||||
|
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p'
|
||||||
|
}
|
||||||
|
|
||||||
|
read_installed_version() {
|
||||||
|
local marker="$1/modules/servers/VirtFusionDirect/$MARKER"
|
||||||
|
if [ -f "$marker" ]; then
|
||||||
|
tr -d '[:space:]' < "$marker"
|
||||||
|
else
|
||||||
|
echo "unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_check() {
|
||||||
|
local WHMCS="$1"
|
||||||
|
if [ ! -d "$WHMCS/modules/servers/VirtFusionDirect" ]; then
|
||||||
|
warn "Not installed at $WHMCS"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
local current latest
|
||||||
|
current=$(read_installed_version "$WHMCS")
|
||||||
|
latest=$(resolve_latest)
|
||||||
|
[ -n "$latest" ] || { err "Could not resolve latest version from GitHub API"; exit 1; }
|
||||||
|
printf ' installed: %s\n latest: %s\n' "$current" "$latest"
|
||||||
|
if [ "$current" = "$latest" ]; then
|
||||||
|
info "Up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
warn "Update available: $current → $latest"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_sync() {
|
||||||
|
local mode="$1"; shift
|
||||||
|
local WITH_ADDON=0 VERSION="${VERSION:-}" WHMCS=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-addon|-a) WITH_ADDON=1; shift ;;
|
||||||
|
--version|-v) VERSION="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
-*) err "Unknown flag: $1"; usage ;;
|
||||||
|
*) WHMCS="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$WHMCS" ] || { err "Missing WHMCS path"; usage; }
|
||||||
|
[ -d "$WHMCS/modules/servers" ] || {
|
||||||
|
err "Not a WHMCS install: $WHMCS/modules/servers not found"; exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
local target="$WHMCS/modules/servers/VirtFusionDirect"
|
||||||
|
if [ "$mode" = "install" ] && [ -d "$target" ]; then
|
||||||
|
err "Already installed at $target — use 'upgrade' to refresh."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$mode" = "upgrade" ] && [ ! -d "$target" ]; then
|
||||||
|
err "Not currently installed at $target — use 'install' instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION=$(resolve_latest)
|
||||||
|
[ -n "$VERSION" ] || { err "Could not resolve latest version from GitHub API"; exit 1; }
|
||||||
|
fi
|
||||||
|
info "Target version: $VERSION"
|
||||||
|
|
||||||
|
local OWNER
|
||||||
|
OWNER=$(stat -c '%U:%G' "$WHMCS/modules/servers" 2>/dev/null || true)
|
||||||
|
[ -n "$OWNER" ] || { err "Could not detect parent directory owner via stat"; exit 1; }
|
||||||
|
info "Owner (from $WHMCS/modules/servers): $OWNER"
|
||||||
|
|
||||||
|
# NOTE: TMP is intentionally NOT declared `local`. The EXIT trap fires when
|
||||||
|
# the shell exits, not when this function returns — by then a function-local
|
||||||
|
# would be out of scope and `set -u` would explode the trap body with
|
||||||
|
# "TMP: unbound variable", masking the script's real exit code with 1.
|
||||||
|
# The `${TMP:-}` expansion in the trap is belt-and-suspenders: harmless
|
||||||
|
# if TMP somehow ends up unset, and prevents future regressions if anyone
|
||||||
|
# moves the assignment back into a tighter scope.
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "${TMP:-}"' EXIT
|
||||||
|
|
||||||
|
info "Downloading $VERSION..."
|
||||||
|
curl -fsSL "https://github.com/$REPO/archive/refs/tags/$VERSION.tar.gz" -o "$TMP/src.tar.gz"
|
||||||
|
mkdir -p "$TMP/src"
|
||||||
|
tar -xzf "$TMP/src.tar.gz" -C "$TMP/src" --strip-components=1
|
||||||
|
|
||||||
|
local SRC="$TMP/src/modules/servers/VirtFusionDirect"
|
||||||
|
[ -d "$SRC" ] || { err "Tarball did not contain modules/servers/VirtFusionDirect"; exit 1; }
|
||||||
|
|
||||||
|
# Preserve user's custom configurable-option mapping across --delete.
|
||||||
|
local MAP_FILE="$target/config/ConfigOptionMapping.php"
|
||||||
|
local MAP_BACKUP=""
|
||||||
|
if [ -f "$MAP_FILE" ]; then
|
||||||
|
MAP_BACKUP="$TMP/ConfigOptionMapping.php.bak"
|
||||||
|
cp -p "$MAP_FILE" "$MAP_BACKUP"
|
||||||
|
info "Backed up custom ConfigOptionMapping.php"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Syncing server module → $target/"
|
||||||
|
rsync -ahP --delete --chown="$OWNER" "$SRC/" "$target/"
|
||||||
|
|
||||||
|
if [ -n "$MAP_BACKUP" ]; then
|
||||||
|
cp -p "$MAP_BACKUP" "$MAP_FILE"
|
||||||
|
chown "$OWNER" "$MAP_FILE"
|
||||||
|
info "Restored custom ConfigOptionMapping.php"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$VERSION" > "$target/$MARKER"
|
||||||
|
chown "$OWNER" "$target/$MARKER"
|
||||||
|
|
||||||
|
if [ "$WITH_ADDON" = 1 ]; then
|
||||||
|
local addon_src="$TMP/src/modules/addons/VirtFusionDns"
|
||||||
|
local addon_target="$WHMCS/modules/addons/VirtFusionDns"
|
||||||
|
[ -d "$addon_src" ] || { err "Tarball did not contain modules/addons/VirtFusionDns"; exit 1; }
|
||||||
|
info "Syncing PowerDNS addon → $addon_target/"
|
||||||
|
rsync -ahP --delete --chown="$OWNER" "$addon_src/" "$addon_target/"
|
||||||
|
printf '%s\n' "$VERSION" > "$addon_target/$MARKER"
|
||||||
|
chown "$OWNER" "$addon_target/$MARKER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "$mode complete: $VERSION (owner $OWNER)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
install) shift; cmd_sync install "$@" ;;
|
||||||
|
upgrade) shift; cmd_sync upgrade "$@" ;;
|
||||||
|
check) shift; [ $# -eq 1 ] || usage; cmd_check "$1" ;;
|
||||||
|
-h|--help|"") usage ;;
|
||||||
|
*) err "Unknown command: $1"; usage ;;
|
||||||
|
esac
|
||||||
49
modify.sql
49
modify.sql
@@ -1,49 +0,0 @@
|
|||||||
-- Insert records for Initial Operating System if they don't already exist
|
|
||||||
INSERT INTO tblcustomfields
|
|
||||||
(type, relid, fieldname, fieldtype, description, fieldoptions, regexpr, adminonly, required, showorder, showinvoice,
|
|
||||||
sortorder, created_at, updated_at)
|
|
||||||
SELECT 'product',
|
|
||||||
id,
|
|
||||||
'Initial Operating System',
|
|
||||||
'text',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'on',
|
|
||||||
'',
|
|
||||||
0,
|
|
||||||
UTC_TIMESTAMP(),
|
|
||||||
UTC_TIMESTAMP()
|
|
||||||
FROM tblproducts
|
|
||||||
WHERE servertype = 'VirtFusionDirect'
|
|
||||||
AND NOT EXISTS (SELECT 1
|
|
||||||
FROM tblcustomfields
|
|
||||||
WHERE fieldname = 'Initial Operating System'
|
|
||||||
AND relid = tblproducts.id);
|
|
||||||
|
|
||||||
-- Insert records for Initial SSH Key if they don't already exist
|
|
||||||
INSERT INTO tblcustomfields
|
|
||||||
(type, relid, fieldname, fieldtype, description, fieldoptions, regexpr, adminonly, required, showorder, showinvoice,
|
|
||||||
sortorder, created_at, updated_at)
|
|
||||||
SELECT 'product',
|
|
||||||
id,
|
|
||||||
'Initial SSH Key',
|
|
||||||
'text',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'on',
|
|
||||||
'',
|
|
||||||
0,
|
|
||||||
UTC_TIMESTAMP(),
|
|
||||||
UTC_TIMESTAMP()
|
|
||||||
FROM tblproducts
|
|
||||||
WHERE servertype = 'VirtFusionDirect'
|
|
||||||
AND NOT EXISTS (SELECT 1
|
|
||||||
FROM tblcustomfields
|
|
||||||
WHERE fieldname = 'Initial SSH Key'
|
|
||||||
AND relid = tblproducts.id);
|
|
||||||
367
modules/addons/VirtFusionDns/VirtFusionDns.php
Normal file
367
modules/addons/VirtFusionDns/VirtFusionDns.php
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VirtFusion DNS — companion WHMCS addon module that holds PowerDNS settings for
|
||||||
|
* the VirtFusionDirect server module. Keeps the server module decoupled from the
|
||||||
|
* addon: the server module reads settings from tbladdonmodules and never loads
|
||||||
|
* addon code at runtime.
|
||||||
|
*
|
||||||
|
* Activation: WHMCS Admin -> System Settings -> Addon Modules -> Activate -> Configure.
|
||||||
|
*
|
||||||
|
* API key handling: WHMCS encrypts password-type addon fields in tbladdonmodules;
|
||||||
|
* the server module calls decrypt() on read (see lib/PowerDns/Config.php).
|
||||||
|
*/
|
||||||
|
if (! defined('WHMCS')) {
|
||||||
|
exit('This file cannot be accessed directly');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the server module's PowerDNS classes on demand. Done inside functions rather
|
||||||
|
* than at file scope so the WHMCS addon list still works if the server module is
|
||||||
|
* absent (e.g., uninstalled while the addon is still activated). Returns true when
|
||||||
|
* the classes are available.
|
||||||
|
*/
|
||||||
|
function virtfusiondns_load_server_libs(): bool
|
||||||
|
{
|
||||||
|
$base = __DIR__ . '/../../servers/VirtFusionDirect/lib/';
|
||||||
|
$files = [
|
||||||
|
'Curl.php',
|
||||||
|
'Log.php',
|
||||||
|
'Cache.php',
|
||||||
|
'PowerDns/Config.php',
|
||||||
|
'PowerDns/IpUtil.php',
|
||||||
|
'PowerDns/Client.php',
|
||||||
|
];
|
||||||
|
foreach ($files as $f) {
|
||||||
|
if (! is_file($base . $f)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHMCS addon metadata.
|
||||||
|
*/
|
||||||
|
function VirtFusionDns_config()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'VirtFusion DNS',
|
||||||
|
'description' => 'Adds reverse DNS (PTR) management to the VirtFusionDirect server module using a PowerDNS HTTP API. Zones must already exist in PowerDNS; the addon never creates zones. Requires the VirtFusionDirect server module.',
|
||||||
|
'version' => '1.0',
|
||||||
|
'author' => 'VirtFusionDirect',
|
||||||
|
'language' => 'english',
|
||||||
|
'fields' => [
|
||||||
|
'enabled' => [
|
||||||
|
'FriendlyName' => 'Enable rDNS Sync',
|
||||||
|
'Type' => 'yesno',
|
||||||
|
'Description' => 'Master switch. When off, the server module skips every PowerDNS call.',
|
||||||
|
],
|
||||||
|
'endpoint' => [
|
||||||
|
'FriendlyName' => 'PowerDNS API Endpoint',
|
||||||
|
'Type' => 'text',
|
||||||
|
'Size' => '60',
|
||||||
|
'Default' => 'http://ns1.example.com:8081',
|
||||||
|
'Description' => 'Scheme + host + port (no path). The /api/v1/... path is appended automatically.',
|
||||||
|
],
|
||||||
|
'apiKey' => [
|
||||||
|
'FriendlyName' => 'PowerDNS API Key',
|
||||||
|
'Type' => 'password',
|
||||||
|
'Size' => '60',
|
||||||
|
'Description' => 'X-API-Key. Stored encrypted by WHMCS; decrypted only server-side when PowerDNS is called.',
|
||||||
|
],
|
||||||
|
'serverId' => [
|
||||||
|
'FriendlyName' => 'PowerDNS Server ID',
|
||||||
|
'Type' => 'text',
|
||||||
|
'Size' => '20',
|
||||||
|
'Default' => 'localhost',
|
||||||
|
'Description' => 'Almost always "localhost" (the PowerDNS API server identifier, not a hostname).',
|
||||||
|
],
|
||||||
|
'defaultTtl' => [
|
||||||
|
'FriendlyName' => 'Default PTR TTL (seconds)',
|
||||||
|
'Type' => 'text',
|
||||||
|
'Size' => '10',
|
||||||
|
'Default' => '3600',
|
||||||
|
'Description' => 'TTL applied to PTR records created by the module.',
|
||||||
|
],
|
||||||
|
'cacheTtl' => [
|
||||||
|
'FriendlyName' => 'Cache TTL (seconds)',
|
||||||
|
'Type' => 'text',
|
||||||
|
'Size' => '10',
|
||||||
|
'Default' => '60',
|
||||||
|
'Description' => 'How long zone lists and DNS-resolution results are cached. Minimum 10s.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the addon is activated. No schema to create — settings live in tbladdonmodules.
|
||||||
|
*/
|
||||||
|
function VirtFusionDns_activate()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'description' => 'VirtFusion DNS activated. Fill in the endpoint + API key in the addon configuration, then use the Test Connection button on the addon page.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the addon is deactivated. Settings preserved (re-activating restores them).
|
||||||
|
*/
|
||||||
|
function VirtFusionDns_deactivate()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'description' => 'VirtFusion DNS deactivated. Server lifecycle PowerDNS calls will now be skipped. Settings are preserved.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin status page — rendered by WHMCS when the addon is clicked from the Addons menu.
|
||||||
|
*
|
||||||
|
* Shows a settings summary, a Test Connection button (calls PowerDNS ping), the current
|
||||||
|
* zone count, and a recent log extract filtered to PowerDNS-related entries.
|
||||||
|
*/
|
||||||
|
function VirtFusionDns_output($vars)
|
||||||
|
{
|
||||||
|
if (! virtfusiondns_load_server_libs()) {
|
||||||
|
echo '<div style="max-width:900px;padding:16px;border-radius:4px;background:#f8d7da;color:#721c24">';
|
||||||
|
echo '<strong>VirtFusionDirect server module not found.</strong> ';
|
||||||
|
echo 'This addon requires the VirtFusionDirect server module at <code>modules/servers/VirtFusionDirect/</code>. ';
|
||||||
|
echo 'Install or restore that module and reload this page.';
|
||||||
|
echo '</div>';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Config::reset();
|
||||||
|
$config = Config::get();
|
||||||
|
|
||||||
|
$pingResult = null;
|
||||||
|
$zoneCount = null;
|
||||||
|
$zoneSample = [];
|
||||||
|
|
||||||
|
if (! empty($_GET['vfdns_test'])) {
|
||||||
|
if (Config::isEnabled()) {
|
||||||
|
$client = new Client;
|
||||||
|
$pingResult = $client->ping();
|
||||||
|
if ($pingResult['ok']) {
|
||||||
|
$client->forgetZoneCache();
|
||||||
|
$zones = $client->listZones();
|
||||||
|
$zoneCount = count($zones);
|
||||||
|
$zoneSample = array_slice($zones, 0, 8);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$pingResult = ['ok' => false, 'http' => 0, 'error' => 'Not enabled or missing endpoint/apiKey.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$modulelink = htmlspecialchars($vars['modulelink'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$endpoint = htmlspecialchars($config['endpoint'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$serverId = htmlspecialchars($config['serverId'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$ttl = (int) $config['defaultTtl'];
|
||||||
|
$cacheTtl = (int) $config['cacheTtl'];
|
||||||
|
$enabledBadge = $config['enabled']
|
||||||
|
? '<span style="color:#28a745;font-weight:bold">enabled</span>'
|
||||||
|
: '<span style="color:#dc3545;font-weight:bold">disabled</span>';
|
||||||
|
$keyBadge = $config['apiKey'] !== '' ? '<span style="color:#28a745">set</span>' : '<span style="color:#dc3545">missing</span>';
|
||||||
|
|
||||||
|
echo '<div style="max-width:900px">';
|
||||||
|
echo '<h2 style="margin-top:0">VirtFusion DNS</h2>';
|
||||||
|
echo '<p>Reverse DNS management for the VirtFusionDirect server module. All PTR writes happen through the VirtFusion server lifecycle (create, rename, terminate) and through the client-area Reverse DNS panel. Forward DNS (A/AAAA) is verified before every PTR write; mismatches are skipped and logged.</p>';
|
||||||
|
|
||||||
|
echo '<h3>Current settings</h3>';
|
||||||
|
echo '<table class="table table-sm" style="max-width:700px"><tbody>';
|
||||||
|
echo '<tr><th style="text-align:left;width:180px">Status</th><td>' . $enabledBadge . '</td></tr>';
|
||||||
|
echo '<tr><th style="text-align:left">Endpoint</th><td><code>' . ($endpoint ?: '<em>not set</em>') . '</code></td></tr>';
|
||||||
|
echo '<tr><th style="text-align:left">API Key</th><td>' . $keyBadge . '</td></tr>';
|
||||||
|
echo '<tr><th style="text-align:left">Server ID</th><td><code>' . $serverId . '</code></td></tr>';
|
||||||
|
echo '<tr><th style="text-align:left">Default PTR TTL</th><td>' . $ttl . 's</td></tr>';
|
||||||
|
echo '<tr><th style="text-align:left">Cache TTL</th><td>' . $cacheTtl . 's</td></tr>';
|
||||||
|
echo '</tbody></table>';
|
||||||
|
|
||||||
|
echo '<h3>Test Connection</h3>';
|
||||||
|
echo '<p>Calls <code>GET /api/v1/servers/' . $serverId . '</code> and, on success, lists available zones.</p>';
|
||||||
|
echo '<a href="' . $modulelink . '&vfdns_test=1" class="btn btn-primary btn-sm">Run Test</a>';
|
||||||
|
|
||||||
|
if ($pingResult !== null) {
|
||||||
|
echo '<div style="margin-top:12px;padding:10px;border-radius:4px;background:' . ($pingResult['ok'] ? '#d4edda' : '#f8d7da') . ';color:' . ($pingResult['ok'] ? '#155724' : '#721c24') . '">';
|
||||||
|
if ($pingResult['ok']) {
|
||||||
|
echo '<strong>OK.</strong> PowerDNS reachable and authenticated. ';
|
||||||
|
if ($zoneCount !== null) {
|
||||||
|
echo $zoneCount . ' zone(s) visible.';
|
||||||
|
if (! empty($zoneSample)) {
|
||||||
|
echo '<div style="margin-top:8px;font-family:monospace;font-size:12px">';
|
||||||
|
foreach ($zoneSample as $z) {
|
||||||
|
echo htmlspecialchars($z, ENT_QUOTES, 'UTF-8') . '<br>';
|
||||||
|
}
|
||||||
|
if ($zoneCount > count($zoneSample)) {
|
||||||
|
echo '<em>... and ' . ($zoneCount - count($zoneSample)) . ' more</em>';
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo '<strong>Failed.</strong> HTTP ' . (int) $pingResult['http'] . ': ' . htmlspecialchars((string) ($pingResult['error'] ?? 'unknown error'), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<h3 style="margin-top:24px">Operation</h3>';
|
||||||
|
echo '<ul>';
|
||||||
|
echo '<li><strong>On server creation:</strong> a PTR is created for each assigned IP, set to the server hostname, <em>only if the forward DNS already resolves to that IP</em>.</li>';
|
||||||
|
echo '<li><strong>On server rename:</strong> PTRs whose current content matches the <em>previous</em> hostname are updated to the new hostname; custom PTRs set by the client are preserved.</li>';
|
||||||
|
echo '<li><strong>On server termination:</strong> every PTR for the server\'s IPs is deleted from PowerDNS.</li>';
|
||||||
|
echo '<li><strong>Clients:</strong> may set a custom PTR per IP via the Reverse DNS panel on the service overview page. Forward DNS must resolve to the IP; mismatch rejects the write.</li>';
|
||||||
|
echo '<li><strong>Reconcile cron:</strong> runs daily, additive-only — creates PTRs where none exist, never overwrites.</li>';
|
||||||
|
echo '<li><strong>Reconcile (admin):</strong> a button on the admin services tab triggers an explicit reconcile with optional <em>force</em> to reset client-custom PTRs back to the server hostname.</li>';
|
||||||
|
echo '</ul>';
|
||||||
|
|
||||||
|
echo '<h3>Requirements</h3>';
|
||||||
|
echo '<ul>';
|
||||||
|
echo '<li>PowerDNS Authoritative with HTTP API enabled (<code>webserver=yes</code>, <code>api=yes</code>).</li>';
|
||||||
|
echo '<li>Reverse zones (<code>*.in-addr.arpa</code> / <code>*.ip6.arpa</code>) for your IP ranges must exist in PowerDNS already — the addon never creates zones.</li>';
|
||||||
|
echo '<li><code>api-allow-from</code> must include the WHMCS host\'s IP.</li>';
|
||||||
|
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>';
|
||||||
|
}
|
||||||
@@ -1,13 +1,58 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
if (!defined("WHMCS")) {
|
/**
|
||||||
die("This file cannot be accessed directly");
|
* VirtFusion Direct Provisioning Module — WHMCS server module entry point.
|
||||||
|
*
|
||||||
|
* This file contains the non-namespaced functions WHMCS calls via its reflection-
|
||||||
|
* based module dispatcher. They follow the naming convention:
|
||||||
|
*
|
||||||
|
* {ModuleDirectoryName}_{FunctionName}(...)
|
||||||
|
*
|
||||||
|
* WHMCS looks for these on every relevant event (provisioning, UI rendering,
|
||||||
|
* daily cron, test connection, etc.). Every function here is a thin shim that
|
||||||
|
* instantiates ModuleFunctions (or Module) and delegates to a method — keeping
|
||||||
|
* the dispatch surface small and the business logic in unit-exercisable classes.
|
||||||
|
*
|
||||||
|
* DO NOT add significant logic directly in these shims. If you need a new
|
||||||
|
* lifecycle behaviour, add it as a method on ModuleFunctions and point the
|
||||||
|
* shim at it. This makes the module predictable: one public function, one method.
|
||||||
|
*
|
||||||
|
* RESERVED NAMES — DO NOT CHANGE
|
||||||
|
* ------------------------------
|
||||||
|
* WHMCS looks up these specific function names by convention; renaming them
|
||||||
|
* disables the corresponding feature in WHMCS silently:
|
||||||
|
* VirtFusionDirect_MetaData → Displayed name + API version
|
||||||
|
* VirtFusionDirect_ConfigOptions → Product-level settings fields
|
||||||
|
* VirtFusionDirect_TestConnection → Admin "Test Connection" button
|
||||||
|
* VirtFusionDirect_CreateAccount → Provisioning on order-activation
|
||||||
|
* VirtFusionDirect_SuspendAccount → Suspension
|
||||||
|
* VirtFusionDirect_UnsuspendAccount → Unsuspension
|
||||||
|
* VirtFusionDirect_TerminateAccount → Termination
|
||||||
|
* VirtFusionDirect_ChangePackage → Package change on upgrade/downgrade
|
||||||
|
* VirtFusionDirect_AdminServicesTabFields → Admin services tab renderer
|
||||||
|
* VirtFusionDirect_AdminServicesTabFieldsSave → Admin services tab save handler
|
||||||
|
* VirtFusionDirect_ClientArea → Client-area template + vars
|
||||||
|
* VirtFusionDirect_ServiceSingleSignOn → SSO button handler
|
||||||
|
* VirtFusionDirect_AdminCustomButtonArray → Custom admin action buttons
|
||||||
|
* VirtFusionDirect_UsageUpdate → Daily cron bandwidth/disk usage sync
|
||||||
|
*/
|
||||||
|
if (! defined('WHMCS')) {
|
||||||
|
exit('This file cannot be accessed directly');
|
||||||
}
|
}
|
||||||
|
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
|
use WHMCS\Database\Capsule;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Client as PowerDnsClient;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns module metadata consumed by WHMCS.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
function VirtFusionDirect_MetaData()
|
function VirtFusionDirect_MetaData()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -19,50 +64,62 @@ function VirtFusionDirect_MetaData()
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns product configuration options displayed in the WHMCS product editor.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
function VirtFusionDirect_ConfigOptions()
|
function VirtFusionDirect_ConfigOptions()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
"defaultHypervisorGroupId" => [
|
'defaultHypervisorGroupId' => [
|
||||||
"FriendlyName" => "Hypervisor Group ID",
|
'FriendlyName' => 'Hypervisor Group ID',
|
||||||
"Type" => "text",
|
'Type' => 'text',
|
||||||
"Size" => "20",
|
'Size' => '20',
|
||||||
"Description" => "The default hypervisor group ID for server placement.",
|
'Description' => 'The default hypervisor group ID for server placement.',
|
||||||
"Default" => "1",
|
'Default' => '1',
|
||||||
],
|
],
|
||||||
"packageID" => [
|
'packageID' => [
|
||||||
"FriendlyName" => "Package ID",
|
'FriendlyName' => 'Package ID',
|
||||||
"Type" => "text",
|
'Type' => 'text',
|
||||||
"Size" => "20",
|
'Size' => '20',
|
||||||
"Description" => "The VirtFusion package ID that defines server resources.",
|
'Description' => 'The VirtFusion package ID that defines server resources.',
|
||||||
"Default" => "1",
|
'Default' => '1',
|
||||||
],
|
],
|
||||||
"defaultIPv4" => [
|
'defaultIPv4' => [
|
||||||
"FriendlyName" => "Default IPv4",
|
'FriendlyName' => 'Default IPv4',
|
||||||
"Type" => "dropdown",
|
'Type' => 'dropdown',
|
||||||
"Options" => "0,1,2,3,4,5,6,7,8,9,10",
|
'Options' => '0,1,2,3,4,5,6,7,8,9,10',
|
||||||
"Description" => "The default number of IPv4 addresses to assign to each server.",
|
'Description' => 'The default number of IPv4 addresses to assign to each server.',
|
||||||
"Default" => "1",
|
'Default' => '1',
|
||||||
],
|
],
|
||||||
"selfServiceMode" => [
|
'selfServiceMode' => [
|
||||||
"FriendlyName" => "Self-Service Mode",
|
'FriendlyName' => 'Self-Service Mode',
|
||||||
"Type" => "dropdown",
|
'Type' => 'dropdown',
|
||||||
"Options" => "0|Disabled,1|Hourly,2|Resource Packs,3|Both",
|
'Options' => '0|Disabled,1|Hourly,2|Resource Packs,3|Both',
|
||||||
"Description" => "Enable VirtFusion self-service billing for users created by this product.",
|
'Description' => 'Enable VirtFusion self-service billing for users created by this product.',
|
||||||
"Default" => "0",
|
'Default' => '0',
|
||||||
],
|
],
|
||||||
"autoTopOffThreshold" => [
|
'autoTopOffThreshold' => [
|
||||||
"FriendlyName" => "Auto Top-Off Threshold",
|
'FriendlyName' => 'Auto Top-Off Threshold',
|
||||||
"Type" => "text",
|
'Type' => 'text',
|
||||||
"Size" => "10",
|
'Size' => '10',
|
||||||
"Description" => "Credit balance below which auto top-off triggers during cron. 0 = disabled.",
|
'Description' => 'Credit balance below which auto top-off triggers during cron. 0 = disabled.',
|
||||||
"Default" => "0",
|
'Default' => '0',
|
||||||
],
|
],
|
||||||
"autoTopOffAmount" => [
|
'autoTopOffAmount' => [
|
||||||
"FriendlyName" => "Auto Top-Off Amount",
|
'FriendlyName' => 'Auto Top-Off Amount',
|
||||||
"Type" => "text",
|
'Type' => 'text',
|
||||||
"Size" => "10",
|
'Size' => '10',
|
||||||
"Description" => "Credit amount to add when auto top-off triggers.",
|
'Description' => 'Credit amount to add when auto top-off triggers.',
|
||||||
"Default" => "100",
|
'Default' => '100',
|
||||||
|
],
|
||||||
|
'stockSafetyBufferPct' => [
|
||||||
|
'FriendlyName' => 'Stock Safety Buffer (%)',
|
||||||
|
'Type' => 'text',
|
||||||
|
'Size' => '5',
|
||||||
|
'Description' => 'Reserved headroom applied per resource when calculating stock. Only effective when the WHMCS Stock Control toggle is enabled on this product. 0-100; ignored for resources with no quota set in VirtFusion. Default is 10% if left blank.',
|
||||||
|
'Default' => '10',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -78,13 +135,49 @@ function VirtFusionDirect_TestConnection(array $params)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$url = 'https://' . $hostname . '/api/v1';
|
$url = 'https://' . $hostname . '/api/v1';
|
||||||
$module = new Module();
|
$module = new Module;
|
||||||
$request = $module->initCurl($password);
|
$request = $module->initCurl($password);
|
||||||
$data = $request->get($url . '/connect');
|
$data = $request->get($url . '/connect');
|
||||||
|
|
||||||
$httpCode = $request->getRequestInfo('http_code');
|
$httpCode = $request->getRequestInfo('http_code');
|
||||||
|
|
||||||
if ($httpCode == 200) {
|
if ($httpCode == 200) {
|
||||||
|
// Probe the compute scope: stock control depends on read access to
|
||||||
|
// /compute/hypervisors/groups. A token scoped only to /servers will pass the
|
||||||
|
// /connect check above but silently break nightly stock recalculation, so we
|
||||||
|
// surface the missing scope at config time rather than a week later.
|
||||||
|
$groupsProbe = $module->initCurl($password);
|
||||||
|
$groupsProbe->get($url . '/compute/hypervisors/groups?results=1');
|
||||||
|
$groupsHttp = (int) $groupsProbe->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
if ($groupsHttp === 401 || $groupsHttp === 403) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'VirtFusion OK but API token lacks read access to /compute/hypervisors/groups (HTTP ' . $groupsHttp . '). Stock Control will not work — re-issue the token with compute:read scope.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupsHttp !== 200) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'VirtFusion OK but /compute/hypervisors/groups returned HTTP ' . $groupsHttp . '. Stock Control may not work correctly.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also verify PowerDNS health when the DNS addon is activated, so the
|
||||||
|
// admin's Test Connection button reflects the full provisioning path.
|
||||||
|
if (PowerDnsConfig::isEnabled()) {
|
||||||
|
$pdns = (new PowerDnsClient)->ping();
|
||||||
|
if (! $pdns['ok']) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'VirtFusion OK; PowerDNS unreachable — '
|
||||||
|
. ($pdns['error'] ?? 'unknown')
|
||||||
|
. ' (HTTP ' . (int) $pdns['http'] . '). Fix the VirtFusion DNS addon settings.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ['success' => true, 'error' => ''];
|
return ['success' => true, 'error' => ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,27 +187,33 @@ function VirtFusionDirect_TestConnection(array $params)
|
|||||||
|
|
||||||
if ($httpCode == 0) {
|
if ($httpCode == 0) {
|
||||||
$curlError = $request->getRequestInfo('curl_error');
|
$curlError = $request->getRequestInfo('curl_error');
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Connection failed: ' . ($curlError ?: 'Unable to reach the VirtFusion server. Verify the hostname and that SSL certificates are valid.')];
|
return ['success' => false, 'error' => 'Connection failed: ' . ($curlError ?: 'Unable to reach the VirtFusion server. Verify the hostname and that SSL certificates are valid.')];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Unexpected response from VirtFusion API (HTTP ' . $httpCode . '). Please check the server configuration.'];
|
return ['success' => false, 'error' => 'Unexpected response from VirtFusion API (HTTP ' . $httpCode . '). Please check the server configuration.'];
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return ['success' => false, 'error' => 'Connection test failed: ' . $e->getMessage()];
|
return ['success' => false, 'error' => 'Connection test failed: ' . $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns custom admin action buttons shown on the service management page.
|
||||||
|
*
|
||||||
|
* @return array Button label => function suffix pairs
|
||||||
|
*/
|
||||||
function VirtFusionDirect_AdminCustomButtonArray()
|
function VirtFusionDirect_AdminCustomButtonArray()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
"Update Server Object" => "updateServerObject",
|
'Update Server Object' => 'updateServerObject',
|
||||||
"Validate Server Config" => "validateServerConfig",
|
'Validate Server Config' => 'validateServerConfig',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function VirtFusionDirect_ServiceSingleSignOn(array $params)
|
function VirtFusionDirect_ServiceSingleSignOn(array $params)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$module = new Module();
|
$module = new Module;
|
||||||
$token = $module->fetchLoginTokens($params['serviceid']);
|
$token = $module->fetchLoginTokens($params['serviceid']);
|
||||||
|
|
||||||
if ($token) {
|
if ($token) {
|
||||||
@@ -122,7 +221,7 @@ function VirtFusionDirect_ServiceSingleSignOn(array $params)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => false, 'errorMsg' => 'Unable to generate a login token. The server may not be active or the VirtFusion API may be unreachable.'];
|
return ['success' => false, 'errorMsg' => 'Unable to generate a login token. The server may not be active or the VirtFusion API may be unreachable.'];
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
return ['success' => false, 'errorMsg' => $e->getMessage()];
|
return ['success' => false, 'errorMsg' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,64 +231,104 @@ function VirtFusionDirect_ServiceSingleSignOn(array $params)
|
|||||||
*/
|
*/
|
||||||
function VirtFusionDirect_CreateAccount(array $params)
|
function VirtFusionDirect_CreateAccount(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->createAccount($params);
|
return (new ModuleFunctions)->createAccount($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends the VirtFusion server associated with a WHMCS service.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return string 'success' or error message
|
||||||
|
*/
|
||||||
function VirtFusionDirect_SuspendAccount(array $params)
|
function VirtFusionDirect_SuspendAccount(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->suspendAccount($params);
|
return (new ModuleFunctions)->suspendAccount($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsuspends the VirtFusion server associated with a WHMCS service.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return string 'success' or error message
|
||||||
|
*/
|
||||||
function VirtFusionDirect_UnsuspendAccount(array $params)
|
function VirtFusionDirect_UnsuspendAccount(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->unsuspendAccount($params);
|
return (new ModuleFunctions)->unsuspendAccount($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminates (deletes) the VirtFusion server associated with a WHMCS service.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return string 'success' or error message
|
||||||
|
*/
|
||||||
function VirtFusionDirect_TerminateAccount(array $params)
|
function VirtFusionDirect_TerminateAccount(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->terminateAccount($params);
|
return (new ModuleFunctions)->terminateAccount($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin custom action: refreshes the local server object from the VirtFusion API.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return string 'success' or error message
|
||||||
|
*/
|
||||||
function VirtFusionDirect_updateServerObject(array $params)
|
function VirtFusionDirect_updateServerObject(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->updateServerObject($params);
|
return (new ModuleFunctions)->updateServerObject($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows changing of the package of a server
|
* Allows changing of the package of a server
|
||||||
*
|
*
|
||||||
* @param array $params
|
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
function VirtFusionDirect_ChangePackage(array $params)
|
function VirtFusionDirect_ChangePackage(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->changePackage($params);
|
return (new ModuleFunctions)->changePackage($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns HTML fields rendered in the custom admin services tab.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return array Field name => HTML value pairs
|
||||||
|
*/
|
||||||
function VirtFusionDirect_AdminServicesTabFields(array $params)
|
function VirtFusionDirect_AdminServicesTabFields(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->adminServicesTabFields($params);
|
return (new ModuleFunctions)->adminServicesTabFields($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles saving of custom admin services tab field values.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
function VirtFusionDirect_AdminServicesTabFieldsSave(array $params)
|
function VirtFusionDirect_AdminServicesTabFieldsSave(array $params)
|
||||||
{
|
{
|
||||||
(new ModuleFunctions())->adminServicesTabFieldsSave($params);
|
(new ModuleFunctions)->adminServicesTabFieldsSave($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the client area template variables and template name for the service overview page.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS module parameters
|
||||||
|
* @return array Smarty template variables and 'templatefile' key
|
||||||
|
*/
|
||||||
function VirtFusionDirect_ClientArea(array $params)
|
function VirtFusionDirect_ClientArea(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->clientArea($params);
|
return (new ModuleFunctions)->clientArea($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates server configuration via dry run without creating the server.
|
* Validates server configuration via dry run without creating the server.
|
||||||
*
|
*
|
||||||
* @param array $params
|
|
||||||
* @return string 'success' or error message
|
* @return string 'success' or error message
|
||||||
*/
|
*/
|
||||||
function VirtFusionDirect_validateServerConfig(array $params)
|
function VirtFusionDirect_validateServerConfig(array $params)
|
||||||
{
|
{
|
||||||
return (new ModuleFunctions())->validateServerConfig($params);
|
return (new ModuleFunctions)->validateServerConfig($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,20 +337,20 @@ function VirtFusionDirect_validateServerConfig(array $params)
|
|||||||
* Updates tblhosting with disk and bandwidth usage data from VirtFusion.
|
* Updates tblhosting with disk and bandwidth usage data from VirtFusion.
|
||||||
* Fields updated: diskused, disklimit, bwused, bwlimit, lastupdate
|
* Fields updated: diskused, disklimit, bwused, bwlimit, lastupdate
|
||||||
*
|
*
|
||||||
* @param array $params Server access credentials
|
* @param array $params Server access credentials
|
||||||
* @return string 'success' or error message
|
* @return string 'success' or error message
|
||||||
*/
|
*/
|
||||||
function VirtFusionDirect_UsageUpdate(array $params)
|
function VirtFusionDirect_UsageUpdate(array $params)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$module = new Module();
|
$module = new Module;
|
||||||
$cp = $module->getCP($params['serverid']);
|
$cp = $module->getCP($params['serverid']);
|
||||||
|
|
||||||
if (!$cp) {
|
if (! $cp) {
|
||||||
return 'No control server found for usage update.';
|
return 'No control server found for usage update.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$services = \WHMCS\Database\Capsule::table('tblhosting')
|
$services = Capsule::table('tblhosting')
|
||||||
->where('server', $params['serverid'])
|
->where('server', $params['serverid'])
|
||||||
->where('domainstatus', 'Active')
|
->where('domainstatus', 'Active')
|
||||||
->get();
|
->get();
|
||||||
@@ -219,7 +358,7 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
|||||||
foreach ($services as $service) {
|
foreach ($services as $service) {
|
||||||
try {
|
try {
|
||||||
$systemService = Database::getSystemService($service->id);
|
$systemService = Database::getSystemService($service->id);
|
||||||
if (!$systemService) {
|
if (! $systemService) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +370,7 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$serverData = json_decode($data, true);
|
$serverData = json_decode($data, true);
|
||||||
if (!isset($serverData['data'])) {
|
if (! isset($serverData['data'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,15 +394,15 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
|||||||
$update['bwlimit'] = $trafficGB > 0 ? $trafficGB * 1024 : 0;
|
$update['bwlimit'] = $trafficGB > 0 ? $trafficGB * 1024 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($update)) {
|
if (! empty($update)) {
|
||||||
$update['lastupdate'] = date('Y-m-d H:i:s');
|
$update['lastupdate'] = date('Y-m-d H:i:s');
|
||||||
\WHMCS\Database\Capsule::table('tblhosting')
|
Capsule::table('tblhosting')
|
||||||
->where('id', $service->id)
|
->where('id', $service->id)
|
||||||
->update($update);
|
->update($update);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-service auto top-off
|
// Self-service auto top-off
|
||||||
$product = \WHMCS\Database\Capsule::table('tblproducts')
|
$product = Capsule::table('tblproducts')
|
||||||
->where('id', $service->packageid)
|
->where('id', $service->packageid)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -278,24 +417,24 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
|||||||
$credit = $usageInner['credit'] ?? $usageInner['balance'] ?? null;
|
$credit = $usageInner['credit'] ?? $usageInner['balance'] ?? null;
|
||||||
if ($credit !== null && (float) $credit < $threshold) {
|
if ($credit !== null && (float) $credit < $threshold) {
|
||||||
$module->addSelfServiceCredit($service->id, $topOffAmount, 'Auto top-off');
|
$module->addSelfServiceCredit($service->id, $topOffAmount, 'Auto top-off');
|
||||||
\WHMCS\Module\Server\VirtFusionDirect\Log::insert(
|
Log::insert(
|
||||||
'UsageUpdate:autoTopOff',
|
'UsageUpdate:autoTopOff',
|
||||||
['serviceId' => $service->id, 'credit' => $credit, 'threshold' => $threshold],
|
['serviceId' => $service->id, 'credit' => $credit, 'threshold' => $threshold],
|
||||||
['amount' => $topOffAmount]
|
['amount' => $topOffAmount],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Log but continue processing other services
|
// Log but continue processing other services
|
||||||
\WHMCS\Module\Server\VirtFusionDirect\Log::insert('UsageUpdate:service:' . $service->id, [], $e->getMessage());
|
Log::insert('UsageUpdate:service:' . $service->id, [], $e->getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'success';
|
return 'success';
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
return 'Usage update failed: ' . $e->getMessage();
|
return 'Usage update failed: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,92 +2,219 @@
|
|||||||
|
|
||||||
require dirname(__DIR__, 3) . '/init.php';
|
require dirname(__DIR__, 3) . '/init.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-facing AJAX API endpoint.
|
||||||
|
*
|
||||||
|
* MIRRORS client.php STRUCTURE
|
||||||
|
* ----------------------------
|
||||||
|
* Same switch-on-$action dispatch pattern, same JSON response shape, same
|
||||||
|
* "output + break" convention. The only substantive difference is the auth
|
||||||
|
* gate at the top: $vf->adminOnly() instead of $vf->isAuthenticated().
|
||||||
|
*
|
||||||
|
* WHY SEPARATE FROM client.php
|
||||||
|
* ----------------------------
|
||||||
|
* A single file with a per-action admin/client switch would risk one bug
|
||||||
|
* (e.g. forgetting to call adminOnly on a new admin-only action) giving a
|
||||||
|
* client authenticated but without admin privileges access to admin data.
|
||||||
|
* Having two physical entry points means the admin auth gate is enforced
|
||||||
|
* at file scope — any action routed here already went through adminOnly().
|
||||||
|
*
|
||||||
|
* ADMIN-LEVEL AUTH ONLY — NO SERVICE OWNERSHIP CHECK
|
||||||
|
* --------------------------------------------------
|
||||||
|
* An admin is allowed to view/operate on any service, so we don't call
|
||||||
|
* validateUserOwnsService() here. If you add an action that needs finer-
|
||||||
|
* grained auth (e.g. restrict to the admin role that owns the product
|
||||||
|
* group), compose the additional check inside the case branch.
|
||||||
|
*
|
||||||
|
* SAME-ORIGIN / POST GATES STILL APPLY TO MUTATIONS
|
||||||
|
* -------------------------------------------------
|
||||||
|
* Admins are still subject to requirePost + requireSameOrigin on writes —
|
||||||
|
* admin sessions are just as CSRF-vulnerable as client sessions. See the
|
||||||
|
* rdnsReconcile case for the pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\StockControl;
|
||||||
|
|
||||||
$vf = new Module();
|
$vf = new Module;
|
||||||
|
|
||||||
$vf->adminOnly();
|
try {
|
||||||
|
|
||||||
switch ($vf->validateAction(true)) {
|
$vf->adminOnly();
|
||||||
|
|
||||||
/**
|
switch ($vf->validateAction(true)) {
|
||||||
*
|
|
||||||
* Get server information.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
case 'serverData':
|
|
||||||
|
|
||||||
if ($vf->validateServiceID(true)) {
|
/**
|
||||||
|
* Get server information.
|
||||||
|
*/
|
||||||
|
case 'serverData':
|
||||||
|
|
||||||
/** No need to validate ownership **/
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
$whmcsService = Database::getWhmcsService((int)$_GET['serviceID']);
|
$whmcsService = Database::getWhmcsService($serviceID);
|
||||||
|
|
||||||
if (!$whmcsService) {
|
if (! $whmcsService) {
|
||||||
$vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 404);
|
$vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 404);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($whmcsService->domainstatus == 'Pending' || $whmcsService->domainstatus == 'Terminated' || $whmcsService->domainstatus == 'Cancelled' || $whmcsService->domainstatus == 'Fraud') {
|
if (in_array($whmcsService->domainstatus, ['Pending', 'Terminated', 'Cancelled', 'Fraud'], true)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 400);
|
$vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 400);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $vf->fetchServerData((int)$_GET['serviceID']);
|
$data = $vf->fetchServerData($serviceID);
|
||||||
|
|
||||||
if (!$data) {
|
if (! $data) {
|
||||||
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 502);
|
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 502);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
(new Module())->updateWhmcsServiceParamsOnServerObject((int)$_GET['serviceID'], $data);
|
$vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
|
||||||
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
|
$vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200);
|
||||||
|
break;
|
||||||
|
|
||||||
}
|
/**
|
||||||
break;
|
* Impersonate server owner.
|
||||||
|
*/
|
||||||
|
case 'impersonateServerOwner':
|
||||||
|
|
||||||
/**
|
$serviceID = $vf->validateServiceID(true);
|
||||||
*
|
|
||||||
* Impersonate server owner.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
case 'impersonateServerOwner':
|
|
||||||
|
|
||||||
if ($vf->validateServiceID(true)) {
|
$service = Database::getSystemService($serviceID);
|
||||||
|
if (! $service) {
|
||||||
$service = Database::getSystemService((int)$_GET['serviceID']);
|
|
||||||
|
|
||||||
if (!$service) {
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 404);
|
$vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 404);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$whmcsService = Database::getWhmcsService((int)$_GET['serviceID']);
|
$whmcsService = Database::getWhmcsService($serviceID);
|
||||||
|
if (! $whmcsService) {
|
||||||
if (!$whmcsService) {
|
|
||||||
$vf->output(['success' => false, 'errors' => 'WHMCS service not found'], true, true, 404);
|
$vf->output(['success' => false, 'errors' => 'WHMCS service not found'], true, true, 404);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cp = $vf->getCP($whmcsService->server);
|
$cp = $vf->getCP($whmcsService->server);
|
||||||
|
if (! $cp) {
|
||||||
if (!$cp) {
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Control server not found'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Control server not found'], true, true, 500);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$request = $vf->initCurl($cp['token']);
|
$request = $vf->initCurl($cp['token']);
|
||||||
|
$data = $request->get($cp['url'] . '/users/' . (int) $whmcsService->userid . '/byExtRelation');
|
||||||
$data = $request->get($cp['url'] . '/users/' . $whmcsService->userid . '/byExtRelation');
|
|
||||||
|
|
||||||
if ($request->getRequestInfo('http_code') === 200) {
|
if ($request->getRequestInfo('http_code') === 200) {
|
||||||
$vf->output(['success' => true, 'url' => $cp['base_url'], 'user' => json_decode($data, true)['data']], true, true, 200);
|
$vf->output(['success' => true, 'url' => $cp['base_url'], 'user' => json_decode($data, true)['data']], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Received HTTP code ' . $request->getRequestInfo('http_code')], true, true, 502);
|
$vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502);
|
||||||
|
break;
|
||||||
|
|
||||||
}
|
// =================================================================
|
||||||
break;
|
// Reverse DNS (PowerDNS)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
default:
|
/**
|
||||||
/** No valid action was specified **/
|
* Admin-side PTR status for a service. Same shape as client-side rdnsList but
|
||||||
|
* accessible without being the service owner (admin-only guard at top).
|
||||||
|
*/
|
||||||
|
case 'rdnsStatus':
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
if (! PowerDnsConfig::isEnabled()) {
|
||||||
|
$vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverData = $vf->fetchServerData($serviceID);
|
||||||
|
if (! $serverData) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ptrs = (new PtrManager)->listPtrs($serverData);
|
||||||
|
$vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger PTR reconciliation for a single service. Additive-only by default
|
||||||
|
* (missing PTRs are created with the current hostname); pass force=1 to also
|
||||||
|
* reset PTRs that differ from the server hostname.
|
||||||
|
*/
|
||||||
|
case 'rdnsReconcile':
|
||||||
|
|
||||||
|
// Mutating action — enforce POST + same-origin even though the session is admin-authenticated.
|
||||||
|
$vf->requirePost();
|
||||||
|
$vf->requireSameOrigin();
|
||||||
|
|
||||||
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
if (! PowerDnsConfig::isEnabled()) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = ! empty($_POST['force']);
|
||||||
|
$summary = (new PtrManager)->reconcile($serviceID, $force);
|
||||||
|
Log::insert(
|
||||||
|
'rdnsReconcile:ok',
|
||||||
|
['serviceID' => $serviceID, 'force' => $force],
|
||||||
|
$summary,
|
||||||
|
);
|
||||||
|
$vf->output(['success' => true, 'data' => $summary], true, true, 200);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Stock Control
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a full stock-quantity recalculation across every VirtFusionDirect
|
||||||
|
* product that has WHMCS stock control enabled. Same logic as the 2-hour
|
||||||
|
* AfterCronJob safety-net hook and the post-provision / post-termination
|
||||||
|
* event hooks in hooks.php, but on-demand. Cache TTLs still govern freshness
|
||||||
|
* of the underlying VirtFusion API reads — run a separate cache bust first
|
||||||
|
* if the admin needs to bypass the 120 s grpres:{id} TTL.
|
||||||
|
*
|
||||||
|
* Usable by admins via POST; returns a JSON map of productId => qty (or null
|
||||||
|
* where the product was skipped / left untouched by the orchestrator).
|
||||||
|
*/
|
||||||
|
case 'stockRecalculate':
|
||||||
|
|
||||||
|
$vf->requirePost();
|
||||||
|
$vf->requireSameOrigin();
|
||||||
|
|
||||||
|
$results = (new StockControl)->recalculateAll();
|
||||||
|
|
||||||
|
// Log a compact summary instead of the full map — the admin client still
|
||||||
|
// gets the detailed per-product map in the JSON response, but the module
|
||||||
|
// log stays readable even on stores with hundreds of VirtFusion products.
|
||||||
|
$summary = ['total' => count($results), 'updated' => 0, 'zeroed' => 0, 'skipped' => 0];
|
||||||
|
foreach ($results as $qty) {
|
||||||
|
if ($qty === null) {
|
||||||
|
$summary['skipped']++;
|
||||||
|
} elseif ((int) $qty === 0) {
|
||||||
|
$summary['zeroed']++;
|
||||||
|
} else {
|
||||||
|
$summary['updated']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log::insert('stockRecalculate:ok', [], $summary);
|
||||||
|
|
||||||
|
$vf->output(['success' => true, 'data' => $results], true, true, 200);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::insert('admin.php', [], $e->getMessage());
|
||||||
|
$vf->output(['success' => false, 'errors' => 'An unexpected error occurred'], true, true, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,302 +2,621 @@
|
|||||||
|
|
||||||
require dirname(__DIR__, 3) . '/init.php';
|
require dirname(__DIR__, 3) . '/init.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-facing AJAX API endpoint.
|
||||||
|
*
|
||||||
|
* ROUTING MODEL
|
||||||
|
* -------------
|
||||||
|
* Every request carries ?action=X&serviceID=Y. We dispatch on $action via the
|
||||||
|
* switch below. Because PHP's switch() is O(N) over case labels that's still
|
||||||
|
* fine at ~20 actions; if this grows large enough that dispatch cost matters
|
||||||
|
* we'd want a lookup table, but we're nowhere near that.
|
||||||
|
*
|
||||||
|
* WHMCS requires every action URL to re-authenticate on each request (no
|
||||||
|
* cross-request sticky state beyond the session cookie). That's why the
|
||||||
|
* isAuthenticated() call is the first thing inside the try block — nothing
|
||||||
|
* downstream may assume a session exists.
|
||||||
|
*
|
||||||
|
* AUTH LAYERS (ORDER MATTERS)
|
||||||
|
* ---------------------------
|
||||||
|
* Each case composes the defenses it needs:
|
||||||
|
*
|
||||||
|
* 1. $vf->isAuthenticated() — client session (401 otherwise)
|
||||||
|
* 2. $vf->validateServiceID(true) — numeric coercion + presence
|
||||||
|
* 3. $vf->validateUserOwnsService($id) — the session owns this service (403)
|
||||||
|
* 4. Optional: requireServiceStatus — filter by tblhosting.domainstatus
|
||||||
|
* 5. Optional (mutations): requirePost — HTTP method gate (405)
|
||||||
|
* 6. Optional (mutations): requireSameOrigin — CSRF origin gate (403)
|
||||||
|
*
|
||||||
|
* The helpers are "fail loudly" — they exit on failure rather than returning.
|
||||||
|
* So everything AFTER a guard in a case branch knows the guard passed.
|
||||||
|
*
|
||||||
|
* EVERY $vf->output() FOLLOWED BY break
|
||||||
|
* -------------------------------------
|
||||||
|
* output() emits a JSON response and exits by default, so in theory `break`
|
||||||
|
* is redundant. In practice we always break explicitly for two reasons:
|
||||||
|
* 1. If someone later passes exit=false to output() the switch would fall
|
||||||
|
* through to the default case and emit a second response body.
|
||||||
|
* 2. Code readers shouldn't have to remember that one function exits.
|
||||||
|
*
|
||||||
|
* RESPONSE SHAPE
|
||||||
|
* --------------
|
||||||
|
* Success: { success: true, data: { ... } }
|
||||||
|
* Error: { success: false, errors: "human-readable message" }
|
||||||
|
* Status codes match HTTP semantics (200/400/401/403/404/405/429/500/502).
|
||||||
|
*
|
||||||
|
* CATCH-ALL
|
||||||
|
* ---------
|
||||||
|
* The outer try/catch guarantees we never expose a raw PHP stack trace to the
|
||||||
|
* client, even on bugs in our own code. All uncaught exceptions are logged and
|
||||||
|
* the user sees a generic 500.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\IpUtil;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
||||||
|
|
||||||
$vf = new Module();
|
$vf = new Module;
|
||||||
|
|
||||||
$vf->isAuthenticated();
|
try {
|
||||||
|
|
||||||
$action = $vf->validateAction(true);
|
$vf->isAuthenticated();
|
||||||
|
|
||||||
switch ($action) {
|
$action = $vf->validateAction(true);
|
||||||
|
|
||||||
/**
|
switch ($action) {
|
||||||
* Reset Password.
|
|
||||||
*/
|
|
||||||
case 'resetPassword':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
$client = $vf->validateUserOwnsService($serviceID);
|
* Reset Password.
|
||||||
|
*/
|
||||||
|
case 'resetPassword':
|
||||||
|
|
||||||
if (!$client) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$client = $vf->validateUserOwnsService($serviceID);
|
||||||
}
|
|
||||||
|
|
||||||
$data = $vf->resetUserPassword($serviceID, $client);
|
if (! $client) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($data) {
|
$data = $vf->resetUserPassword($serviceID, $client);
|
||||||
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
|
if ($data) {
|
||||||
break;
|
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
|
||||||
* Get server information.
|
break;
|
||||||
*/
|
|
||||||
case 'serverData':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Get server information.
|
||||||
|
*/
|
||||||
|
case 'serverData':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $vf->fetchServerData($serviceID);
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($data) {
|
$data = $vf->fetchServerData($serviceID);
|
||||||
(new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
|
|
||||||
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
|
if ($data) {
|
||||||
break;
|
$vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
|
||||||
|
$vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
|
||||||
* Login as server owner.
|
break;
|
||||||
*/
|
|
||||||
case 'loginAsServerOwner':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Login as server owner.
|
||||||
|
*/
|
||||||
|
case 'loginAsServerOwner':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = $vf->fetchLoginTokens($serviceID);
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($token) {
|
$token = $vf->fetchLoginTokens($serviceID);
|
||||||
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
|
if ($token) {
|
||||||
break;
|
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
|
||||||
* Power management actions: boot, shutdown, restart, poweroff
|
break;
|
||||||
*/
|
|
||||||
case 'powerAction':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Power management actions: boot, shutdown, restart, poweroff
|
||||||
|
*/
|
||||||
|
case 'powerAction':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$powerAction = isset($_GET['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_GET['powerAction']) : '';
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (!in_array($powerAction, $allowedActions, true)) {
|
$powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
|
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
|
||||||
}
|
|
||||||
|
|
||||||
$result = $vf->serverPowerAction($serviceID, $powerAction);
|
if (! in_array($powerAction, $allowedActions, true)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result) {
|
$result = $vf->serverPowerAction($serviceID, $powerAction);
|
||||||
$vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500);
|
if ($result) {
|
||||||
break;
|
$vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500);
|
||||||
* Rebuild/reinstall server with new OS.
|
break;
|
||||||
*/
|
|
||||||
case 'rebuild':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Rebuild/reinstall server with new OS.
|
||||||
|
*/
|
||||||
|
case 'rebuild':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$osId = isset($_GET['osId']) ? (int) $_GET['osId'] : 0;
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
$hostname = isset($_GET['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_GET['hostname']) : null;
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($osId <= 0) {
|
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
|
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
|
||||||
}
|
|
||||||
|
|
||||||
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
|
if ($osId <= 0) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result) {
|
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
|
||||||
$vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Server rebuild failed. The server may be locked or unavailable.'], true, true, 500);
|
if ($result) {
|
||||||
break;
|
$vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'Server rebuild failed. The server may be locked or unavailable.'], true, true, 500);
|
||||||
* Rename server.
|
break;
|
||||||
*/
|
|
||||||
case 'rename':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Rename server.
|
||||||
|
*/
|
||||||
|
case 'rename':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$newName = isset($_GET['name']) ? trim($_GET['name']) : '';
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
$newName = htmlspecialchars($newName, ENT_QUOTES, 'UTF-8');
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($newName) || strlen($newName) > 255) {
|
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $vf->renameServer($serviceID, $newName);
|
if (empty($newName) || strlen($newName) > 63 || ! preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result) {
|
$result = $vf->renameServer($serviceID, $newName);
|
||||||
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
|
if ($result) {
|
||||||
break;
|
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
|
||||||
* Get available OS templates for rebuild.
|
break;
|
||||||
*/
|
|
||||||
case 'osTemplates':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Get available OS templates for rebuild.
|
||||||
|
*/
|
||||||
|
case 'osTemplates':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$templates = $vf->fetchOsTemplates($serviceID);
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($templates !== false) {
|
$templates = $vf->fetchOsTemplates($serviceID);
|
||||||
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
|
if ($templates !== false) {
|
||||||
break;
|
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// =================================================================
|
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
|
||||||
// IP Address Management
|
break;
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
/**
|
// =================================================================
|
||||||
* Remove an IPv4 address.
|
// Server Password Reset
|
||||||
*/
|
// =================================================================
|
||||||
case 'removeIPv4':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Reset server root password.
|
||||||
|
*/
|
||||||
|
case 'resetServerPassword':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ipAddress = isset($_GET['ip']) ? trim($_GET['ip']) : '';
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid IPv4 address'], true, true, 400);
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->removeIPv4($serviceID, $ipAddress);
|
$result = $vf->resetServerPassword($serviceID);
|
||||||
|
|
||||||
if ($result) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => ['message' => 'IPv4 address removed successfully']], true, true, 200);
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Failed to remove IPv4 address'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// VNC Console
|
// Backup Listing
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get VNC console URL.
|
* Get server backups.
|
||||||
*/
|
*/
|
||||||
case 'vnc':
|
case 'backups':
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $vf->getVncConsole($serviceID);
|
$result = $vf->getServerBackups($serviceID);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve backups'], true, true, 500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Self Service — Credit & Usage
|
// Traffic Statistics
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get self-service usage data.
|
* Get traffic statistics for a server.
|
||||||
*/
|
*/
|
||||||
case 'selfServiceUsage':
|
case 'trafficStats':
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $vf->getSelfServiceUsage($serviceID);
|
$result = $vf->getTrafficStats($serviceID);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve traffic statistics'], true, true, 500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
/**
|
// =================================================================
|
||||||
* Get self-service billing report.
|
// VNC Console
|
||||||
*/
|
// =================================================================
|
||||||
case 'selfServiceReport':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Get VNC console URL.
|
||||||
|
*/
|
||||||
|
case 'vnc':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $vf->getSelfServiceReport($serviceID);
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result !== false) {
|
$result = $vf->getVncConsole($serviceID);
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
|
if ($result !== false) {
|
||||||
break;
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
|
||||||
* Add self-service credit.
|
break;
|
||||||
*/
|
|
||||||
case 'selfServiceAddCredit':
|
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
/**
|
||||||
|
* Toggle VNC on/off.
|
||||||
|
*/
|
||||||
|
case 'toggleVnc':
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
$serviceID = $vf->validateServiceID(true);
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokens = isset($_GET['tokens']) ? (float) $_GET['tokens'] : 0;
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
if ($tokens <= 0) {
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
|
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
|
||||||
|
$result = $vf->toggleVnc($serviceID, $enabled);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Failed to toggle VNC'], true, true, 500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
// =================================================================
|
||||||
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
// Self Service — Credit & Usage
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get self-service usage data.
|
||||||
|
*/
|
||||||
|
case 'selfServiceUsage':
|
||||||
|
|
||||||
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $vf->getSelfServiceUsage($serviceID);
|
||||||
|
|
||||||
|
if ($result !== false) {
|
||||||
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get self-service billing report.
|
||||||
|
*/
|
||||||
|
case 'selfServiceReport':
|
||||||
|
|
||||||
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $vf->getSelfServiceReport($serviceID);
|
||||||
|
|
||||||
|
if ($result !== false) {
|
||||||
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add self-service credit.
|
||||||
|
*/
|
||||||
|
case 'selfServiceAddCredit':
|
||||||
|
|
||||||
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0;
|
||||||
|
if ($tokens <= 0) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
|
||||||
|
|
||||||
|
if ($result !== false) {
|
||||||
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Reverse DNS (PowerDNS)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List PTR state for every IP assigned to the service's server.
|
||||||
|
*
|
||||||
|
* Always fetches fresh server data from VirtFusion (not cached server_object)
|
||||||
|
* so the displayed IPs match current reality — if an IP was reassigned out
|
||||||
|
* of this server since last sync, it won't appear here.
|
||||||
|
*/
|
||||||
|
case 'rdnsList':
|
||||||
|
|
||||||
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads are permitted for Active + Suspended (a suspended user can still see their rDNS);
|
||||||
|
// Terminated/Pending/Cancelled/Fraud return a clear 400 upfront.
|
||||||
|
$vf->requireServiceStatus($serviceID, ['Active', 'Suspended']);
|
||||||
|
|
||||||
|
if (! PowerDnsConfig::isEnabled()) {
|
||||||
|
$vf->output(['success' => true, 'data' => ['enabled' => false, 'ips' => []]], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverData = $vf->fetchServerData($serviceID);
|
||||||
|
if (! $serverData) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 502);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ptrs = (new PtrManager)->listPtrs($serverData);
|
||||||
|
$vf->output(['success' => true, 'data' => ['enabled' => true, 'ips' => $ptrs]], true, true, 200);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update (or delete) the PTR for a single IP assigned to the user's server.
|
||||||
|
*
|
||||||
|
* Validation order: ownership -> IP format -> PTR regex -> IP belongs to this server
|
||||||
|
* -> rate-limit/forward-DNS checks inside PtrManager. Sending an empty `ptr` deletes.
|
||||||
|
*/
|
||||||
|
case 'rdnsUpdate':
|
||||||
|
|
||||||
|
// Mutation: enforce POST, same-origin, active service status in that order.
|
||||||
|
// requirePost/requireSameOrigin exit on failure (405/403 respectively), so nothing below runs.
|
||||||
|
$vf->requirePost();
|
||||||
|
$vf->requireSameOrigin();
|
||||||
|
|
||||||
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
|
$clientId = $vf->validateUserOwnsService($serviceID);
|
||||||
|
if (! $clientId) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS.
|
||||||
|
$vf->requireServiceStatus($serviceID, ['Active']);
|
||||||
|
|
||||||
|
if (! PowerDnsConfig::isEnabled()) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled on this installation'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = isset($_POST['ip']) ? trim((string) $_POST['ip']) : '';
|
||||||
|
$ptr = isset($_POST['ptr']) ? trim((string) $_POST['ptr']) : '';
|
||||||
|
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Invalid IP address'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ptr !== '' && ! preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/', $ptr)) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Invalid hostname for PTR record'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (strlen($ptr) > 253) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Hostname too long'], true, true, 400);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check: the submitted IP must be currently assigned to this user's server.
|
||||||
|
// Fetch fresh from VirtFusion (not the stored object) to prevent stale-ownership writes
|
||||||
|
// after an IP reassignment.
|
||||||
|
$serverData = $vf->fetchServerData($serviceID);
|
||||||
|
if (! $serverData) {
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Unable to verify IP ownership'], true, true, 502);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$extracted = IpUtil::extractIps($serverData);
|
||||||
|
$targetBin = @inet_pton($ip);
|
||||||
|
$owns = false;
|
||||||
|
|
||||||
|
// Stage 1: exact-IP match. Covers every v4 case and any v6 host address
|
||||||
|
// VirtFusion exposes directly (per-host records or /128 subnet entries).
|
||||||
|
foreach ($extracted['addresses'] as $a) {
|
||||||
|
if (@inet_pton($a) === $targetBin) {
|
||||||
|
$owns = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: v6 subnet containment. If the exact match failed and this is
|
||||||
|
// a v6 address, check whether it falls inside any of the server's
|
||||||
|
// allocated v6 subnets. This is the path for "my VirtFusion VPS has a
|
||||||
|
// /64 routed to it and I want a PTR for mail.example.com on one of the
|
||||||
|
// host addresses inside that /64" — we don't know which host addresses
|
||||||
|
// are actually in use, but we can prove this one lies within a range
|
||||||
|
// the customer is authorised for.
|
||||||
|
if (! $owns && IpUtil::isIpv6($ip)) {
|
||||||
|
foreach ($extracted['subnets'] as $s) {
|
||||||
|
if (IpUtil::ipv6InSubnet($ip, $s['subnet'], (int) $s['cidr'])) {
|
||||||
|
$owns = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $owns) {
|
||||||
|
Log::insert('rdnsUpdate:ownership', ['serviceID' => $serviceID, 'ip' => $ip], 'IP not assigned to this service');
|
||||||
|
$vf->output(['success' => false, 'errors' => 'This IP is not assigned to your server'], true, true, 403);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new PtrManager)->setPtr($ip, $ptr);
|
||||||
|
|
||||||
|
if ($result['ok']) {
|
||||||
|
// Audit trail for successful edits — surfaces in Utilities → Logs → Module Log,
|
||||||
|
// searchable by clientId / serviceId / ip for "who changed this PTR".
|
||||||
|
Log::insert(
|
||||||
|
'rdnsUpdate:ok',
|
||||||
|
['clientId' => $clientId, 'serviceID' => $serviceID, 'ip' => $ip, 'reason' => $result['reason']],
|
||||||
|
['ptr' => $ptr === '' ? '(deleted)' : $ptr],
|
||||||
|
);
|
||||||
|
$vf->output(['success' => true, 'data' => ['reason' => $result['reason']]], true, true, 200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map internal reasons to client-facing messages/status codes.
|
||||||
|
switch ($result['reason']) {
|
||||||
|
case 'forward-missing':
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Forward DNS for "' . $ptr . '" does not resolve to ' . $ip . '. Configure the A/AAAA record with your DNS provider first, then try again.'], true, true, 400);
|
||||||
|
break;
|
||||||
|
case 'rate-limited':
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Too many updates for this IP. Try again in a few seconds.'], true, true, 429);
|
||||||
|
break;
|
||||||
|
case 'no-zone':
|
||||||
|
$vf->output(['success' => false, 'errors' => 'This IP has no reverse DNS zone configured on the nameserver.'], true, true, 400);
|
||||||
|
break;
|
||||||
|
case 'disabled':
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Reverse DNS is not enabled'], true, true, 400);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$vf->output(['success' => false, 'errors' => 'Reverse DNS update failed (' . $result['reason'] . ')'], true, true, 500);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::insert('client.php', [], $e->getMessage());
|
||||||
|
$vf->output(['success' => false, 'errors' => 'An unexpected error occurred'], true, true, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
if (!defined("WHMCS")) {
|
if (! defined('WHMCS')) {
|
||||||
die("This file cannot be accessed directly");
|
exit('This file cannot be accessed directly');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,13 +1,259 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHMCS hooks for the VirtFusion module.
|
||||||
|
*
|
||||||
|
* HOW HOOKS WORK IN WHMCS
|
||||||
|
* -----------------------
|
||||||
|
* add_hook('EventName', $priority, $callback) registers $callback to fire on
|
||||||
|
* the named event. WHMCS discovers hook files by walking modules/servers/*
|
||||||
|
* /hooks.php and modules/addons/* /hooks.php on every page load, then invokes
|
||||||
|
* every registered hook for the current event.
|
||||||
|
*
|
||||||
|
* Hooks run IN-REQUEST — there's no queue or background worker. Anything
|
||||||
|
* expensive in a hook (like an external API call) blocks the user's page
|
||||||
|
* load. For that reason we only do:
|
||||||
|
* - Fast in-process work (building DOM snippets, validating session state)
|
||||||
|
* - Scheduled work on DailyCronJob where "in-request" means the cron worker,
|
||||||
|
* not a user session
|
||||||
|
*
|
||||||
|
* HOOKS REGISTERED HERE
|
||||||
|
* ---------------------
|
||||||
|
|
||||||
|
* DailyCronJob — PowerDNS reconciliation across all services
|
||||||
|
* AfterCronJob — Every-2-hour stock recalculation safety net
|
||||||
|
* AfterModuleCreate — Stock refresh + order auto-accept after a VPS provisions
|
||||||
|
* AfterModuleTerminate — Stock refresh after a VPS is destroyed
|
||||||
|
* ClientAreaPageCart — Lazy per-product stock refresh during the order flow
|
||||||
|
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
|
||||||
|
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
|
||||||
|
*
|
||||||
|
* FAILURE SEMANTICS
|
||||||
|
* -----------------
|
||||||
|
* Every hook wraps its body in try/catch and silently absorbs any exception.
|
||||||
|
* A hook that throws would potentially break the entire WHMCS request for
|
||||||
|
* all users, not just this module — so we log and swallow, preferring
|
||||||
|
* degraded functionality over site-wide breakage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use WHMCS\Database\Capsule;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||||
use WHMCS\User\User;
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\StockControl;
|
||||||
|
|
||||||
if (!defined("WHMCS")) {
|
if (! defined('WHMCS')) {
|
||||||
die("This file cannot be accessed directly");
|
exit('This file cannot be accessed directly');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily PowerDNS reconciliation.
|
||||||
|
*
|
||||||
|
* Walks every managed service and creates any missing PTRs (never overwrites existing
|
||||||
|
* values — cron is additive-only). Requires the VirtFusion DNS addon to be activated
|
||||||
|
* and enabled; otherwise short-circuits immediately.
|
||||||
|
*
|
||||||
|
* All error handling lives inside reconcileAll(); this wrapper just logs any escape
|
||||||
|
* without disturbing the rest of the daily cron run.
|
||||||
|
*/
|
||||||
|
add_hook('DailyCronJob', 1, function ($vars) {
|
||||||
|
try {
|
||||||
|
if (PowerDnsConfig::isEnabled()) {
|
||||||
|
(new PtrManager)->reconcileAll();
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('PowerDns:DailyCronJob', [], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every-~2-hour stock recalculation safety net.
|
||||||
|
*
|
||||||
|
* Events (AfterModuleCreate/Terminate) cover every capacity change driven
|
||||||
|
* through WHMCS. But an operator can also create/destroy VMs directly in the
|
||||||
|
* VirtFusion panel — no WHMCS hook fires for that, so stock qty would drift
|
||||||
|
* until the next cart-page visit or the next event-driven refresh. This hook
|
||||||
|
* closes that blind spot.
|
||||||
|
*
|
||||||
|
* AfterCronJob fires on every main WHMCS cron invocation (typically every
|
||||||
|
* 5 minutes). Cache::get on the rate-limit key means the hook is effectively
|
||||||
|
* free on the 99% of invocations where no recalc is due — one cache read,
|
||||||
|
* return. The actual recalc only runs when the key has expired.
|
||||||
|
*
|
||||||
|
* Interval: 2 hours. Tunable via the STOCK_CRON_INTERVAL_SECONDS constant
|
||||||
|
* below. Short enough that out-of-band VirtFusion panel changes surface the
|
||||||
|
* same business day; long enough that the storefront isn't writing
|
||||||
|
* tblproducts.qty every five minutes.
|
||||||
|
*
|
||||||
|
* FAIL-SAFE: StockControl::recalculateAll() returns a map of productId =>
|
||||||
|
* qty|null, where null means the orchestrator left qty UNTOUCHED (transient
|
||||||
|
* API failure, missing CP, etc.). Our catch here only fires on truly unexpected
|
||||||
|
* errors that escape the orchestrator itself.
|
||||||
|
*/
|
||||||
|
const STOCK_CRON_INTERVAL_SECONDS = 2 * 3600; // 2 hours
|
||||||
|
|
||||||
|
add_hook('AfterCronJob', 5, function ($vars) {
|
||||||
|
try {
|
||||||
|
$rateKey = 'stockrefresh:cron';
|
||||||
|
if (Cache::get($rateKey) !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Cache::set($rateKey, 1, STOCK_CRON_INTERVAL_SECONDS);
|
||||||
|
|
||||||
|
(new StockControl)->recalculateAll();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:AfterCronJob', [], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-provision: auto-accept the originating order and refresh stock.
|
||||||
|
*
|
||||||
|
* Fires after every successful VirtFusion CreateAccount. Two responsibilities,
|
||||||
|
* independent try/catch blocks so a failure in one doesn't short-circuit the other:
|
||||||
|
*
|
||||||
|
* 1. AUTO-ACCEPT — if the service's parent order is still 'Pending' (admin
|
||||||
|
* hasn't manually accepted yet), call WHMCS's AcceptOrder API with
|
||||||
|
* autosetup=false (we already provisioned, don't re-trigger CreateAccount).
|
||||||
|
* This closes the loop for installs that rely on pending-order workflows
|
||||||
|
* for non-VF products but want VF provisions to auto-advance.
|
||||||
|
*
|
||||||
|
* 2. STOCK REFRESH — a new VM just consumed memory/cpu/disk/IPv4 on the
|
||||||
|
* target hypervisor group. Bust the grpres:{id} cache and recalculate
|
||||||
|
* every stock-controlled product. A shared 30 s rate-limit key prevents
|
||||||
|
* a burst of 10 parallel provisions from triggering 10 full recalcs.
|
||||||
|
*
|
||||||
|
* Filtering by moduletype='VirtFusionDirect' keeps this hook harmless for
|
||||||
|
* unrelated products that happen to share the WHMCS install.
|
||||||
|
*/
|
||||||
|
add_hook('AfterModuleCreate', 1, function ($vars) {
|
||||||
|
if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part 1: auto-accept the originating order if still Pending.
|
||||||
|
try {
|
||||||
|
$serviceId = (int) ($vars['params']['serviceid'] ?? 0);
|
||||||
|
if ($serviceId > 0) {
|
||||||
|
$hosting = Capsule::table('tblhosting')->where('id', $serviceId)->first();
|
||||||
|
$orderId = $hosting ? (int) ($hosting->orderid ?? 0) : 0;
|
||||||
|
if ($orderId > 0) {
|
||||||
|
$order = Capsule::table('tblorders')->where('id', $orderId)->first();
|
||||||
|
if ($order && strcasecmp((string) $order->status, 'Pending') === 0) {
|
||||||
|
$resp = localAPI('AcceptOrder', [
|
||||||
|
'orderid' => $orderId,
|
||||||
|
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
||||||
|
'sendemail' => true,
|
||||||
|
]);
|
||||||
|
Log::insert(
|
||||||
|
'AutoAcceptOrder',
|
||||||
|
['orderid' => $orderId, 'serviceid' => $serviceId],
|
||||||
|
$resp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('AutoAcceptOrder:fail', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part 2: refresh stock (capacity just decreased).
|
||||||
|
try {
|
||||||
|
if (Cache::get('stockrefresh:event') === null) {
|
||||||
|
Cache::set('stockrefresh:event', 1, 30);
|
||||||
|
|
||||||
|
$groupId = (int) ($vars['params']['configoption1'] ?? 0);
|
||||||
|
if ($groupId > 0) {
|
||||||
|
Cache::forget('grpres:' . $groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new StockControl)->recalculateAll();
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:AfterModuleCreate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-termination stock refresh.
|
||||||
|
*
|
||||||
|
* A destroyed VM just freed memory/cpu/disk/IPv4 on the target hypervisor group.
|
||||||
|
* Refresh so the storefront reflects the restored capacity immediately. Shares
|
||||||
|
* the 30 s rate-limit key with AfterModuleCreate — a provision-then-terminate in
|
||||||
|
* quick succession only triggers one full recalc.
|
||||||
|
*/
|
||||||
|
add_hook('AfterModuleTerminate', 1, function ($vars) {
|
||||||
|
if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Cache::get('stockrefresh:event') !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Cache::set('stockrefresh:event', 1, 30);
|
||||||
|
|
||||||
|
$groupId = (int) ($vars['params']['configoption1'] ?? 0);
|
||||||
|
if ($groupId > 0) {
|
||||||
|
Cache::forget('grpres:' . $groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new StockControl)->recalculateAll();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:AfterModuleTerminate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy stock refresh on order-flow cart pages.
|
||||||
|
*
|
||||||
|
* Keeps "hot" products fresh between daily cron runs without a polling loop: when a
|
||||||
|
* customer lands on a cart page for a specific product, we opportunistically recalculate
|
||||||
|
* that product's qty. If the upstream grpres:{id} cache is warm (populated in the last
|
||||||
|
* 120 s by an earlier view or the daily cron), recalculateForProduct does no HTTP calls
|
||||||
|
* and just re-writes the same qty — effectively free.
|
||||||
|
*
|
||||||
|
* WHY ClientAreaPageCart (not ClientAreaPageProductDetails)
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* ClientAreaPageProductDetails fires on the My Services → product-details view for an
|
||||||
|
* EXISTING service, which is the wrong place — the stock number only matters during
|
||||||
|
* pre-order. ClientAreaPageCart fires on every cart/order page (product browse, config,
|
||||||
|
* checkout) and WHMCS consults tblproducts.qty on each of those, so this is where a
|
||||||
|
* fresh number pays off.
|
||||||
|
*
|
||||||
|
* RATE LIMIT
|
||||||
|
* ----------
|
||||||
|
* 60 s per product (stockrefresh:{pid}). Short enough that a busy product refreshes
|
||||||
|
* near-continuously across viewers; long enough that two customers arriving within the
|
||||||
|
* same second don't trigger two identical DB UPDATEs. The pid check below filters this
|
||||||
|
* hook to only fire when a specific product is known — generic cart pages (templatefile=
|
||||||
|
* "cart.tpl") pass no pid and are no-ops.
|
||||||
|
*/
|
||||||
|
add_hook('ClientAreaPageCart', 1, function ($vars) {
|
||||||
|
try {
|
||||||
|
$productId = (int) ($vars['pid'] ?? $vars['productid'] ?? ($vars['productinfo']['pid'] ?? 0));
|
||||||
|
if ($productId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateKey = 'stockrefresh:' . $productId;
|
||||||
|
if (Cache::get($rateKey) !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Cache::set($rateKey, 1, 60);
|
||||||
|
|
||||||
|
(new StockControl)->recalculateForProduct($productId);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:ClientAreaPageCart', ['pid' => $vars['pid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shopping Cart Validation Hook
|
* Shopping Cart Validation Hook
|
||||||
*
|
*
|
||||||
@@ -17,47 +263,51 @@ if (!defined("WHMCS")) {
|
|||||||
add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
|
add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
if (!isset($_SESSION['cart']['products']) || !is_array($_SESSION['cart']['products'])) {
|
try {
|
||||||
return $errors;
|
if (! isset($_SESSION['cart']['products']) || ! is_array($_SESSION['cart']['products'])) {
|
||||||
}
|
return $errors;
|
||||||
|
|
||||||
foreach ($_SESSION['cart']['products'] as $key => $product) {
|
|
||||||
$pid = $product['pid'] ?? null;
|
|
||||||
if (!$pid) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$dbProduct = \WHMCS\Database\Capsule::table('tblproducts')
|
foreach ($_SESSION['cart']['products'] as $key => $product) {
|
||||||
->where('id', $pid)
|
$pid = $product['pid'] ?? null;
|
||||||
->where('servertype', 'VirtFusionDirect')
|
if (! $pid) {
|
||||||
->first();
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$dbProduct) {
|
$dbProduct = Capsule::table('tblproducts')
|
||||||
continue;
|
->where('id', $pid)
|
||||||
}
|
->where('servertype', 'VirtFusionDirect')
|
||||||
|
->first();
|
||||||
|
|
||||||
// Check if Initial Operating System custom field has a value
|
if (! $dbProduct) {
|
||||||
if (isset($product['customfields']) && is_array($product['customfields'])) {
|
continue;
|
||||||
$osSelected = false;
|
}
|
||||||
$customFields = \WHMCS\Database\Capsule::table('tblcustomfields')
|
|
||||||
->where('relid', $pid)
|
|
||||||
->where('type', 'product')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($customFields as $field) {
|
// Check if Initial Operating System custom field has a value
|
||||||
if (strtolower(str_replace(' ', '', $field->fieldname)) === 'initialoperatingsystem') {
|
if (isset($product['customfields']) && is_array($product['customfields'])) {
|
||||||
$fieldValue = $product['customfields'][$field->id] ?? '';
|
$osSelected = false;
|
||||||
if (!empty($fieldValue) && is_numeric($fieldValue)) {
|
$customFields = Capsule::table('tblcustomfields')
|
||||||
$osSelected = true;
|
->where('relid', $pid)
|
||||||
|
->where('type', 'product')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($customFields as $field) {
|
||||||
|
if (strtolower(str_replace(' ', '', $field->fieldname)) === 'initialoperatingsystem') {
|
||||||
|
$fieldValue = $product['customfields'][$field->id] ?? '';
|
||||||
|
if (! empty($fieldValue) && is_numeric($fieldValue)) {
|
||||||
|
$osSelected = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
if (! $osSelected) {
|
||||||
|
$errors[] = 'Please select an Operating System for your VPS order.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$osSelected) {
|
|
||||||
$errors[] = 'Please select an Operating System for your VPS order.';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Don't block checkout on internal errors
|
||||||
}
|
}
|
||||||
|
|
||||||
return $errors;
|
return $errors;
|
||||||
@@ -71,34 +321,31 @@ add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
|
|||||||
* Works with all WHMCS themes by using vanilla JavaScript and standard form-control classes.
|
* Works with all WHMCS themes by using vanilla JavaScript and standard form-control classes.
|
||||||
*/
|
*/
|
||||||
add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
||||||
if (!isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
|
if (! isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$cs = new ConfigureService();
|
$cs = new ConfigureService;
|
||||||
|
|
||||||
$templates_data = $cs->fetchTemplates(
|
$templates_data = $cs->fetchTemplates(
|
||||||
$cs->fetchPackageByDbId($vars['productinfo']['pid']) ?? $cs->fetchPackageId($vars['productinfo']['name'])
|
$cs->fetchPackageByDbId($vars['productinfo']['pid']) ?? $cs->fetchPackageId($vars['productinfo']['name']),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (empty($templates_data)) {
|
if (empty($templates_data)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dropdownOptions = [];
|
$vfServer = Capsule::table('tblservers')
|
||||||
|
->where('type', 'VirtFusionDirect')
|
||||||
|
->where('disabled', 0)
|
||||||
|
->first();
|
||||||
|
$baseUrl = $vfServer ? rtrim('https://' . $vfServer->hostname, '/') : '';
|
||||||
|
|
||||||
foreach ($templates_data['data'] as $osCategory) {
|
$galleryData = [
|
||||||
foreach ($osCategory['templates'] as $template) {
|
'baseUrl' => $baseUrl,
|
||||||
$optionValue = $template['id'];
|
'categories' => Module::groupOsTemplates($templates_data['data'] ?? [], true),
|
||||||
$optionLabel = htmlspecialchars($template['name'] . " " . $template['version'] . " " . $template['variant'], ENT_QUOTES, 'UTF-8');
|
];
|
||||||
$dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($dropdownOptions, function ($a, $b) {
|
|
||||||
return strcmp($a['name'], $b['name']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$sshKeys = [];
|
$sshKeys = [];
|
||||||
$sshKeysOptions = [];
|
$sshKeysOptions = [];
|
||||||
@@ -109,9 +356,10 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
if ($sshKey['enabled'] === false) {
|
if ($sshKey['enabled'] === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $sshKey['id'],
|
'id' => $sshKey['id'],
|
||||||
'name' => htmlspecialchars($sshKey['name'], ENT_QUOTES, 'UTF-8')
|
'name' => htmlspecialchars($sshKey['name'], ENT_QUOTES, 'UTF-8'),
|
||||||
];
|
];
|
||||||
}, $sshKeysData['data'])));
|
}, $sshKeysData['data'])));
|
||||||
}
|
}
|
||||||
@@ -138,41 +386,186 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
|
|
||||||
$systemUrl = Database::getSystemUrl();
|
$systemUrl = Database::getSystemUrl();
|
||||||
|
|
||||||
return "
|
return '
|
||||||
<script src=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/js/keygen.js?v=20260207\"></script>
|
<link href="' . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . 'modules/servers/VirtFusionDirect/templates/css/module.css?v=' . time() . '" rel="stylesheet">
|
||||||
|
<script src="' . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . 'modules/servers/VirtFusionDirect/templates/js/keygen.js?v=' . time() . "\"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var osTemplates = " . json_encode($dropdownOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
|
var osGalleryData = " . json_encode($galleryData, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';
|
||||||
var sshKeys = " . json_encode($sshKeysOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
|
var sshKeys = ' . json_encode($sshKeysOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
|
||||||
|
|
||||||
var osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
|
var osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
|
||||||
var sshInputField = " . ($sshFieldId !== null ? "document.querySelector('[name=\"customfield[" . (int) $sshFieldId . "]\"]')" : "null") . ";
|
var sshInputField = " . ($sshFieldId !== null ? "document.querySelector('[name=\"customfield[" . (int) $sshFieldId . "]\"]')" : 'null') . ';
|
||||||
var sshInputLabel = " . ($sshFieldId !== null ? "document.querySelector('[for=\"customfield" . (int) $sshFieldId . "\"]')" : "null") . ";
|
var sshInputLabel = ' . ($sshFieldId !== null ? "document.querySelector('[for=\"customfield" . (int) $sshFieldId . "\"]')" : 'null') . ";
|
||||||
|
|
||||||
if (!osInputField) return;
|
if (!osInputField) return;
|
||||||
|
|
||||||
// Create OS dropdown
|
// Brand color map (must match vfOsBrandColors in module.js)
|
||||||
var osSelect = document.createElement('select');
|
var brandColors = {
|
||||||
osSelect.className = 'form-control';
|
'ubuntu':'#E95420','debian':'#A81D33','rocky':'#10B981','centos':'#932279',
|
||||||
osSelect.setAttribute('id', 'vf-os-select');
|
'almalinux':'#0F4266','alma':'#0F4266','windows':'#0078D4','fedora':'#51A2DA',
|
||||||
|
'arch':'#1793D1','opensuse':'#73BA25','suse':'#73BA25','freebsd':'#AB2B28',
|
||||||
|
'oracle':'#F80000','rhel':'#EE0000','red hat':'#EE0000','cloudlinux':'#0095D9',
|
||||||
|
'gentoo':'#54487A','slackware':'#000','nixos':'#7EBAE4','alpine':'#0D597F'
|
||||||
|
};
|
||||||
|
function getBrandColor(name) {
|
||||||
|
var l = (name || '').toLowerCase();
|
||||||
|
for (var k in brandColors) { if (l.indexOf(k) !== -1) return brandColors[k]; }
|
||||||
|
return '#6c757d';
|
||||||
|
}
|
||||||
|
|
||||||
var defaultOption = document.createElement('option');
|
// Build gallery container
|
||||||
defaultOption.value = '';
|
var galleryWrap = document.createElement('div');
|
||||||
defaultOption.text = '-- Select Operating System --';
|
galleryWrap.style.marginTop = '8px';
|
||||||
osSelect.appendChild(defaultOption);
|
|
||||||
|
|
||||||
osTemplates.forEach(function(template) {
|
var searchInput = document.createElement('input');
|
||||||
var option = document.createElement('option');
|
searchInput.type = 'text';
|
||||||
option.value = template.id;
|
searchInput.className = 'form-control vf-os-search';
|
||||||
option.text = template.name;
|
searchInput.placeholder = 'Search templates...';
|
||||||
osSelect.appendChild(option);
|
galleryWrap.appendChild(searchInput);
|
||||||
|
|
||||||
|
var galleryContainer = document.createElement('div');
|
||||||
|
galleryContainer.setAttribute('id', 'vf-checkout-os-gallery');
|
||||||
|
galleryContainer.style.marginTop = '8px';
|
||||||
|
|
||||||
|
if (osGalleryData.categories && osGalleryData.categories.length > 0) {
|
||||||
|
osGalleryData.categories.forEach(function(cat, ci) {
|
||||||
|
var section = document.createElement('div');
|
||||||
|
section.className = 'vf-os-category';
|
||||||
|
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'vf-os-category-header';
|
||||||
|
var catColor = getBrandColor(cat.name);
|
||||||
|
|
||||||
|
var catIcon = document.createElement('span');
|
||||||
|
catIcon.className = 'vf-os-category-icon';
|
||||||
|
if (cat.icon && osGalleryData.baseUrl) {
|
||||||
|
var catImg = document.createElement('img');
|
||||||
|
catImg.src = osGalleryData.baseUrl + '/img/logo/' + encodeURIComponent(cat.icon);
|
||||||
|
catImg.alt = '';
|
||||||
|
catImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = (cat.name || '?')[0].toUpperCase(); };
|
||||||
|
catIcon.appendChild(catImg);
|
||||||
|
} else if (cat.name === 'Other') {
|
||||||
|
catIcon.style.background = '#6c757d';
|
||||||
|
catIcon.innerHTML = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"#fff\"><path d=\"M3 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm1 2h8v2H4V4zm0 3h8v1H4V7zm0 2h5v1H4V9z\"/></svg>';
|
||||||
|
} else {
|
||||||
|
catIcon.style.background = catColor;
|
||||||
|
catIcon.textContent = (cat.name || '?')[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
var catTitle = document.createElement('span');
|
||||||
|
catTitle.textContent = cat.name + ' (' + cat.templates.length + ')';
|
||||||
|
|
||||||
|
var arrow = document.createElement('span');
|
||||||
|
arrow.className = 'vf-os-category-arrow';
|
||||||
|
arrow.textContent = ci === 0 ? '\u25BC' : '\u25B6';
|
||||||
|
|
||||||
|
header.appendChild(catIcon);
|
||||||
|
header.appendChild(catTitle);
|
||||||
|
header.appendChild(arrow);
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
var grid = document.createElement('div');
|
||||||
|
grid.className = 'vf-os-grid';
|
||||||
|
if (ci !== 0) grid.style.display = 'none';
|
||||||
|
|
||||||
|
header.addEventListener('click', function() {
|
||||||
|
var isOpen = grid.style.display !== 'none';
|
||||||
|
// Collapse all
|
||||||
|
galleryContainer.querySelectorAll('.vf-os-grid').forEach(function(g) { g.style.display = 'none'; });
|
||||||
|
galleryContainer.querySelectorAll('.vf-os-category-arrow').forEach(function(a) { a.textContent = '\u25B6'; });
|
||||||
|
// Toggle this one
|
||||||
|
if (!isOpen) {
|
||||||
|
grid.style.display = '';
|
||||||
|
arrow.textContent = '\u25BC';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cat.templates.forEach(function(tpl) {
|
||||||
|
var fullLabel = tpl.name + (tpl.version ? ' ' + tpl.version : '') + (tpl.variant ? ' ' + tpl.variant : '');
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = 'vf-os-card' + (tpl.eol ? ' vf-os-card-eol' : '');
|
||||||
|
card.setAttribute('data-id', tpl.id);
|
||||||
|
card.setAttribute('data-search', fullLabel.toLowerCase());
|
||||||
|
|
||||||
|
var iconDiv = document.createElement('div');
|
||||||
|
iconDiv.className = 'vf-os-icon';
|
||||||
|
if (tpl.icon && osGalleryData.baseUrl) {
|
||||||
|
var tplImg = document.createElement('img');
|
||||||
|
tplImg.src = osGalleryData.baseUrl + '/img/logo/' + encodeURIComponent(tpl.icon);
|
||||||
|
tplImg.alt = '';
|
||||||
|
tplImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = ''; var s = document.createElement('span'); s.textContent = (tpl.name || '?')[0].toUpperCase(); this.parentNode.appendChild(s); };
|
||||||
|
iconDiv.appendChild(tplImg);
|
||||||
|
} else {
|
||||||
|
iconDiv.style.background = catColor;
|
||||||
|
var sp = document.createElement('span');
|
||||||
|
sp.textContent = (tpl.name || '?')[0].toUpperCase();
|
||||||
|
iconDiv.appendChild(sp);
|
||||||
|
}
|
||||||
|
card.appendChild(iconDiv);
|
||||||
|
|
||||||
|
var labelDiv = document.createElement('div');
|
||||||
|
labelDiv.className = 'vf-os-label';
|
||||||
|
labelDiv.textContent = tpl.name;
|
||||||
|
card.appendChild(labelDiv);
|
||||||
|
|
||||||
|
var verDiv = document.createElement('div');
|
||||||
|
verDiv.className = 'vf-os-version';
|
||||||
|
verDiv.textContent = (tpl.version || '') + (tpl.variant ? ' ' + tpl.variant : '');
|
||||||
|
card.appendChild(verDiv);
|
||||||
|
|
||||||
|
if (tpl.eol) {
|
||||||
|
var eolBadge = document.createElement('span');
|
||||||
|
eolBadge.className = 'vf-os-eol-badge';
|
||||||
|
eolBadge.textContent = 'EOL';
|
||||||
|
card.appendChild(eolBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.addEventListener('click', function() {
|
||||||
|
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) { c.classList.remove('vf-os-card-selected'); });
|
||||||
|
card.classList.add('vf-os-card-selected');
|
||||||
|
osInputField.value = tpl.id;
|
||||||
|
galleryContainer.style.borderColor = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(grid);
|
||||||
|
galleryContainer.appendChild(section);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryWrap.appendChild(galleryContainer);
|
||||||
|
|
||||||
|
// Search handler
|
||||||
|
searchInput.addEventListener('keyup', function() {
|
||||||
|
var q = this.value.toLowerCase();
|
||||||
|
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) {
|
||||||
|
c.style.display = c.getAttribute('data-search').indexOf(q) !== -1 ? '' : 'none';
|
||||||
|
});
|
||||||
|
galleryContainer.querySelectorAll('.vf-os-category').forEach(function(s) {
|
||||||
|
var cards = s.querySelectorAll('.vf-os-card');
|
||||||
|
var hasVisible = false;
|
||||||
|
cards.forEach(function(c) { if (c.style.display !== 'none') hasVisible = true; });
|
||||||
|
s.style.display = hasVisible ? '' : 'none';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
osSelect.addEventListener('change', function() {
|
// Validation: red border if no selection on form submit
|
||||||
osInputField.value = this.value;
|
var form = osInputField.closest('form');
|
||||||
});
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (!osInputField.value) {
|
||||||
|
galleryContainer.style.border = '2px solid #dc3545';
|
||||||
|
galleryContainer.style.borderRadius = '8px';
|
||||||
|
galleryContainer.style.padding = '4px';
|
||||||
|
galleryContainer.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
osInputField.parentNode.insertBefore(osSelect, osInputField.nextSibling);
|
osInputField.parentNode.insertBefore(galleryWrap, osInputField.nextSibling);
|
||||||
osInputField.style.display = 'none';
|
osInputField.style.display = 'none';
|
||||||
|
|
||||||
// Handle SSH keys
|
// Handle SSH keys
|
||||||
@@ -423,7 +816,7 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
";
|
";
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Silently fail - don't break the checkout page
|
// Silently fail - don't break the checkout page
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,97 @@
|
|||||||
|
|
||||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static methods that generate HTML fragments for the WHMCS admin services tab.
|
||||||
|
*
|
||||||
|
* WHY RAW HTML STRINGS INSTEAD OF TEMPLATES
|
||||||
|
* -----------------------------------------
|
||||||
|
* WHMCS's AdminServicesTabFields hook expects an associative array of
|
||||||
|
* label => HTML-string pairs. It renders each entry as a table row with the
|
||||||
|
* label on the left and the raw HTML inserted verbatim on the right. There's
|
||||||
|
* no way to return a Smarty template reference from that hook — WHMCS doesn't
|
||||||
|
* know how to render one in that context.
|
||||||
|
*
|
||||||
|
* So we concatenate HTML here. All variable interpolation uses htmlspecialchars()
|
||||||
|
* at the PHP boundary — never trust that a value passed in is safe for HTML.
|
||||||
|
*
|
||||||
|
* ASSET INJECTION
|
||||||
|
* ---------------
|
||||||
|
* Some renderers (serverInfo, rdnsSection) embed <link> and <script> tags so
|
||||||
|
* the admin services tab picks up our CSS and JS without a separate loader
|
||||||
|
* hook. This is safe because WHMCS's admin CSP allows same-origin resources
|
||||||
|
* and the admin page is already inside an authenticated admin session.
|
||||||
|
*
|
||||||
|
* Cache-busting uses time() as a query string — fine for an admin-only surface
|
||||||
|
* where we'd rather pay for the extra fetch than let stale JS cause bugs.
|
||||||
|
*/
|
||||||
class AdminHTML
|
class AdminHTML
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Render the "Impersonate Server Owner" button for the admin services tab.
|
||||||
|
*
|
||||||
|
* @param string $systemUrl WHMCS system URL
|
||||||
|
* @param int $serviceId VirtFusion server ID
|
||||||
|
* @return string HTML button markup
|
||||||
|
*/
|
||||||
public static function options($systemUrl, $serviceId)
|
public static function options($systemUrl, $serviceId)
|
||||||
{
|
{
|
||||||
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
return <<<EOT
|
return <<<EOT
|
||||||
<button onclick="impersonateServerOwner('${serviceId}', '${systemUrl}')" type="button" class="btn btn-primary">Impersonate Server Owner</button>
|
<button onclick="impersonateServerOwner('${serviceId}', '${systemUrl}')" type="button" class="btn btn-primary">Impersonate Server Owner</button>
|
||||||
<span class="text-info"> A valid VirtFusion admin session in the same browser is required for this functionality to work.</span>
|
<span class="text-info"> A valid VirtFusion admin session in the same browser is required for this functionality to work.</span>
|
||||||
EOT;
|
EOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a read-only textarea containing the raw VirtFusion server JSON object.
|
||||||
|
*
|
||||||
|
* @param string $serverObject JSON-encoded server object from the VirtFusion API
|
||||||
|
* @return string HTML textarea markup
|
||||||
|
*/
|
||||||
public static function serverObject($serverObject)
|
public static function serverObject($serverObject)
|
||||||
{
|
{
|
||||||
$serverObject = htmlspecialchars($serverObject, ENT_QUOTES, 'UTF-8');
|
$serverObject = htmlspecialchars($serverObject, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
return <<<EOT
|
return <<<EOT
|
||||||
<textarea class="form-control" name="modulefields[1]" rows="10" style="width: 100%" disabled>${serverObject}</textarea>
|
<textarea class="form-control" name="modulefields[1]" rows="10" style="width: 100%" disabled>${serverObject}</textarea>
|
||||||
EOT;
|
EOT;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an editable text input for the VirtFusion server ID field.
|
||||||
|
*
|
||||||
|
* @param int $serverId Current VirtFusion server ID
|
||||||
|
* @return string HTML input markup with a warning note
|
||||||
|
*/
|
||||||
public static function serverId($serverId)
|
public static function serverId($serverId)
|
||||||
{
|
{
|
||||||
|
$serverId = (int) $serverId;
|
||||||
|
|
||||||
return <<<EOT
|
return <<<EOT
|
||||||
<input type="text" class="form-control input-200 input-inline" name="modulefields[0]" size="20" value="${serverId}" />
|
<input type="text" class="form-control input-200 input-inline" name="modulefields[0]" size="20" value="${serverId}" />
|
||||||
<span class="text-info"> Changing the Sever ID manually is not recommended. Alterations to this field are usually handled automatically.</span>
|
<span class="text-info"> Changing the Sever ID manually is not recommended. Alterations to this field are usually handled automatically.</span>
|
||||||
EOT;
|
EOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the inline server info panel for the admin services tab, including CSS/JS assets.
|
||||||
|
*
|
||||||
|
* @param string $systemUrl WHMCS system URL (used to build asset and AJAX URLs)
|
||||||
|
* @param int $serviceId VirtFusion server ID passed to the JS data-loader
|
||||||
|
* @return string HTML panel markup with embedded script and asset tags
|
||||||
|
*/
|
||||||
public static function serverInfo($systemUrl, $serviceId)
|
public static function serverInfo($systemUrl, $serviceId)
|
||||||
{
|
{
|
||||||
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
||||||
|
$serviceId = (int) $serviceId;
|
||||||
|
$cacheV = time();
|
||||||
|
|
||||||
return <<<EOT
|
return <<<EOT
|
||||||
<link href="${systemUrl}modules/servers/VirtFusionDirect/templates/css/module.css?v=20260207" rel="stylesheet">
|
<link href="${systemUrl}modules/servers/VirtFusionDirect/templates/css/module.css?v=${cacheV}" rel="stylesheet">
|
||||||
<script src="${systemUrl}modules/servers/VirtFusionDirect/templates/js/module.js?v=20260207"></script>
|
<script src="${systemUrl}modules/servers/VirtFusionDirect/templates/js/module.js?v=${cacheV}"></script>
|
||||||
<div id="vf-loader" class="vf-loader">
|
<div id="vf-loader" class="vf-loader">
|
||||||
<div id="vf-loading"></div>
|
<div id="vf-loading"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +168,38 @@ EOT;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>vfServerDataAdmin("${serviceId}","${systemUrl}");</script>
|
<script>vfServerDataAdmin("${serviceId}","${systemUrl}");</script>
|
||||||
|
EOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the admin Reverse DNS section for the services tab.
|
||||||
|
*
|
||||||
|
* Ships an empty container + a Reconcile button. Data is loaded client-side via
|
||||||
|
* the admin rdnsStatus AJAX endpoint once the page opens. The JS function
|
||||||
|
* vfAdminLoadRdns (defined in templates/js/module.js) populates #vf-rdns-list
|
||||||
|
* and wires up the Reconcile button's onclick to admin.php?action=rdnsReconcile.
|
||||||
|
*
|
||||||
|
* @param string $systemUrl WHMCS system URL
|
||||||
|
* @param int $serviceId WHMCS service ID
|
||||||
|
* @return string HTML fragment for the admin services tab
|
||||||
|
*/
|
||||||
|
public static function rdnsSection($systemUrl, $serviceId)
|
||||||
|
{
|
||||||
|
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
|
||||||
|
$serviceId = (int) $serviceId;
|
||||||
|
|
||||||
|
return <<<EOT
|
||||||
|
<div id="vf-rdns-admin-wrap">
|
||||||
|
<div id="vf-rdns-list" class="vf-rdns-list">
|
||||||
|
<em class="text-muted">Loading reverse DNS…</em>
|
||||||
|
</div>
|
||||||
|
<div class="vf-rdns-actions" style="margin-top:10px">
|
||||||
|
<button type="button" class="btn btn-default btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', false)">Reconcile (additive)</button>
|
||||||
|
<button type="button" class="btn btn-warning btn-sm" onclick="vfAdminReconcileRdns(${serviceId}, '${systemUrl}', true)">Reconcile (force reset)</button>
|
||||||
|
<span id="vf-rdns-report" style="margin-left:10px"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>if(typeof vfAdminLoadRdns==='function'){vfAdminLoadRdns(${serviceId},"${systemUrl}");}</script>
|
||||||
EOT;
|
EOT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
226
modules/servers/VirtFusionDirect/lib/Cache.php
Normal file
226
modules/servers/VirtFusionDirect/lib/Cache.php
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-tier cache: Redis when ext-redis is available, atomic filesystem fallback otherwise.
|
||||||
|
*
|
||||||
|
* WHY TWO TIERS
|
||||||
|
* -------------
|
||||||
|
* The module is deployed to every kind of WHMCS install — shared hosting, dedicated
|
||||||
|
* VPS, bare-metal. Requiring Redis would exclude the long tail of smaller operators
|
||||||
|
* who never installed the extension. But operators who DO have Redis get a huge
|
||||||
|
* performance win for cross-request caching (PowerDNS zone lists, OS template
|
||||||
|
* listings, traffic stats), so we opportunistically use it when present.
|
||||||
|
*
|
||||||
|
* The fallback is filesystem-based, using the OS temp directory. Writes are atomic
|
||||||
|
* via the classic tmp-file + rename pattern so a process crash mid-write can never
|
||||||
|
* corrupt an existing cache entry for another concurrent reader.
|
||||||
|
*
|
||||||
|
* EXPIRY SEMANTICS
|
||||||
|
* ----------------
|
||||||
|
* Redis: native SETEX — the key auto-expires on the server side.
|
||||||
|
* Filesystem: we store a JSON envelope {expires, data} and check expiry on read,
|
||||||
|
* deleting stale entries lazily. This means a cache with lots of expired entries
|
||||||
|
* will slowly accumulate files until accessed — acceptable for the module's scale
|
||||||
|
* (tens-to-hundreds of keys per install) but worth noting if someone ports this
|
||||||
|
* to a higher-volume context.
|
||||||
|
*
|
||||||
|
* NAMESPACE
|
||||||
|
* ---------
|
||||||
|
* Every key is prefixed with "vfd:" to avoid collisions with anything else that
|
||||||
|
* shares the Redis instance. Nested keys add their own sub-prefix (e.g.
|
||||||
|
* "pdns:zones:<hash>" for PowerDNS zone lists) for semantic clarity in the logs.
|
||||||
|
*
|
||||||
|
* FAILURE MODES
|
||||||
|
* -------------
|
||||||
|
* Redis unreachable: we set $redisAvailable = false on first failure, which
|
||||||
|
* permanently disables Redis for the rest of this PHP process (subsequent calls
|
||||||
|
* skip straight to the file cache). Prevents paying reconnect overhead on every
|
||||||
|
* miss when Redis is down.
|
||||||
|
* File cache write fails: silently skipped. Cache is best-effort; a failed SET
|
||||||
|
* just means the next GET will re-fetch from the authoritative source.
|
||||||
|
*/
|
||||||
|
class Cache
|
||||||
|
{
|
||||||
|
/** Module-global key prefix — keeps us out of Redis key collisions on shared installs. */
|
||||||
|
const PREFIX = 'vfd:';
|
||||||
|
|
||||||
|
/** @var \Redis|null */
|
||||||
|
private static $redis = null;
|
||||||
|
|
||||||
|
/** @var bool|null */
|
||||||
|
private static $redisAvailable = null;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
private static $fileDir = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to connect to Redis. Returns the connection or null.
|
||||||
|
*/
|
||||||
|
private static function redis(): ?\Redis
|
||||||
|
{
|
||||||
|
if (self::$redisAvailable === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$redis !== null) {
|
||||||
|
return self::$redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! extension_loaded('redis')) {
|
||||||
|
self::$redisAvailable = false;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redis = new \Redis;
|
||||||
|
$redis->connect('127.0.0.1', 6379, 1.0);
|
||||||
|
self::$redis = $redis;
|
||||||
|
self::$redisAvailable = true;
|
||||||
|
|
||||||
|
return $redis;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::$redisAvailable = false;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the filesystem cache directory, creating it if needed.
|
||||||
|
*/
|
||||||
|
private static function fileDir(): string
|
||||||
|
{
|
||||||
|
if (self::$fileDir !== '') {
|
||||||
|
return self::$fileDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = sys_get_temp_dir() . '/vfd_cache';
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0700, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$fileDir = $dir;
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a cache key to a safe filename.
|
||||||
|
*/
|
||||||
|
private static function filePath(string $key): string
|
||||||
|
{
|
||||||
|
return self::fileDir() . '/' . md5($key) . '.cache';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached value.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return mixed|null Returns null on miss
|
||||||
|
*/
|
||||||
|
public static function get($key)
|
||||||
|
{
|
||||||
|
// Try Redis first
|
||||||
|
$redis = self::redis();
|
||||||
|
if ($redis) {
|
||||||
|
try {
|
||||||
|
$data = $redis->get(self::PREFIX . $key);
|
||||||
|
if ($data !== false) {
|
||||||
|
return json_decode($data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fall through to file cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File cache fallback
|
||||||
|
$path = self::filePath($key);
|
||||||
|
if (! file_exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = json_decode($raw, true);
|
||||||
|
if (! $entry || ! isset($entry['expires']) || ! isset($entry['data'])) {
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entry['expires'] < time()) {
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entry['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in cache.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $value
|
||||||
|
* @param int $ttl Time-to-live in seconds
|
||||||
|
*/
|
||||||
|
public static function set($key, $value, $ttl = 300)
|
||||||
|
{
|
||||||
|
// Try Redis first
|
||||||
|
$redis = self::redis();
|
||||||
|
if ($redis) {
|
||||||
|
try {
|
||||||
|
$redis->setex(self::PREFIX . $key, $ttl, json_encode($value));
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fall through to file cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File cache fallback with atomic write.
|
||||||
|
// Writing to a temp file + rename ensures that readers either see the
|
||||||
|
// complete previous entry or the complete new entry — never a truncated
|
||||||
|
// or partially-written file. getmypid() suffix lets concurrent PHP
|
||||||
|
// processes write to the same key without stomping each other's temp files.
|
||||||
|
$path = self::filePath($key);
|
||||||
|
$tmp = $path . '.' . getmypid() . '.tmp';
|
||||||
|
$entry = json_encode(['expires' => time() + $ttl, 'data' => $value]);
|
||||||
|
|
||||||
|
if (@file_put_contents($tmp, $entry, LOCK_EX) !== false) {
|
||||||
|
// rename() is atomic on POSIX when source and target are on the same
|
||||||
|
// filesystem (which they always are here — both in sys_get_temp_dir).
|
||||||
|
@rename($tmp, $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a cached value.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
*/
|
||||||
|
public static function forget($key)
|
||||||
|
{
|
||||||
|
$redis = self::redis();
|
||||||
|
if ($redis) {
|
||||||
|
try {
|
||||||
|
$redis->del(self::PREFIX . $key);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Continue to file cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = self::filePath($key);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,61 @@
|
|||||||
|
|
||||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
use JsonException;
|
|
||||||
use WHMCS\Database\Capsule as DB;
|
use WHMCS\Database\Capsule as DB;
|
||||||
use WHMCS\User\User;
|
use WHMCS\User\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles order-time and provisioning-time operations for VirtFusion servers.
|
||||||
|
*
|
||||||
|
* WHY A SIBLING OF ModuleFunctions RATHER THAN METHODS ON IT
|
||||||
|
* ----------------------------------------------------------
|
||||||
|
* ModuleFunctions handles the WHMCS LIFECYCLE (create, suspend, terminate, etc.)
|
||||||
|
* — operations driven by WHMCS service-state transitions.
|
||||||
|
*
|
||||||
|
* ConfigureService handles ORDER-TIME logic — package lookups, template fetching,
|
||||||
|
* SSH key creation, initial build triggering. These run during checkout (via the
|
||||||
|
* ClientAreaFooterOutput hook that populates dropdowns on the order form) and
|
||||||
|
* immediately after account creation (initServerBuild is called from
|
||||||
|
* ModuleFunctions::createAccount once the VirtFusion server exists).
|
||||||
|
*
|
||||||
|
* Splitting the concerns keeps ModuleFunctions focused on lifecycle state machines
|
||||||
|
* and ConfigureService focused on catalogue/discovery calls. They share the base
|
||||||
|
* Module's API plumbing via inheritance.
|
||||||
|
*
|
||||||
|
* CACHING
|
||||||
|
* -------
|
||||||
|
* Package/template lookups use the module's Cache class with 10-minute TTLs.
|
||||||
|
* These values change rarely (a package list is typically edited once per
|
||||||
|
* month at most) but the endpoints are on the checkout hot path, so aggressive
|
||||||
|
* caching matters for page-load performance.
|
||||||
|
*
|
||||||
|
* CP RESOLVED IN CONSTRUCTOR
|
||||||
|
* --------------------------
|
||||||
|
* Unlike ModuleFunctions which resolves the control panel per-request via the
|
||||||
|
* service ID, ConfigureService resolves it ONCE in the constructor via
|
||||||
|
* getCP(false, true) — "any available VirtFusion server". Order-time operations
|
||||||
|
* happen BEFORE a WHMCS service exists, so we can't dereference a specific
|
||||||
|
* server through mod_virtfusion_direct. "Any enabled server" is the pragmatic
|
||||||
|
* default for catalogue operations that typically return the same data
|
||||||
|
* regardless of which panel you hit.
|
||||||
|
*/
|
||||||
class ConfigureService extends Module
|
class ConfigureService extends Module
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var array|false $cp
|
* The first available VirtFusion control panel connection, as returned by
|
||||||
|
* getCP(). Holds server URL and API token used for all API calls in this
|
||||||
|
* class. False if no active VirtFusion server is configured in WHMCS.
|
||||||
|
*
|
||||||
|
* @var array|false
|
||||||
*/
|
*/
|
||||||
private array|bool $cp;
|
private array|bool $cp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service configurator with the first available VirtFusion server.
|
||||||
|
*
|
||||||
|
* Calls the parent Module constructor then resolves the control panel connection
|
||||||
|
* so all methods in this class have a ready API endpoint.
|
||||||
|
*/
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -20,193 +64,298 @@ class ConfigureService extends Module
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $packageName
|
* Find a VirtFusion package ID by its name via the API.
|
||||||
* @return int|null
|
*
|
||||||
* @throws JsonException
|
* Searches the packages list for an enabled package whose name matches
|
||||||
|
* exactly. Result is cached for 10 minutes. Returns null if not found
|
||||||
|
* or if no control panel is available.
|
||||||
|
*
|
||||||
|
* @param string $packageName Exact package name as configured in VirtFusion.
|
||||||
|
* @return int|null Package ID, or null if not found.
|
||||||
*/
|
*/
|
||||||
public function fetchPackageId(string $packageName): ?int
|
public function fetchPackageId(string $packageName): ?int
|
||||||
{
|
{
|
||||||
if (!$this->cp) return null;
|
try {
|
||||||
|
$cacheKey = 'pkg_name:' . md5($packageName);
|
||||||
$request = $this->initCurl($this->cp['token']);
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
$response = $request->get(
|
return $cached;
|
||||||
sprintf("%s/packages", $this->cp['url'])
|
|
||||||
);
|
|
||||||
|
|
||||||
$packages = $this->decodeResponseFromJson($response);
|
|
||||||
|
|
||||||
foreach ($packages['data'] as $package) {
|
|
||||||
if ($package['name'] === $packageName && $package['enabled'] === true) {
|
|
||||||
return $package['id'];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
if (! $this->cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
|
$response = $request->get(
|
||||||
|
sprintf('%s/packages', $this->cp['url']),
|
||||||
|
);
|
||||||
|
|
||||||
|
$packages = $this->decodeResponseFromJson($response);
|
||||||
|
|
||||||
|
foreach ($packages['data'] as $package) {
|
||||||
|
if ($package['name'] === $packageName && $package['enabled'] === true) {
|
||||||
|
Cache::set($cacheKey, $package['id'], 600);
|
||||||
|
|
||||||
|
return $package['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $productId
|
* Get the VirtFusion package ID from a WHMCS product's config option.
|
||||||
* @return int|null
|
*
|
||||||
|
* Reads configoption2 directly from the tblproducts database record for
|
||||||
|
* the given WHMCS product ID. Returns null if the product does not exist.
|
||||||
|
*
|
||||||
|
* @param int $productId WHMCS product (tblproducts) ID.
|
||||||
|
* @return int|null VirtFusion package ID, or null if the product is not found.
|
||||||
*/
|
*/
|
||||||
public function fetchPackageByDbId(int $productId): ?int
|
public function fetchPackageByDbId(int $productId): ?int
|
||||||
{
|
{
|
||||||
$product = DB::table('tblproducts')->where('id', $productId)->first();
|
try {
|
||||||
|
$product = DB::table('tblproducts')->where('id', $productId)->first();
|
||||||
|
|
||||||
|
if (is_null($product)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $product->configoption2;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
if (is_null($product)) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int)$product->configoption2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $serverPackageId
|
* Fetch the available OS templates for a given VirtFusion server package.
|
||||||
* @return array|null
|
*
|
||||||
* @throws JsonException
|
* Queries the VirtFusion API for templates compatible with the specified
|
||||||
|
* package spec ID. Result is cached for 10 minutes. Returns null if no
|
||||||
|
* package ID is provided or no control panel is available.
|
||||||
|
*
|
||||||
|
* @param int|null $serverPackageId VirtFusion server package spec ID.
|
||||||
|
* @return array|null Template list from the API, or null on failure.
|
||||||
*/
|
*/
|
||||||
public function fetchTemplates(?int $serverPackageId): ?array
|
public function fetchTemplates(?int $serverPackageId): ?array
|
||||||
{
|
{
|
||||||
if (is_null($serverPackageId)) {
|
try {
|
||||||
|
if (is_null($serverPackageId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'tpl:' . $serverPackageId;
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
|
$response = $request->get(
|
||||||
|
sprintf('%s/media/templates/fromServerPackageSpec/%d', $this->cp['url'], $serverPackageId),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->decodeResponseFromJson($response);
|
||||||
|
Cache::set($cacheKey, $result, 600);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->cp) return null;
|
|
||||||
|
|
||||||
$request = $this->initCurl($this->cp['token']);
|
|
||||||
|
|
||||||
$response = $request->get(
|
|
||||||
sprintf("%s/media/templates/fromServerPackageSpec/%d", $this->cp['url'], $serverPackageId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->decodeResponseFromJson($response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param User|null $user
|
* Get the SSH keys registered for a VirtFusion user.
|
||||||
* @return array|null
|
*
|
||||||
* @throws JsonException
|
* Looks up the VirtFusion account for the given WHMCS user via external
|
||||||
|
* relation ID, then fetches their SSH key list from the API. Returns null
|
||||||
|
* if the user is not found in VirtFusion or no control panel is available.
|
||||||
|
*
|
||||||
|
* @param User|null $user WHMCS User object.
|
||||||
|
* @return array|null SSH key list from the API, or null on failure.
|
||||||
*/
|
*/
|
||||||
public function getUserSshKeys(?User $user): ?array
|
public function getUserSshKeys(?User $user): ?array
|
||||||
{
|
{
|
||||||
if (is_null($user)) {
|
try {
|
||||||
|
if (is_null($user)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
|
$vfUser = $this->getVFUserDetails($user['id']);
|
||||||
|
|
||||||
|
if (! $vfUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $request->get(
|
||||||
|
sprintf('%s/ssh_keys/user/%d', $this->cp['url'], $vfUser['id']),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->decodeResponseFromJson($response);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->cp) return null;
|
|
||||||
|
|
||||||
$request = $this->initCurl($this->cp['token']);
|
|
||||||
|
|
||||||
$vfUser = $this->getVFUserDetails($user['id']);
|
|
||||||
|
|
||||||
if (!$vfUser) return null;
|
|
||||||
|
|
||||||
$response = $request->get(
|
|
||||||
sprintf("%s/ssh_keys/user/%d", $this->cp['url'], $vfUser['id'])
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->decodeResponseFromJson($response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $id
|
* Look up a VirtFusion user by WHMCS external relation ID.
|
||||||
* @return array|null
|
*
|
||||||
* @throws JsonException
|
* Calls the VirtFusion API's byExtRelation endpoint using the WHMCS client
|
||||||
|
* ID. Returns null if the user does not exist in VirtFusion or no control
|
||||||
|
* panel is available.
|
||||||
|
*
|
||||||
|
* @param int $id WHMCS client ID used as the VirtFusion external relation ID.
|
||||||
|
* @return array|null VirtFusion user data array, or null if not found.
|
||||||
*/
|
*/
|
||||||
public function getVFUserDetails(int $id): ?array
|
public function getVFUserDetails(int $id): ?array
|
||||||
{
|
{
|
||||||
if (!$this->cp) return null;
|
try {
|
||||||
|
if (! $this->cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$request = $this->initCurl($this->cp['token']);
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
$response = $this->decodeResponseFromJson($request->get(
|
$response = $this->decodeResponseFromJson($request->get(
|
||||||
sprintf("%s/users/%d/byExtRelation", $this->cp['url'], $id)
|
sprintf('%s/users/%d/byExtRelation', $this->cp['url'], $id),
|
||||||
));
|
));
|
||||||
|
|
||||||
return isset($response['msg']) && $response['msg'] === "ext_relation_id not found" ? null : $response['data'];
|
return isset($response['msg']) && $response['msg'] === 'ext_relation_id not found' ? null : $response['data'];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $id
|
* Trigger OS installation on a newly created VirtFusion server.
|
||||||
* @param array $vars
|
*
|
||||||
* @param int|null $vfUserId VirtFusion user ID (for creating SSH keys from raw public key)
|
* Posts a build request to the VirtFusion API with the selected OS template
|
||||||
* @return bool
|
* and optionally an SSH key. If the custom field contains a numeric value it
|
||||||
|
* is treated as an existing key ID; if it is a raw public key string, the key
|
||||||
|
* is created first via createUserSshKey(). Returns true on HTTP 200/201.
|
||||||
|
*
|
||||||
|
* @param int $id VirtFusion server ID to build.
|
||||||
|
* @param array $vars WHMCS order vars, including customfields for OS and SSH key.
|
||||||
|
* @param int|null $vfUserId VirtFusion user ID, required when creating a new SSH key from a raw public key.
|
||||||
|
* @return bool True if the build request was accepted, false otherwise.
|
||||||
*/
|
*/
|
||||||
public function initServerBuild(int $id, array $vars, ?int $vfUserId = null): bool
|
public function initServerBuild(int $id, array $vars, ?int $vfUserId = null): bool
|
||||||
{
|
{
|
||||||
if (!$this->cp) return false;
|
try {
|
||||||
|
if (! $this->cp) {
|
||||||
$request = $this->initCurl($this->cp['token']);
|
return false;
|
||||||
|
|
||||||
// Generate a random 8 character hostname
|
|
||||||
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
|
|
||||||
|
|
||||||
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
|
|
||||||
$sshKeyId = null;
|
|
||||||
|
|
||||||
if (!empty($sshKeyValue)) {
|
|
||||||
if (is_numeric($sshKeyValue)) {
|
|
||||||
// Existing SSH key ID
|
|
||||||
$sshKeyId = (int) $sshKeyValue;
|
|
||||||
} elseif (preg_match('/^ssh-/', $sshKeyValue) && $vfUserId) {
|
|
||||||
// Raw public key — create it via API
|
|
||||||
$sshKeyId = $this->createUserSshKey($vfUserId, $sshKeyValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
|
// Generate a hostname with sufficient entropy to avoid collisions
|
||||||
|
$hostname = 'vps-' . bin2hex(random_bytes(4));
|
||||||
|
|
||||||
|
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
|
||||||
|
$sshKeyId = null;
|
||||||
|
|
||||||
|
if (! empty($sshKeyValue)) {
|
||||||
|
if (is_numeric($sshKeyValue)) {
|
||||||
|
// Existing SSH key ID
|
||||||
|
$sshKeyId = (int) $sshKeyValue;
|
||||||
|
} elseif (preg_match('/^ssh-/', $sshKeyValue) && $vfUserId) {
|
||||||
|
// Raw public key — create it via API
|
||||||
|
$sshKeyId = $this->createUserSshKey($vfUserId, $sshKeyValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$inputData = [
|
||||||
|
'operatingSystemId' => $vars['customfields']['Initial Operating System'] ?? null,
|
||||||
|
'name' => $hostname,
|
||||||
|
'email' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($sshKeyId) {
|
||||||
|
$inputData['sshKeys'] = [$sshKeyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->addOption(CURLOPT_POSTFIELDS, json_encode($inputData));
|
||||||
|
|
||||||
|
$response = $request->post(
|
||||||
|
sprintf('%s/servers/%d/build', $this->cp['url'], $id),
|
||||||
|
);
|
||||||
|
|
||||||
|
$httpCode = $request->getRequestInfo('http_code');
|
||||||
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
|
||||||
|
|
||||||
|
return $httpCode == 200 || $httpCode == 201;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$inputData = [
|
|
||||||
"operatingSystemId" => $vars['customfields']['Initial Operating System'] ?? null,
|
|
||||||
"name" => $hostname,
|
|
||||||
'email' => true
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($sshKeyId) {
|
|
||||||
$inputData['sshKeys'] = [$sshKeyId];
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->addOption(CURLOPT_POSTFIELDS, json_encode($inputData));
|
|
||||||
|
|
||||||
$response = $request->post(
|
|
||||||
sprintf("%s/servers/%d/build", $this->cp['url'], $id)
|
|
||||||
);
|
|
||||||
|
|
||||||
$httpCode = $request->getRequestInfo('http_code');
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
|
|
||||||
|
|
||||||
return ($httpCode == 200 || $httpCode == 201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an SSH key for a VirtFusion user from a raw public key string.
|
* Create an SSH key for a VirtFusion user from a raw public key string.
|
||||||
*
|
*
|
||||||
* @param int $userId VirtFusion user ID
|
* @param int $userId VirtFusion user ID
|
||||||
* @param string $publicKey Raw SSH public key (ssh-rsa ..., ssh-ed25519 ..., etc.)
|
* @param string $publicKey Raw SSH public key (ssh-rsa ..., ssh-ed25519 ..., etc.)
|
||||||
* @return int|null Created key ID or null on failure
|
* @return int|null Created key ID or null on failure
|
||||||
*/
|
*/
|
||||||
public function createUserSshKey(int $userId, string $publicKey): ?int
|
public function createUserSshKey(int $userId, string $publicKey): ?int
|
||||||
{
|
{
|
||||||
if (!$this->cp) return null;
|
try {
|
||||||
|
if (! $this->cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$request = $this->initCurl($this->cp['token']);
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
$keyData = [
|
$keyData = [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
'name' => 'WHMCS-' . date('Y-m-d'),
|
'name' => 'WHMCS-' . date('Y-m-d'),
|
||||||
'publicKey' => trim($publicKey),
|
'publicKey' => trim($publicKey),
|
||||||
];
|
];
|
||||||
|
|
||||||
$request->addOption(CURLOPT_POSTFIELDS, json_encode($keyData));
|
$request->addOption(CURLOPT_POSTFIELDS, json_encode($keyData));
|
||||||
$response = $request->post($this->cp['url'] . '/ssh_keys');
|
$response = $request->post($this->cp['url'] . '/ssh_keys');
|
||||||
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
|
||||||
|
|
||||||
$httpCode = $request->getRequestInfo('http_code');
|
$httpCode = $request->getRequestInfo('http_code');
|
||||||
if ($httpCode == 200 || $httpCode == 201) {
|
if ($httpCode == 200 || $httpCode == 201) {
|
||||||
$data = json_decode($response, true);
|
$data = json_decode($response, true);
|
||||||
return $data['data']['id'] ?? null;
|
|
||||||
|
return $data['data']['id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,35 @@
|
|||||||
|
|
||||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client wrapper with Bearer token auth, SSL verification, and a 30s timeout.
|
||||||
|
* Single-use — each instance makes one request.
|
||||||
|
*/
|
||||||
class Curl
|
class Curl
|
||||||
{
|
{
|
||||||
|
/** @var resource|\CurlHandle cURL handle */
|
||||||
private $ch;
|
private $ch;
|
||||||
|
|
||||||
|
/** @var array Response info and parsed header data collected after exec */
|
||||||
private $data;
|
private $data;
|
||||||
|
|
||||||
|
/** @var array User-supplied cURL options that override defaults */
|
||||||
private $customOptions = [];
|
private $customOptions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Default cURL options applied to every request.
|
||||||
|
*
|
||||||
|
* Rationale:
|
||||||
|
* VERIFYPEER/VERIFYHOST: Full TLS chain + hostname validation. Disabling
|
||||||
|
* either is a common source of MITM bugs, so we never do it silently.
|
||||||
|
* RETURNTRANSFER: We always want the response body back as a string.
|
||||||
|
* HEADER off: Callers almost never need headers. Saves a parse cycle.
|
||||||
|
* NOBODY off: Default to GET-style body-returning requests.
|
||||||
|
* TIMEOUT 30s: Covers slow API endpoints without letting a hung connection
|
||||||
|
* block a whole WHMCS request indefinitely.
|
||||||
|
* CONNECTTIMEOUT 10s: Separate from the total timeout so a failed TCP
|
||||||
|
* handshake (firewall black-hole) fails fast rather than burning 30s.
|
||||||
|
*/
|
||||||
private $defaultOptions = [
|
private $defaultOptions = [
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
CURLOPT_SSL_VERIFYHOST => 2,
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
@@ -18,32 +42,17 @@ class Curl
|
|||||||
CURLOPT_CONNECTTIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Initialise the cURL handle. */
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->ch = curl_init();
|
$this->ch = curl_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function useCookies()
|
|
||||||
{
|
|
||||||
$cookiesFile = tempnam(sys_get_temp_dir(), 'virtfusion_cookies');
|
|
||||||
$this->defaultOptions[CURLOPT_COOKIEFILE] = $cookiesFile;
|
|
||||||
$this->defaultOptions[CURLOPT_COOKIEJAR] = $cookiesFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setLog()
|
|
||||||
{
|
|
||||||
$log = fopen(__DIR__ . '/CURL.log', 'a');
|
|
||||||
if ($log) {
|
|
||||||
fwrite($log, str_repeat('=', 80) . PHP_EOL);
|
|
||||||
$this->addOption(CURLOPT_STDERR, $log);
|
|
||||||
$this->addOption(CURLOPT_VERBOSE, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $name
|
* Set a custom cURL option, overriding the defaults.
|
||||||
* @param $value
|
*
|
||||||
|
* @param int $name A CURLOPT_* constant
|
||||||
|
* @param mixed $value The option value
|
||||||
*/
|
*/
|
||||||
public function addOption($name, $value)
|
public function addOption($name, $value)
|
||||||
{
|
{
|
||||||
@@ -51,8 +60,10 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null $url
|
* Execute a PUT request.
|
||||||
* @return bool|string|void
|
*
|
||||||
|
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
|
||||||
|
* @return bool|string Response body, or false on failure
|
||||||
*/
|
*/
|
||||||
public function put($url = null)
|
public function put($url = null)
|
||||||
{
|
{
|
||||||
@@ -60,8 +71,10 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null $url
|
* Execute a PATCH request.
|
||||||
* @return bool|string|void
|
*
|
||||||
|
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
|
||||||
|
* @return bool|string Response body, or false on failure
|
||||||
*/
|
*/
|
||||||
public function patch($url = null)
|
public function patch($url = null)
|
||||||
{
|
{
|
||||||
@@ -69,14 +82,18 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $method
|
* Set the HTTP method and URL, then execute the request.
|
||||||
* @param $url
|
*
|
||||||
* @return bool|string|void
|
* @param string $method HTTP method (GET, POST, PUT, PATCH, DELETE)
|
||||||
|
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
|
||||||
|
* @return bool|string Response body, or false on failure
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException If no URL is available
|
||||||
*/
|
*/
|
||||||
private function send($method, $url)
|
private function send($method, $url)
|
||||||
{
|
{
|
||||||
if ($url === null) {
|
if ($url === null) {
|
||||||
if (!isset($this->customOptions[CURLOPT_URL]) || empty($this->customOptions[CURLOPT_URL])) {
|
if (! isset($this->customOptions[CURLOPT_URL]) || empty($this->customOptions[CURLOPT_URL])) {
|
||||||
throw new \RuntimeException('Curl: empty URL provided');
|
throw new \RuntimeException('Curl: empty URL provided');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +104,9 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return bool|string
|
* Apply options, run the cURL handle, collect response info, and close the handle.
|
||||||
|
*
|
||||||
|
* @return bool|string Response body, or false on cURL error
|
||||||
*/
|
*/
|
||||||
private function exec()
|
private function exec()
|
||||||
{
|
{
|
||||||
@@ -111,6 +130,7 @@ class Curl
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge custom and default cURL options and apply them to the handle. */
|
||||||
private function setOptions()
|
private function setOptions()
|
||||||
{
|
{
|
||||||
if (isset($this->customOptions[CURLOPT_HEADER]) && $this->customOptions[CURLOPT_HEADER]) {
|
if (isset($this->customOptions[CURLOPT_HEADER]) && $this->customOptions[CURLOPT_HEADER]) {
|
||||||
@@ -122,7 +142,9 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $data
|
* Split a response containing headers into header and body parts and store them.
|
||||||
|
*
|
||||||
|
* @param string $data Raw response string (headers + body); replaced with body only
|
||||||
*/
|
*/
|
||||||
private function processHeaders(&$data)
|
private function processHeaders(&$data)
|
||||||
{
|
{
|
||||||
@@ -133,15 +155,17 @@ class Curl
|
|||||||
|
|
||||||
$tmp = explode("\r\n", $this->data['info']['response_header']);
|
$tmp = explode("\r\n", $this->data['info']['response_header']);
|
||||||
$this->data['data']['Message'] = $tmp[0];
|
$this->data['data']['Message'] = $tmp[0];
|
||||||
for ($i = 1, $size = count($tmp); $i < $size; ++$i) {
|
for ($i = 1, $size = count($tmp); $i < $size; $i++) {
|
||||||
$string = explode(': ', $tmp[$i], 2);
|
$string = explode(': ', $tmp[$i], 2);
|
||||||
$this->data['data'][$string[0]] = $string[1];
|
$this->data['data'][$string[0]] = $string[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null $url
|
* Execute a GET request.
|
||||||
* @return bool|string|void
|
*
|
||||||
|
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
|
||||||
|
* @return bool|string Response body, or false on failure
|
||||||
*/
|
*/
|
||||||
public function get($url = null)
|
public function get($url = null)
|
||||||
{
|
{
|
||||||
@@ -149,8 +173,10 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null $url
|
* Execute a DELETE request.
|
||||||
* @return bool|string|void
|
*
|
||||||
|
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
|
||||||
|
* @return bool|string Response body, or false on failure
|
||||||
*/
|
*/
|
||||||
public function delete($url = null)
|
public function delete($url = null)
|
||||||
{
|
{
|
||||||
@@ -158,8 +184,10 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null $url
|
* Execute a POST request.
|
||||||
* @return bool|string|void
|
*
|
||||||
|
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
|
||||||
|
* @return bool|string Response body, or false on failure
|
||||||
*/
|
*/
|
||||||
public function post($url = null)
|
public function post($url = null)
|
||||||
{
|
{
|
||||||
@@ -167,17 +195,10 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null $url
|
* Return curl_getinfo data for the completed request.
|
||||||
* @return bool|string|void
|
*
|
||||||
*/
|
* @param string|false $param A specific info key to retrieve, or false for the full array
|
||||||
public function head($url = null)
|
* @return mixed|null The requested info value, the full info array, or null if the key is absent
|
||||||
{
|
|
||||||
return $this->send('HEAD', $url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param false $param
|
|
||||||
* @return mixed|null
|
|
||||||
*/
|
*/
|
||||||
public function getRequestInfo($param = false)
|
public function getRequestInfo($param = false)
|
||||||
{
|
{
|
||||||
@@ -189,9 +210,11 @@ class Curl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $what
|
* Retrieve a single item from the internal data store by section and key.
|
||||||
* @param $name
|
*
|
||||||
* @return mixed|null
|
* @param string $what Top-level section key (e.g. 'info', 'data')
|
||||||
|
* @param string $name Item key within that section
|
||||||
|
* @return mixed|null The stored value, or null if not found
|
||||||
*/
|
*/
|
||||||
private function getDataItem($what, $name)
|
private function getDataItem($what, $name)
|
||||||
{
|
{
|
||||||
@@ -201,17 +224,4 @@ class Curl
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param false $param
|
|
||||||
* @return mixed|null
|
|
||||||
*/
|
|
||||||
public function getHeadersData($param = false)
|
|
||||||
{
|
|
||||||
if ($param) {
|
|
||||||
return $this->getDataItem('data', $param);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->data['data'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,61 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
|
|||||||
|
|
||||||
use WHMCS\Database\Capsule as DB;
|
use WHMCS\Database\Capsule as DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all database operations for the module's custom table (mod_virtfusion_direct)
|
||||||
|
* and queries against core WHMCS tables (tblhosting, tblclients, tblservers, etc.).
|
||||||
|
*
|
||||||
|
* SCHEMA AUTO-MIGRATION
|
||||||
|
* ---------------------
|
||||||
|
* schema() runs on every Module construction — the first call per request creates
|
||||||
|
* or migrates the module table and ensures all required custom fields exist on
|
||||||
|
* every VirtFusionDirect product. Subsequent calls within the same request hit
|
||||||
|
* the $fieldsChecked idempotency flag and short-circuit, so the overhead is
|
||||||
|
* one SHOW-columns query per request.
|
||||||
|
*
|
||||||
|
* This design means operators never need to run a separate install script —
|
||||||
|
* dropping the module files into place and hitting any admin page triggers the
|
||||||
|
* migration. The trade-off is small per-request overhead; we take it because
|
||||||
|
* WHMCS modules historically had fragile install/uninstall hooks.
|
||||||
|
*
|
||||||
|
* SCHEMA VERSIONING
|
||||||
|
* -----------------
|
||||||
|
* No explicit version table. Migrations are expressed as "create if missing"
|
||||||
|
* checks — hasTable(), hasColumn() — which makes forward migration additive
|
||||||
|
* and safe to re-run. Deletions would require a proper versioning scheme, but
|
||||||
|
* we have none so far; every column added has been non-breaking.
|
||||||
|
*
|
||||||
|
* WHMCS TABLE ACCESS
|
||||||
|
* ------------------
|
||||||
|
* Reads from tblhosting / tblclients / tblconfiguration are done via Capsule's
|
||||||
|
* fluent query builder, not raw SQL, to inherit WHMCS's database abstraction
|
||||||
|
* (connection pooling, character set, prepared statement handling).
|
||||||
|
*/
|
||||||
class Database
|
class Database
|
||||||
{
|
{
|
||||||
|
/** Module's own per-service state table. Created on first Module instantiation. */
|
||||||
const SYSTEM_TABLE = 'mod_virtfusion_direct';
|
const SYSTEM_TABLE = 'mod_virtfusion_direct';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Tracks whether custom field existence has already been verified this request.
|
||||||
|
*
|
||||||
|
* Custom-field creation is idempotent (updateOrInsert) but touching every
|
||||||
|
* product on every request is wasteful. This flag ensures it runs exactly
|
||||||
|
* once per PHP request.
|
||||||
|
*/
|
||||||
|
private static $fieldsChecked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or migrates the module table schema and ensures custom fields exist.
|
||||||
|
*
|
||||||
|
* Creates mod_virtfusion_direct with service_id and server_id columns if absent,
|
||||||
|
* adds the server_object column if missing, then calls ensureCustomFields().
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public static function schema()
|
public static function schema()
|
||||||
{
|
{
|
||||||
if (!DB::schema()->hasTable(self::SYSTEM_TABLE)) {
|
if (! DB::schema()->hasTable(self::SYSTEM_TABLE)) {
|
||||||
try {
|
try {
|
||||||
DB::schema()->create(self::SYSTEM_TABLE, function ($table) {
|
DB::schema()->create(self::SYSTEM_TABLE, function ($table) {
|
||||||
$table->unsignedBigInteger('service_id')->nullable()->default(null)->index();
|
$table->unsignedBigInteger('service_id')->nullable()->default(null)->index();
|
||||||
@@ -22,7 +70,7 @@ class Database
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!DB::schema()->hasColumn(self::SYSTEM_TABLE, 'server_object')) {
|
if (! DB::schema()->hasColumn(self::SYSTEM_TABLE, 'server_object')) {
|
||||||
try {
|
try {
|
||||||
DB::schema()->table(self::SYSTEM_TABLE, function ($table) {
|
DB::schema()->table(self::SYSTEM_TABLE, function ($table) {
|
||||||
$table->longText('server_object')->nullable()->default(null);
|
$table->longText('server_object')->nullable()->default(null);
|
||||||
@@ -31,92 +79,283 @@ class Database
|
|||||||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::ensureCustomFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the "Initial Operating System" and "Initial SSH Key" custom fields exist
|
||||||
|
* for every VirtFusionDirect product, creating them via upsert if absent.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function ensureCustomFields()
|
||||||
|
{
|
||||||
|
if (self::$fieldsChecked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::$fieldsChecked = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$productIds = DB::table('tblproducts')
|
||||||
|
->where('servertype', 'VirtFusionDirect')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($productIds as $productId) {
|
||||||
|
foreach (['Initial Operating System', 'Initial SSH Key'] as $fieldName) {
|
||||||
|
DB::table('tblcustomfields')->updateOrInsert(
|
||||||
|
['type' => 'product', 'relid' => $productId, 'fieldname' => $fieldName],
|
||||||
|
[
|
||||||
|
'fieldtype' => 'text',
|
||||||
|
'description' => '',
|
||||||
|
'fieldoptions' => '',
|
||||||
|
'regexpr' => '',
|
||||||
|
'adminonly' => '',
|
||||||
|
'required' => '',
|
||||||
|
'showorder' => 'on',
|
||||||
|
'showinvoice' => '',
|
||||||
|
'sortorder' => 0,
|
||||||
|
'updated_at' => DB::raw('UTC_TIMESTAMP()'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a VirtFusionDirect server record from tblservers.
|
||||||
|
*
|
||||||
|
* When $server is non-zero, returns the matching server by ID.
|
||||||
|
* When $any is true and $server is 0, returns the first enabled server.
|
||||||
|
*
|
||||||
|
* @param int $server WHMCS server ID to look up (0 to skip ID filter).
|
||||||
|
* @param bool $any If true, fall back to the first active server.
|
||||||
|
* @return object|false Row object on success, false on failure or not found.
|
||||||
|
*/
|
||||||
public static function getWhmcsServer(int $server, $any = false)
|
public static function getWhmcsServer(int $server, $any = false)
|
||||||
{
|
{
|
||||||
if ($server) {
|
try {
|
||||||
return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('id', $server)->first();
|
if ($server) {
|
||||||
}
|
return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('id', $server)->first();
|
||||||
|
}
|
||||||
|
|
||||||
if ($any) {
|
if ($any) {
|
||||||
return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('disabled', 0)->first();
|
return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('disabled', 0)->first();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a WHMCS service belongs to the given client.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @param int $userId WHMCS client ID.
|
||||||
|
* @return bool True if the service is owned by the client, false otherwise.
|
||||||
|
*/
|
||||||
public static function userWhmcsService(int $serviceId, int $userId)
|
public static function userWhmcsService(int $serviceId, int $userId)
|
||||||
{
|
{
|
||||||
return DB::table('tblhosting')->where('id', $serviceId)->where('userid', $userId)->exists();
|
try {
|
||||||
|
return DB::table('tblhosting')->where('id', $serviceId)->where('userid', $userId)->exists();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the WHMCS system URL from tblconfiguration.
|
||||||
|
*
|
||||||
|
* @return string The system URL, or an empty string if not found or on error.
|
||||||
|
*/
|
||||||
public static function getSystemUrl()
|
public static function getSystemUrl()
|
||||||
{
|
{
|
||||||
$url = DB::table('tblconfiguration')->where('setting', '=', 'SystemURL')->first();
|
try {
|
||||||
if (!$url) return '';
|
$url = DB::table('tblconfiguration')->where('setting', '=', 'SystemURL')->first();
|
||||||
return $url->value;
|
if (! $url) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url->value;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a WHMCS client record by ID.
|
||||||
|
*
|
||||||
|
* @param int $id WHMCS client ID.
|
||||||
|
* @return object|null Row object on success, null on failure or not found.
|
||||||
|
*/
|
||||||
public static function getUser(int $id)
|
public static function getUser(int $id)
|
||||||
{
|
{
|
||||||
return DB::table('tblclients')->where('id', $id)->first();
|
try {
|
||||||
|
return DB::table('tblclients')->where('id', $id)->first();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a WHMCS hosting service record by ID.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @return object|null Row object on success, null on failure or not found.
|
||||||
|
*/
|
||||||
public static function getWhmcsService(int $serviceId)
|
public static function getWhmcsService(int $serviceId)
|
||||||
{
|
{
|
||||||
return DB::table('tblhosting')->where('id', $serviceId)->first();
|
try {
|
||||||
|
return DB::table('tblhosting')->where('id', $serviceId)->first();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upserts the VirtFusion server ID for a given WHMCS service in the module table.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @param int $serverId VirtFusion server ID.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public static function updateSystemServiceServerId(int $serviceId, int $serverId)
|
public static function updateSystemServiceServerId(int $serviceId, int $serverId)
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
DB::table(self::SYSTEM_TABLE)->updateOrInsert(
|
DB::table(self::SYSTEM_TABLE)->updateOrInsert(
|
||||||
[
|
[
|
||||||
"service_id" => $serviceId
|
'service_id' => $serviceId,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'server_id' => $serverId
|
'server_id' => $serverId,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates one or more WHMCS tables with the provided data for a given service ID.
|
||||||
|
*
|
||||||
|
* $data is keyed by table name; each value is an associative array of column => value
|
||||||
|
* pairs passed to an update() WHERE id = $serviceId.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @param array $data Map of table name to column-value pairs to update.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public static function updateWhmcsServiceParams(int $serviceId, $data)
|
public static function updateWhmcsServiceParams(int $serviceId, $data)
|
||||||
{
|
{
|
||||||
if (count($data)) {
|
try {
|
||||||
foreach ($data as $key => $items) {
|
if (count($data)) {
|
||||||
DB::table($key)->where('id', $serviceId)->update($items);
|
foreach ($data as $key => $items) {
|
||||||
|
DB::table($key)->where('id', $serviceId)->update($items);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a module table record exists for the given service.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @return bool True if a record exists, false otherwise.
|
||||||
|
*/
|
||||||
public static function checkSystemService(int $serviceId)
|
public static function checkSystemService(int $serviceId)
|
||||||
{
|
{
|
||||||
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists();
|
try {
|
||||||
}
|
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
public static function deleteSystemService(int $serviceId)
|
return false;
|
||||||
{
|
|
||||||
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function updateSystemServiceServerObject(int $serviceId, $data)
|
|
||||||
{
|
|
||||||
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function systemOnServerCreate(int $serviceId, $data)
|
|
||||||
{
|
|
||||||
if (DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists()) {
|
|
||||||
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
|
|
||||||
} else {
|
|
||||||
DB::table(self::SYSTEM_TABLE)->insert(['service_id' => $serviceId, 'server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getSystemService(int $serviceId)
|
/**
|
||||||
|
* Deletes the module table record for the given service.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function deleteSystemService(int $serviceId)
|
||||||
{
|
{
|
||||||
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->first();
|
try {
|
||||||
|
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->delete();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the raw VirtFusion server API response as JSON in the module table.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @param mixed $data Server object from the VirtFusion API (will be JSON-encoded).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function updateSystemServiceServerObject(int $serviceId, $data)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates the module table record immediately after a VirtFusion server is created.
|
||||||
|
*
|
||||||
|
* Stores both the VirtFusion server ID (from $data->data->id) and the full server
|
||||||
|
* object JSON. Uses update if a record already exists, otherwise inserts.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @param mixed $data Full API response object from the VirtFusion server creation call.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function systemOnServerCreate(int $serviceId, $data)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists()) {
|
||||||
|
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
|
||||||
|
} else {
|
||||||
|
DB::table(self::SYSTEM_TABLE)->insert(['service_id' => $serviceId, 'server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the module table record for the given service.
|
||||||
|
*
|
||||||
|
* @param int $serviceId WHMCS hosting service ID.
|
||||||
|
* @return object|null Row object on success, null on failure or not found.
|
||||||
|
*/
|
||||||
|
public static function getSystemService(int $serviceId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->first();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,22 +2,50 @@
|
|||||||
|
|
||||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around the WHMCS logModuleCall() function.
|
||||||
|
*
|
||||||
|
* WHY A WRAPPER
|
||||||
|
* -------------
|
||||||
|
* Consolidating log writes lets us:
|
||||||
|
* - Pin the module name in one place (the LOG_MODULE constant). All entries
|
||||||
|
* go under "VirtFusionDirect" regardless of which caller inserted them,
|
||||||
|
* which keeps WHMCS Admin → Utilities → Logs → Module Log filterable.
|
||||||
|
* - Get a stable import path for every file that logs (Log::insert).
|
||||||
|
* - Add cross-cutting policy later (e.g. redaction, sampling) without
|
||||||
|
* touching every call site.
|
||||||
|
*
|
||||||
|
* OUTPUT SURFACE
|
||||||
|
* --------------
|
||||||
|
* Entries appear in WHMCS Admin → Utilities → Logs → Module Log. The request
|
||||||
|
* and response parameters accept strings OR arrays — WHMCS serialises arrays
|
||||||
|
* to readable form automatically. Pass structured data (["zone" => $z, "ip" => $ip])
|
||||||
|
* rather than string-concatenated messages; the UI renders arrays as key/value
|
||||||
|
* pairs which makes filtering and debugging much easier.
|
||||||
|
*
|
||||||
|
* REDACTION EXPECTATION
|
||||||
|
* ---------------------
|
||||||
|
* Callers are responsible for not passing secrets into logs. In particular:
|
||||||
|
* - Never log Authorization/X-API-Key headers
|
||||||
|
* - Never log full request_header info from the Curl class
|
||||||
|
* - Never log the decrypted VirtFusion bearer token or PowerDNS API key
|
||||||
|
* The Curl class deliberately defaults CURLOPT_HEADER to off so header capture
|
||||||
|
* doesn't accidentally populate a field that callers might log.
|
||||||
|
*/
|
||||||
class Log
|
class Log
|
||||||
{
|
{
|
||||||
|
/** Keep this in sync with the WHMCS server module name, so filters work. */
|
||||||
const LOG_MODULE = 'VirtFusionDirect';
|
const LOG_MODULE = 'VirtFusionDirect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an entry to the WHMCS module log.
|
||||||
|
*
|
||||||
|
* @param string $action Short tag identifying the operation (used as the "Function" column in the log UI)
|
||||||
|
* @param string|array $requestString Outbound payload or context data. Arrays preferred — rendered as key/value pairs.
|
||||||
|
* @param string|array $responseData Inbound response or result. Same conventions as $requestString.
|
||||||
|
*/
|
||||||
public static function insert($action, $requestString, $responseData)
|
public static function insert($action, $requestString, $responseData)
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Log module call.
|
|
||||||
*
|
|
||||||
* @param string $module The name of the module
|
|
||||||
* @param string $action The name of the action being performed
|
|
||||||
* @param string|array $requestString The input parameters for the API call
|
|
||||||
* @param string|array $responseData The response data from the API call
|
|
||||||
* @param string|array $processedData The resulting data after any post processing (eg. json decode, xml decode, etc...)
|
|
||||||
* @param array $replaceVars An array of strings for replacement
|
|
||||||
*/
|
|
||||||
logModuleCall(self::LOG_MODULE, $action, $requestString, $responseData);
|
logModuleCall(self::LOG_MODULE, $action, $requestString, $responseData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends Module to handle the WHMCS service lifecycle for VirtFusion servers.
|
||||||
|
*
|
||||||
|
* WHY A SEPARATE CLASS FROM MODULE
|
||||||
|
* --------------------------------
|
||||||
|
* The WHMCS module interface (VirtFusionDirect.php) expects top-level functions
|
||||||
|
* like VirtFusionDirect_CreateAccount(). Those functions delegate into methods
|
||||||
|
* on this class so:
|
||||||
|
* 1. The top-level functions stay one-liners that are easy to audit.
|
||||||
|
* 2. All lifecycle logic lives in an object we can instantiate and unit-exercise
|
||||||
|
* without going through WHMCS's dispatch machinery.
|
||||||
|
* 3. The shared behaviour with Module (API calls, auth, validation) comes for
|
||||||
|
* free via inheritance — no copy-pasted curl setup or error handling.
|
||||||
|
*
|
||||||
|
* ERROR MESSAGE CONVENTION
|
||||||
|
* ------------------------
|
||||||
|
* Every public method either returns the literal string 'success' or an error
|
||||||
|
* string that WHMCS will render to the admin in the service activity log. Do NOT
|
||||||
|
* return arrays, objects, or booleans — WHMCS treats anything other than
|
||||||
|
* 'success' as an error and displays it verbatim.
|
||||||
|
*
|
||||||
|
* EXCEPTION HANDLING
|
||||||
|
* ------------------
|
||||||
|
* Every public method is wrapped in try/catch. Uncaught exceptions bubbling up
|
||||||
|
* to WHMCS appear as stack traces in the admin UI and leak implementation detail,
|
||||||
|
* so we catch and convert to a human error string. Log::insert() captures the
|
||||||
|
* original exception message for diagnostics in the module log.
|
||||||
|
*
|
||||||
|
* PowerDNS INTEGRATION
|
||||||
|
* --------------------
|
||||||
|
* createAccount(), terminateAccount(), and (via parent Module) renameServer()
|
||||||
|
* call into PowerDns\PtrManager to sync rDNS. Those calls are wrapped in their
|
||||||
|
* OWN try/catch so DNS failures never bubble up to WHMCS — provisioning must
|
||||||
|
* succeed even if PowerDNS is temporarily unreachable. See cleanupPowerDnsForService()
|
||||||
|
* for the termination-time cleanup helper.
|
||||||
|
*/
|
||||||
class ModuleFunctions extends Module
|
class ModuleFunctions extends Module
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -10,13 +46,13 @@ class ModuleFunctions extends Module
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Provision a new VirtFusion server for a WHMCS service.
|
||||||
*
|
*
|
||||||
* CREATE SERVER
|
* Ensures a matching VirtFusion user exists (creating one if needed), then creates
|
||||||
*
|
* the server and triggers the OS build via ConfigureService::initServerBuild().
|
||||||
* Before creating a server, we check to see if a user exists in VirtFusion that matches
|
|
||||||
* the WHMCS user. If it matches, We move on to create the server, if not, we attempt to
|
|
||||||
* create a user to assign to the new server.
|
|
||||||
*
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
*/
|
*/
|
||||||
public function createAccount($params)
|
public function createAccount($params)
|
||||||
{
|
{
|
||||||
@@ -33,9 +69,9 @@ class ModuleFunctions extends Module
|
|||||||
* If no VirtFusionDirect control server exists, cancel the create account action.
|
* If no VirtFusionDirect control server exists, cancel the create account action.
|
||||||
*/
|
*/
|
||||||
$server = $params['serverid'] ?: false;
|
$server = $params['serverid'] ?: false;
|
||||||
$cp = $this->getCP($server, !$server);
|
$cp = $this->getCP($server, ! $server);
|
||||||
|
|
||||||
if (!$cp) {
|
if (! $cp) {
|
||||||
return 'No Control server found. Please ensure a VirtFusion server is configured in WHMCS.';
|
return 'No Control server found. Please ensure a VirtFusion server is configured in WHMCS.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,16 +98,16 @@ class ModuleFunctions extends Module
|
|||||||
*/
|
*/
|
||||||
$user = Database::getUser($params['userid']);
|
$user = Database::getUser($params['userid']);
|
||||||
|
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
return 'WHMCS user not found for ID ' . (int) $params['userid'];
|
return 'WHMCS user not found for ID ' . (int) $params['userid'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
$request = $this->initCurl($cp['token']);
|
||||||
|
|
||||||
$userData = [
|
$userData = [
|
||||||
"name" => $user->firstname . ' ' . $user->lastname,
|
'name' => $user->firstname . ' ' . $user->lastname,
|
||||||
"email" => $user->email,
|
'email' => $user->email,
|
||||||
"extRelationId" => $user->id,
|
'extRelationId' => $user->id,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Enable self-service billing if configured
|
// Enable self-service billing if configured
|
||||||
@@ -100,7 +136,6 @@ class ModuleFunctions extends Module
|
|||||||
/**
|
/**
|
||||||
* A user is available. We can now attempt to create a server.
|
* A user is available. We can now attempt to create a server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$configOptionDefaultNaming = [
|
$configOptionDefaultNaming = [
|
||||||
'ipv4' => 'IPv4',
|
'ipv4' => 'IPv4',
|
||||||
'packageId' => 'Package',
|
'packageId' => 'Package',
|
||||||
@@ -122,10 +157,10 @@ class ModuleFunctions extends Module
|
|||||||
}
|
}
|
||||||
|
|
||||||
$options = [
|
$options = [
|
||||||
"packageId" => (int) $params['configoption2'],
|
'packageId' => (int) $params['configoption2'],
|
||||||
"userId" => $data->data->id,
|
'userId' => $data->data->id,
|
||||||
"hypervisorId" => (int) $params['configoption1'],
|
'hypervisorId' => (int) $params['configoption1'],
|
||||||
"ipv4" => (int) $params['configoption3'],
|
'ipv4' => (int) $params['configoption3'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (array_key_exists('configoptions', $params)) {
|
if (array_key_exists('configoptions', $params)) {
|
||||||
@@ -158,8 +193,35 @@ class ModuleFunctions extends Module
|
|||||||
Database::systemOnServerCreate($params['serviceid'], $data);
|
Database::systemOnServerCreate($params['serviceid'], $data);
|
||||||
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
|
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
|
||||||
|
|
||||||
|
// Initialize reverse DNS for the newly-assigned IPs.
|
||||||
|
//
|
||||||
|
// Ordering: after Database::systemOnServerCreate() AND
|
||||||
|
// updateWhmcsServiceParamsOnServerObject() so mod_virtfusion_direct
|
||||||
|
// has the stored server_object (admin reconcile later reads it) and
|
||||||
|
// tblhosting has the primary IP (for cross-check on client edits).
|
||||||
|
//
|
||||||
|
// But BEFORE ConfigureService::initServerBuild() so rDNS is in place
|
||||||
|
// when the VPS first boots — mail servers and other services that
|
||||||
|
// check FCrDNS during early-boot see correct PTRs.
|
||||||
|
//
|
||||||
|
// Non-blocking: rDNS failures are logged but never fail provisioning.
|
||||||
|
// A broken PowerDNS or missing zone must not prevent a customer
|
||||||
|
// from getting the VPS they paid for.
|
||||||
|
try {
|
||||||
|
if (PowerDns\Config::isEnabled()) {
|
||||||
|
// syncServer with $oldHostname=null means "create mode" — see
|
||||||
|
// PtrManager::syncServer() docblock for the semantics.
|
||||||
|
$hostname = PowerDns\PtrManager::extractHostname($data);
|
||||||
|
if ($hostname !== null) {
|
||||||
|
(new PowerDns\PtrManager)->syncServer($data, null, $hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:createAccount', ['serviceid' => $params['serviceid']], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
// If the server is created successfully, we can initialize the server build.
|
// If the server is created successfully, we can initialize the server build.
|
||||||
$cs = new ConfigureService();
|
$cs = new ConfigureService;
|
||||||
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
|
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
|
||||||
$cs->initServerBuild($data->data->id, $params, $vfUserId);
|
$cs->initServerBuild($data->data->id, $params, $vfUserId);
|
||||||
|
|
||||||
@@ -171,328 +233,472 @@ class ModuleFunctions extends Module
|
|||||||
if (isset($data->msg)) {
|
if (isset($data->msg)) {
|
||||||
return $data->msg;
|
return $data->msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Server creation failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
return 'Server creation failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
|
||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows changing of the package of a server
|
* Change the VirtFusion package assigned to a server and apply resource modifications.
|
||||||
*
|
*
|
||||||
* @param $params
|
* Updates the package via the API, then individually adjusts memory, CPU, and bandwidth
|
||||||
* @return string
|
* if those configurable options are present.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
*/
|
*/
|
||||||
public function changePackage($params)
|
public function changePackage($params)
|
||||||
{
|
{
|
||||||
$service = Database::getSystemService($params['serviceid']);
|
try {
|
||||||
|
$service = Database::getSystemService($params['serviceid']);
|
||||||
|
|
||||||
if ($service) {
|
if ($service) {
|
||||||
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
||||||
if (!$whmcsService) return 'WHMCS service record not found.';
|
if (! $whmcsService) {
|
||||||
|
return 'WHMCS service record not found.';
|
||||||
$cp = $this->getCP($whmcsService->server);
|
|
||||||
if (!$cp) return 'No control server found.';
|
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
|
||||||
$data = $request->put($cp['url'] . '/servers/' . (int) $service->server_id . '/package/' . (int) $params['configoption2']);
|
|
||||||
$data = json_decode($data);
|
|
||||||
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
|
||||||
|
|
||||||
switch ($request->getRequestInfo('http_code')) {
|
|
||||||
|
|
||||||
case 204:
|
|
||||||
break;
|
|
||||||
case 404:
|
|
||||||
return 'The server or package was not found in VirtFusion (HTTP 404).';
|
|
||||||
case 423:
|
|
||||||
if (isset($data->msg)) {
|
|
||||||
return $data->msg;
|
|
||||||
}
|
|
||||||
return 'The server is currently locked. Please try again later.';
|
|
||||||
default:
|
|
||||||
return 'Update package request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply individual resource modifications from configurable options
|
|
||||||
if (isset($params['configoptions']) && is_array($params['configoptions'])) {
|
|
||||||
$configOptionDefaultNaming = [
|
|
||||||
'memory' => 'Memory',
|
|
||||||
'cpuCores' => 'CPU Cores',
|
|
||||||
'traffic' => 'Bandwidth',
|
|
||||||
];
|
|
||||||
|
|
||||||
$configOptionCustomNaming = [];
|
|
||||||
if (file_exists(ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php')) {
|
|
||||||
$configOptionCustomNaming = require ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($configOptionDefaultNaming as $resource => $optionName) {
|
$cp = $this->getCP($whmcsService->server);
|
||||||
$currentOption = array_key_exists($resource, $configOptionCustomNaming) ? $configOptionCustomNaming[$resource] : $optionName;
|
if (! $cp) {
|
||||||
if (isset($params['configoptions'][$currentOption]) && is_numeric($params['configoptions'][$currentOption])) {
|
return 'No control server found.';
|
||||||
$value = (int) $params['configoptions'][$currentOption];
|
}
|
||||||
if ($resource === 'memory' && $value < 1024) {
|
|
||||||
$value = $value * 1024;
|
$request = $this->initCurl($cp['token']);
|
||||||
|
$data = $request->put($cp['url'] . '/servers/' . (int) $service->server_id . '/package/' . (int) $params['configoption2']);
|
||||||
|
$data = json_decode($data);
|
||||||
|
|
||||||
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
|
switch ($request->getRequestInfo('http_code')) {
|
||||||
|
|
||||||
|
case 204:
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
return 'The server or package was not found in VirtFusion (HTTP 404).';
|
||||||
|
case 423:
|
||||||
|
if (isset($data->msg)) {
|
||||||
|
return $data->msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'The server is currently locked. Please try again later.';
|
||||||
|
default:
|
||||||
|
return 'Update package request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply individual resource modifications from configurable options
|
||||||
|
if (isset($params['configoptions']) && is_array($params['configoptions'])) {
|
||||||
|
$configOptionDefaultNaming = [
|
||||||
|
'memory' => 'Memory',
|
||||||
|
'cpuCores' => 'CPU Cores',
|
||||||
|
'traffic' => 'Bandwidth',
|
||||||
|
];
|
||||||
|
|
||||||
|
$configOptionCustomNaming = [];
|
||||||
|
if (file_exists(ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php')) {
|
||||||
|
$configOptionCustomNaming = require ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($configOptionDefaultNaming as $resource => $optionName) {
|
||||||
|
$currentOption = array_key_exists($resource, $configOptionCustomNaming) ? $configOptionCustomNaming[$resource] : $optionName;
|
||||||
|
if (isset($params['configoptions'][$currentOption]) && is_numeric($params['configoptions'][$currentOption])) {
|
||||||
|
$value = (int) $params['configoptions'][$currentOption];
|
||||||
|
if ($resource === 'memory' && $value < 1024) {
|
||||||
|
$value = $value * 1024;
|
||||||
|
}
|
||||||
|
$this->modifyResource($params['serviceid'], $resource, $value);
|
||||||
}
|
}
|
||||||
$this->modifyResource($params['serviceid'], $resource, $value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'success';
|
return 'Service not found in module database.';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
|
||||||
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
return 'Service not found in module database.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Delete a VirtFusion server, applying the default 5-minute grace period before destruction.
|
||||||
*
|
*
|
||||||
* TERMINATE SERVER
|
* On success, removes the service record from the module database and clears WHMCS service fields.
|
||||||
*
|
* If VirtFusion reports the server is already gone (404 + "server not found"), treats it as success.
|
||||||
* When requesting to terminate a server in VirtFusion, we leave it set to
|
|
||||||
* the default 5-minute delay allowing to un-terminate in VirtFusion if the
|
|
||||||
* request was done in error.
|
|
||||||
*
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
*/
|
*/
|
||||||
public function terminateAccount($params)
|
public function terminateAccount($params)
|
||||||
{
|
{
|
||||||
$service = Database::getSystemService($params['serviceid']);
|
try {
|
||||||
|
$service = Database::getSystemService($params['serviceid']);
|
||||||
|
|
||||||
if ($service) {
|
if ($service) {
|
||||||
|
|
||||||
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
||||||
if (!$whmcsService) return 'WHMCS service record not found.';
|
if (! $whmcsService) {
|
||||||
|
return 'WHMCS service record not found.';
|
||||||
|
}
|
||||||
|
|
||||||
$cp = $this->getCP($whmcsService->server);
|
$cp = $this->getCP($whmcsService->server);
|
||||||
if (!$cp) return 'No control server found.';
|
if (! $cp) {
|
||||||
|
return 'No control server found.';
|
||||||
|
}
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
$request = $this->initCurl($cp['token']);
|
||||||
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id);
|
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id);
|
||||||
$data = json_decode($data);
|
$data = json_decode($data);
|
||||||
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
switch ($request->getRequestInfo('http_code')) {
|
switch ($request->getRequestInfo('http_code')) {
|
||||||
|
|
||||||
case 204:
|
case 204:
|
||||||
Database::deleteSystemService($params['serviceid']);
|
$this->cleanupPowerDnsForService($service);
|
||||||
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
|
Database::deleteSystemService($params['serviceid']);
|
||||||
return 'success';
|
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
|
||||||
|
|
||||||
case 404:
|
return 'success';
|
||||||
if (isset($data->msg)) {
|
|
||||||
if ($data->msg == 'server not found') {
|
case 404:
|
||||||
Database::deleteSystemService($params['serviceid']);
|
if (isset($data->msg)) {
|
||||||
return 'success';
|
if ($data->msg == 'server not found') {
|
||||||
|
$this->cleanupPowerDnsForService($service);
|
||||||
|
Database::deleteSystemService($params['serviceid']);
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
} else {
|
||||||
|
return 'VirtFusion returned 404: ' . $data->msg;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return 'VirtFusion returned 404: ' . $data->msg;
|
return 'VirtFusion returned 404 without details. The API may be unavailable.';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return 'VirtFusion returned 404 without details. The API may be unavailable.';
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'Termination request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
return 'Termination request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 'Service not found in module database. Has termination already been run?';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
|
||||||
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
return 'Service not found in module database. Has termination already been run?';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Delete any PTR records owned by this service before the local record is erased.
|
||||||
|
* The stored server_object is the last source of the IP list; once deleted from
|
||||||
|
* the module table we'd have no way to find them again. Non-fatal — DNS failures
|
||||||
|
* never block termination.
|
||||||
*
|
*
|
||||||
* SUSPEND SERVER
|
* @param object|null $service Row from mod_virtfusion_direct (has server_object JSON)
|
||||||
|
*/
|
||||||
|
protected function cleanupPowerDnsForService($service): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (! PowerDns\Config::isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $service || empty($service->server_object)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$decoded = json_decode($service->server_object, true);
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(new PowerDns\PtrManager)->deleteForServer($decoded);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:terminate', ['service' => $service->service_id ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend a VirtFusion server, queuing the action if another operation is in progress.
|
||||||
*
|
*
|
||||||
* When requesting to suspend a server in VirtFusion it may be delayed if another action
|
* Returns 'success' whether the server is suspended immediately or queued for suspension.
|
||||||
* is being processed. This function will return success if the server is either suspended
|
|
||||||
* now or has been queued for suspension.
|
|
||||||
*
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
*/
|
*/
|
||||||
public function suspendAccount($params)
|
public function suspendAccount($params)
|
||||||
{
|
{
|
||||||
$service = Database::getSystemService($params['serviceid']);
|
try {
|
||||||
|
$service = Database::getSystemService($params['serviceid']);
|
||||||
|
|
||||||
if ($service) {
|
if ($service) {
|
||||||
|
|
||||||
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
||||||
if (!$whmcsService) return 'WHMCS service record not found.';
|
if (! $whmcsService) {
|
||||||
|
return 'WHMCS service record not found.';
|
||||||
|
}
|
||||||
|
|
||||||
$cp = $this->getCP($whmcsService->server);
|
$cp = $this->getCP($whmcsService->server);
|
||||||
if (!$cp) return 'No control server found.';
|
if (! $cp) {
|
||||||
|
return 'No control server found.';
|
||||||
|
}
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
$request = $this->initCurl($cp['token']);
|
||||||
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/suspend');
|
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/suspend');
|
||||||
$data = json_decode($data);
|
$data = json_decode($data);
|
||||||
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
switch ($request->getRequestInfo('http_code')) {
|
switch ($request->getRequestInfo('http_code')) {
|
||||||
|
|
||||||
case 204:
|
case 204:
|
||||||
return 'success';
|
return 'success';
|
||||||
|
|
||||||
case 404:
|
case 404:
|
||||||
if (isset($data->msg)) {
|
if (isset($data->msg)) {
|
||||||
if ($data->msg == 'server not found') {
|
if ($data->msg == 'server not found') {
|
||||||
Database::deleteSystemService($params['serviceid']);
|
Database::deleteSystemService($params['serviceid']);
|
||||||
return 'success';
|
|
||||||
|
return 'success';
|
||||||
|
} else {
|
||||||
|
return 'VirtFusion returned 404: ' . $data->msg;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return 'VirtFusion returned 404: ' . $data->msg;
|
return 'VirtFusion returned 404 without details. The API may be unavailable.';
|
||||||
}
|
}
|
||||||
} else {
|
case 423:
|
||||||
return 'VirtFusion returned 404 without details. The API may be unavailable.';
|
if (isset($data->msg)) {
|
||||||
}
|
return $data->msg;
|
||||||
case 423:
|
|
||||||
if (isset($data->msg)) {
|
|
||||||
return $data->msg;
|
|
||||||
}
|
|
||||||
return 'The server is currently locked. Please try again later.';
|
|
||||||
|
|
||||||
default:
|
|
||||||
return 'Suspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'Service not found in module database.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServerObject($params)
|
|
||||||
{
|
|
||||||
$service = Database::getSystemService($params['serviceid']);
|
|
||||||
|
|
||||||
if ($service) {
|
|
||||||
|
|
||||||
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
|
||||||
if (!$whmcsService) return 'WHMCS service record not found.';
|
|
||||||
|
|
||||||
$cp = $this->getCP($whmcsService->server);
|
|
||||||
if (!$cp) return 'No control server found.';
|
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
|
||||||
$data = $request->get($cp['url'] . '/servers/' . (int) $service->server_id);
|
|
||||||
$data = json_decode($data);
|
|
||||||
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
|
||||||
|
|
||||||
switch ($request->getRequestInfo('http_code')) {
|
|
||||||
|
|
||||||
case 200:
|
|
||||||
Database::updateSystemServiceServerObject($params['serviceid'], $data);
|
|
||||||
|
|
||||||
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
|
|
||||||
|
|
||||||
return 'success';
|
|
||||||
default:
|
|
||||||
return 'Request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'Service not found in module database.';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function unsuspendAccount($params)
|
|
||||||
{
|
|
||||||
$service = Database::getSystemService($params['serviceid']);
|
|
||||||
|
|
||||||
if ($service) {
|
|
||||||
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
|
||||||
if (!$whmcsService) return 'WHMCS service record not found.';
|
|
||||||
|
|
||||||
$cp = $this->getCP($whmcsService->server);
|
|
||||||
if (!$cp) return 'No control server found.';
|
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
|
||||||
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/unsuspend');
|
|
||||||
$data = json_decode($data);
|
|
||||||
|
|
||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
|
||||||
|
|
||||||
switch ($request->getRequestInfo('http_code')) {
|
|
||||||
|
|
||||||
case 204:
|
|
||||||
return 'success';
|
|
||||||
|
|
||||||
case 404:
|
|
||||||
if (isset($data->msg)) {
|
|
||||||
if ($data->msg == 'server not found') {
|
|
||||||
Database::deleteSystemService($params['serviceid']);
|
|
||||||
return 'success';
|
|
||||||
} else {
|
|
||||||
return 'VirtFusion returned 404: ' . $data->msg;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return 'VirtFusion returned 404 without details. The API may be unavailable.';
|
|
||||||
}
|
|
||||||
case 423:
|
|
||||||
if (isset($data->msg)) {
|
|
||||||
return $data->msg;
|
|
||||||
}
|
|
||||||
return 'The server is currently locked. Please try again later.';
|
|
||||||
|
|
||||||
default:
|
return 'The server is currently locked. Please try again later.';
|
||||||
return 'Unsuspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
|
||||||
|
default:
|
||||||
|
return 'Suspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return 'Service not found in module database.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function adminServicesTabFields($params)
|
return 'Service not found in module database.';
|
||||||
{
|
} catch (\Exception $e) {
|
||||||
$serverId = '';
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
$serverObject = '';
|
|
||||||
|
|
||||||
$service = Database::getSystemService($params['serviceid']);
|
return $e->getMessage();
|
||||||
$systemUrl = Database::getSystemUrl();
|
|
||||||
|
|
||||||
if ($service) {
|
|
||||||
$serverId = $service->server_id;
|
|
||||||
$serverObject = $service->server_object;
|
|
||||||
}
|
|
||||||
$fields = [
|
|
||||||
'Server ID' => AdminHTML::serverId($serverId),
|
|
||||||
'Server Info' => AdminHTML::serverInfo($systemUrl, $params['serviceid']),
|
|
||||||
'Server Object' => AdminHTML::serverObject($serverObject),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($params['status'] != 'Terminated') {
|
|
||||||
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function adminServicesTabFieldsSave($params)
|
|
||||||
{
|
|
||||||
if (!isset($_POST['modulefields'][0]) || $_POST['modulefields'][0] === '') {
|
|
||||||
Database::deleteSystemService($params['serviceid']);
|
|
||||||
} else {
|
|
||||||
$serverId = (int) $_POST['modulefields'][0];
|
|
||||||
if ($serverId > 0) {
|
|
||||||
Database::updateSystemServiceServerId($params['serviceid'], $serverId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate server creation parameters via dry run.
|
* Refresh the cached server object by fetching fresh data from the VirtFusion API.
|
||||||
*
|
*
|
||||||
* @param array $params WHMCS service params
|
* Updates both the module database record and the WHMCS service fields (IP, username, etc.).
|
||||||
* @return string 'success' or error message
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
|
*/
|
||||||
|
public function updateServerObject($params)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$service = Database::getSystemService($params['serviceid']);
|
||||||
|
|
||||||
|
if ($service) {
|
||||||
|
|
||||||
|
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
||||||
|
if (! $whmcsService) {
|
||||||
|
return 'WHMCS service record not found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cp = $this->getCP($whmcsService->server);
|
||||||
|
if (! $cp) {
|
||||||
|
return 'No control server found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($cp['token']);
|
||||||
|
$data = $request->get($cp['url'] . '/servers/' . (int) $service->server_id);
|
||||||
|
$data = json_decode($data);
|
||||||
|
|
||||||
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
|
switch ($request->getRequestInfo('http_code')) {
|
||||||
|
|
||||||
|
case 200:
|
||||||
|
Database::updateSystemServiceServerObject($params['serviceid'], $data);
|
||||||
|
|
||||||
|
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'Request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Service not found in module database.';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsuspend a VirtFusion server, queuing the action if another operation is in progress.
|
||||||
|
*
|
||||||
|
* Returns 'success' whether the server is unsuspended immediately or queued for unsuspension.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
|
*/
|
||||||
|
public function unsuspendAccount($params)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$service = Database::getSystemService($params['serviceid']);
|
||||||
|
|
||||||
|
if ($service) {
|
||||||
|
$whmcsService = Database::getWhmcsService($params['serviceid']);
|
||||||
|
if (! $whmcsService) {
|
||||||
|
return 'WHMCS service record not found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cp = $this->getCP($whmcsService->server);
|
||||||
|
if (! $cp) {
|
||||||
|
return 'No control server found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($cp['token']);
|
||||||
|
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/unsuspend');
|
||||||
|
$data = json_decode($data);
|
||||||
|
|
||||||
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
|
switch ($request->getRequestInfo('http_code')) {
|
||||||
|
|
||||||
|
case 204:
|
||||||
|
return 'success';
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
if (isset($data->msg)) {
|
||||||
|
if ($data->msg == 'server not found') {
|
||||||
|
Database::deleteSystemService($params['serviceid']);
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
} else {
|
||||||
|
return 'VirtFusion returned 404: ' . $data->msg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return 'VirtFusion returned 404 without details. The API may be unavailable.';
|
||||||
|
}
|
||||||
|
case 423:
|
||||||
|
if (isset($data->msg)) {
|
||||||
|
return $data->msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'The server is currently locked. Please try again later.';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'Unsuspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Service not found in module database.';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the admin Services tab custom fields for a VirtFusion service.
|
||||||
|
*
|
||||||
|
* Returns fields for Server ID (editable), Server Info, Server Object (JSON viewer),
|
||||||
|
* and Options (action buttons), omitting Options for terminated services.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return array Associative array of field label => HTML content
|
||||||
|
*/
|
||||||
|
public function adminServicesTabFields($params)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$serverId = '';
|
||||||
|
$serverObject = '';
|
||||||
|
|
||||||
|
$service = Database::getSystemService($params['serviceid']);
|
||||||
|
$systemUrl = Database::getSystemUrl();
|
||||||
|
|
||||||
|
if ($service) {
|
||||||
|
$serverId = $service->server_id;
|
||||||
|
$serverObject = $service->server_object;
|
||||||
|
}
|
||||||
|
$fields = [
|
||||||
|
'Server ID' => AdminHTML::serverId($serverId),
|
||||||
|
'Server Info' => AdminHTML::serverInfo($systemUrl, $params['serviceid']),
|
||||||
|
'Server Object' => AdminHTML::serverObject($serverObject),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($params['status'] != 'Terminated') {
|
||||||
|
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
|
||||||
|
if (PowerDns\Config::isEnabled()) {
|
||||||
|
$fields['Reverse DNS'] = AdminHTML::rdnsSection($systemUrl, $params['serviceid']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the admin Services tab custom fields for a VirtFusion service.
|
||||||
|
*
|
||||||
|
* Deletes the module database record if the Server ID field is cleared,
|
||||||
|
* or updates it with the new integer server ID if a value is provided.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function adminServicesTabFieldsSave($params)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (! isset($_POST['modulefields'][0]) || $_POST['modulefields'][0] === '') {
|
||||||
|
Database::deleteSystemService($params['serviceid']);
|
||||||
|
} else {
|
||||||
|
$serverId = (int) $_POST['modulefields'][0];
|
||||||
|
if ($serverId > 0) {
|
||||||
|
Database::updateSystemServiceServerId($params['serviceid'], $serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::insert(__FUNCTION__, $params, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a dry-run server creation to validate the current product configuration.
|
||||||
|
*
|
||||||
|
* Used by the WHMCS "Test Connection" button to confirm that the package, hypervisor,
|
||||||
|
* and IP settings are accepted by the VirtFusion API without creating a server.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return string 'success' or an error message
|
||||||
*/
|
*/
|
||||||
public function validateServerConfig($params)
|
public function validateServerConfig($params)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$server = $params['serverid'] ?: false;
|
$server = $params['serverid'] ?: false;
|
||||||
$cp = $this->getCP($server, !$server);
|
$cp = $this->getCP($server, ! $server);
|
||||||
|
|
||||||
if (!$cp) {
|
if (! $cp) {
|
||||||
return 'No Control server found.';
|
return 'No Control server found.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$options = [
|
$options = [
|
||||||
"packageId" => (int) $params['configoption2'],
|
'packageId' => (int) $params['configoption2'],
|
||||||
"hypervisorId" => (int) $params['configoption1'],
|
'hypervisorId' => (int) $params['configoption1'],
|
||||||
"ipv4" => (int) $params['configoption3'],
|
'ipv4' => (int) $params['configoption3'],
|
||||||
];
|
];
|
||||||
|
|
||||||
// We need a userId for dry run - use the service owner
|
// We need a userId for dry run - use the service owner
|
||||||
@@ -517,6 +723,16 @@ class ModuleFunctions extends Module
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the client area overview tab for a VirtFusion service.
|
||||||
|
*
|
||||||
|
* Returns the template name and variables (system URL, service status, hostname,
|
||||||
|
* self-service mode) needed by the Smarty overview template. Falls back to an
|
||||||
|
* error template on any exception.
|
||||||
|
*
|
||||||
|
* @param array $params WHMCS service parameters
|
||||||
|
* @return array Template name and variables for WHMCS to render
|
||||||
|
*/
|
||||||
public function clientArea($params)
|
public function clientArea($params)
|
||||||
{
|
{
|
||||||
$serverHostname = null;
|
$serverHostname = null;
|
||||||
@@ -532,6 +748,7 @@ class ModuleFunctions extends Module
|
|||||||
'serviceStatus' => $params['status'],
|
'serviceStatus' => $params['status'],
|
||||||
'serverHostname' => $serverHostname,
|
'serverHostname' => $serverHostname,
|
||||||
'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
|
'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
|
||||||
|
'rdnsEnabled' => PowerDns\Config::isEnabled(),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
341
modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
Normal file
341
modules/servers/VirtFusionDirect/lib/PowerDns/Client.php
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||||
|
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Curl;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin HTTP wrapper around the PowerDNS Authoritative HTTP API.
|
||||||
|
*
|
||||||
|
* WHY A SEPARATE CLIENT INSTEAD OF REUSING MODULE::INITCURL()
|
||||||
|
* -----------------------------------------------------------
|
||||||
|
* Module::initCurl() is hardcoded to Bearer auth for VirtFusion. PowerDNS uses
|
||||||
|
* X-API-Key, and mixing the two authorization styles inside one factory method
|
||||||
|
* would either require a new flag (leaky abstraction) or accidental leakage of
|
||||||
|
* the VirtFusion token into a PowerDNS request. A dedicated wrapper keeps the
|
||||||
|
* two credential flows completely isolated — a bug in PowerDNS handling can
|
||||||
|
* never leak a VirtFusion token, and vice versa.
|
||||||
|
*
|
||||||
|
* LOGGING RULES
|
||||||
|
* -------------
|
||||||
|
* We NEVER pass the API key or any header containing it to Log::insert().
|
||||||
|
* PATCH/NOTIFY calls log the zone+operation+HTTP code, successes log minimally,
|
||||||
|
* errors include up to 500 bytes of response body (PowerDNS error responses are
|
||||||
|
* small JSON fragments, not customer data). The Curl class doesn't capture
|
||||||
|
* request headers by default (CURLOPT_HEADER is off), so even the internal
|
||||||
|
* request_header field doesn't contain the API key.
|
||||||
|
*
|
||||||
|
* CACHING
|
||||||
|
* -------
|
||||||
|
* listZones() caches the zone list via the module's Cache class (Redis/filesystem)
|
||||||
|
* for Config::cacheTtl() seconds. Zone lists rarely change — the TTL balances
|
||||||
|
* "pick up a newly-created zone soon" against "don't hammer PowerDNS for every
|
||||||
|
* listZones call across unrelated lifecycle events".
|
||||||
|
*
|
||||||
|
* getZone() and patchRRset() are NOT cached here; per-request memoisation of
|
||||||
|
* getZone results lives in PtrManager::getZoneCached so it can invalidate on
|
||||||
|
* write from within the same request.
|
||||||
|
*
|
||||||
|
* SINGLE-USE CURL INSTANCES
|
||||||
|
* -------------------------
|
||||||
|
* newCurl() returns a fresh Curl for every HTTP call. That's how the existing
|
||||||
|
* module's Curl class is designed — reusing a handle across requests produces
|
||||||
|
* undefined behaviour because options from the first call bleed into the second.
|
||||||
|
* It's cheap (curl_init is microseconds).
|
||||||
|
*/
|
||||||
|
class Client
|
||||||
|
{
|
||||||
|
/** @var string */
|
||||||
|
private $endpoint;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
private $apiKey;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
private $serverId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed>|null $config Optional pre-resolved config; defaults to PowerDns\Config::get()
|
||||||
|
*/
|
||||||
|
public function __construct(?array $config = null)
|
||||||
|
{
|
||||||
|
$config = $config ?? Config::get();
|
||||||
|
$this->endpoint = rtrim((string) ($config['endpoint'] ?? ''), '/');
|
||||||
|
$this->apiKey = (string) ($config['apiKey'] ?? '');
|
||||||
|
$this->serverId = (string) ($config['serverId'] ?? 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base URL for the configured PowerDNS server (no trailing slash). */
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return $this->endpoint . '/api/v1/servers/' . rawurlencode($this->serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a zone name to its PowerDNS URL-safe id form.
|
||||||
|
*
|
||||||
|
* PowerDNS's API uses a custom URL encoding for zone names that have characters
|
||||||
|
* like "/" which would collide with path semantics. Instead of using %-encoding
|
||||||
|
* (which many HTTP frameworks would parse back out at routing time), PowerDNS
|
||||||
|
* uses "=HH" where HH is the hex code — so "/" becomes "=2F".
|
||||||
|
*
|
||||||
|
* This only matters for RFC 2317 classless-delegation zone names like
|
||||||
|
* "64/64.113.0.203.in-addr.arpa." whose zone id in the API is
|
||||||
|
* "64=2F64.113.0.203.in-addr.arpa.". Standard zones pass through unchanged
|
||||||
|
* because they contain no "/" characters.
|
||||||
|
*
|
||||||
|
* Using rawurlencode() here would produce "%2F" which PowerDNS does NOT accept.
|
||||||
|
* That's why this is a plain str_replace.
|
||||||
|
*/
|
||||||
|
private function zoneIdEncode(string $zoneName): string
|
||||||
|
{
|
||||||
|
return str_replace('/', '=2F', rtrim($zoneName, '.') . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh Curl instance with PowerDNS auth + JSON headers. */
|
||||||
|
private function newCurl(): Curl
|
||||||
|
{
|
||||||
|
$curl = new Curl;
|
||||||
|
$curl->addOption(CURLOPT_HTTPHEADER, [
|
||||||
|
'Accept: application/json',
|
||||||
|
'Content-Type: application/json; charset=utf-8',
|
||||||
|
'X-API-Key: ' . $this->apiKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $curl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Healthcheck. Returns [ok: bool, http: int, error: ?string].
|
||||||
|
* Used by the addon's Test Connection button and by VirtFusionDirect_TestConnection().
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, http: int, error: ?string}
|
||||||
|
*/
|
||||||
|
public function ping(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$curl = $this->newCurl();
|
||||||
|
$body = $curl->get($this->base());
|
||||||
|
$http = (int) $curl->getRequestInfo('http_code');
|
||||||
|
if ($http === 200) {
|
||||||
|
return ['ok' => true, 'http' => 200, 'error' => null];
|
||||||
|
}
|
||||||
|
if ($http === 0) {
|
||||||
|
$err = (string) ($curl->getRequestInfo('curl_error') ?: 'connection failed');
|
||||||
|
|
||||||
|
return ['ok' => false, 'http' => 0, 'error' => $err];
|
||||||
|
}
|
||||||
|
if ($http === 401 || $http === 403) {
|
||||||
|
// 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)];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:ping', [], $e->getMessage());
|
||||||
|
|
||||||
|
return ['ok' => false, 'http' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List every zone on the configured PowerDNS server.
|
||||||
|
*
|
||||||
|
* Result is cached for the configured cacheTtl. Used as the primary zone-discovery
|
||||||
|
* strategy: PtrManager finds the containing zone for a PTR name by longest-suffix
|
||||||
|
* matching against this list rather than probing individual zones.
|
||||||
|
*
|
||||||
|
* @return string[] Zone names with trailing dot
|
||||||
|
*/
|
||||||
|
public function listZones(): array
|
||||||
|
{
|
||||||
|
$ttl = Config::cacheTtl();
|
||||||
|
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
|
||||||
|
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zones = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$curl = $this->newCurl();
|
||||||
|
$body = $curl->get($this->base() . '/zones');
|
||||||
|
$http = (int) $curl->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
if ($http === 200) {
|
||||||
|
$decoded = json_decode((string) $body, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
foreach ($decoded as $z) {
|
||||||
|
if (! empty($z['name'])) {
|
||||||
|
$zones[] = rtrim((string) $z['name'], '.') . '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::insert('PowerDns:listZones', ['http' => $http], substr((string) $body, 0, 500));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:listZones', [], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::set($cacheKey, $zones, $ttl);
|
||||||
|
|
||||||
|
return $zones;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop any cached zone list (call after PATCHes or settings changes). */
|
||||||
|
public function forgetZoneCache(): void
|
||||||
|
{
|
||||||
|
$cacheKey = 'pdns:zones:' . md5($this->endpoint . '|' . $this->serverId);
|
||||||
|
Cache::forget($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single zone by name. Returns decoded JSON array, or null on 404/error.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function getZone(string $zoneName): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$zoneName = rtrim($zoneName, '.') . '.';
|
||||||
|
$curl = $this->newCurl();
|
||||||
|
$body = $curl->get($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
|
||||||
|
$http = (int) $curl->getRequestInfo('http_code');
|
||||||
|
if ($http === 200) {
|
||||||
|
$decoded = json_decode((string) $body, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
if ($http !== 404) {
|
||||||
|
Log::insert('PowerDns:getZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 500));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:getZone', ['zone' => $zoneName], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an RRset change to a zone via PATCH.
|
||||||
|
*
|
||||||
|
* $rrset keys (per PowerDNS API): name, type, ttl?, changetype (REPLACE|DELETE|EXTEND), records[].
|
||||||
|
* On success PowerDNS returns 204 No Content.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, http: int, body: string}
|
||||||
|
*/
|
||||||
|
public function patchRRset(string $zoneName, array $rrset): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$zoneName = rtrim($zoneName, '.') . '.';
|
||||||
|
if (isset($rrset['name'])) {
|
||||||
|
$rrset['name'] = rtrim((string) $rrset['name'], '.') . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = ['rrsets' => [$rrset]];
|
||||||
|
$curl = $this->newCurl();
|
||||||
|
$curl->addOption(CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
$body = $curl->patch($this->base() . '/zones/' . $this->zoneIdEncode($zoneName));
|
||||||
|
$http = (int) $curl->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
Log::insert(
|
||||||
|
'PowerDns:patchRRset',
|
||||||
|
[
|
||||||
|
'zone' => $zoneName,
|
||||||
|
'name' => $rrset['name'] ?? null,
|
||||||
|
'type' => $rrset['type'] ?? null,
|
||||||
|
'changetype' => $rrset['changetype'] ?? null,
|
||||||
|
],
|
||||||
|
['http' => $http, 'body' => substr((string) $body, 0, 500)],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($http === 204) {
|
||||||
|
// Fire-and-forget NOTIFY so slaves pick up the bumped SOA serial immediately.
|
||||||
|
//
|
||||||
|
// Background: PowerDNS auto-increments SOA on every API write when the zone
|
||||||
|
// has soa_edit_api=INCREASE (recommended; see README). Slaves normally learn
|
||||||
|
// about the new serial via polling at the refresh interval (often 15+ min)
|
||||||
|
// OR via a NOTIFY push from the master. Without our NOTIFY, rDNS changes
|
||||||
|
// made via this module would take effect on the authoritative master
|
||||||
|
// immediately but wouldn't propagate until the next scheduled poll.
|
||||||
|
//
|
||||||
|
// Only meaningful for Master-kind zones. For Native zones (no slaves) or
|
||||||
|
// Slave zones (reverse direction), PowerDNS returns a 422 or similar —
|
||||||
|
// notifyZone() logs that and returns ok=false, but we don't care here:
|
||||||
|
// the PATCH itself succeeded, which is what we report upward.
|
||||||
|
$this->notifyZone($zoneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => $http === 204, 'http' => $http, 'body' => (string) $body];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:patchRRset', ['zone' => $zoneName], $e->getMessage());
|
||||||
|
|
||||||
|
return ['ok' => false, 'http' => 0, 'body' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a DNS NOTIFY to all slaves for this zone. Only applicable to Master-kind zones;
|
||||||
|
* PowerDNS returns 400/422 for Native/Slave kinds and that's fine — we log and continue.
|
||||||
|
*
|
||||||
|
* SOA serial bumping itself is handled by PowerDNS (soa_edit_api=INCREASE or similar
|
||||||
|
* on the zone); this call just ensures slaves learn about the new serial right away
|
||||||
|
* rather than waiting for the next scheduled refresh.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, http: int}
|
||||||
|
*/
|
||||||
|
public function notifyZone(string $zoneName): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$zoneName = rtrim($zoneName, '.') . '.';
|
||||||
|
$curl = $this->newCurl();
|
||||||
|
$body = $curl->put($this->base() . '/zones/' . $this->zoneIdEncode($zoneName) . '/notify');
|
||||||
|
$http = (int) $curl->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
if ($http !== 200) {
|
||||||
|
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName, 'http' => $http], substr((string) $body, 0, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => $http === 200, 'http' => $http];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:notifyZone', ['zone' => $zoneName], $e->getMessage());
|
||||||
|
|
||||||
|
return ['ok' => false, 'http' => 0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
Normal file
212
modules/servers/VirtFusionDirect/lib/PowerDns/Config.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||||
|
|
||||||
|
use WHMCS\Database\Capsule as DB;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads PowerDNS addon settings from tbladdonmodules (module="virtfusiondns") and
|
||||||
|
* decrypts the API key using WHMCS's native decrypt() helper.
|
||||||
|
*
|
||||||
|
* WHY "LOOSE COUPLING" VIA TBLADDONMODULES
|
||||||
|
* ----------------------------------------
|
||||||
|
* WHMCS lets an operator activate/deactivate addon modules independently of server
|
||||||
|
* modules. If the server module required addon PHP code at load time (e.g. via
|
||||||
|
* require_once on the addon's files), deactivating the addon would fatal-error every
|
||||||
|
* checkout and service page.
|
||||||
|
*
|
||||||
|
* Instead, the server module reads raw rows from tbladdonmodules. If the addon is
|
||||||
|
* missing OR deactivated OR "enabled" is set to No, isEnabled() returns false and
|
||||||
|
* every PtrManager call site short-circuits. The server module never dereferences
|
||||||
|
* addon code directly; it just asks the DB "what are the PowerDNS settings?" and
|
||||||
|
* does nothing with them if they're absent.
|
||||||
|
*
|
||||||
|
* REQUEST-SCOPED CACHE
|
||||||
|
* --------------------
|
||||||
|
* get() caches the resolved config in a static property for the remainder of the
|
||||||
|
* PHP request. Without that, every PtrManager call would re-query tbladdonmodules
|
||||||
|
* and re-decrypt the API key — wasteful on the provisioning path where we touch
|
||||||
|
* PowerDNS 1-5 times per server. reset() is exposed for scenarios where settings
|
||||||
|
* change mid-request (the addon's _output() page after a vfdns_test click).
|
||||||
|
*
|
||||||
|
* API KEY HANDLING
|
||||||
|
* ----------------
|
||||||
|
* WHMCS stores password-type addon config fields encrypted in tbladdonmodules.value.
|
||||||
|
* We call decrypt() — the same helper the server-module uses for the VirtFusion
|
||||||
|
* bearer token — to get plaintext. If decryption fails (e.g. the WHMCS encryption
|
||||||
|
* key changed or the value was inserted manually as plaintext), we fall back to
|
||||||
|
* using the raw value. This is defensive; logs note the failure so an operator
|
||||||
|
* can diagnose.
|
||||||
|
*
|
||||||
|
* The decrypted key exists only in memory inside this process's request lifetime.
|
||||||
|
* It's passed to PowerDns\Client via the get() array and used for the X-API-Key
|
||||||
|
* header; it's never written to disk, logged, or sent anywhere except to the
|
||||||
|
* configured PowerDNS endpoint.
|
||||||
|
*/
|
||||||
|
class Config
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Name used for this addon in modules/addons/ AND stored in tbladdonmodules.module.
|
||||||
|
* These two MUST match — WHMCS auto-lowercases the module directory name when
|
||||||
|
* writing to the DB, so "VirtFusionDns" (directory) becomes "virtfusiondns" here.
|
||||||
|
*/
|
||||||
|
public const MODULE_NAME = 'virtfusiondns';
|
||||||
|
|
||||||
|
/** @var array<string,mixed>|null Null = not loaded yet; an array = resolved settings */
|
||||||
|
private static $cached = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a reload on next get().
|
||||||
|
*
|
||||||
|
* Primary use case: the addon's _output() page calls this before re-fetching
|
||||||
|
* config so a test-connection click after saving settings sees the saved values.
|
||||||
|
* Most other code should NOT call this — the request-scoped cache is there for
|
||||||
|
* good performance reasons.
|
||||||
|
*/
|
||||||
|
public static function reset(): void
|
||||||
|
{
|
||||||
|
self::$cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the fully-resolved configuration array with decrypted apiKey.
|
||||||
|
*
|
||||||
|
* Keys: enabled(bool), endpoint(string), apiKey(string), serverId(string),
|
||||||
|
* defaultTtl(int), cacheTtl(int).
|
||||||
|
*/
|
||||||
|
public static function get(): array
|
||||||
|
{
|
||||||
|
if (self::$cached !== null) {
|
||||||
|
return self::$cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'enabled' => false,
|
||||||
|
'endpoint' => '',
|
||||||
|
'apiKey' => '',
|
||||||
|
'serverId' => 'localhost',
|
||||||
|
'defaultTtl' => 3600,
|
||||||
|
'cacheTtl' => 60,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// pluck('value', 'setting') returns a Collection keyed by 'setting' with
|
||||||
|
// 'value' as the values — so $rows['enabled'] reads the row where
|
||||||
|
// setting='enabled'. Efficient: one query regardless of how many
|
||||||
|
// settings exist.
|
||||||
|
$rows = DB::table('tbladdonmodules')
|
||||||
|
->where('module', self::MODULE_NAME)
|
||||||
|
->pluck('value', 'setting')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// WHMCS yesno fields store either "on"/"" or "1"/"0" depending on version
|
||||||
|
// and form handling. Accept all common truthy representations rather than
|
||||||
|
// relying on a single literal.
|
||||||
|
$enabledRaw = $rows['enabled'] ?? '';
|
||||||
|
$config['enabled'] = in_array(strtolower((string) $enabledRaw), ['on', 'yes', '1', 'true'], true);
|
||||||
|
|
||||||
|
// Trim trailing slash from endpoint so Client::base() can safely concatenate
|
||||||
|
// "/api/v1/..." without producing doubled slashes.
|
||||||
|
$config['endpoint'] = rtrim((string) ($rows['endpoint'] ?? ''), '/');
|
||||||
|
$config['serverId'] = (string) ($rows['serverId'] ?? 'localhost');
|
||||||
|
|
||||||
|
// Floor at 60s for defaultTtl and 10s for cacheTtl. Prevents a foot-gun
|
||||||
|
// where an operator accidentally saves "0" and causes PowerDNS to treat
|
||||||
|
// PTRs as non-cacheable (which some resolvers refuse) or this module to
|
||||||
|
// hammer PowerDNS on every call.
|
||||||
|
$config['defaultTtl'] = max(60, (int) ($rows['defaultTtl'] ?? 3600));
|
||||||
|
$config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60));
|
||||||
|
|
||||||
|
if (! empty($rows['apiKey'])) {
|
||||||
|
$raw = (string) $rows['apiKey'];
|
||||||
|
$decrypted = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// decrypt() is WHMCS's global helper — matches how the VirtFusion
|
||||||
|
// bearer token is handled in Module::getCP().
|
||||||
|
$decrypted = (string) decrypt($raw);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Even when decrypt throws, we try the raw value so a diagnostic
|
||||||
|
// path exists. Operator sees the decrypt error in the module log
|
||||||
|
// but isn't locked out of using the addon while they investigate.
|
||||||
|
Log::insert('PowerDns:Config', 'decrypt threw', $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) {
|
||||||
|
// Any DB-level failure (table doesn't exist, connection dropped, etc.)
|
||||||
|
// leaves $config at its safe defaults — isEnabled() returns false,
|
||||||
|
// nothing gets written to PowerDNS, and the server module continues
|
||||||
|
// to provision as if the addon weren't installed.
|
||||||
|
Log::insert('PowerDns:Config', 'load failed', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$cached = $config;
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only when the addon is activated, configured, and has both endpoint and key. */
|
||||||
|
public static function isEnabled(): bool
|
||||||
|
{
|
||||||
|
$c = self::get();
|
||||||
|
|
||||||
|
return $c['enabled'] && $c['endpoint'] !== '' && $c['apiKey'] !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function endpoint(): string
|
||||||
|
{
|
||||||
|
return self::get()['endpoint'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function apiKey(): string
|
||||||
|
{
|
||||||
|
return self::get()['apiKey'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function serverId(): string
|
||||||
|
{
|
||||||
|
return self::get()['serverId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function defaultTtl(): int
|
||||||
|
{
|
||||||
|
return self::get()['defaultTtl'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cacheTtl(): int
|
||||||
|
{
|
||||||
|
return self::get()['cacheTtl'];
|
||||||
|
}
|
||||||
|
}
|
||||||
499
modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
Normal file
499
modules/servers/VirtFusionDirect/lib/PowerDns/IpUtil.php
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure static helpers for IP address manipulation and PTR-name construction.
|
||||||
|
*
|
||||||
|
* DESIGN NOTES
|
||||||
|
* ------------
|
||||||
|
* Everything here is pure — no I/O, no globals, no state. That matters for two reasons:
|
||||||
|
* 1. PtrManager can compose these helpers freely without worrying about test isolation.
|
||||||
|
* 2. They are safe to call inside tight loops (e.g. iterating every zone in PowerDNS
|
||||||
|
* and testing it against a PTR name) without triggering hidden network or DB hits.
|
||||||
|
*
|
||||||
|
* Naming conventions used here:
|
||||||
|
* - "PTR name" = the fully-qualified record name the PTR lives at,
|
||||||
|
* e.g. "5.113.0.203.in-addr.arpa." (trailing dot always).
|
||||||
|
* - "zone name" = the zone the record belongs to,
|
||||||
|
* e.g. "113.0.203.in-addr.arpa." (trailing dot always).
|
||||||
|
* - "nibble" = a single hex digit representing 4 bits, used in IPv6 reverse names.
|
||||||
|
* - "classless" = an RFC 2317 sub-zone like "64/64.113.0.203.in-addr.arpa." —
|
||||||
|
* a delegation of a sub-range of a /24, covered in parseClasslessZone().
|
||||||
|
*
|
||||||
|
* All zone/PTR strings are normalised with a trailing dot because PowerDNS's canonical
|
||||||
|
* form always carries one, and mixing dotted/undotted forms makes string comparison
|
||||||
|
* unreliable (".example.com." ≠ ".example.com").
|
||||||
|
*/
|
||||||
|
class IpUtil
|
||||||
|
{
|
||||||
|
/** Strict IPv4 validation (rejects "1", "::1", and other ambiguous forms). */
|
||||||
|
public static function isIpv4(string $ip): bool
|
||||||
|
{
|
||||||
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict IPv6 validation (rejects IPv4-mapped, etc. — only pure v6 addresses). */
|
||||||
|
public static function isIpv6(string $ip): bool
|
||||||
|
{
|
||||||
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully-expand an IPv6 address to 32 lowercase hex characters (no colons).
|
||||||
|
* e.g. 2001:db8::1 -> "20010db8000000000000000000000001"
|
||||||
|
*
|
||||||
|
* Why: PTR names under ip6.arpa use *all* 32 nibbles (no compression, no :: shorthand),
|
||||||
|
* so we need the fully-expanded form before we can reverse the nibbles.
|
||||||
|
*
|
||||||
|
* Implementation: inet_pton normalises any valid IPv6 notation to 16 raw bytes,
|
||||||
|
* and bin2hex turns that into 32 lowercase hex chars. No manual padding/splitting
|
||||||
|
* logic means we can't get ":" vs "::" compression wrong.
|
||||||
|
*
|
||||||
|
* @return string|null 32-char hex string, or null if input isn't valid IPv6
|
||||||
|
*/
|
||||||
|
public static function expandIpv6(string $ip): ?string
|
||||||
|
{
|
||||||
|
$bin = @inet_pton($ip);
|
||||||
|
// inet_pton returns 16 bytes for v6, 4 bytes for v4. Guard on both conditions
|
||||||
|
// so a valid IPv4 like "192.0.2.1" doesn't silently pass through this v6 helper.
|
||||||
|
if ($bin === false || strlen($bin) !== 16) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bin2hex($bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the fully-qualified PTR name (trailing dot) for an IPv4 or IPv6 address.
|
||||||
|
*
|
||||||
|
* IPv4 example: 203.0.113.5 -> "5.113.0.203.in-addr.arpa."
|
||||||
|
* IPv6 example: 2001:db8::1 -> "1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa."
|
||||||
|
*
|
||||||
|
* @return string|null PTR name with trailing dot, or null if input isn't a valid IP
|
||||||
|
*/
|
||||||
|
public static function ptrNameForIp(string $ip): ?string
|
||||||
|
{
|
||||||
|
// IPv4: reverse the four octets and suffix with in-addr.arpa.
|
||||||
|
// 203.0.113.5 -> 5.113.0.203.in-addr.arpa.
|
||||||
|
if (self::isIpv4($ip)) {
|
||||||
|
$octets = array_reverse(explode('.', $ip));
|
||||||
|
|
||||||
|
return implode('.', $octets) . '.in-addr.arpa.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6: expand to 32 nibbles, reverse each nibble, suffix with ip6.arpa.
|
||||||
|
// 2001:db8::1 -> 1.0.0.0.[...].8.b.d.0.1.0.0.2.ip6.arpa.
|
||||||
|
// The nibble-level reversal (not byte-level) is important: each hex digit
|
||||||
|
// becomes its own DNS label. inet_pton/bin2hex give us the 32-char form;
|
||||||
|
// str_split with no length arg defaults to 1 so each char becomes one label.
|
||||||
|
if (self::isIpv6($ip)) {
|
||||||
|
$hex = self::expandIpv6($ip);
|
||||||
|
if ($hex === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$nibbles = array_reverse(str_split($hex));
|
||||||
|
|
||||||
|
return implode('.', $nibbles) . '.ip6.arpa.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract every IP address and IPv6 subnet from a VirtFusion server object.
|
||||||
|
*
|
||||||
|
* Walks every interface, not just interfaces[0] (ServerResource only reads the primary).
|
||||||
|
* Returns three buckets:
|
||||||
|
*
|
||||||
|
* addresses — discrete host IPs (v4 always, v6 when the API exposes per-host records
|
||||||
|
* or a /128 subnet entry). Each entry is a plain IP string.
|
||||||
|
*
|
||||||
|
* subnets — IPv6 subnet allocations (e.g. 2001:db8:0:5d::/64) where the module
|
||||||
|
* cannot auto-discover individual host addresses. These are surfaced
|
||||||
|
* so the client UI can show "here's your /64" and offer an "Add host PTR"
|
||||||
|
* path where the customer types a specific address inside the subnet.
|
||||||
|
* Each entry: ['subnet' => '2001:db8:0:5d::', 'cidr' => 64].
|
||||||
|
*
|
||||||
|
* skipped — malformed / unusable entries (non-IP, missing cidr, etc.) kept for
|
||||||
|
* logging so we can diagnose schema drift in the VirtFusion API.
|
||||||
|
*
|
||||||
|
* @param object|array $serverObject Raw VirtFusion server payload (may be wrapped in `data`)
|
||||||
|
* @return array{addresses: string[], subnets: array<int, array{subnet: string, cidr: int}>, skipped: array}
|
||||||
|
*/
|
||||||
|
public static function extractIps($serverObject): array
|
||||||
|
{
|
||||||
|
$addresses = [];
|
||||||
|
$subnets = [];
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
// Normalise object-or-array input. json_decode(json_encode($x), true) is the
|
||||||
|
// cheapest defensive way to turn a stdClass tree (VirtFusion's response) or
|
||||||
|
// an already-decoded array (stored server_object blob) into a uniform array.
|
||||||
|
if (is_object($serverObject)) {
|
||||||
|
$serverObject = json_decode(json_encode($serverObject), true);
|
||||||
|
}
|
||||||
|
if (! is_array($serverObject)) {
|
||||||
|
return ['addresses' => [], 'subnets' => [], 'skipped' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
// VirtFusion wraps the payload in a "data" key on GET responses but the stored
|
||||||
|
// server_object blob is sometimes already unwrapped. Accept both shapes.
|
||||||
|
$data = $serverObject['data'] ?? $serverObject;
|
||||||
|
$interfaces = $data['network']['interfaces'] ?? [];
|
||||||
|
if (! is_array($interfaces)) {
|
||||||
|
return ['addresses' => [], 'subnets' => [], 'skipped' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk every interface (not just interfaces[0]). ServerResource only reads [0]
|
||||||
|
// because it's building display data for the "primary" IP; rDNS needs PTRs
|
||||||
|
// for every IP no matter which interface it lives on.
|
||||||
|
foreach ($interfaces as $iface) {
|
||||||
|
foreach (($iface['ipv4'] ?? []) as $v4) {
|
||||||
|
// Accept both "address" and "ip" field names — VirtFusion's schema
|
||||||
|
// has evolved and we want the module to survive minor shape changes.
|
||||||
|
$candidate = $v4['address'] ?? ($v4['ip'] ?? null);
|
||||||
|
if ($candidate && self::isIpv4($candidate)) {
|
||||||
|
// Use the IP as an array key for free de-duplication. If the same
|
||||||
|
// IP appears on two interfaces (unusual but possible), we write
|
||||||
|
// one PTR not two.
|
||||||
|
$addresses[$candidate] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (($iface['ipv6'] ?? []) as $v6) {
|
||||||
|
// Preferred shape: a discrete host address (the normal v6 pattern).
|
||||||
|
$candidate = $v6['address'] ?? ($v6['ip'] ?? null);
|
||||||
|
if ($candidate && self::isIpv6($candidate)) {
|
||||||
|
$addresses[$candidate] = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subnet-with-cidr shape. VirtFusion's common v6 allocation model is
|
||||||
|
// to route a whole /64 to the VPS and let the OS auto-assign specific
|
||||||
|
// host addresses. The module can't know which host the customer
|
||||||
|
// actually uses, so we surface the subnet as a first-class entry and
|
||||||
|
// let the client UI offer an "Add host PTR" path with containment
|
||||||
|
// ownership verification.
|
||||||
|
$subnet = $v6['subnet'] ?? null;
|
||||||
|
$cidr = isset($v6['cidr']) ? (int) $v6['cidr'] : null;
|
||||||
|
if ($subnet && self::isIpv6($subnet) && $cidr !== null) {
|
||||||
|
if ($cidr === 128) {
|
||||||
|
// Single-host "subnet" — treat as a discrete address.
|
||||||
|
$addresses[$subnet] = true;
|
||||||
|
} elseif ($cidr > 0 && $cidr < 128) {
|
||||||
|
// Genuine subnet allocation. Dedupe by (subnet, cidr) pair.
|
||||||
|
$key = $subnet . '/' . $cidr;
|
||||||
|
if (! isset($subnets[$key])) {
|
||||||
|
$subnets[$key] = ['subnet' => $subnet, 'cidr' => $cidr];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$skipped[] = ['subnet' => $subnet, 'cidr' => $cidr, 'reason' => 'invalid-cidr'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'addresses' => array_keys($addresses),
|
||||||
|
'subnets' => array_values($subnets),
|
||||||
|
'skipped' => $skipped,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if $ip falls inside the subnet $prefix/$cidrBits.
|
||||||
|
*
|
||||||
|
* Used for subnet-containment ownership checks when the customer wants to set
|
||||||
|
* a PTR for a specific host address inside an IPv6 subnet allocated to their
|
||||||
|
* VPS — we can't enumerate their assigned hosts, but we CAN prove the address
|
||||||
|
* they're claiming lies within one of their subnets.
|
||||||
|
*
|
||||||
|
* Works on the binary (inet_pton) representation so v6 notation differences
|
||||||
|
* (compression, case) don't affect the comparison.
|
||||||
|
*
|
||||||
|
* ALGORITHM
|
||||||
|
* ---------
|
||||||
|
* 1. Convert both IPs to 16 raw bytes via inet_pton (or 4 for v4).
|
||||||
|
* 2. Compare the first floor(cidr/8) bytes byte-wise (full-byte prefix).
|
||||||
|
* 3. If cidr isn't a multiple of 8, mask the next byte and compare bits.
|
||||||
|
*
|
||||||
|
* Example: 2001:db8::5 vs 2001:db8::/32
|
||||||
|
* fullBytes = 32/8 = 4; first 4 bytes of both are 20:01:0d:b8 → match
|
||||||
|
* remBits = 0 → no partial byte to compare
|
||||||
|
* → true
|
||||||
|
*/
|
||||||
|
public static function ipv6InSubnet(string $ip, string $subnetPrefix, int $cidrBits): bool
|
||||||
|
{
|
||||||
|
if (! self::isIpv6($ip) || ! self::isIpv6($subnetPrefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($cidrBits < 0 || $cidrBits > 128) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$ipBin = @inet_pton($ip);
|
||||||
|
$subBin = @inet_pton($subnetPrefix);
|
||||||
|
if ($ipBin === false || $subBin === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullBytes = intdiv($cidrBits, 8);
|
||||||
|
$remBits = $cidrBits % 8;
|
||||||
|
|
||||||
|
// Compare whole-byte prefix with a single substr compare.
|
||||||
|
if ($fullBytes > 0 && substr($ipBin, 0, $fullBytes) !== substr($subBin, 0, $fullBytes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the partial byte at the cidr boundary, if any.
|
||||||
|
if ($remBits > 0) {
|
||||||
|
$mask = (0xFF << (8 - $remBits)) & 0xFF;
|
||||||
|
if ((ord($ipBin[$fullBytes]) & $mask) !== (ord($subBin[$fullBytes]) & $mask)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the longest-suffix zone from a list of zone names that contains a given PTR name.
|
||||||
|
* Both inputs are normalised to a trailing dot before matching.
|
||||||
|
*
|
||||||
|
* @param string $ptrName Fully-qualified PTR name (with or without trailing dot)
|
||||||
|
* @param string[] $zones List of zone names from PowerDNS (with or without trailing dots)
|
||||||
|
* @return string|null Matching zone name with trailing dot, or null if no zone covers the PTR
|
||||||
|
*/
|
||||||
|
public static function findContainingZone(string $ptrName, array $zones): ?string
|
||||||
|
{
|
||||||
|
$ptrName = rtrim($ptrName, '.') . '.';
|
||||||
|
$best = null;
|
||||||
|
$bestLen = 0;
|
||||||
|
|
||||||
|
foreach ($zones as $zone) {
|
||||||
|
if (! is_string($zone) || $zone === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strpos($zone, '/') !== false) {
|
||||||
|
// RFC 2317 classless zones can't be identified by plain suffix match:
|
||||||
|
// a PTR like "5.113.0.203.in-addr.arpa." does NOT end with
|
||||||
|
// ".64/64.113.0.203.in-addr.arpa." even when 5 is in range. Range
|
||||||
|
// matching lives in findZoneAndPtrName; this helper is kept for any
|
||||||
|
// caller that only deals with standard zones.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$z = rtrim($zone, '.') . '.';
|
||||||
|
// Prefix with "." so a zone "example.com." doesn't accidentally match
|
||||||
|
// "foo.anotherexample.com." via naive substring compare.
|
||||||
|
$suffix = '.' . $z;
|
||||||
|
if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) {
|
||||||
|
// Longest match wins. For nested delegations (e.g. both
|
||||||
|
// "0.203.in-addr.arpa." and "113.0.203.in-addr.arpa." exist),
|
||||||
|
// the more specific one is the correct authoritative zone.
|
||||||
|
$len = strlen($z);
|
||||||
|
if ($len > $bestLen) {
|
||||||
|
$best = $z;
|
||||||
|
$bestLen = $len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an RFC 2317 classless-delegation IPv4 reverse zone name.
|
||||||
|
*
|
||||||
|
* RFC 2317 lets a /24 owner delegate sub-ranges of that /24 to separate
|
||||||
|
* authoritative servers by creating CNAMEs in the parent zone that point
|
||||||
|
* into a named sub-zone. The sub-zone's label conventionally uses "X/Y"
|
||||||
|
* where the slash carries structural meaning, not path semantics.
|
||||||
|
*
|
||||||
|
* Two "Y" conventions exist in the wild. We accept both:
|
||||||
|
*
|
||||||
|
* (a) Y is a CIDR prefix length, Y ∈ [24, 32]. Standard per the RFC.
|
||||||
|
* "64/26.113.0.203.in-addr.arpa." — /26 → 64 addresses → covers 64..127
|
||||||
|
* "0/25.1.168.192.in-addr.arpa." — /25 → 128 addresses → covers 0..127
|
||||||
|
*
|
||||||
|
* (b) Y is a block size (count of addresses), Y > 32. Non-standard but
|
||||||
|
* used by some operators because the label reads naturally:
|
||||||
|
* "64/64.113.0.203.in-addr.arpa." — size 64 → covers 64..127
|
||||||
|
*
|
||||||
|
* We disambiguate by Y's magnitude: ≤32 is a prefix length, >32 is a count.
|
||||||
|
* (Y=32 would be "a single-host delegation", valid under convention (a).)
|
||||||
|
*
|
||||||
|
* ALIGNMENT CHECK
|
||||||
|
* ---------------
|
||||||
|
* We also verify X is a multiple of the block size. Misaligned entries
|
||||||
|
* like "3/26.x.y.z" don't correspond to any real DNS delegation — a /26
|
||||||
|
* must start at a multiple of 64 (0, 64, 128, or 192). Rejecting these
|
||||||
|
* prevents silent write-into-wrong-zone if an operator mis-names a zone.
|
||||||
|
*
|
||||||
|
* @return array{parent: string, start: int, end: int}|null
|
||||||
|
* parent: parent /24 reverse zone name with trailing dot (e.g. "113.0.203.in-addr.arpa.")
|
||||||
|
* start/end: inclusive last-octet range covered by this classless zone
|
||||||
|
*/
|
||||||
|
public static function parseClasslessZone(string $zone): ?array
|
||||||
|
{
|
||||||
|
$zone = rtrim($zone, '.') . '.';
|
||||||
|
|
||||||
|
// Structural gate 1: must end in .in-addr.arpa. — classless only applies to IPv4.
|
||||||
|
if (substr($zone, -strlen('.in-addr.arpa.')) !== '.in-addr.arpa.') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural gate 2: must have at least 5 labels to contain both the
|
||||||
|
// classless label and a full /24 parent: "X/Y . o . o . o . in-addr . arpa . ''"
|
||||||
|
// The trailing empty label from the terminal dot bumps this to ≥ 7 in practice,
|
||||||
|
// but 5 is the minimum we need to safely slice below.
|
||||||
|
$labels = explode('.', $zone);
|
||||||
|
if (count($labels) < 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural gate 3: the first label must contain a "/". If not, this is a
|
||||||
|
// standard zone (e.g. "113.0.203.in-addr.arpa.") — let the caller handle it.
|
||||||
|
$first = $labels[0];
|
||||||
|
if (strpos($first, '/') === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "X/Y" — reject if either side isn't a non-negative integer.
|
||||||
|
$parts = explode('/', $first, 2);
|
||||||
|
if (count($parts) !== 2 || ! ctype_digit($parts[0]) || ! ctype_digit($parts[1])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$x = (int) $parts[0];
|
||||||
|
$y = (int) $parts[1];
|
||||||
|
|
||||||
|
// X must fit in an octet; Y must be positive (0 and negative make no sense).
|
||||||
|
if ($x < 0 || $x > 255 || $y <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Y → block size using the dual-convention rule described in the doc-block.
|
||||||
|
if ($y <= 32) {
|
||||||
|
// CIDR prefix convention. Values <24 would cross /24 boundaries (outside
|
||||||
|
// the scope of a single-/24 delegation), >32 is impossible for IPv4.
|
||||||
|
if ($y < 24 || $y > 32) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 1 << (32 - Y) gives the block size. Y=24→256 (whole /24), Y=32→1 (host).
|
||||||
|
$size = 1 << (32 - $y);
|
||||||
|
} else {
|
||||||
|
// Block-size convention. Accept any positive Y that fits the /24 range check below.
|
||||||
|
$size = $y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment: X must sit on a block boundary. For size=64, legal starts are
|
||||||
|
// 0, 64, 128, 192. Mis-alignments indicate a misconfigured zone label.
|
||||||
|
if ($x % $size !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $x + $size - 1;
|
||||||
|
// The range must stay within the parent /24 (last octet 0..255).
|
||||||
|
if ($end > 255) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parent zone is everything after the first label, i.e. the /24 reverse zone.
|
||||||
|
// array_slice(labels, 1) drops "X/Y" and the implode reconstructs the trailing-dot form.
|
||||||
|
$parent = implode('.', array_slice($labels, 1));
|
||||||
|
|
||||||
|
return ['parent' => $parent, 'start' => $x, 'end' => $end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an IP to its (zone, ptrName) pair in one shot, handling both standard
|
||||||
|
* reverse zones and RFC 2317 classless delegations.
|
||||||
|
*
|
||||||
|
* For a classless match, the returned ptrName includes the classless zone
|
||||||
|
* label (e.g. "100.64/64.113.0.203.in-addr.arpa.") — this is the actual DNS
|
||||||
|
* name the PTR record lives at in PowerDNS. Classless zones take precedence
|
||||||
|
* over any matching parent zone, because in a properly-delegated setup the
|
||||||
|
* parent only holds CNAMEs pointing into the classless sub-zone.
|
||||||
|
*
|
||||||
|
* @param string[] $zones Zone names from PowerDNS (trailing dots optional)
|
||||||
|
* @return array{zone: string, ptrName: string}|null
|
||||||
|
*/
|
||||||
|
public static function findZoneAndPtrName(string $ip, array $zones): ?array
|
||||||
|
{
|
||||||
|
$ptrName = self::ptrNameForIp($ip);
|
||||||
|
if ($ptrName === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipv4 = self::isIpv4($ip);
|
||||||
|
// Extract the last octet up front for classless range comparison.
|
||||||
|
// Only meaningful for IPv4 since RFC 2317 is IPv4-only (IPv6 delegations
|
||||||
|
// naturally align on nibble boundaries and don't need classless tricks).
|
||||||
|
$lastOctet = null;
|
||||||
|
if ($ipv4) {
|
||||||
|
$octets = explode('.', $ip);
|
||||||
|
$lastOctet = (int) $octets[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bestDirect = null;
|
||||||
|
$bestDirectLen = 0;
|
||||||
|
$classlessMatch = null;
|
||||||
|
|
||||||
|
// Single pass over the zone list, bucketing each candidate into the
|
||||||
|
// classless path or the direct-suffix-match path.
|
||||||
|
foreach ($zones as $zone) {
|
||||||
|
if (! is_string($zone) || $zone === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$z = rtrim($zone, '.') . '.';
|
||||||
|
|
||||||
|
if (strpos($z, '/') !== false) {
|
||||||
|
// Classless path. Skip for IPv6 entirely.
|
||||||
|
if (! $ipv4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parsed = self::parseClasslessZone($z);
|
||||||
|
if ($parsed === null) {
|
||||||
|
// Malformed classless zone name (misaligned, wrong TLD, etc.) — skip.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The PTR still needs to suffix-match the PARENT zone; otherwise the
|
||||||
|
// classless zone lives under a different /24 and isn't relevant.
|
||||||
|
$parentSuffix = '.' . $parsed['parent'];
|
||||||
|
if (substr($ptrName, -strlen($parentSuffix)) !== $parentSuffix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Range gate: the host octet must fall inside this classless zone's window.
|
||||||
|
if ($lastOctet < $parsed['start'] || $lastOctet > $parsed['end']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The record name inside a classless zone prepends the full host octet
|
||||||
|
// to the classless label, e.g. PTR "100" lives at:
|
||||||
|
// "100.64/64.113.0.203.in-addr.arpa."
|
||||||
|
// (NOT "100.113.0.203.in-addr.arpa." — the classless sub-zone holds the RRset).
|
||||||
|
$classlessMatch = [
|
||||||
|
'zone' => $z,
|
||||||
|
'ptrName' => $lastOctet . '.' . $z,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct suffix-match path (standard reverse zones).
|
||||||
|
$suffix = '.' . $z;
|
||||||
|
if ($ptrName === $z || substr($ptrName, -strlen($suffix)) === $suffix) {
|
||||||
|
// Longest-match wins (see findContainingZone() for rationale).
|
||||||
|
if (strlen($z) > $bestDirectLen) {
|
||||||
|
$bestDirect = ['zone' => $z, 'ptrName' => $ptrName];
|
||||||
|
$bestDirectLen = strlen($z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRECEDENCE: classless beats direct. In a correctly-delegated RFC 2317 setup
|
||||||
|
// the parent /24 zone only contains CNAMEs pointing into the classless sub-zone —
|
||||||
|
// it does NOT hold the PTR RRset directly. Writing to the parent would create a
|
||||||
|
// record that's shadowed by the CNAME and never consulted during resolution.
|
||||||
|
return $classlessMatch ?? $bestDirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
732
modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
Normal file
732
modules/servers/VirtFusionDirect/lib/PowerDns/PtrManager.php
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||||
|
|
||||||
|
use WHMCS\Database\Capsule as DB;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates PTR lifecycle against PowerDNS for VirtFusion servers.
|
||||||
|
*
|
||||||
|
* RESPONSIBILITIES
|
||||||
|
* ----------------
|
||||||
|
* - Compute zone membership for a given IP by matching against PowerDNS's zone list
|
||||||
|
* - Verify forward DNS (A/AAAA) before writing any PTR; never write a PTR whose
|
||||||
|
* hostname doesn't already resolve to the target IP
|
||||||
|
* - Preserve client-customised PTRs during server renames (only overwrite PTRs
|
||||||
|
* whose current content equals the previous hostname)
|
||||||
|
* - Provide read-through views for client-area and admin panels with status flags
|
||||||
|
* - Support an explicit admin reconcile (optionally forceful) and an additive-only
|
||||||
|
* cron reconciliation that never overwrites existing values
|
||||||
|
*
|
||||||
|
* CACHING MODEL
|
||||||
|
* -------------
|
||||||
|
* Two tiers, both serving different purposes:
|
||||||
|
*
|
||||||
|
* $zoneListCache — the list of every zone PowerDNS knows about. Populated once
|
||||||
|
* per PtrManager instance via locate(). The underlying Client
|
||||||
|
* caches the HTTP response for Config::cacheTtl() seconds across
|
||||||
|
* requests; this instance field just memoises the lookup within
|
||||||
|
* one request so multiple IPs on the same server don't each
|
||||||
|
* call Client::listZones().
|
||||||
|
*
|
||||||
|
* $zoneCache — decoded RRset contents of individual zones, keyed by zone
|
||||||
|
* name. Populated lazily as findPtrRRset() looks up each IP's
|
||||||
|
* zone. IMPORTANT: request-scoped only — we must invalidate on
|
||||||
|
* writes (see invalidateZone) so a read-after-write within the
|
||||||
|
* same request sees fresh data. This is why deletePtr/writePtr
|
||||||
|
* call invalidateZone before returning.
|
||||||
|
*
|
||||||
|
* Neither cache is shared between PtrManager instances (new PtrManager per WHMCS
|
||||||
|
* request is cheap). The Client's HTTP-response cache IS shared across requests via
|
||||||
|
* the module's Cache class (Redis or filesystem), which is where cross-request
|
||||||
|
* amortisation happens.
|
||||||
|
*
|
||||||
|
* SHORT-CIRCUIT BEHAVIOUR
|
||||||
|
* -----------------------
|
||||||
|
* Every public method checks Config::isEnabled() and returns an empty/no-op summary
|
||||||
|
* when the addon is inactive. This means unrelated calling code (createAccount,
|
||||||
|
* terminateAccount, renameServer, the client panel endpoint, cron) can always
|
||||||
|
* invoke PtrManager without a feature flag — the gate lives here.
|
||||||
|
*
|
||||||
|
* The summary arrays deliberately include 'enabled' => bool so test harnesses and
|
||||||
|
* admin UIs can tell "we did nothing because disabled" apart from "we did nothing
|
||||||
|
* because there were no IPs".
|
||||||
|
*/
|
||||||
|
class PtrManager
|
||||||
|
{
|
||||||
|
/** @var Client */
|
||||||
|
private $client;
|
||||||
|
|
||||||
|
/** @var array<string, array<string,mixed>|null> Request-scoped zone contents cache, keyed by zone name */
|
||||||
|
private $zoneCache = [];
|
||||||
|
|
||||||
|
/** @var string[]|null Request-scoped zone-list memo (Client handles cross-request caching) */
|
||||||
|
private $zoneListCache = null;
|
||||||
|
|
||||||
|
public function __construct(?Client $client = null)
|
||||||
|
{
|
||||||
|
// Dependency-inject the Client so tests can pass a mock; default to the
|
||||||
|
// Config-driven instance so production code never has to wire this up.
|
||||||
|
$this->client = $client ?? new Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync PTRs for every IP on the given server object.
|
||||||
|
*
|
||||||
|
* TWO MODES OF OPERATION
|
||||||
|
* ----------------------
|
||||||
|
* CREATE ($oldHostname = null) — provisioning path.
|
||||||
|
* Write $newHostname to every IP that doesn't
|
||||||
|
* already have a PTR. Pre-existing PTRs are
|
||||||
|
* preserved (shouldn't exist on a new server,
|
||||||
|
* but if they do they're likely left over from
|
||||||
|
* a previous owner of the IP and must not be
|
||||||
|
* silently overwritten).
|
||||||
|
*
|
||||||
|
* RENAME ($oldHostname given) — rename path.
|
||||||
|
* Only overwrite PTRs whose current content
|
||||||
|
* equals $oldHostname. Anything else was set
|
||||||
|
* by the client (custom rDNS like mail servers
|
||||||
|
* need to match HELO) and must be preserved.
|
||||||
|
*
|
||||||
|
* The forward-DNS check runs before every write. A PTR without a matching
|
||||||
|
* A/AAAA is FCrDNS-broken and actively harms deliverability, so we'd rather
|
||||||
|
* leave the PTR absent than set a broken one.
|
||||||
|
*
|
||||||
|
* ERROR SEMANTICS
|
||||||
|
* ---------------
|
||||||
|
* This method never throws. Every per-IP failure is caught, logged, and
|
||||||
|
* recorded in $summary['errors']. Lifecycle callers (createAccount,
|
||||||
|
* renameServer) wrap the call in their own try/catch as belt-and-braces,
|
||||||
|
* but the expectation is that DNS issues never bubble up to WHMCS as
|
||||||
|
* provisioning failures.
|
||||||
|
*
|
||||||
|
* @param object|array $serverObject VirtFusion server payload
|
||||||
|
* @return array Summary counts: written, preserved, forward_missing, no_zone, skipped_ipv6, errors, details[]
|
||||||
|
*/
|
||||||
|
public function syncServer($serverObject, ?string $oldHostname, string $newHostname): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'enabled' => false,
|
||||||
|
'written' => 0,
|
||||||
|
'preserved' => 0,
|
||||||
|
'forward_missing' => 0,
|
||||||
|
'no_zone' => 0,
|
||||||
|
'skipped_ipv6' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
'details' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! Config::isEnabled()) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
$summary['enabled'] = true;
|
||||||
|
|
||||||
|
$extracted = IpUtil::extractIps($serverObject);
|
||||||
|
// Report (not write) v6 subnet-only allocations. UI can surface "IPv6 PTR
|
||||||
|
// not configured — /64 without explicit host" as guidance.
|
||||||
|
$summary['skipped_ipv6'] = count($extracted['skipped']);
|
||||||
|
|
||||||
|
foreach ($extracted['addresses'] as $ip) {
|
||||||
|
try {
|
||||||
|
$loc = $this->locate($ip);
|
||||||
|
if ($loc === null) {
|
||||||
|
// IP isn't covered by any zone we host. Not an error — the
|
||||||
|
// operator may manage reverse DNS for this range elsewhere.
|
||||||
|
$summary['no_zone']++;
|
||||||
|
$summary['details'][] = ['ip' => $ip, 'status' => 'no-zone'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $this->readPtr($loc);
|
||||||
|
|
||||||
|
// Rename-mode preservation check. The "current PTR equals old
|
||||||
|
// hostname" comparison is the whole safety mechanism for protecting
|
||||||
|
// client-custom rDNS across server renames — see class docblock.
|
||||||
|
// On CREATE mode ($oldHostname === null) we skip this branch,
|
||||||
|
// which means pre-existing PTRs on a new IP get overwritten; this
|
||||||
|
// is acceptable because a fresh IP shouldn't have PTRs yet.
|
||||||
|
if ($oldHostname !== null && $current !== null) {
|
||||||
|
if (self::normalizeHost($current) !== self::normalizeHost($oldHostname)) {
|
||||||
|
$summary['preserved']++;
|
||||||
|
$summary['details'][] = ['ip' => $ip, 'status' => 'preserved', 'current' => $current];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Resolver::resolvesTo($newHostname, $ip, Config::cacheTtl())) {
|
||||||
|
$summary['forward_missing']++;
|
||||||
|
$summary['details'][] = ['ip' => $ip, 'status' => 'forward-missing', 'desired' => $newHostname];
|
||||||
|
Log::insert('PowerDns:syncServer', ['ip' => $ip, 'hostname' => $newHostname], 'forward DNS mismatch; PTR skipped');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->writePtr($loc, $newHostname);
|
||||||
|
if ($result['ok']) {
|
||||||
|
$summary['written']++;
|
||||||
|
$summary['details'][] = ['ip' => $ip, 'status' => 'written', 'content' => $newHostname];
|
||||||
|
} else {
|
||||||
|
$summary['errors']++;
|
||||||
|
$summary['details'][] = ['ip' => $ip, 'status' => 'error', 'http' => $result['http']];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$summary['errors']++;
|
||||||
|
Log::insert('PowerDns:syncServer', ['ip' => $ip], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every PTR belonging to the given server.
|
||||||
|
*
|
||||||
|
* @return array Summary counts: deleted, no_zone, errors
|
||||||
|
*/
|
||||||
|
public function deleteForServer($serverObject): array
|
||||||
|
{
|
||||||
|
$summary = ['enabled' => false, 'deleted' => 0, 'no_zone' => 0, 'errors' => 0];
|
||||||
|
if (! Config::isEnabled()) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
$summary['enabled'] = true;
|
||||||
|
|
||||||
|
$extracted = IpUtil::extractIps($serverObject);
|
||||||
|
foreach ($extracted['addresses'] as $ip) {
|
||||||
|
try {
|
||||||
|
$loc = $this->locate($ip);
|
||||||
|
if ($loc === null) {
|
||||||
|
$summary['no_zone']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = $this->deletePtr($loc);
|
||||||
|
if ($result['ok']) {
|
||||||
|
$summary['deleted']++;
|
||||||
|
} else {
|
||||||
|
$summary['errors']++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$summary['errors']++;
|
||||||
|
Log::insert('PowerDns:deleteForServer', ['ip' => $ip], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a per-IP status list suitable for client-area and admin display.
|
||||||
|
*
|
||||||
|
* Each entry: [ip, ptr, ttl, zone, status]
|
||||||
|
* Status values: ok, unverified, missing, no-zone, error, disabled.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function listPtrs($serverObject, ?string $expectedHostname = null): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$extracted = IpUtil::extractIps($serverObject);
|
||||||
|
|
||||||
|
if (! Config::isEnabled()) {
|
||||||
|
foreach ($extracted['addresses'] as $ip) {
|
||||||
|
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'disabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subnet-only rows come first so the client UI can render "you have a /64,
|
||||||
|
// here's how to add a host PTR inside it" above the discrete-IP list.
|
||||||
|
// These carry no PTR content themselves — they're informational anchors
|
||||||
|
// plus the "Add custom host" entry point.
|
||||||
|
foreach ($extracted['subnets'] as $s) {
|
||||||
|
$out[] = [
|
||||||
|
'ip' => null,
|
||||||
|
'subnet' => $s['subnet'],
|
||||||
|
'cidr' => $s['cidr'],
|
||||||
|
'ptr' => null,
|
||||||
|
'ttl' => null,
|
||||||
|
'zone' => null,
|
||||||
|
'status' => 'subnet-only',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($extracted['addresses'] as $ip) {
|
||||||
|
try {
|
||||||
|
$loc = $this->locate($ip);
|
||||||
|
if ($loc === null) {
|
||||||
|
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'no-zone'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rrset = $this->findPtrRRset($loc);
|
||||||
|
if ($rrset === null) {
|
||||||
|
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => $loc['zone'], 'status' => 'missing'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ptr = $rrset['content'];
|
||||||
|
$status = Resolver::resolvesTo($ptr, $ip, Config::cacheTtl()) ? 'ok' : 'unverified';
|
||||||
|
$out[] = [
|
||||||
|
'ip' => $ip,
|
||||||
|
'ptr' => $ptr,
|
||||||
|
'ttl' => $rrset['ttl'],
|
||||||
|
'zone' => $loc['zone'],
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:listPtrs', ['ip' => $ip], $e->getMessage());
|
||||||
|
$out[] = ['ip' => $ip, 'ptr' => null, 'ttl' => null, 'zone' => null, 'status' => 'error'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-initiated PTR set/delete.
|
||||||
|
*
|
||||||
|
* Differences from syncServer():
|
||||||
|
* - Only ever writes one PTR, not a whole server's worth
|
||||||
|
* - Rate-limited per IP (10s window) to stop save-button abuse
|
||||||
|
* - Forward-DNS failure is a HARD REJECT that surfaces to the user — not a
|
||||||
|
* silent skip like the automatic paths. The client wants immediate feedback
|
||||||
|
* when their A record is missing.
|
||||||
|
* - Empty content path is an explicit delete (DELETE changetype, not REPLACE-empty)
|
||||||
|
*
|
||||||
|
* IP-OWNERSHIP NOTE
|
||||||
|
* -----------------
|
||||||
|
* This method TRUSTS that the caller has already verified the client owns $ip —
|
||||||
|
* that check lives in the calling endpoint (client.php rdnsUpdate) where it has
|
||||||
|
* access to the WHMCS session. If you call setPtr() from a new code path, you
|
||||||
|
* MUST add the ownership guard upstream of it.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, reason: string, http?: int}
|
||||||
|
* reason values: disabled, invalid-ip, rate-limited, no-zone,
|
||||||
|
* forward-missing, deleted, delete-failed, written, write-failed
|
||||||
|
*/
|
||||||
|
public function setPtr(string $ip, string $content): array
|
||||||
|
{
|
||||||
|
if (! Config::isEnabled()) {
|
||||||
|
return ['ok' => false, 'reason' => 'disabled'];
|
||||||
|
}
|
||||||
|
if (! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
|
||||||
|
return ['ok' => false, 'reason' => 'invalid-ip'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: one successful check per IP per 10s. Uses the module's
|
||||||
|
// two-tier Cache (Redis or filesystem), so the limit spans PHP processes.
|
||||||
|
// md5 of IP as the key keeps filesystem filenames short and safe.
|
||||||
|
$rateKey = 'pdns:write-lock:' . md5($ip);
|
||||||
|
if (Cache::get($rateKey) !== null) {
|
||||||
|
return ['ok' => false, 'reason' => 'rate-limited'];
|
||||||
|
}
|
||||||
|
// Set the lock BEFORE any downstream work so a parallel request racing
|
||||||
|
// through the same IP sees the lock and gets rate-limited cleanly.
|
||||||
|
Cache::set($rateKey, 1, 10);
|
||||||
|
|
||||||
|
$loc = $this->locate($ip);
|
||||||
|
if ($loc === null) {
|
||||||
|
return ['ok' => false, 'reason' => 'no-zone'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = trim($content);
|
||||||
|
if ($content === '') {
|
||||||
|
$result = $this->deletePtr($loc);
|
||||||
|
|
||||||
|
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'deleted' : 'delete-failed', 'http' => $result['http']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Resolver::resolvesTo($content, $ip, Config::cacheTtl())) {
|
||||||
|
return ['ok' => false, 'reason' => 'forward-missing'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->writePtr($loc, $content);
|
||||||
|
|
||||||
|
return ['ok' => $result['ok'], 'reason' => $result['ok'] ? 'written' : 'write-failed', 'http' => $result['http']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin reconciliation for a single service.
|
||||||
|
*
|
||||||
|
* The user-facing purpose: "make the PTRs match what they should be, but don't
|
||||||
|
* step on client customisations unless I explicitly ask".
|
||||||
|
*
|
||||||
|
* Uses the STORED server_object (from mod_virtfusion_direct) rather than fetching
|
||||||
|
* fresh from VirtFusion. Reasons:
|
||||||
|
* 1. Admin reconcile runs from the services tab — no live-data dependency
|
||||||
|
* 2. Cron calls this once per service; fetching fresh would mean N VirtFusion
|
||||||
|
* calls per reconcile run
|
||||||
|
* 3. The stored object is the ground truth for "what IPs/hostname did this
|
||||||
|
* service have at last sync" — if VirtFusion temporarily returns a different
|
||||||
|
* shape, we'd rather work from known-good data than retry.
|
||||||
|
*
|
||||||
|
* If the stored state is materially out of date (e.g. IPs were added in VirtFusion
|
||||||
|
* after last sync), an admin should hit "Update Server Object" first.
|
||||||
|
*
|
||||||
|
* FORCE MODE
|
||||||
|
* ----------
|
||||||
|
* $force = true is the only code path in the entire module that overwrites a
|
||||||
|
* non-matching PTR. It's reachable exclusively via the admin "Reconcile (force
|
||||||
|
* reset)" button — never from cron, never from client writes, never from
|
||||||
|
* automatic lifecycle. This asymmetry is deliberate: forceful overrides are
|
||||||
|
* the admin's explicit choice, not a silent automation.
|
||||||
|
*
|
||||||
|
* @return array Summary counts: added, reset, preserved, forward_missing, no_zone, errors
|
||||||
|
*/
|
||||||
|
public function reconcile(int $serviceId, bool $force = false): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'enabled' => false,
|
||||||
|
'added' => 0,
|
||||||
|
'reset' => 0,
|
||||||
|
'preserved' => 0,
|
||||||
|
'forward_missing' => 0,
|
||||||
|
'no_zone' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
if (! Config::isEnabled()) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
$summary['enabled'] = true;
|
||||||
|
|
||||||
|
$row = Database::getSystemService($serviceId);
|
||||||
|
if (! $row || empty($row->server_object)) {
|
||||||
|
$summary['errors']++;
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
$serverObject = json_decode($row->server_object, true);
|
||||||
|
if (! is_array($serverObject)) {
|
||||||
|
$summary['errors']++;
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = self::extractHostname($serverObject);
|
||||||
|
if ($hostname === null) {
|
||||||
|
$summary['errors']++;
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extracted = IpUtil::extractIps($serverObject);
|
||||||
|
foreach ($extracted['addresses'] as $ip) {
|
||||||
|
try {
|
||||||
|
$loc = $this->locate($ip);
|
||||||
|
if ($loc === null) {
|
||||||
|
$summary['no_zone']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $this->readPtr($loc);
|
||||||
|
$verified = Resolver::resolvesTo($hostname, $ip, Config::cacheTtl());
|
||||||
|
|
||||||
|
if ($current === null) {
|
||||||
|
if (! $verified) {
|
||||||
|
$summary['forward_missing']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = $this->writePtr($loc, $hostname);
|
||||||
|
if ($result['ok']) {
|
||||||
|
$summary['added']++;
|
||||||
|
} else {
|
||||||
|
$summary['errors']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($force && self::normalizeHost($current) !== self::normalizeHost($hostname)) {
|
||||||
|
if (! $verified) {
|
||||||
|
$summary['forward_missing']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = $this->writePtr($loc, $hostname);
|
||||||
|
if ($result['ok']) {
|
||||||
|
$summary['reset']++;
|
||||||
|
} else {
|
||||||
|
$summary['errors']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['preserved']++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$summary['errors']++;
|
||||||
|
Log::insert('PowerDns:reconcile', ['ip' => $ip, 'service' => $serviceId], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron reconciliation across every managed service.
|
||||||
|
*
|
||||||
|
* Called from the DailyCronJob hook. Iterates every row in mod_virtfusion_direct
|
||||||
|
* and runs reconcile() on each with $force = false. That means:
|
||||||
|
*
|
||||||
|
* - IPs missing a PTR get one (if forward DNS resolves)
|
||||||
|
* - Existing PTRs are NEVER touched, even if they differ from the hostname
|
||||||
|
*
|
||||||
|
* This asymmetry is the safety property. A brief forward-DNS blip during the
|
||||||
|
* cron window shouldn't trigger mass-rewrites that corrupt client-custom
|
||||||
|
* records. Admins who need forceful re-alignment must run the per-service
|
||||||
|
* "Reconcile (force reset)" button explicitly.
|
||||||
|
*
|
||||||
|
* Failures on individual services are logged and counted but never abort the
|
||||||
|
* job — a misconfigured single zone or one VirtFusion-unreachable service
|
||||||
|
* should not block reconciliation for the rest of the fleet.
|
||||||
|
*
|
||||||
|
* @return array Aggregate summary across all services
|
||||||
|
*/
|
||||||
|
public function reconcileAll(): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'enabled' => false,
|
||||||
|
'services' => 0,
|
||||||
|
'added' => 0,
|
||||||
|
'preserved' => 0,
|
||||||
|
'forward_missing' => 0,
|
||||||
|
'no_zone' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
if (! Config::isEnabled()) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
$summary['enabled'] = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rows = DB::table(Database::SYSTEM_TABLE)->pluck('service_id');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('PowerDns:reconcileAll', [], $e->getMessage());
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $serviceId) {
|
||||||
|
$summary['services']++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$r = $this->reconcile((int) $serviceId, false);
|
||||||
|
$summary['added'] += $r['added'];
|
||||||
|
$summary['preserved'] += $r['preserved'];
|
||||||
|
$summary['forward_missing'] += $r['forward_missing'];
|
||||||
|
$summary['no_zone'] += $r['no_zone'];
|
||||||
|
$summary['errors'] += $r['errors'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$summary['errors']++;
|
||||||
|
Log::insert('PowerDns:reconcileAll:service', ['service' => $serviceId], $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::insert('PowerDns:reconcileAll', [], $summary);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an IP to the (zone, ptrName) pair using the cached zone list.
|
||||||
|
* Handles both standard and RFC 2317 classless zones (delegates to IpUtil).
|
||||||
|
*
|
||||||
|
* Memoised within this instance: the zone list is fetched once (via the Client,
|
||||||
|
* which itself caches across requests per Config::cacheTtl()) and reused for
|
||||||
|
* every IP of the current server. A server with 3 IPs in the same /24 therefore
|
||||||
|
* triggers ONE listZones call, not three.
|
||||||
|
*
|
||||||
|
* @return array{zone: string, ptrName: string}|null null means "no zone covers this IP"
|
||||||
|
*/
|
||||||
|
private function locate(string $ip): ?array
|
||||||
|
{
|
||||||
|
if ($this->zoneListCache === null) {
|
||||||
|
$this->zoneListCache = $this->client->listZones();
|
||||||
|
}
|
||||||
|
|
||||||
|
return IpUtil::findZoneAndPtrName($ip, $this->zoneListCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function getZoneCached(string $zoneName): ?array
|
||||||
|
{
|
||||||
|
if (array_key_exists($zoneName, $this->zoneCache)) {
|
||||||
|
return $this->zoneCache[$zoneName];
|
||||||
|
}
|
||||||
|
$this->zoneCache[$zoneName] = $this->client->getZone($zoneName);
|
||||||
|
|
||||||
|
return $this->zoneCache[$zoneName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current PTR content for a located address, or null if absent.
|
||||||
|
*
|
||||||
|
* @param array{zone: string, ptrName: string} $loc
|
||||||
|
*/
|
||||||
|
private function readPtr(array $loc): ?string
|
||||||
|
{
|
||||||
|
$rrset = $this->findPtrRRset($loc);
|
||||||
|
|
||||||
|
return $rrset === null ? null : $rrset['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a PTR RRset at the located name.
|
||||||
|
*
|
||||||
|
* @param array{zone: string, ptrName: string} $loc
|
||||||
|
* @return array{content: string, ttl: int}|null
|
||||||
|
*/
|
||||||
|
private function findPtrRRset(array $loc): ?array
|
||||||
|
{
|
||||||
|
$zone = $this->getZoneCached($loc['zone']);
|
||||||
|
if ($zone === null || empty($zone['rrsets']) || ! is_array($zone['rrsets'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($zone['rrsets'] as $rrset) {
|
||||||
|
if (($rrset['type'] ?? '') !== 'PTR') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (self::normalizeHost($rrset['name'] ?? '') !== self::normalizeHost($loc['ptrName'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$records = $rrset['records'] ?? [];
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! empty($record['disabled'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! empty($record['content'])) {
|
||||||
|
return [
|
||||||
|
'content' => rtrim((string) $record['content'], '.'),
|
||||||
|
'ttl' => (int) ($rrset['ttl'] ?? Config::defaultTtl()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write/replace a PTR record.
|
||||||
|
*
|
||||||
|
* Always uses REPLACE changetype rather than a create-then-update pattern —
|
||||||
|
* REPLACE is idempotent and atomic from PowerDNS's view, whereas separate
|
||||||
|
* create + update would briefly leave the record absent.
|
||||||
|
*
|
||||||
|
* Content is canonicalised to end with a trailing dot before sending (PowerDNS
|
||||||
|
* treats unqualified names as relative to the zone, which is not what we want
|
||||||
|
* for PTR content — "host.example.com" without a trailing dot would be stored
|
||||||
|
* as "host.example.com.113.0.203.in-addr.arpa.").
|
||||||
|
*
|
||||||
|
* @param array{zone: string, ptrName: string} $loc
|
||||||
|
* @return array{ok: bool, http: int}
|
||||||
|
*/
|
||||||
|
private function writePtr(array $loc, string $content): array
|
||||||
|
{
|
||||||
|
$content = rtrim(trim($content), '.') . '.';
|
||||||
|
$ttl = Config::defaultTtl();
|
||||||
|
|
||||||
|
$result = $this->client->patchRRset($loc['zone'], [
|
||||||
|
'name' => $loc['ptrName'],
|
||||||
|
'type' => 'PTR',
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'changetype' => 'REPLACE',
|
||||||
|
'records' => [['content' => $content, 'disabled' => false]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invalidateZone($loc['zone']);
|
||||||
|
|
||||||
|
return ['ok' => $result['ok'], 'http' => $result['http']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a PTR record.
|
||||||
|
*
|
||||||
|
* @param array{zone: string, ptrName: string} $loc
|
||||||
|
* @return array{ok: bool, http: int}
|
||||||
|
*/
|
||||||
|
private function deletePtr(array $loc): array
|
||||||
|
{
|
||||||
|
$result = $this->client->patchRRset($loc['zone'], [
|
||||||
|
'name' => $loc['ptrName'],
|
||||||
|
'type' => 'PTR',
|
||||||
|
'changetype' => 'DELETE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invalidateZone($loc['zone']);
|
||||||
|
|
||||||
|
return ['ok' => $result['ok'], 'http' => $result['http']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the cached zone contents so the next read re-fetches from PowerDNS.
|
||||||
|
* Called after every successful write so read-after-write in the same request
|
||||||
|
* (e.g. listPtrs right after setPtr in a test harness) observes fresh data.
|
||||||
|
*/
|
||||||
|
private function invalidateZone(string $zoneName): void
|
||||||
|
{
|
||||||
|
unset($this->zoneCache[$zoneName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a hostname for comparison: lowercase, no trailing dot.
|
||||||
|
*
|
||||||
|
* DNS hostnames are case-insensitive and the trailing dot is syntactic, not
|
||||||
|
* semantic. PowerDNS returns content with a trailing dot ("host.example.com.");
|
||||||
|
* user input typically doesn't have one. Both forms of "FooBar.example.com."
|
||||||
|
* vs "foobar.example.com" should compare equal, which is what this produces.
|
||||||
|
*/
|
||||||
|
private static function normalizeHost(string $h): string
|
||||||
|
{
|
||||||
|
return strtolower(rtrim(trim($h), '.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the server hostname from a VirtFusion server payload.
|
||||||
|
*
|
||||||
|
* Accepts either object or array shape, wrapped or unwrapped by a `data` property.
|
||||||
|
* Falls back to `name` when `hostname` is absent or "-", matching the semantics
|
||||||
|
* of the existing ServerResource::process() behavior.
|
||||||
|
*
|
||||||
|
* Public so lifecycle call sites (createAccount, renameServer) can pull the
|
||||||
|
* hostname from a response or stored JSON blob without duplicating the logic.
|
||||||
|
*
|
||||||
|
* @param object|array $serverObject
|
||||||
|
*/
|
||||||
|
public static function extractHostname($serverObject): ?string
|
||||||
|
{
|
||||||
|
if (is_object($serverObject)) {
|
||||||
|
$serverObject = json_decode(json_encode($serverObject), true);
|
||||||
|
}
|
||||||
|
if (! is_array($serverObject)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = $serverObject['data'] ?? $serverObject;
|
||||||
|
if (! empty($data['hostname']) && $data['hostname'] !== '-') {
|
||||||
|
return (string) $data['hostname'];
|
||||||
|
}
|
||||||
|
if (! empty($data['name']) && $data['name'] !== '-') {
|
||||||
|
return (string) $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
Normal file
140
modules/servers/VirtFusionDirect/lib/PowerDns/Resolver.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect\PowerDns;
|
||||||
|
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-DNS verification helper used for forward-confirmed reverse DNS (FCrDNS) checks.
|
||||||
|
*
|
||||||
|
* WHAT FCrDNS IS AND WHY IT MATTERS HERE
|
||||||
|
* --------------------------------------
|
||||||
|
* A PTR record by itself is easy to lie about — anyone who controls a reverse zone
|
||||||
|
* can say "this IP is mail.example.com". Receivers defend against that by looking
|
||||||
|
* UP the hostname the PTR claims and checking that its A/AAAA records point back
|
||||||
|
* at the IP. That "two-way agreement" is FCrDNS.
|
||||||
|
*
|
||||||
|
* For mail deliverability in particular, a PTR without matching forward DNS is
|
||||||
|
* worse than no PTR at all — some filters treat it as evidence of a compromised
|
||||||
|
* host. The module enforces FCrDNS before every PTR write: if the user asks us
|
||||||
|
* to set "mail.example.com" as the PTR for 1.2.3.4 but mail.example.com resolves
|
||||||
|
* to something other than 1.2.3.4, we refuse.
|
||||||
|
*
|
||||||
|
* USES PUBLIC DNS, NOT POWERDNS
|
||||||
|
* -----------------------------
|
||||||
|
* This calls dns_get_record(), which hits the system's configured recursive
|
||||||
|
* resolver. That's deliberate: the hostname in a PTR may live in a zone hosted
|
||||||
|
* anywhere (client's own domain, another DNS provider, etc.) — not necessarily
|
||||||
|
* in the PowerDNS instance we're managing. Using the recursive public view means
|
||||||
|
* our verification matches what mail servers and other FCrDNS checkers actually
|
||||||
|
* see downstream.
|
||||||
|
*
|
||||||
|
* CNAME FOLLOWING
|
||||||
|
* ---------------
|
||||||
|
* If the hostname is itself a CNAME, dns_get_record returns the CNAME record
|
||||||
|
* (with DNS_CNAME flag) rather than auto-resolving to the ultimate A/AAAA. We
|
||||||
|
* follow up to MAX_CNAME_DEPTH hops before giving up. The depth cap prevents
|
||||||
|
* accidental infinite loops from misconfigured zones and bounds work per check.
|
||||||
|
*
|
||||||
|
* CACHING
|
||||||
|
* -------
|
||||||
|
* Keyed by md5(hostname|ip). A bad-A-record result lives in the cache just like
|
||||||
|
* a good one, which means a client who fixes their forward DNS must wait up to
|
||||||
|
* cacheTtl seconds before a retry succeeds. Documented in the admin settings
|
||||||
|
* tooltip as the tradeoff for not hammering authoritative resolvers when a
|
||||||
|
* user mashes the Save button while debugging.
|
||||||
|
*/
|
||||||
|
class Resolver
|
||||||
|
{
|
||||||
|
private const CACHE_PREFIX = 'pdns:resolve:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum hops through a CNAME chain before we give up.
|
||||||
|
* Real-world chains are usually 0-2 hops; 5 is generous headroom without
|
||||||
|
* letting a loop run unbounded.
|
||||||
|
*/
|
||||||
|
private const MAX_CNAME_DEPTH = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the public DNS A/AAAA of $hostname resolve to $ip?
|
||||||
|
* Follows up to 5 CNAME hops. Cached for $ttl seconds on the initial call.
|
||||||
|
*/
|
||||||
|
public static function resolvesTo(string $hostname, string $ip, int $ttl = 60): bool
|
||||||
|
{
|
||||||
|
$hostname = rtrim(trim($hostname), '.');
|
||||||
|
if ($hostname === '' || ! (IpUtil::isIpv4($ip) || IpUtil::isIpv6($ip))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = self::CACHE_PREFIX . md5($hostname . '|' . $ip);
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return (bool) $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = self::resolveInternal($hostname, $ip, 0);
|
||||||
|
Cache::set($cacheKey, $match ? 1 : 0, $ttl);
|
||||||
|
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveInternal(string $hostname, string $ip, int $depth): bool
|
||||||
|
{
|
||||||
|
if ($depth > self::MAX_CNAME_DEPTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request both the matching forward type AND CNAME in one query so we see
|
||||||
|
// the whole picture at each hop. If the hostname is a direct A/AAAA, we
|
||||||
|
// see that and match immediately; if it's a CNAME, we see the target and
|
||||||
|
// recurse.
|
||||||
|
$type = IpUtil::isIpv6($ip) ? DNS_AAAA | DNS_CNAME : DNS_A | DNS_CNAME;
|
||||||
|
$records = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @-suppress: dns_get_record emits a PHP warning on NXDOMAIN, which we'd
|
||||||
|
// rather just treat as "no match". The return value (empty array or false)
|
||||||
|
// tells us the same thing without polluting the error log.
|
||||||
|
$records = @dns_get_record($hostname, $type);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Some PHP configurations throw on resolver failure instead of returning false.
|
||||||
|
// We treat those as "no match" and log once per (hostname, ip) since callers
|
||||||
|
// cache the result — we won't spam the log even for a permanently-broken name.
|
||||||
|
Log::insert('PowerDns:Resolver', ['hostname' => $hostname, 'ip' => $ip], $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (! is_array($records)) {
|
||||||
|
// dns_get_record returns false on resolver failure. Same semantics as above.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert target to binary once, outside the loop. inet_pton normalises
|
||||||
|
// "2001:db8::1" and "2001:0db8:0000:0000:0000:0000:0000:0001" to the same
|
||||||
|
// bytes, so we can compare regardless of how the resolver formatted its reply.
|
||||||
|
$targetBin = @inet_pton($ip);
|
||||||
|
foreach ($records as $r) {
|
||||||
|
$t = $r['type'] ?? null;
|
||||||
|
if ($t === 'CNAME') {
|
||||||
|
// CNAME hop: recurse on the target. We don't use a visited-set to
|
||||||
|
// detect cycles — MAX_CNAME_DEPTH is a simpler, sufficient guard.
|
||||||
|
$next = $r['target'] ?? null;
|
||||||
|
if ($next && self::resolveInternal(rtrim($next, '.'), $ip, $depth + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A records expose the address under 'ip', AAAA records under 'ipv6'.
|
||||||
|
// Only one of these will be set per record; the other is null.
|
||||||
|
$candidate = $r['ip'] ?? ($r['ipv6'] ?? null);
|
||||||
|
if ($candidate && $targetBin !== false && @inet_pton($candidate) === $targetBin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,58 @@
|
|||||||
|
|
||||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a VirtFusion API server response into a flat key-value array for Smarty templates and admin display.
|
||||||
|
*
|
||||||
|
* WHY A FLAT ARRAY
|
||||||
|
* ----------------
|
||||||
|
* Smarty templates can traverse nested structures (`{$data.network.interfaces[0].ipv4[0].address}`)
|
||||||
|
* but that leaks the API shape into the template layer. A flat array ("hostname",
|
||||||
|
* "primaryNetwork.ipv4[]", "memoryRaw", etc.) decouples the template from the upstream
|
||||||
|
* schema: if VirtFusion renames `network.interfaces` tomorrow, only this file needs
|
||||||
|
* to change.
|
||||||
|
*
|
||||||
|
* PRIMARY-INTERFACE-ONLY DESIGN
|
||||||
|
* -----------------------------
|
||||||
|
* process() only reads interfaces[0]. That's the primary network — the one the
|
||||||
|
* client-area "Overview" card displays. Servers with multiple interfaces (common
|
||||||
|
* for dedicated IPMI networks, storage networks, etc.) still work for display
|
||||||
|
* because the primary interface holds the customer-facing IP.
|
||||||
|
*
|
||||||
|
* The reverse-DNS subsystem (PowerDns\IpUtil::extractIps) walks ALL interfaces
|
||||||
|
* explicitly because PTRs matter for every IP no matter which NIC it's on.
|
||||||
|
* If you add a feature that needs secondary-interface data for display, do NOT
|
||||||
|
* generalise this class — add a new one or a helper that doesn't disturb the
|
||||||
|
* well-tested primary-interface behaviour.
|
||||||
|
*
|
||||||
|
* UNIT CONVERSIONS
|
||||||
|
* ----------------
|
||||||
|
* VirtFusion stores:
|
||||||
|
* - traffic as bytes (usage) or GB (limits)
|
||||||
|
* - storage as GB (limits) or bytes (usage)
|
||||||
|
* - memory as MB
|
||||||
|
* WHMCS expects MB for storage/traffic in tblhosting. This class produces two
|
||||||
|
* pairs of values per resource: a human-readable string with unit suffix
|
||||||
|
* (e.g. "200 GB") AND a raw integer without the unit (for slider UIs and
|
||||||
|
* arithmetic). Keep both — removing one breaks a UI consumer somewhere.
|
||||||
|
*
|
||||||
|
* "-" SENTINELS
|
||||||
|
* -------------
|
||||||
|
* Fields that are missing or empty are rendered as "-" rather than empty strings.
|
||||||
|
* That makes the client-area card always have content (a dash is a valid visual
|
||||||
|
* placeholder) and distinguishes "missing data" from "empty string returned by
|
||||||
|
* the API". Consumers who need boolean presence checks should test against "-",
|
||||||
|
* not "" / null — and upstream (e.g. updateWhmcsServiceParamsOnServerObject)
|
||||||
|
* already does.
|
||||||
|
*/
|
||||||
class ServerResource
|
class ServerResource
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Normalise a VirtFusion API server response into a flat associative array.
|
||||||
|
*
|
||||||
|
* @param object $data VirtFusion API server response object (with a `data` property)
|
||||||
|
* @return array Flat associative array containing server name, hostname, resources, network info, and usage
|
||||||
|
*/
|
||||||
public function process($data)
|
public function process($data)
|
||||||
{
|
{
|
||||||
$server = json_decode(json_encode($data->data), true);
|
$server = json_decode(json_encode($data->data), true);
|
||||||
|
|||||||
557
modules/servers/VirtFusionDirect/lib/StockControl.php
Normal file
557
modules/servers/VirtFusionDirect/lib/StockControl.php
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
use WHMCS\Database\Capsule as DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes accurate stock quantities for VirtFusionDirect products and writes them
|
||||||
|
* to tblproducts.qty, leveraging WHMCS's native stock-control feature (badges,
|
||||||
|
* disabled Add-to-Cart, checkout block) instead of building parallel UI.
|
||||||
|
*
|
||||||
|
* HOW THE NUMBER IS DERIVED
|
||||||
|
* -------------------------
|
||||||
|
* For every product with tblproducts.stockcontrol=1:
|
||||||
|
*
|
||||||
|
* qty = Σ groupCapacity(g, package, ipv4Req, bufferPct) for every eligible group g
|
||||||
|
*
|
||||||
|
* where groupCapacity is computed from live /compute/hypervisors/groups/{id}/resources
|
||||||
|
* data and package is the VirtFusion /packages/{id} response — the authoritative
|
||||||
|
* per-VPS resource footprint. Each hypervisor's per-metric capacity is
|
||||||
|
* min(memory, cpu, storage), summed across hypervisors in the group; IPv4 is a
|
||||||
|
* group-level pool so its cap is taken as the per-hypervisor max within the group
|
||||||
|
* (not summed) to avoid double-counting.
|
||||||
|
*
|
||||||
|
* ELIGIBLE GROUPS
|
||||||
|
* ---------------
|
||||||
|
* The default group (tblproducts.configoption1) plus every value of the Location
|
||||||
|
* configurable option, if the product exposes one. Location is detected by matching
|
||||||
|
* the configurable option name against the "hypervisorId" label from
|
||||||
|
* config/ConfigOptionMapping.php (falls back to "Location") — same convention
|
||||||
|
* ModuleFunctions::createAccount() uses to map configoptions to VirtFusion fields.
|
||||||
|
* This lets a single product span multiple regions and still get a meaningful qty.
|
||||||
|
*
|
||||||
|
* ELIGIBLE HYPERVISORS
|
||||||
|
* --------------------
|
||||||
|
* enabled=true AND commissioned=true AND prohibit=false. Everything else is skipped
|
||||||
|
* with zero contribution to the group total.
|
||||||
|
*
|
||||||
|
* FAIL-SAFE INVARIANT
|
||||||
|
* -------------------
|
||||||
|
* CRITICAL: if the computation cannot complete (missing CP, transient API failure,
|
||||||
|
* malformed response, no groups resolved), recalculateForProduct() returns null and
|
||||||
|
* the caller MUST NOT touch tblproducts.qty. The reason: a false zero during a
|
||||||
|
* transient failure would pull every product out of the storefront, causing
|
||||||
|
* lost-order incidents that take human intervention to recover. Better to keep a
|
||||||
|
* slightly-stale qty than to silently take the catalogue offline.
|
||||||
|
*
|
||||||
|
* Confirmed-missing cases (package 404 or package.enabled=false) DO return 0 —
|
||||||
|
* that's the right answer, the product genuinely cannot be provisioned.
|
||||||
|
*
|
||||||
|
* CACHING
|
||||||
|
* -------
|
||||||
|
* Packages cached 10 min (rarely change), group resources cached 120 s (change
|
||||||
|
* meaningfully minute-to-minute under load). Both handled inside Module's
|
||||||
|
* fetchPackage / fetchGroupResources helpers, keyed 'pkg:{id}' / 'grpres:{id}' so
|
||||||
|
* multiple products in a cron sweep share cached data for the same upstream call.
|
||||||
|
*/
|
||||||
|
class StockControl
|
||||||
|
{
|
||||||
|
/** Default mapping from internal VF key → WHMCS configurable-option label.
|
||||||
|
* Kept in sync with $configOptionDefaultNaming in ModuleFunctions::createAccount(). */
|
||||||
|
private const DEFAULT_OPTION_LABELS = [
|
||||||
|
'ipv4' => 'IPv4',
|
||||||
|
'packageId' => 'Package',
|
||||||
|
'hypervisorId' => 'Location',
|
||||||
|
'storage' => 'Storage',
|
||||||
|
'memory' => 'Memory',
|
||||||
|
'traffic' => 'Bandwidth',
|
||||||
|
'networkSpeedInbound' => 'Inbound Network Speed',
|
||||||
|
'networkSpeedOutbound' => 'Outbound Network Speed',
|
||||||
|
'cpuCores' => 'CPU Cores',
|
||||||
|
'networkProfile' => 'Network Type',
|
||||||
|
'storageProfile' => 'Storage Type',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var Module Shared for its CP memoisation + initCurl/fetchPackage/fetchGroupResources helpers. */
|
||||||
|
private $module;
|
||||||
|
|
||||||
|
/** @var array<string,string>|null Resolved per-request once. */
|
||||||
|
private $optionLabelMap = null;
|
||||||
|
|
||||||
|
public function __construct(?Module $module = null)
|
||||||
|
{
|
||||||
|
// Dependency-inject for testability; default wires up a real Module so production
|
||||||
|
// callers (hooks.php, admin.php) don't have to know about the dependency.
|
||||||
|
$this->module = $module ?? new Module;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate qty for every VirtFusionDirect product that has WHMCS stock control enabled.
|
||||||
|
*
|
||||||
|
* Called from the every-2-hour AfterCronJob safety-net hook, from the post-provision
|
||||||
|
* and post-termination event hooks in hooks.php, and from the admin stockRecalculate
|
||||||
|
* AJAX endpoint in admin.php. Returns a map of productId => resulting qty (or null
|
||||||
|
* where the product was skipped / left untouched), useful for the admin endpoint's
|
||||||
|
* JSON response and for per-event logging.
|
||||||
|
*
|
||||||
|
* @return array<int,int|null>
|
||||||
|
*/
|
||||||
|
public function recalculateAll(): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$products = DB::table('tblproducts')
|
||||||
|
->where('servertype', 'VirtFusionDirect')
|
||||||
|
->where('stockcontrol', 1)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$results[(int) $product->id] = $this->recalculateForProduct((int) $product->id);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('StockControl:recalculateAll', [], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate qty for a single product.
|
||||||
|
*
|
||||||
|
* Returns the new qty on success, or null on any unrecoverable failure — in which case
|
||||||
|
* tblproducts.qty is left unchanged (fail-safe invariant).
|
||||||
|
*/
|
||||||
|
public function recalculateForProduct(int $productId): ?int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$product = DB::table('tblproducts')->where('id', $productId)->first();
|
||||||
|
if (! $product) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($product->servertype !== 'VirtFusionDirect') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((int) $product->stockcontrol !== 1) {
|
||||||
|
// Stock control disabled on this product — don't manage qty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qty = $this->computeQtyForProduct($product);
|
||||||
|
if ($qty === null) {
|
||||||
|
// Transient / unrecoverable — preserve existing qty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('tblproducts')
|
||||||
|
->where('id', $productId)
|
||||||
|
->update(['qty' => (int) $qty]);
|
||||||
|
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:recalculate',
|
||||||
|
[
|
||||||
|
'productId' => $productId,
|
||||||
|
'packageId' => (int) $product->configoption2,
|
||||||
|
'defaultGroupId' => (int) $product->configoption1,
|
||||||
|
],
|
||||||
|
['qty' => $qty],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $qty;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('StockControl:recalculateForProduct', ['productId' => $productId], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Computation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the qty integer without touching the DB.
|
||||||
|
*
|
||||||
|
* @param object $product tblproducts row.
|
||||||
|
* @return int|null Non-negative qty, or null when the computation cannot complete.
|
||||||
|
*/
|
||||||
|
private function computeQtyForProduct($product): ?int
|
||||||
|
{
|
||||||
|
$productId = (int) $product->id;
|
||||||
|
|
||||||
|
$packageId = (int) $product->configoption2;
|
||||||
|
if ($packageId <= 0) {
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId],
|
||||||
|
'no packageId in configoption2 — skipped',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = $this->module->fetchPackage($packageId);
|
||||||
|
if ($package === null) {
|
||||||
|
// Transient — preserve qty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($package === false) {
|
||||||
|
// Confirmed 404: package deleted in VirtFusion. Product is unfulfillable.
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId, 'packageId' => $packageId],
|
||||||
|
'package 404 — qty forced to 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (empty($package['enabled'])) {
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId, 'packageId' => $packageId],
|
||||||
|
'package disabled in VirtFusion — qty forced to 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = $this->resolveHypervisorGroupIds($product);
|
||||||
|
if (empty($groupIds)) {
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId],
|
||||||
|
'no hypervisor groups resolved — qty untouched',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipv4Required = max(1, (int) ($product->configoption3 ?? 1));
|
||||||
|
$bufferPct = $this->bufferPctForProduct($product);
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($groupIds as $groupId) {
|
||||||
|
$resources = $this->module->fetchGroupResources($groupId);
|
||||||
|
if ($resources === null) {
|
||||||
|
// Transient failure on any group aborts the whole computation — we can't
|
||||||
|
// safely reduce qty to a partial total and risk under-reporting stock.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($resources === false) {
|
||||||
|
// Group 404 — deleted; contributes 0. Keep going so other eligible groups still count.
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId, 'groupId' => $groupId],
|
||||||
|
'group 404 — contributing 0 capacity',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total += $this->groupCapacity($resources, $package, $ipv4Required, $bufferPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum of per-hypervisor minimums (mem/cpu/storage), capped by the group-level IPv4 pool.
|
||||||
|
*
|
||||||
|
* IPv4 CAP IS MAX-WITHIN-GROUP, NOT SUMMED
|
||||||
|
* ----------------------------------------
|
||||||
|
* network.total.ipv4.free in the API is a group-level pool visible from every hypervisor
|
||||||
|
* in the group — the same number is reported on each. Summing per-hypervisor IPv4 caps
|
||||||
|
* would overcount the pool by the hypervisor count. Taking max() within a group, then
|
||||||
|
* summing across groups, reflects the real constraint.
|
||||||
|
*/
|
||||||
|
private function groupCapacity(array $resources, array $package, int $ipv4Required, float $bufferPct): int
|
||||||
|
{
|
||||||
|
$hypervisors = $resources['data'] ?? [];
|
||||||
|
if (! is_array($hypervisors) || empty($hypervisors)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hypMinSum = 0;
|
||||||
|
$ipv4CapForGroup = 0;
|
||||||
|
|
||||||
|
foreach ($hypervisors as $h) {
|
||||||
|
$hyp = $h['hypervisor'] ?? [];
|
||||||
|
if (empty($hyp['enabled']) || empty($hyp['commissioned']) || ! empty($hyp['prohibit'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $h['resources'] ?? [];
|
||||||
|
if (! is_array($res)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$memCap = self::capFor($res['memory'] ?? null, (int) ($package['memory'] ?? 0), $bufferPct);
|
||||||
|
$cpuCap = self::capFor($res['cpuCores'] ?? null, (int) ($package['cpuCores'] ?? 0), $bufferPct);
|
||||||
|
$storeCap = self::capForStorage(
|
||||||
|
$res,
|
||||||
|
(int) ($package['primaryStorageProfile'] ?? 0),
|
||||||
|
(int) ($package['primaryStorage'] ?? 0),
|
||||||
|
$bufferPct,
|
||||||
|
);
|
||||||
|
|
||||||
|
$hypMinSum += min($memCap, $cpuCap, $storeCap);
|
||||||
|
|
||||||
|
$ipv4Free = (int) ($res['network']['total']['ipv4']['free'] ?? 0);
|
||||||
|
if ($ipv4Free > 0) {
|
||||||
|
$ipv4Cap = intdiv($ipv4Free, max(1, $ipv4Required));
|
||||||
|
if ($ipv4Cap > $ipv4CapForGroup) {
|
||||||
|
$ipv4CapForGroup = $ipv4Cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no hypervisor reported any ipv4 data (unusual but defensible), don't let
|
||||||
|
// the cap kill an otherwise-valid count — treat as "no IPv4 constraint known".
|
||||||
|
if ($ipv4CapForGroup === 0) {
|
||||||
|
foreach ($hypervisors as $h) {
|
||||||
|
if (isset($h['resources']['network']['total']['ipv4']['free'])) {
|
||||||
|
// There WAS an ipv4 value (possibly 0); the cap is genuinely 0.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ipv4 data anywhere in the response → don't apply the cap.
|
||||||
|
return max(0, $hypMinSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($hypMinSum, $ipv4CapForGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many VPSes fit into a single (free, max, buffer) cell for one resource.
|
||||||
|
*
|
||||||
|
* Handles three edge cases consistent with live API behaviour:
|
||||||
|
* - need <= 0 → unlimited fit (nothing consumed for this dimension)
|
||||||
|
* - resource.max = 0 → unlimited quota; free can be negative but we don't care
|
||||||
|
* - negative/zero available after buffer → 0 (clamp; never negative qty)
|
||||||
|
*/
|
||||||
|
private static function capFor($resource, int $need, float $bufferPct): int
|
||||||
|
{
|
||||||
|
if ($need <= 0) {
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
if (! is_array($resource)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = (int) ($resource['max'] ?? 0);
|
||||||
|
$free = (int) ($resource['free'] ?? 0);
|
||||||
|
|
||||||
|
if ($max === 0) {
|
||||||
|
// Unlimited quota — buffer doesn't apply (X% of 0 is 0).
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reserve = (int) ceil(((float) $max) * ($bufferPct / 100.0));
|
||||||
|
$available = $free - $reserve;
|
||||||
|
|
||||||
|
if ($available <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return intdiv($available, $need);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage variant of capFor() that respects the package's primaryStorageProfile.
|
||||||
|
*
|
||||||
|
* NOTE on naming: VirtFusion exposes two confusingly-named fields with the
|
||||||
|
* same numeric domain. `package.primaryStorageProfile` (mirrors the DB column
|
||||||
|
* `server_packages.storage_type`) is a **storage type code** — a filter,
|
||||||
|
* not an ID — and matches `otherStorage[].storageType` on each hypervisor.
|
||||||
|
* The pool's own `id` is unique per hypervisor and is never what the package
|
||||||
|
* targets. Treating $storageTypeId as `pool.id` (as this method previously
|
||||||
|
* did) returned 0 for every package whose type code didn't happen to also
|
||||||
|
* exist as a pool id, silently zeroing qty fleet-wide.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - storageTypeId > 0 → match any enabled otherStorage[] whose storageType
|
||||||
|
* equals this code. If multiple match (e.g. several
|
||||||
|
* mountpoint pools on one hypervisor), pick the one
|
||||||
|
* that fits the most VMs.
|
||||||
|
* - storageTypeId <= 0 → fall back to localStorage. If local is disabled, 0.
|
||||||
|
*/
|
||||||
|
private static function capForStorage(array $res, int $storageTypeId, int $needGb, float $bufferPct): int
|
||||||
|
{
|
||||||
|
if ($needGb <= 0) {
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storageTypeId > 0) {
|
||||||
|
$best = 0;
|
||||||
|
$matched = false;
|
||||||
|
foreach ($res['otherStorage'] ?? [] as $pool) {
|
||||||
|
if ((int) ($pool['storageType'] ?? 0) !== $storageTypeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$matched = true;
|
||||||
|
if (empty($pool['enabled'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cap = self::capFor(
|
||||||
|
['max' => (int) ($pool['max'] ?? 0), 'free' => (int) ($pool['free'] ?? 0)],
|
||||||
|
$needGb,
|
||||||
|
$bufferPct,
|
||||||
|
);
|
||||||
|
if ($cap > $best) {
|
||||||
|
$best = $cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matched) {
|
||||||
|
// No pool of this storage type on this hypervisor — cannot place the VM.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
$local = $res['localStorage'] ?? null;
|
||||||
|
if (is_array($local) && ! empty($local['enabled'])) {
|
||||||
|
return self::capFor(
|
||||||
|
['max' => (int) ($local['max'] ?? 0), 'free' => (int) ($local['free'] ?? 0)],
|
||||||
|
$needGb,
|
||||||
|
$bufferPct,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin-tunable safety buffer (configoption7), clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* Default is 10% when the field is blank or non-numeric — reserves 10% of each
|
||||||
|
* resource's max so we stop selling a product before the hypervisor is literally
|
||||||
|
* at 100%, which is where placement timing issues and fragmentation start biting.
|
||||||
|
* Admins can override per product (including down to 0) in the module settings.
|
||||||
|
*/
|
||||||
|
private function bufferPctForProduct($product): float
|
||||||
|
{
|
||||||
|
$raw = $product->configoption7 ?? '';
|
||||||
|
if ($raw === null || $raw === '') {
|
||||||
|
return 10.0;
|
||||||
|
}
|
||||||
|
$val = is_numeric($raw) ? (float) $raw : 10.0;
|
||||||
|
|
||||||
|
return max(0.0, min(100.0, $val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hypervisor-group resolution
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every hypervisor group ID this product could be provisioned into:
|
||||||
|
* the default (configoption1) plus every numeric value of the "Location"
|
||||||
|
* configurable option (if one is attached).
|
||||||
|
*
|
||||||
|
* @return int[] Deduplicated list of group IDs, strictly positive.
|
||||||
|
*/
|
||||||
|
private function resolveHypervisorGroupIds($product): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
$defaultGroup = (int) ($product->configoption1 ?? 0);
|
||||||
|
if ($defaultGroup > 0) {
|
||||||
|
$groups[] = $defaultGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationLabel = $this->optionLabelFor('hypervisorId');
|
||||||
|
if ($locationLabel !== null && $locationLabel !== '') {
|
||||||
|
foreach ($this->fetchConfigurableOptionValues((int) $product->id, $locationLabel) as $value) {
|
||||||
|
$asInt = (int) $value;
|
||||||
|
if ($asInt > 0) {
|
||||||
|
$groups[] = $asInt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($groups));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up every sub-option value for a given configurable option name on a product.
|
||||||
|
*
|
||||||
|
* WHMCS stores option names as either "Location" or "Location|Display Override" —
|
||||||
|
* this method normalises both by comparing just the part before the pipe.
|
||||||
|
*
|
||||||
|
* @return array<int,string> Raw sub-option names (callers decide numeric parsing).
|
||||||
|
*/
|
||||||
|
private function fetchConfigurableOptionValues(int $productId, string $label): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$options = DB::table('tblproductconfiglinks as l')
|
||||||
|
->join('tblproductconfigoptions as o', 'o.gid', '=', 'l.gid')
|
||||||
|
->where('l.pid', $productId)
|
||||||
|
->select('o.id', 'o.optionname')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$matchedIds = [];
|
||||||
|
foreach ($options as $opt) {
|
||||||
|
$name = (string) $opt->optionname;
|
||||||
|
$pipe = strpos($name, '|');
|
||||||
|
if ($pipe !== false) {
|
||||||
|
$name = substr($name, 0, $pipe);
|
||||||
|
}
|
||||||
|
if ($name === $label) {
|
||||||
|
$matchedIds[] = (int) $opt->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($matchedIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('tblproductconfigoptionssub')
|
||||||
|
->whereIn('configid', $matchedIds)
|
||||||
|
->pluck('optionname')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('StockControl:fetchConfigurableOptionValues', ['productId' => $productId, 'label' => $label], $e->getMessage());
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the WHMCS configurable-option label for an internal key, respecting
|
||||||
|
* config/ConfigOptionMapping.php overrides — same contract as ModuleFunctions::createAccount().
|
||||||
|
*/
|
||||||
|
private function optionLabelFor(string $key): ?string
|
||||||
|
{
|
||||||
|
if ($this->optionLabelMap === null) {
|
||||||
|
$this->optionLabelMap = self::DEFAULT_OPTION_LABELS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve the mapping file directly relative to this class — avoids
|
||||||
|
// depending on WHMCS's ROOTDIR, which isn't defined when the module
|
||||||
|
// is loaded outside a full WHMCS request (cron tooling, tests).
|
||||||
|
// __DIR__ is .../modules/servers/VirtFusionDirect/lib, so the config
|
||||||
|
// directory is one level up.
|
||||||
|
$overridePath = dirname(__DIR__) . '/config/ConfigOptionMapping.php';
|
||||||
|
if (is_file($overridePath)) {
|
||||||
|
$override = require $overridePath;
|
||||||
|
if (is_array($override)) {
|
||||||
|
$this->optionLabelMap = array_merge($this->optionLabelMap, $override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Swallow — mapping override is best-effort; defaults still work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->optionLabelMap[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,16 +9,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.vf-button {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.95rem 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.vf-button-small {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.75rem 1.3rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.vf-spinner-margin {
|
.vf-spinner-margin {
|
||||||
margin-right: 7px;
|
margin-right: 7px;
|
||||||
}
|
}
|
||||||
@@ -84,9 +74,48 @@
|
|||||||
}
|
}
|
||||||
#vf-server-info-error {
|
#vf-server-info-error {
|
||||||
display: none;
|
display: none;
|
||||||
|
margin: 10px;
|
||||||
}
|
}
|
||||||
#vf-data-server-traffic-sep {
|
|
||||||
display: inline;
|
/* Skeleton Loading */
|
||||||
|
.vf-skeleton {
|
||||||
|
background: linear-gradient(90deg, #e9ecef 25%, #f4f4f4 50%, #e9ecef 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: vf-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.vf-skeleton-line {
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.vf-skeleton-line-short {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
.vf-skeleton-line-medium {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
@keyframes vf-skeleton-pulse {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Progress Banner */
|
||||||
|
#vf-action-progress {
|
||||||
|
background: #337ab7;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
#vf-action-progress .spinner-border {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loader */
|
/* Loader */
|
||||||
@@ -118,11 +147,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error message spacing */
|
|
||||||
#vf-server-info-error {
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Network / IP Management */
|
/* Network / IP Management */
|
||||||
.vf-ip-row {
|
.vf-ip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -134,9 +158,226 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.vf-ip-remove {
|
/* Backup Timeline */
|
||||||
|
.vf-timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
border-left: 2px solid #dee2e6;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.vf-timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 0 8px 12px;
|
||||||
|
}
|
||||||
|
.vf-timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -27px;
|
||||||
|
top: 12px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
.vf-timeline-dot-success {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
.vf-timeline-dot-pending {
|
||||||
|
background: #ffc107;
|
||||||
|
}
|
||||||
|
.vf-timeline-content {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server Name Dropdown */
|
||||||
|
#vf-name-dropdown {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-width: 250px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.vf-name-option {
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.vf-name-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.vf-name-option:hover {
|
||||||
|
background: rgba(51,122,183,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy to Clipboard */
|
||||||
|
.vf-ip-copy {
|
||||||
|
padding: 2px 5px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #6c757d;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.vf-ip-copy:hover {
|
||||||
|
color: #337ab7;
|
||||||
|
background: rgba(51,122,183,0.08);
|
||||||
|
border-color: rgba(51,122,183,0.2);
|
||||||
|
}
|
||||||
|
.vf-copy-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
background: #28a745;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: vf-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes vf-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OS Template Gallery */
|
||||||
|
.vf-os-category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.vf-os-category:first-child .vf-os-category-header {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.vf-os-category-header:hover {
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
.vf-os-category-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.vf-os-category-icon img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.vf-os-category-arrow {
|
||||||
|
margin-left: auto;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
padding: 0.15rem 0.4rem;
|
color: #888;
|
||||||
|
}
|
||||||
|
.vf-os-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.vf-os-card {
|
||||||
|
width: 120px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.vf-os-card:hover {
|
||||||
|
border-color: #337ab7;
|
||||||
|
}
|
||||||
|
.vf-os-card-selected {
|
||||||
|
border-color: #337ab7;
|
||||||
|
background: rgba(51,122,183,0.06);
|
||||||
|
box-shadow: 0 0 0 1px rgba(51,122,183,0.3);
|
||||||
|
}
|
||||||
|
.vf-os-card-eol {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.vf-os-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 auto 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vf-os-icon img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.vf-os-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.vf-os-version {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.vf-os-eol-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #dc3545;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
#vf-os-details {
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.vf-os-search {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vf-os-grid {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.vf-os-card {
|
||||||
|
width: calc(50% - 3px);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resource panel */
|
/* Resource panel */
|
||||||
@@ -186,6 +427,38 @@
|
|||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.vf-toggle-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.vf-toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vf-toggle-switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.vf-toggle-input:checked + .vf-toggle-switch {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
.vf-toggle-input:checked + .vf-toggle-switch::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.vf-power-buttons {
|
.vf-power-buttons {
|
||||||
@@ -198,3 +471,106 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Reverse DNS panel
|
||||||
|
========================================================================= */
|
||||||
|
.vf-rdns-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.vf-rdns-row:last-child { border-bottom: none; }
|
||||||
|
.vf-rdns-ip {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 180px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vf-rdns-edit {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
.vf-rdns-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 420px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.vf-rdns-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.vf-rdns-msg {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
display: none;
|
||||||
|
padding-left: 180px;
|
||||||
|
}
|
||||||
|
.vf-rdns-admin-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.vf-rdns-ip-admin {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.vf-rdns-ptr-admin {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #333;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vf-rdns-row { flex-direction: column; align-items: stretch; }
|
||||||
|
.vf-rdns-edit { flex-direction: column; align-items: stretch; }
|
||||||
|
.vf-rdns-msg { padding-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subnet-only rows (IPv6 /64 allocations). Distinct visual treatment so
|
||||||
|
customers see "this is a subnet, not a host" without reading the badge. */
|
||||||
|
.vf-rdns-subnet-row {
|
||||||
|
background: rgba(23, 162, 184, 0.04);
|
||||||
|
border-left: 3px solid #17a2b8;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
.vf-rdns-subnet-form {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding: 10px 0 0 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.vf-rdns-subnet-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.vf-rdns-subnet-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vf-rdns-subnet-form { padding-left: 0; }
|
||||||
|
.vf-rdns-subnet-inputs { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<link href="{$systemURL}modules/servers/VirtFusionDirect/templates/css/module.css?v=20260207" rel="stylesheet">
|
<link href="{$systemURL}modules/servers/VirtFusionDirect/templates/css/module.css?v={$smarty.now}" rel="stylesheet">
|
||||||
<script src="{$systemURL}modules/servers/VirtFusionDirect/templates/js/module.js?v=20260207"></script>
|
<script src="{$systemURL}modules/servers/VirtFusionDirect/templates/js/module.js?v={$smarty.now}"></script>
|
||||||
|
|
||||||
{if $serviceStatus eq 'Active'}
|
{if $serviceStatus eq 'Active'}
|
||||||
|
|
||||||
@@ -12,9 +12,27 @@
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body card-body p-4">
|
<div class="panel-body card-body p-4">
|
||||||
|
<div id="vf-action-progress" style="display:none;">
|
||||||
|
<div class="spinner-border spinner-border-sm text-light"></div>
|
||||||
|
<span id="vf-action-progress-text"></span>
|
||||||
|
<span id="vf-action-progress-timer" class="ml-auto" style="margin-left:auto;"></span>
|
||||||
|
</div>
|
||||||
<div id="vf-server-info-loader-container">
|
<div id="vf-server-info-loader-container">
|
||||||
<div id="vf-server-info-loader" class="d-flex align-items-center justify-content-center">
|
<div id="vf-server-info-loader">
|
||||||
<div class="spinner-border"></div>
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>vfServerData('{$serviceid}', '{$systemURL}');</script>
|
<script>vfServerData('{$serviceid}', '{$systemURL}');</script>
|
||||||
@@ -27,7 +45,15 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="row p-1">
|
<div class="row p-1">
|
||||||
<div class="col-xs-4 col-4 text-right vf-bold">Name:</div>
|
<div class="col-xs-4 col-4 text-right vf-bold">Name:</div>
|
||||||
<div class="col-xs-8 col-8" id="vf-data-server-name"></div>
|
<div class="col-xs-8 col-8">
|
||||||
|
<div class="d-flex" style="display:flex; gap:6px; align-items:center;">
|
||||||
|
<input type="text" id="vf-rename-input" class="form-control form-control-sm" maxlength="63" style="max-width:200px;" placeholder="Server name">
|
||||||
|
<button id="vf-randomise-btn" onclick="vfShowNameDropdown('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-outline-secondary" title="Randomise">↻</button>
|
||||||
|
<button id="vf-rename-save" onclick="vfRenameServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="vf-name-dropdown" style="display:none;"></div>
|
||||||
|
<div id="vf-rename-alert" class="mt-1" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row p-1">
|
<div class="row p-1">
|
||||||
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
|
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
|
||||||
@@ -136,6 +162,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="col-12">
|
||||||
|
<hr>
|
||||||
|
<div id="vf-server-password-alert" class="alert" style="display:none;"></div>
|
||||||
|
<p class="vf-small text-muted">Reset the server's root password. The new password will be copied to your clipboard automatically.</p>
|
||||||
|
<button id="vf-server-password-btn" onclick="vfResetServerPassword('{$serviceid}','{$systemURL}')" type="button" class="btn btn-warning text-uppercase d-flex align-items-center">
|
||||||
|
<span id="vf-server-password-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||||
|
Reset Server Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12" id="vf-backups-section" style="display:none;">
|
||||||
|
<hr>
|
||||||
|
<h5 class="vf-bold">Backups</h5>
|
||||||
|
<div id="vf-backups-loader"><div class="spinner-border spinner-border-sm"></div></div>
|
||||||
|
<div id="vf-backups-timeline" class="vf-timeline"></div>
|
||||||
|
<button id="vf-backups-show-all" class="btn btn-sm btn-link" style="display:none;" onclick="$('.vf-timeline-item-hidden').show(); $(this).hide();">Show all</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if (typeof vfLoadBackups === 'function') {
|
||||||
|
vfLoadBackups('{$serviceid}', '{$systemURL}');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,16 +197,16 @@
|
|||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<strong>Warning:</strong> Rebuilding your server will erase all data on the server and reinstall the operating system. This action cannot be undone.
|
<strong>Warning:</strong> Rebuilding your server will erase all data on the server and reinstall the operating system. This action cannot be undone.
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<input type="hidden" id="vf-rebuild-os" value="">
|
||||||
<div class="col-md-6">
|
<div class="form-group mb-3">
|
||||||
<div class="form-group mb-3">
|
<label>Operating System</label>
|
||||||
<label for="vf-rebuild-os">Operating System</label>
|
<input type="text" id="vf-os-search" class="form-control vf-os-search" placeholder="Search templates...">
|
||||||
<select id="vf-rebuild-os" class="form-control">
|
|
||||||
<option value="">Loading...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="vf-os-gallery-loader" class="mb-3">
|
||||||
|
<div class="vf-skeleton" style="height:120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="vf-os-gallery" class="mb-3" style="display:none;"></div>
|
||||||
|
<div id="vf-os-details" class="mb-3" style="display:none;"></div>
|
||||||
<button id="vf-rebuild-button" onclick="vfRebuildServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-danger text-uppercase d-flex align-items-center">
|
<button id="vf-rebuild-button" onclick="vfRebuildServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-danger text-uppercase d-flex align-items-center">
|
||||||
<span id="vf-rebuild-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
<span id="vf-rebuild-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||||
Rebuild Server
|
Rebuild Server
|
||||||
@@ -190,6 +237,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{if $rdnsEnabled}
|
||||||
|
{* Reverse DNS Panel *}
|
||||||
|
<div class="panel card panel-default mb-3">
|
||||||
|
<div class="panel-heading card-header">
|
||||||
|
<h3 class="panel-title card-title m-0">Reverse DNS</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body card-body p-4">
|
||||||
|
<p class="vf-small text-muted mb-3">Set a custom PTR record for each assigned IP. Forward DNS (A/AAAA) for the hostname must already resolve to the IP before the PTR can be saved.</p>
|
||||||
|
<div id="vf-rdns-alert" class="alert" style="display:none;"></div>
|
||||||
|
<div id="vf-rdns-list">
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||||
|
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if (typeof vfLoadRdns === 'function') {
|
||||||
|
vfLoadRdns('{$serviceid}', '{$systemURL}');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{* Resources Panel — populated by JS after server data loads *}
|
{* Resources Panel — populated by JS after server data loads *}
|
||||||
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
|
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||||
<div class="panel-heading card-header">
|
<div class="panel-heading card-header">
|
||||||
@@ -235,6 +304,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="vf-traffic-chart-section" style="display:none;">
|
||||||
|
<hr>
|
||||||
|
<h5 class="vf-bold mb-2">Traffic Usage</h5>
|
||||||
|
<canvas id="vf-traffic-chart" style="width:100%; height:200px;"></canvas>
|
||||||
|
<div class="row mt-2 text-center">
|
||||||
|
<div class="col-4"><small class="text-muted">Used</small><div id="vf-traffic-used" class="vf-bold">-</div></div>
|
||||||
|
<div class="col-4"><small class="text-muted">Limit</small><div id="vf-traffic-limit" class="vf-bold">-</div></div>
|
||||||
|
<div class="col-4"><small class="text-muted">Remaining</small><div id="vf-traffic-remaining" class="vf-bold">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if (typeof vfLoadTrafficStats === 'function') {
|
||||||
|
vfLoadTrafficStats('{$serviceid}', '{$systemURL}');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,10 +330,37 @@
|
|||||||
<div class="panel-body card-body p-4">
|
<div class="panel-body card-body p-4">
|
||||||
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
||||||
<p>Access your server's console directly in your browser. The server must be running for VNC access.</p>
|
<p>Access your server's console directly in your browser. The server must be running for VNC access.</p>
|
||||||
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
|
<div class="d-flex align-items-center mb-3" style="display:flex; gap:12px; align-items:center;">
|
||||||
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
|
||||||
Open Console
|
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||||
</button>
|
Open Console
|
||||||
|
</button>
|
||||||
|
<label class="vf-toggle-label mb-0" style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="vf-vnc-toggle" class="vf-toggle-input" onchange="vfToggleVnc('{$serviceid}','{$systemURL}', this.checked)">
|
||||||
|
<span class="vf-toggle-switch"></span>
|
||||||
|
<span class="vf-small">VNC Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="vf-vnc-details" style="display:none;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="row p-1">
|
||||||
|
<div class="col-4 text-right vf-bold vf-small">IP:</div>
|
||||||
|
<div class="col-8 vf-small" id="vf-vnc-ip">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="row p-1">
|
||||||
|
<div class="col-4 text-right vf-bold vf-small">Port:</div>
|
||||||
|
<div class="col-8 vf-small" id="vf-vnc-port">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="vfCopyVncPassword('{$serviceid}','{$systemURL}')">
|
||||||
|
Copy VNC Password
|
||||||
|
</button>
|
||||||
|
<span id="vf-vnc-copy-confirm" class="text-success vf-small" style="display:none;">Copied!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
24
pint.json
Normal file
24
pint.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"preset": "laravel",
|
||||||
|
"rules": {
|
||||||
|
"declare_strict_types": false,
|
||||||
|
"blank_line_before_statement": {
|
||||||
|
"statements": ["return", "throw", "try"]
|
||||||
|
},
|
||||||
|
"concat_space": {
|
||||||
|
"spacing": "one"
|
||||||
|
},
|
||||||
|
"ordered_imports": {
|
||||||
|
"sort_algorithm": "alpha"
|
||||||
|
},
|
||||||
|
"single_quote": true,
|
||||||
|
"no_unused_imports": true,
|
||||||
|
"trailing_comma_in_multiline": {
|
||||||
|
"elements": ["arrays", "arguments"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"vendor",
|
||||||
|
"templates"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user