36 Commits

Author SHA1 Message Date
semantic-release-bot
3d3df6e2dc chore(release): 1.0.0 [skip ci]
# 1.0.0 (2026-03-19)

### Bug Fixes

* add null/false guards, proper error handling, and VNC popup fix ([49fdd9e](49fdd9e49b))
* OS gallery accordion auto-collapses other sections when one opens ([a9565ff](a9565ff6f9))
* OS gallery accordion layout and remove broken remote icon fetching ([9cd737c](9cd737c5d5))
* TestConnection for unsaved servers, traffic display, and cache-busting ([e8d2eb0](e8d2eb0aa1))
* XSS escaping, null guards, JS bug fixes, and documentation updates ([6c7cdc6](6c7cdc6421))

### Features

* add client-side SSH Ed25519 key generator on order page ([209e01d](209e01deb6))
* add VNC check, SSH key paste, resources panel, sliders, and self-service billing ([1e471af](1e471affd0))
* major enhancement — OS gallery, server rename, traffic chart, backups, VNC toggle, password reset, Redis caching, UX improvements ([90a97c4](90a97c4afb))
* streamline network panel, conditional self-service, remove IP add endpoints ([e73e85c](e73e85c5a9))
2026-03-19 18:52:21 +00:00
Prophet731
0ade74dd4e refactor: consolidate duplicate logic across codebase
Some checks failed
Automated Semantic Versioning Release / release (push) Failing after 44s
PHP (Module.php):
- Extract resolveServiceContext() helper — eliminates 15 repeated
  service/whmcsService/getCP/initCurl lookup chains (~200 lines saved)
- Extract static groupOsTemplates() — single source for OS template
  category grouping logic, used by both Module.php and hooks.php

PHP (Cache.php):
- Add filesystem cache fallback when Redis extension is unavailable
- Atomic writes with tmp+rename pattern for race condition safety
- extension_loaded() check instead of class_exists()

JS (module.js):
- Extract vfUrl() helper — replaces 18 identical URL construction strings
- Extract vfShowAlert() helper — replaces 25 repeated alert show/hide/class
  toggle patterns across all action functions

hooks.php:
- Use Module::groupOsTemplates(data, htmlEscape: true) instead of
  inline duplicate grouping logic (~40 lines removed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:49:00 -05:00
Prophet731
a9565ff6f9 fix: OS gallery accordion auto-collapses other sections when one opens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:39:02 -05:00
Prophet731
9cd737c5d5 fix: OS gallery accordion layout and remove broken remote icon fetching
- Replace flat category display with collapsible accordion (first category
  expanded, rest collapsed with click-to-toggle)
- Remove VirtFusion remote icon fetching (icons are behind auth/404) —
  use brand-colored letter badges instead
- Add accordion header CSS with category icon, template count, and
  arrow indicator
- Update checkout page gallery (hooks.php) with matching accordion behavior
- Flush Redis OS template cache on deploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 05:46:24 -05:00
Prophet731
90a97c4afb feat: major enhancement — OS gallery, server rename, traffic chart, backups, VNC toggle, password reset, Redis caching, UX improvements
- Remove client IP removal capability (keep backend methods removed too)
- Add copy-to-clipboard buttons for IP addresses with tooltip feedback
- Replace OS dropdown with tile gallery (grouped, searchable, brand colors, EOL badges) in rebuild panel and checkout page
- Add inline server rename with friendly name generator and RFC 1123 validation
- Add traffic statistics canvas chart with responsive resize in resources panel
- Add backup listing timeline in manage panel with show-all expansion
- Add VNC enable/disable toggle with connection details and password copy
- Add server root password reset with auto-clipboard copy (never displayed)
- Add skeleton loading placeholders, action cooldowns (power 3s, rebuild 30s), progress indicator with elapsed timer
- Sanitize all client-facing error messages (no raw API errors exposed)
- Convert all state-mutating AJAX calls from GET to POST
- Add explicit break after all output() calls in client.php
- Add Redis-backed API response caching (Cache.php): OS templates 10min, traffic/backups 2min, currencies 30min, packages 10min
- Add GitHub Actions workflow for weekly VirtFusion API change detection
- Move cache busting step after semantic-release in publish workflow
- Add endpoint doc generator script and OpenAPI baseline placeholder
- Improve hostname generation entropy (bin2hex random_bytes)
- Add .superpowers/ to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 05:40:32 -05:00
semantic-release-bot
538974e0fe chore(release): 1.0.0 [skip ci]
# 1.0.0 (2026-02-07)

### Bug Fixes

* add null/false guards, proper error handling, and VNC popup fix ([49fdd9e](49fdd9e49b))
* TestConnection for unsaved servers, traffic display, and cache-busting ([e8d2eb0](e8d2eb0aa1))
* XSS escaping, null guards, JS bug fixes, and documentation updates ([6c7cdc6](6c7cdc6421))

### Features

* add client-side SSH Ed25519 key generator on order page ([209e01d](209e01deb6))
* add VNC check, SSH key paste, resources panel, sliders, and self-service billing ([1e471af](1e471affd0))
* streamline network panel, conditional self-service, remove IP add endpoints ([e73e85c](e73e85c5a9))
2026-02-07 21:56:09 +00:00
Andrew
0d997a0cc2 Merge pull request #4 from EZSCALE/claude/enhance-virtfusion-whmcs-ORKCR
fix: code review fixes and documentation update
2026-02-07 16:51:48 -05:00
EZSCALE
6c7cdc6421 fix: XSS escaping, null guards, JS bug fixes, and documentation updates
- Escape $serverObject and $systemUrl in AdminHTML.php heredocs to prevent XSS
- Add null guard in Database::getSystemUrl() to prevent fatal error
- Guard primaryNetwork access in module.js to prevent null dereference
- Reset badge/traffic-bar CSS classes on refresh to prevent accumulation
- Add VNC popup-blocked check with user-facing message
- Add BS3 input-group-btn dual class for theme compatibility
- Escape billing template variables with |escape:'htmlall'
- Add cache-busting to admin CSS/JS includes
- Switch cache-busting format from version to date-based (20260207)
- Create .releaserc.json for automated CHANGELOG.md management
- Add changelog/git plugins to semantic-release workflow
- Remove manual [Unreleased] section from CHANGELOG.md
- Update README: install/upgrade with rsync, accuracy fixes, add keygen.js
- Update CLAUDE.md: add keygen.js, document removed features
- Fix SECURITY.md grammar and version operator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:48:49 -06:00
EZSCALE
e73e85c5a9 feat: streamline network panel, conditional self-service, remove IP add endpoints
- Populate network panel from server data response instead of separate API call
- Conditionally render self-service billing panel based on selfServiceMode config
- Pass selfServiceMode to Smarty template vars
- Remove addIPv4, addIPv6, serverIPs client endpoints and UI buttons
- Remove upgrade/downgrade link from resources panel
- Bump cache-busting version to v0.0.20
- Update CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:23:56 -06:00
EZSCALE
209e01deb6 feat: add client-side SSH Ed25519 key generator on order page
Adds a "Generate a new key" button to the checkout SSH key section that
creates an Ed25519 keypair entirely in the browser using Web Crypto API.
The public key auto-fills the form field, and the private key is presented
for download/copy with a clear "save now" warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:22:33 -06:00
EZSCALE
e8d2eb0aa1 fix: TestConnection for unsaved servers, traffic display, and cache-busting
- Use $params['serverhostname']/serverpassword directly in TestConnection
  instead of database lookup (serverid=0 is falsy for new servers)
- Default traffic "Used" to 0 GB when allocated but no usage reported
- Add ?v=0.0.19 cache-busting to JS/CSS includes in overview.tpl

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:51:58 -06:00
EZSCALE
1e471affd0 feat: add VNC check, SSH key paste, resources panel, sliders, and self-service billing
- VNC panel auto-hides when VNC is disabled on the server
- SSH key paste textarea at checkout with API key creation during provisioning
- Resources panel with current allocation, traffic progress bar, and upgrade link
- changePackage() now applies individual resource modifications from configurable options
- Order form configurable option dropdowns replaced with styled range sliders
- Self-service billing: credit balance, usage breakdown, credit top-up from client area
- Self-service config options (mode, auto top-off threshold/amount) on products
- Auto top-off via WHMCS cron when credit falls below threshold
- CHANGELOG.md covering all versions from 0.0.6 to present

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:25:43 -06:00
EZSCALE
49fdd9e49b fix: add null/false guards, proper error handling, and VNC popup fix
- Add isset() guards before count() on ipv4/ipv6 arrays in ServerResource
  to prevent PHP 8.0+ TypeError
- Add null checks after getWhmcsService() and getCP() in 18 Module methods
  and 5 ModuleFunctions methods to prevent fatal null dereference errors
- Add null guards for $whmcsService and $cp in admin.php impersonateServerOwner
- Fix HTTP status codes throughout admin.php (404, 400, 500, 502 instead of 200)
- Guard ConfigureService methods against $this->cp === false
- Use null coalescing for customfields access in initServerBuild
- Check API response code in initServerBuild instead of always returning true
- Replace exit() with RuntimeException in Curl.php
- Change catch(Exception) to catch(Throwable) in hooks.php for PHP 8.0+
- Open VNC window before AJAX call to avoid popup blocker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:49:12 -06:00
EZSCALE
d52e379d5f Add CLAUDE.md with project architecture and development guidance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:30:39 -06:00
Claude
cfb1ddb4e5 Fix firewall API endpoints to use correct {interface} path parameter
- Firewall endpoints now use /firewall/{interface}/ where interface is
  "primary" or "secondary" (was missing the interface segment)
- Add applyFirewallRulesets() method for applying predefined rulesets by ID
- Add firewallApplyRulesets client endpoint (comma-separated ruleset IDs)
- Add sanitizeFirewallInterface() helper for input validation
- All firewall methods now accept optional interface parameter (default: primary)
- Document that VirtFusion uses ruleset-based firewall (no individual rule CRUD)
- Update README with correct API paths and ruleset documentation

https://claude.ai/code/session_01TCsJ4WZCGuEX3zqh1tQ2zx
2026-02-07 12:51:36 +00:00
Claude
cad1af18c1 Add firewall, network, VNC, backup, resource management and UsageUpdate
New features implemented:
- Firewall management: enable/disable, status display, apply rules
- IP address management: add/remove IPv4 and IPv6 with client UI
- VNC console access integration (VirtFusion v6.1.0+)
- Backup plan assignment/removal via API
- Resource modification: in-place memory/CPU/traffic changes
- UsageUpdate cron: automated bandwidth and disk usage sync to WHMCS
- Dry run validation: test server creation config before provisioning
- Admin "Validate Server Config" button for dry run testing

Client area additions:
- Firewall panel with enable/disable/apply controls and status badge
- Network panel with IPv4/IPv6 listing, add, and remove buttons
- VNC Console panel with browser-based access
- All panels load asynchronously with spinner indicators

Comprehensive README rewrite with:
- Table of contents, requirements matrix, step-by-step installation
- Detailed configuration guide for all features
- Theme compatibility documentation (Six, Twenty-One, Lagom)
- Complete API endpoints reference organized by category
- UsageUpdate cron documentation with data format details
- Troubleshooting tables for common issues
- Known issues section covering version requirements
- Security architecture documentation
- File structure reference

https://claude.ai/code/session_01TCsJ4WZCGuEX3zqh1tQ2zx
2026-02-07 12:43:02 +00:00
Claude
c93072b1c6 Enhance VirtFusion WHMCS module with security fixes, new features, and improved UX
Security improvements:
- Enable SSL/TLS certificate verification by default (was disabled, MITM risk)
- Remove error_reporting(0) that silenced all errors
- Add input sanitization on all user parameters (int casting, regex filtering)
- Return proper HTTP status codes (401, 403, 400, 500) instead of always 200
- Add XSS protection with htmlspecialchars and encodeURIComponent
- Add null checks on API response data before property access

New features:
- Power management: boot, shutdown, restart, and force power off controls
- Server rebuild: reinstall with any available OS template from client area
- Server rename: change server display name via PATCH API
- OS template fetching: client-side endpoint for rebuild OS selection
- TestConnection: validate API credentials from WHMCS server settings
- ServiceSingleSignOn: native WHMCS SSO integration for VirtFusion panel
- Server status badge: visual indicator of server state in overview
- Traffic usage display: show bandwidth used vs allocated
- Checkout validation: ShoppingCartValidateCheckout hook ensures OS selection

Ordering process improvements:
- Add default "Select Operating System" placeholder option
- Add "No SSH Key (Optional)" default for SSH dropdown
- Hide SSH key field/container when no keys available
- Wrap hook in try/catch to prevent checkout page breakage
- Sanitize template names with htmlspecialchars
- Use JSON_HEX_* flags for safe script injection

Theme compatibility:
- Properly formatted Smarty templates with readable indentation
- Dual panel/card CSS classes for Bootstrap 3/4/5 compatibility
- Responsive power button layout with mobile breakpoint
- Framework-agnostic HTML that works with Six, Twenty-One, Lagom, and custom themes
- Suspended service state messaging

Code quality:
- Readable, unminified JavaScript with JSDoc header
- Structured CSS with logical section organization
- Improved error messages throughout all provisioning functions
- Added PATCH method support to Curl wrapper
- Added curl error capture on connection failures
- Added connection and request timeouts (10s/30s)
- Fixed memory conversion to check key name instead of display name

Documentation:
- Complete README rewrite with installation, configuration, and troubleshooting guides
- API endpoint reference table
- Configurable options mapping documentation
- Theme override instructions
- Security considerations section

https://claude.ai/code/session_01TCsJ4WZCGuEX3zqh1tQ2zx
2026-02-07 12:18:11 +00:00
7b87fdcc3f Updated github action 2025-10-01 13:06:43 -04:00
a2275f4444 Moved SQL to a file 2025-10-01 13:02:52 -04:00
798d3fcdb5 Small tweaks 2025-10-01 13:02:42 -04:00
Andrew
9aa8378599 Merge pull request #2 from EZSCALE/Prophet731-patch-1
Update hooks.php
2024-01-16 12:13:15 -05:00
Andrew
f0c28a4961 Update hooks.php
Hide the SSH key field if the user isn't logged in to see them or is a new user.
2024-01-16 12:11:26 -05:00
Prophet731
a46223e5ac Update issue templates 2023-09-11 00:31:03 -04:00
98250f2f4c Ahh typos 2023-09-10 23:58:17 -04:00
2691013cc4 Put the on in the wrong spot. 2023-09-10 23:57:00 -04:00
24e79eed31 Forgot to remove this 2023-09-10 23:50:54 -04:00
db3afee99e Deleted linter as its not really needed. 2023-09-10 23:50:36 -04:00
fb0cffc844 Changed to not required. 2023-09-10 23:49:53 -04:00
c6012fa63c Updated readme.me 2023-09-10 23:46:43 -04:00
b25530f063 Successful build of server. Ready for testing. 2023-09-10 23:32:59 -04:00
bb2e8ac538 Check the database first to see if the ID has been set, otherwise, fallback to querying the API based on product name. 2023-09-10 20:56:53 -04:00
07f3c69977 Added changes done by BlinkohHost.
See https://github.com/BlinkohHost/virtfusion-whmcs-module
2023-09-10 20:36:22 -04:00
e5570785db Updated README.md 2023-09-10 19:52:19 -04:00
b25535ba5f Cleaned up code to make it OOP. 2023-09-10 19:48:18 -04:00
00ccd2c902 Migrated to below heading 2023-09-10 18:53:17 -04:00
33f466af04 Updated badge 2023-09-10 18:43:48 -04:00
31 changed files with 5557 additions and 495 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

65
.github/workflows/api-sync-check.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: VirtFusion API Change Detection
on:
schedule:
- cron: '0 9 * * 1' # Monday 9am UTC
workflow_dispatch:
jobs:
check-api:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Download current API spec
run: curl -sSL -o /tmp/openapi-current.yaml https://docs.virtfusion.com/api/openapi.yaml
- name: Compare with baseline
id: diff
run: |
if [ ! -f docs/openapi-baseline.yaml ]; then
echo "No baseline found — creating initial baseline"
cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml
echo "changed=initial" >> "$GITHUB_OUTPUT"
elif ! diff -q docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /dev/null 2>&1; then
echo "API spec has changed"
diff docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /tmp/api-diff.txt || true
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "No changes detected"
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Create issue on change
if: steps.diff.outputs.changed == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const diff = fs.readFileSync('/tmp/api-diff.txt', 'utf8').substring(0, 60000);
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `VirtFusion API spec changed (${new Date().toISOString().split('T')[0]})`,
body: `The VirtFusion OpenAPI spec has been updated.\n\n<details><summary>Diff</summary>\n\n\`\`\`diff\n${diff}\n\`\`\`\n</details>\n\nReview the changes and update the module if needed.`,
labels: ['api-sync']
});
- name: Update baseline and create PR
if: steps.diff.outputs.changed == 'true' || steps.diff.outputs.changed == 'initial'
run: |
cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BRANCH="api-sync/$(date +%Y-%m-%d)"
git checkout -b "$BRANCH"
git add docs/openapi-baseline.yaml
git commit -m "chore: update VirtFusion API baseline spec"
git push origin "$BRANCH"
gh pr create --title "chore: update VirtFusion API baseline" --body "Automated update of the VirtFusion OpenAPI baseline spec." --base main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,22 +0,0 @@
---
name: Lint Code Base
on: [ workflow_call ]
jobs:
build:
name: Lint Code Base
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
statuses: write
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Lint Code Base
uses: super-linter/super-linter@v5
env:
VALIDATE_ALL_CODEBASE: false
DEFAULT_BRANCH: main
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@@ -1,22 +1,52 @@
---
name: Publish Release
# .github/workflows/semantic-versioning-release.yml
name: Automated Semantic Versioning Release
on:
push:
branches:
- main
jobs:
linter:
uses: ./.github/workflows/linter.yml
publish-release:
release:
runs-on: ubuntu-latest
needs: linter
permissions:
contents: write # for creating tags and releases
issues: write # for commenting on issues
pull-requests: write # for commenting on PRs
steps:
- name: Publish Release
uses: ncipollo/release-action@v1
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{secrets.GITHUB_TOKEN}}
draft: false
prerelease: false
name: "0.0.${{ github.run_number }}"
tag: "0.0.${{ github.run_number }}"
body: "Release 0.0.${{ github.run_number }}"
# This is required to analyze the full commit history
fetch-depth: 0
- name: Automated Semantic Release
# This action wraps the popular semantic-release tool
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:
# GITHUB_TOKEN is required for authentication
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache busting version hashes
run: |
CSS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/css/module.css | cut -c1-8)
JS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/js/module.js | cut -c1-8)
echo "{\"css\":\"$CSS_HASH\",\"js\":\"$JS_HASH\"}" > modules/servers/VirtFusionDirect/templates/version.json
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add modules/servers/VirtFusionDirect/templates/version.json
git diff --cached --quiet || git commit -m "chore: update asset version hashes [skip ci]"
git push || true
# To make this work, you must follow the Conventional Commits specification.
# Examples:
# - fix: correct a typo in the documentation
# - feat: add a new user authentication endpoint
# - feat(api): add rate limiting
# BREAKING CHANGE: The API now returns 429 when rate limit is exceeded.

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/.idea/
/.superpowers/

13
.releaserc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"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}"
}]
]
}

98
CHANGELOG.md Normal file
View File

@@ -0,0 +1,98 @@
# 1.0.0 (2026-03-19)
### Bug Fixes
* add null/false guards, proper error handling, and VNC popup fix ([49fdd9e](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/49fdd9e49ba87bfb4b72dd741e15f790c1050033))
* OS gallery accordion auto-collapses other sections when one opens ([a9565ff](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/a9565ff6f920ab480a9298c055b8f4581786f3a4))
* OS gallery accordion layout and remove broken remote icon fetching ([9cd737c](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/9cd737c5d5d26587bea8fa40bf75f5e25544ff18))
* TestConnection for unsaved servers, traffic display, and cache-busting ([e8d2eb0](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/e8d2eb0aa1f173f13bb0b8d7dfca0acebb821ac7))
* XSS escaping, null guards, JS bug fixes, and documentation updates ([6c7cdc6](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/6c7cdc6421678390746adcee4877a7ade8f2a061))
### Features
* add client-side SSH Ed25519 key generator on order page ([209e01d](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/209e01deb6832dce76a307410fbab28b1e420093))
* add VNC check, SSH key paste, resources panel, sliders, and self-service billing ([1e471af](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/1e471affd0ae9a68358afa5704523bce9bb413d0))
* major enhancement — OS gallery, server rename, traffic chart, backups, VNC toggle, password reset, Redis caching, UX improvements ([90a97c4](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/90a97c4afb61a179eda40e23b97637dd90507b55))
* streamline network panel, conditional self-service, remove IP add endpoints ([e73e85c](https://git.ezscale.cloud/EZSCALE/virtfusion-whmcs-module/commit/e73e85c5a9faa79b50e4949328c1d2a3cbc49ddf))
# 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
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
## [0.0.18] - 2025-10-01
### Changed
- Updated GitHub Actions publish workflow
- Moved custom field SQL to `modify.sql` file
- Minor code tweaks
## [0.0.17] - 2024-01-16
### Fixed
- Fix in hooks.php (PR #2 by Prophet731)
## [0.0.16] - 2023-09-11
### Added
- GitHub issue templates
## [0.0.15] - 2023-09-10
### Fixed
- Typo fixes in module code
## [0.0.14] - 2023-09-10
### Fixed
- Fix hook event registration placement
## [0.0.13] - 2023-09-10
### Added
- Contributions from BlinkohHost
- Database-first package ID lookup with API fallback by product name
- Server build initialization on successful server creation
### Changed
- Custom fields changed to not required
- Removed linter workflow (not needed for this project)
- Code cleanup
## [0.0.9] - 2023-09-10
### Changed
- Refactored codebase to object-oriented architecture (OOP)
- Updated README with badges and documentation
## [0.0.6] - 2023-09-10
### Added
- Initial release
- Core provisioning: server create, suspend, unsuspend, terminate
- WHMCS hooks for dynamic OS template and SSH key dropdowns
- Checkout validation for OS selection
- Client area overview template with server information
- Admin services tab with server ID management
- Package change (upgrade/downgrade) support
- Configurable option mapping for dynamic resource allocation
- GitHub Actions CI/CD with semantic-release
- Security policy (SECURITY.md)
- License (GPL v3)

114
CLAUDE.md Normal file
View File

@@ -0,0 +1,114 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
VirtFusion Direct Provisioning Module for WHMCS — a PHP module that integrates WHMCS with the VirtFusion control panel API for automated VPS provisioning, management, and client self-service. No build system or package manager; the module is pure PHP installed by copying `modules/servers/VirtFusionDirect/` into a WHMCS installation.
## Development & Testing
There is no automated test suite, linter, or build step. Testing is manual:
- **Test connection:** WHMCS Admin → System Settings → Servers → Test Connection button
- **Dry run validation:** `VirtFusionDirect_validateServerConfig()` tests configuration without creating a server
- **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
## Release Process
Releases are automated via GitHub Actions using semantic-release on pushes to `main`. Use **conventional commits**:
- `fix:` → patch release
- `feat:` → minor release
- `BREAKING CHANGE:` in commit body → major release
## Architecture
**Namespace:** `WHMCS\Module\Server\VirtFusionDirect`
### Entry Points
| File | Purpose |
|------|---------|
| `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 |
| `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 |
### Core Classes (in `lib/`)
| 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. |
| `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. |
| `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`. |
| `ServerResource` | Transforms VirtFusion API response into flat key-value format for Smarty templates. |
| `AdminHTML` | Static methods generating admin services tab HTML (server ID editor, JSON viewer, action buttons). |
| `Log` | Thin wrapper around WHMCS module logging. |
### 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).
### Client-Side
- **`templates/overview.tpl`** — Smarty template for client area (server info, power, network, rebuild, resources, VNC, self-service billing, billing overview)
- **`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/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`)
### Removed Features
- **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
- **Upgrade/Downgrade link** — Removed from resources panel
### Data Flow: Server Creation
1. WHMCS calls `VirtFusionDirect_CreateAccount()``ModuleFunctions::createAccount()`
2. Checks/creates VirtFusion user via external relation ID (WHMCS client ID)
3. Reads configurable options (Package, Location, IPv4, Memory, CPU, Bandwidth, etc.)
4. Dry-run validation → actual API POST to `/servers`
5. Stores server ID in `mod_virtfusion_direct` table
6. Updates WHMCS hosting record (IP, username, password, domain)
7. Calls `ConfigureService::initServerBuild()` with selected OS + SSH key
### 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`.
## Security Patterns
- All PHP files start with `if (!defined("WHMCS")) die()` to prevent direct access
- Client endpoints validate WHMCS session AND service ownership before any operation
- API tokens stored encrypted in WHMCS server password field (decrypted via `localAPI('DecryptPassword')`)
- Input validation: type casting, regex filtering, `filter_var()` for IP addresses
- Output escaping: `htmlspecialchars()` in Smarty, `encodeURIComponent()` / `.text()` in JS
- SSL verification enabled on all API calls (`CURLOPT_SSL_VERIFYPEER` + `CURLOPT_SSL_VERIFYHOST = 2`)
## VirtFusion API Compatibility
- **API reference (OpenAPI spec):** https://docs.virtfusion.com/api/openapi.yaml
- **Base features:** VirtFusion v1.7.3+
- **VNC console:** v6.1.0+
- **Resource modification:** v6.2.0+
- **Self-service billing:** Requires self-service feature enabled in VirtFusion
## Product Config Options
| Option | Name | Description | Default |
|--------|------|-------------|---------|
| configoption1 | Hypervisor Group ID | VirtFusion hypervisor group for server placement | 1 |
| configoption2 | Package ID | VirtFusion package defining server resources | 1 |
| configoption3 | Default IPv4 | Number of IPv4 addresses to assign (0-10) | 1 |
| 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 |
| configoption6 | Auto Top-Off Amount | Credit amount to add on auto top-off | 100 |
## WHMCS Compatibility
- WHMCS 8.x+ (tested 8.08.10)
- PHP 8.0+ with cURL extension
- All WHMCS themes supported (Six, Twenty-One, Lagom, custom) via Bootstrap 3/4/5 dual classes

606
README.md
View File

@@ -1,33 +1,607 @@
[![GitHub Super-Linter](https://github.com/EZSCALE/virtfusion-whmcs-module/actions/workflows/linter/badge.svg)](https://github.com/marketplace/actions/super-linter)
# VirtFusion Direct Provisioning Module for WHMCS
[![Automated Release](https://github.com/EZSCALE/virtfusion-whmcs-module/actions/workflows/publish-release.yml/badge.svg)](https://github.com/EZSCALE/virtfusion-whmcs-module/actions)
![GitHub](https://img.shields.io/github/license/EZSCALE/virtfusion-whmcs-module)
![GitHub issues](https://img.shields.io/github/issues/EZSCALE/virtfusion-whmcs-module)
![GitHub pull requests](https://img.shields.io/github/issues-pr/EZSCALE/virtfusion-whmcs-module)
# VirtFusion Direct Provisioning Module for WHMCS
A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.com) that enables automated VPS server provisioning, management, and client self-service directly from WHMCS.
This module requires VirtFusion v1.7.3 or higher as this is what it's based on. Please refer to the official [documenataion](https://docs.virtfusion.com/integrations/whmcs).
## Table of Contents
- [Requirements](#requirements)
- [Features](#features)
- [Installation](#installation)
- [Upgrading](#upgrading)
- [Configuration](#configuration)
- [Server Setup](#server-setup)
- [Product Setup](#product-setup)
- [Custom Fields](#custom-fields)
- [Module Configuration Options](#module-configuration-options)
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
- [Custom Option Name Mapping](#custom-option-name-mapping)
- [Client Area Features](#client-area-features)
- [Admin Area Features](#admin-area-features)
- [Theme Compatibility](#theme-compatibility)
- [API Endpoints Used](#api-endpoints-used)
- [Usage Update (Cron)](#usage-update-cron)
- [Troubleshooting](#troubleshooting)
- [Known Issues](#known-issues)
- [Security](#security)
- [Contributing](#contributing)
- [License](#license)
## Requirements
| Requirement | Minimum Version | Notes |
|---|---|---|
| **VirtFusion** | v1.7.3+ | v6.1.0+ required for VNC console |
| **WHMCS** | 8.x+ | Tested with 8.0 through 8.10 |
| **PHP** | 8.0+ | With cURL extension enabled |
| **SSL** | Valid certificate | Required on VirtFusion panel |
You also need a VirtFusion API token with the following permissions:
- Server management (create, read, update, delete, power, build)
- User management (create, read, reset password, authentication tokens)
- Package and template read access
- Network management (if using IP management features)
## Features
### Server Provisioning
- Automatic server creation with VirtFusion user account linking
- Server suspension, unsuspension, and termination
- Package/plan upgrades and downgrades
- Configurable options mapping for dynamic resource allocation (CPU, RAM, disk, bandwidth, network speed)
- **Dry run validation** - Test server creation parameters before provisioning
- Automatic memory unit conversion (GB to MB for values < 1024)
### Client Area - Server Management
- **Server Overview** - Real-time server info (hostname, IPs, resources) with status badge
- **Power Management** - Start, restart, graceful shutdown, and force power off
- **Control Panel SSO** - One-click login to VirtFusion panel
- **Server Rebuild** - Reinstall with any available OS template
- **Password Reset** - Reset VirtFusion panel login credentials
- **Network Management** - View and remove IPv4 addresses; view IPv6 subnets
- **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)
- **Self-Service Billing** - Credit balance display, usage breakdown, and credit top-up (when enabled)
- **Bandwidth Usage** - Traffic usage display with allocation limits
- **Billing Overview** - Product, billing cycle, dates, and payment information
### Admin Area
- **Test Connection** - Verify API connectivity from WHMCS
- **Server Data Display** - Live server information from VirtFusion
- **Admin Impersonation** - Log into VirtFusion panel as server owner
- **Server ID Management** - Editable Server ID for manual adjustments
- **Server Object Viewer** - Full JSON response from VirtFusion API
- **Validate Server Config** - Dry run server creation to check configuration
- **Update Server Object** - Refresh cached server data from VirtFusion
### Ordering Process
- Dynamic OS template dropdown populated from VirtFusion API
- 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
- Checkout validation ensuring OS selection before order placement
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
- Compatible with all WHMCS order form templates
### Usage Tracking
- **Automated bandwidth sync** - WHMCS daily cron pulls traffic usage from VirtFusion
- **Disk usage sync** - Storage usage updated automatically
- Visible in WHMCS client area and admin product details
### Backup Management
- Assign backup plans to servers via the VirtFusion API
- Remove backup plans from servers
### Resource Modification
- In-place modification of server resources (memory, CPU cores, traffic)
- No server rebuild required for resource changes
- **Package change** now also applies individual resource modifications from configurable options
### Self-Service Billing
- Credit balance display and top-up from client area
- Usage breakdown reporting
- Auto top-off via WHMCS cron when credit falls below threshold
- Self-service mode configurable per product (Hourly, Resource Packs, or Both)
## Installation
1. Download the latest release from the [releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases) page.
2. Extract the contents of the archive and upload the modules folder to your WHMCS installation directory.
### Step 1: Download & Install
Download the latest release from the [releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases) page, or install directly via the command line:
```bash
cd /tmp
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git
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.
The resulting file structure should be:
```
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
1. Go to **Configuration > System Settings > Servers**
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
1. Go to **Configuration > System Settings > Products/Services**
2. Create a new product or edit an existing one
3. On the **Module Settings** tab:
- Set **Module Name** to "VirtFusion Direct Provisioning"
- Select your VirtFusion server
- Set **Hypervisor Group ID**, **Package ID**, and **Default IPv4** count
4. Save the product
### Step 4: Set Up Custom Fields
See [Custom Fields](#custom-fields) section below.
### Step 5: Activate Hooks
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.
## 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
cd /tmp
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git
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
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.
## Configuration
You'll need to configure the following constraints in your `configuration.php` file.
This is only temporary and will be replaced with pulling the token from the database in the future.
### Server Setup
```php
// VirtFusion API URL
const VIRTFUSION_API_URL = "https://your-virtfusion-url.com/api/v1";
In WHMCS Admin under **Configuration > System Settings > Servers**:
// VirtFusion API Token
const VIRT_TOKEN = "your-virtfusion-token";
| Field | Value |
|---|---|
| Hostname | Your VirtFusion panel domain (e.g., `cp.example.com`) |
| Password | Your VirtFusion API token |
| Type | VirtFusion Direct Provisioning |
**Important**: Do not include `https://` or `/api/v1` in the hostname. The module constructs the full URL automatically.
### Product Setup
Each WHMCS product using this module needs:
1. Module set to "VirtFusion Direct Provisioning"
2. A linked server (or the module will use any available VirtFusion server)
3. The three configuration options set (Hypervisor Group ID, Package ID, Default IPv4)
4. Custom fields created (see below)
### Custom Fields
You **must** create two custom fields on each product that uses this module:
| Field Name | Field Type | Show on Order Form | Admin Only | Required |
|---|---|---|---|---|
| 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
```
## What does this module change?
### Module Configuration Options
This module changes the following things:
Each product has three module-specific settings:
- Adds configurable options to the product configuration page to allow the user to select the operating system and add
a ssh key to the initial deployment.
| Option | Name | Description | Default |
|---|---|---|---|
| Config Option 1 | Hypervisor Group ID | VirtFusion hypervisor group for server placement | 1 |
| Config Option 2 | Package ID | VirtFusion package defining server resources | 1 |
| Config Option 3 | Default IPv4 | Number of IPv4 addresses to assign (0-10) | 1 |
| 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 6 | Auto Top-Off Amount | Credit amount to add when auto top-off triggers | 100 |
You can find your Hypervisor Group IDs and Package IDs in the VirtFusion admin panel.
### Configurable Options (Dynamic Pricing)
To allow customers to select different resource levels with pricing tiers, create WHMCS Configurable Options groups with these option names:
| VirtFusion Parameter | Default Option Name | Description | Unit |
|---|---|---|---|
| `packageId` | Package | VirtFusion package ID | ID |
| `hypervisorId` | Location | Hypervisor group for placement | ID |
| `ipv4` | IPv4 | Number of IPv4 addresses | Count |
| `storage` | Storage | Disk space | GB |
| `memory` | Memory | RAM (values < 1024 auto-converted from GB) | MB |
| `traffic` | Bandwidth | Monthly traffic allowance | GB |
| `cpuCores` | CPU Cores | Number of CPU cores | Count |
| `networkSpeedInbound` | Inbound Network Speed | Inbound speed | Mbps |
| `networkSpeedOutbound` | Outbound Network Speed | Outbound speed | Mbps |
| `networkProfile` | Network Type | VirtFusion network profile | ID |
| `storageProfile` | Storage Type | VirtFusion storage profile | ID |
### Custom Option Name Mapping
If your configurable option names differ from the defaults above:
1. Copy `config/ConfigOptionMapping-example.php` to `config/ConfigOptionMapping.php`
2. Edit the mapping array:
```php
return [
'memory' => 'RAM', // Your option name for memory
'cpuCores' => 'vCPU Count', // Your option name for CPU
'traffic' => 'Data Transfer', // Your option name for bandwidth
// ... add only the options that differ from defaults
];
```
## Client Area Features
### Server Overview
Displays real-time server information fetched from VirtFusion:
- Server name and hostname
- Memory, CPU cores, storage allocation
- IPv4 and IPv6 addresses
- Traffic usage vs. allocation
- Server status badge (Active, Suspended, etc.)
### Power Management
Four power control buttons:
- **Start** - Boot the server
- **Restart** - Graceful restart
- **Shutdown** - Graceful ACPI shutdown
- **Force Off** - Immediate power cut (use with caution)
### Network Management
- View all IPv4 addresses and IPv6 subnets assigned to the server
- Remove secondary IPv4 addresses (primary cannot be removed)
### VNC Console
- Opens a browser-based VNC console to the server
- Requires VirtFusion v6.1.0+ and the server must be running
- Opens in a new browser window/tab
### Server Rebuild
- Select from available OS templates (filtered by server package)
- Includes a confirmation dialog warning about data loss
- Triggers email notification on completion
### Control Panel SSO
- One-click login to the VirtFusion panel
- Opens in a new window (with fallback to same-window navigation)
- Password reset option for direct VirtFusion panel access
### Billing Overview
- Product name and group
- Recurring amount and billing cycle
- Registration and next due dates
- Payment method
## Admin Area Features
### Admin Services Tab
When viewing a service in WHMCS admin, the module adds:
- **Server ID** - Editable field showing the VirtFusion server ID
- **Server Info** - Button to load live data from VirtFusion API
- **Server Object** - Full JSON response viewer
- **Options** - Admin impersonation link
### Module Commands (Admin Buttons)
- **Create** - Provision a new server
- **Suspend** / **Unsuspend** - Manage server suspension
- **Terminate** - Delete the server (with 5-minute grace period in VirtFusion)
- **Change Package** - Update server to a different VirtFusion package
- **Update Server Object** - Refresh cached data from VirtFusion
- **Validate Server Config** - Dry run server creation to test configuration
## Theme Compatibility
This module is designed to work with **all WHMCS themes**:
| Theme | Status | Notes |
|---|---|---|
| Six (default) | Fully compatible | Bootstrap 3 |
| Twenty-One | Fully compatible | Bootstrap 4 |
| Lagom (ModulesGarden) | Fully compatible | Bootstrap 5 |
| Custom themes | Compatible | Uses dual CSS classes |
### How Theme Compatibility Works
The module uses dual CSS class names that work across Bootstrap versions:
- `panel card` - Works in BS3 (panel) and BS4/BS5 (card)
- `panel-heading card-header` - Works in BS3 and BS4/BS5
- `panel-body card-body` - Works in BS3 and BS4/BS5
- `panel-title card-title` - Works in BS3 and BS4/BS5
The order form hooks use vanilla JavaScript (no jQuery dependency) for maximum compatibility.
### Theme Override
To customize templates for a specific theme:
```
/templates/yourthemename/modules/servers/VirtFusionDirect/
overview.tpl # Client area template
error.tpl # Error template
```
WHMCS automatically loads theme-specific templates when they exist. Copy the originals from `modules/servers/VirtFusionDirect/templates/` as a starting point.
## API Endpoints Used
### Core Provisioning
| Method | Endpoint | Purpose |
|---|---|---|
| `GET` | `/connect` | Connection testing |
| `GET/POST` | `/users` | User lookup and creation |
| `GET` | `/users/{id}/byExtRelation` | Find VirtFusion user by WHMCS ID |
| `POST` | `/servers` | Server creation |
| `POST` | `/servers?dryRun=true` | Dry run validation |
| `POST` | `/servers/{id}/build` | OS installation / rebuild |
| `GET` | `/servers/{id}` | Server details (also used by UsageUpdate) |
| `DELETE` | `/servers/{id}` | Server termination |
| `POST` | `/servers/{id}/suspend` | Server suspension |
| `POST` | `/servers/{id}/unsuspend` | Server unsuspension |
| `PUT` | `/servers/{id}/package/{pkgId}` | Package changes |
### Client Management
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/servers/{id}/power/{action}` | Power management |
| `PATCH` | `/servers/{id}/name` | Server renaming |
| `POST` | `/users/{id}/serverAuthenticationTokens/{serverId}` | SSO token |
| `POST` | `/users/{id}/byExtRelation/resetPassword` | Password reset |
| `GET` | `/media/templates/fromServerPackageSpec/{id}` | OS templates |
| `GET` | `/ssh_keys/user/{id}` | SSH key listing |
### Network
| Method | Endpoint | Purpose |
|---|---|---|
| `DELETE` | `/servers/{id}/ipv4` | Remove IPv4 address |
### SSH Keys
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/ssh_keys` | Create SSH key for a user (checkout key paste) |
### Self-Service Billing
| Method | Endpoint | Purpose |
|---|---|---|
| `GET` | `/selfService/usage/byUserExtRelationId/{id}` | Usage data 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 |
| `GET` | `/selfService/currencies` | Available self-service currencies |
### Advanced
| Method | Endpoint | Purpose |
|---|---|---|
| `GET` | `/servers/{id}/vnc` | VNC console (v6.1.0+) |
| `PUT` | `/servers/{id}/modify/memory` | Modify memory (v6.2.0+) |
| `PUT` | `/servers/{id}/modify/cpuCores` | Modify CPU cores (v6.2.0+) |
| `PUT` | `/servers/{id}/modify/traffic` | Modify traffic (v6.0.0+) |
| `POST/DELETE` | `/servers/{id}/backup/plan` | Backup plan management (v4.3.0+) |
## Usage Update (Cron)
The module implements the `UsageUpdate` function that is called by the WHMCS daily cron. It automatically syncs:
- **Disk usage** (used and limit) from VirtFusion to WHMCS `tblhosting`
- **Bandwidth usage** (used and limit) from VirtFusion to WHMCS `tblhosting`
This data appears in the WHMCS client area and admin product details.
**Requirements**: The WHMCS cron must be running (`php -q /path/to/whmcs/crons/cron.php`). No additional configuration is needed - the module registers itself automatically.
**How it works**:
1. WHMCS calls `VirtFusionDirect_UsageUpdate()` once per configured server
2. The module queries all Active services assigned to that server
3. For each service, it fetches server data from VirtFusion API
4. Disk and bandwidth usage/limits are written to `tblhosting`
**Data format conversion**:
- VirtFusion traffic: bytes -> WHMCS expects: MB
- VirtFusion storage: bytes -> WHMCS expects: MB
- VirtFusion storage limit: GB -> WHMCS expects: MB
- VirtFusion traffic limit: GB -> WHMCS expects: MB (0 = unlimited)
## Troubleshooting
### Connection Test Fails
| Symptom | Cause | Solution |
|---|---|---|
| "Authentication failed" | Invalid or expired API token | Generate a new token in VirtFusion |
| "Connection failed" | Hostname unreachable or SSL issue | Verify hostname, check SSL cert validity |
| "Unexpected response" | API version mismatch or server issue | Check VirtFusion is running, verify API version |
### Server Creation Fails
| Symptom | Cause | Solution |
|---|---|---|
| "Service already exists" | Duplicate provisioning attempt | Run termination first, then create |
| "No Control server found" | No VirtFusion server in WHMCS | Add server in System Settings > Servers |
| "Unable to create user" | API permission issue | Check token has user create permission |
| "Server creation failed" | Invalid config options | Use "Validate Server Config" button to diagnose |
| HTTP 423 response | Server is locked | Wait and retry, or check VirtFusion for lock reason |
### OS Templates Not Showing on Order Form
1. Verify the **Package ID** (Config Option 2) is correct
2. Check that the package has OS templates assigned in VirtFusion
3. Ensure the **"Initial Operating System"** custom field exists (exact name match required)
4. Check that hooks are loading: re-save product settings to trigger hook detection
5. Inspect browser console for JavaScript errors
### Client Area Shows Error Template
1. Ensure a VirtFusion server is configured and linked to the product
2. Check the service status is Active or Suspended (not Pending/Terminated)
3. Review **Utilities > Logs > Module Log** for API errors
4. Verify the `mod_virtfusion_direct` table has an entry for the service
### SSO / Control Panel Login Fails
1. VirtFusion panel must be accessible from the client's browser
2. Verify the VirtFusion user exists (check by external relation ID in VirtFusion admin)
3. Ensure authentication token generation is enabled on the API token
4. Check for popup blockers if the new window doesn't open
### VNC Console Not Working
1. Requires VirtFusion v6.1.0 or higher
2. The server must be powered on and running
3. Check that VNC is enabled for the hypervisor in VirtFusion
4. Popup blockers may prevent the console window from opening
### UsageUpdate Not Syncing
1. Verify the WHMCS cron is running: `php -q /path/to/whmcs/crons/cron.php`
2. Check **Utilities > Logs > Module Log** for UsageUpdate errors
3. Ensure services are in "Active" status (other statuses are skipped)
4. The cron runs daily; wait for the next cycle after initial setup
## Known Issues
1. **VNC Console** - Requires VirtFusion v6.1.0+. Earlier versions do not expose a VNC API endpoint. The module gracefully handles this by showing an error message.
2. **Resource Modification** - Memory and CPU modification requires VirtFusion v6.2.0+. Traffic modification requires v6.0.0+. Backup management requires v4.3.0+.
3. **IPv6 Display** - IPv6 subnet display depends on the VirtFusion installation having IPv6 pools configured. If no IPv6 is assigned, the network panel shows "No IPv6 subnets".
4. **Order Form Custom Fields** - The custom fields ("Initial Operating System" and "Initial SSH Key") must be named exactly as specified. The module matches by field name with spaces removed and converted to lowercase.
5. **Hooks File Detection** - WHMCS detects the `hooks.php` file when the module is first activated. If you add the module files to an already-active installation, you may need to deactivate and reactivate the module, or re-save the product settings.
6. **Bootstrap 3 Themes** - While the module supports BS3 themes, some visual differences may exist (e.g., `d-flex` not available in BS3). The module uses `display: flex` in CSS as a fallback.
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.
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
### Architecture
- All client API endpoints validate service ownership before processing
- Admin endpoints require WHMCS admin authentication
- Input sanitization on all user-supplied parameters (type casting, regex filtering, `filter_var`)
- Proper HTTP status codes (401, 403, 400, 500) for error responses
- XSS prevention via `htmlspecialchars()`, `encodeURIComponent()`, and jQuery `.text()`
### Best Practices
- **API Tokens**: Store only in the WHMCS server password field (encrypted at rest by WHMCS)
- **SSL Verification**: Enabled by default. Never disable in production.
- **File Access**: All PHP files include direct access prevention checks
- **Module Updates**: Keep updated for security patches
- **Permissions**: Use the minimum required API token permissions
### Reporting Vulnerabilities
If you discover a security vulnerability, please report it responsibly by emailing the maintainers rather than opening a public issue. See [SECURITY.md](SECURITY.md) for details.
## File Structure
```
modules/servers/VirtFusionDirect/
VirtFusionDirect.php # WHMCS module entry point (MetaData, ConfigOptions, all module functions)
client.php # Client-facing AJAX API (authenticated, ownership-validated)
admin.php # Admin-facing AJAX API (admin authentication required)
hooks.php # WHMCS hooks (order form OS/SSH dropdowns, checkout validation)
modify.sql # SQL for creating custom fields
lib/
Module.php # Base class: API communication, power, network, VNC, rebuild
ModuleFunctions.php # Provisioning: create, suspend, unsuspend, terminate, change package
ConfigureService.php # Order configuration: OS templates, SSH keys, server build init
Database.php # Database operations: custom table, WHMCS table queries
Curl.php # HTTP client: GET, POST, PUT, PATCH, DELETE with SSL verification
ServerResource.php # Data transformer: VirtFusion API response -> display format
AdminHTML.php # Admin interface: HTML generation for admin services tab
Log.php # Logging: WHMCS module log integration
templates/
overview.tpl # Client area Smarty template (all management panels)
error.tpl # Error display template
css/module.css # Module styles (responsive, BS3/4/5 compatible)
js/module.js # Client JavaScript (all AJAX interactions)
js/keygen.js # SSH Ed25519 key generator (Web Crypto API)
config/
ConfigOptionMapping-example.php # Example custom option name mapping
```
## Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Commit your changes with clear messages
4. Push to your fork and open a Pull Request
For bug reports, please include:
- WHMCS version
- VirtFusion version
- PHP version
- Steps to reproduce
- Module Log output (Utilities > Logs > Module Log)
## License
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE.md](LICENSE.md) file for details.
Copyright (c) EZSCALE

View File

@@ -2,11 +2,11 @@
## Supported Versions
The support version of this module with VirtFusion
Supported VirtFusion versions:
| Version | Supported |
|---------|--------------------|
| > 1.7.3 | :white_check_mark: |
|----------|--------------------|
| >= 1.7.3 | :white_check_mark: |
| < 1.7.3 | :x: |
## Reporting a Vulnerability

View File

@@ -0,0 +1,7 @@
# VirtFusion OpenAPI Baseline
# This file will be auto-populated by the api-sync-check workflow
# on first run. Do not edit manually.
openapi: "3.0.0"
info:
title: VirtFusion API Baseline Placeholder
version: "0.0.0"

49
modify.sql Normal file
View File

@@ -0,0 +1,49 @@
-- 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);

View File

@@ -5,6 +5,8 @@ if (!defined("WHMCS")) {
}
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
use WHMCS\Module\Server\VirtFusionDirect\Module;
use WHMCS\Module\Server\VirtFusionDirect\Database;
function VirtFusionDirect_MetaData()
{
@@ -12,7 +14,7 @@ function VirtFusionDirect_MetaData()
'DisplayName' => 'VirtFusion Direct Provisioning',
'APIVersion' => '1.1',
'RequiresServer' => true,
'ServiceSingleSignOnLabel' => false,
'ServiceSingleSignOnLabel' => 'Login to VirtFusion Panel',
'AdminSingleSignOnLabel' => false,
];
}
@@ -24,39 +26,109 @@ function VirtFusionDirect_ConfigOptions()
"FriendlyName" => "Hypervisor Group ID",
"Type" => "text",
"Size" => "20",
"Description" => "The default hypervisor group ID",
"Description" => "The default hypervisor group ID for server placement.",
"Default" => "1",
],
"packageID" => [
"FriendlyName" => "Package ID",
"Type" => "text",
"Size" => "20",
"Description" => "The package ID",
"Description" => "The VirtFusion package ID that defines server resources.",
"Default" => "1",
],
"defaultIPv4" => [
"FriendlyName" => "Default IPv4",
"Type" => "dropdown",
"Options" => "0,1,2,3,4,5,6,7,8,9,10",
"Description" => "The default amount of IPv4 addresses to assign to the server.",
"Description" => "The default number of IPv4 addresses to assign to each server.",
"Default" => "1",
],
"selfServiceMode" => [
"FriendlyName" => "Self-Service Mode",
"Type" => "dropdown",
"Options" => "0|Disabled,1|Hourly,2|Resource Packs,3|Both",
"Description" => "Enable VirtFusion self-service billing for users created by this product.",
"Default" => "0",
],
"autoTopOffThreshold" => [
"FriendlyName" => "Auto Top-Off Threshold",
"Type" => "text",
"Size" => "10",
"Description" => "Credit balance below which auto top-off triggers during cron. 0 = disabled.",
"Default" => "0",
],
"autoTopOffAmount" => [
"FriendlyName" => "Auto Top-Off Amount",
"Type" => "text",
"Size" => "10",
"Description" => "Credit amount to add when auto top-off triggers.",
"Default" => "100",
],
];
}
function VirtFusionDirect_TestConnection(array $params)
{
try {
$hostname = trim($params['serverhostname'] ?? '');
$password = $params['serverpassword'] ?? '';
if (empty($hostname) || empty($password)) {
return ['success' => false, 'error' => 'Server hostname and password are required. Please verify the server configuration.'];
}
$url = 'https://' . $hostname . '/api/v1';
$module = new Module();
$request = $module->initCurl($password);
$data = $request->get($url . '/connect');
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200) {
return ['success' => true, 'error' => ''];
}
if ($httpCode == 401) {
return ['success' => false, 'error' => 'Authentication failed. Please verify your API token is correct and has not expired.'];
}
if ($httpCode == 0) {
$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' => 'Unexpected response from VirtFusion API (HTTP ' . $httpCode . '). Please check the server configuration.'];
} catch (\Throwable $e) {
return ['success' => false, 'error' => 'Connection test failed: ' . $e->getMessage()];
}
}
function VirtFusionDirect_AdminCustomButtonArray()
{
$buttonarray = array(
return [
"Update Server Object" => "updateServerObject",
);
return $buttonarray;
"Validate Server Config" => "validateServerConfig",
];
}
function VirtFusionDirect_ServiceSingleSignOn(array $params)
{
try {
$module = new Module();
$token = $module->fetchLoginTokens($params['serviceid']);
if ($token) {
return ['success' => true, 'redirectTo' => $token];
}
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) {
return ['success' => false, 'errorMsg' => $e->getMessage()];
}
}
/**
*
*
* Service functions
*
*/
function VirtFusionDirect_CreateAccount(array $params)
{
@@ -83,9 +155,15 @@ function VirtFusionDirect_updateServerObject(array $params)
return (new ModuleFunctions())->updateServerObject($params);
}
/**
* Allows changing of the package of a server
*
* @param array $params
* @return string
*/
function VirtFusionDirect_ChangePackage(array $params)
{
return 'success';
return (new ModuleFunctions())->changePackage($params);
}
function VirtFusionDirect_AdminServicesTabFields(array $params)
@@ -102,3 +180,122 @@ function VirtFusionDirect_ClientArea(array $params)
{
return (new ModuleFunctions())->clientArea($params);
}
/**
* Validates server configuration via dry run without creating the server.
*
* @param array $params
* @return string 'success' or error message
*/
function VirtFusionDirect_validateServerConfig(array $params)
{
return (new ModuleFunctions())->validateServerConfig($params);
}
/**
* Usage Update - called by WHMCS daily cron to sync bandwidth and disk usage.
*
* Updates tblhosting with disk and bandwidth usage data from VirtFusion.
* Fields updated: diskused, disklimit, bwused, bwlimit, lastupdate
*
* @param array $params Server access credentials
* @return string 'success' or error message
*/
function VirtFusionDirect_UsageUpdate(array $params)
{
try {
$module = new Module();
$cp = $module->getCP($params['serverid']);
if (!$cp) {
return 'No control server found for usage update.';
}
$services = \WHMCS\Database\Capsule::table('tblhosting')
->where('server', $params['serverid'])
->where('domainstatus', 'Active')
->get();
foreach ($services as $service) {
try {
$systemService = Database::getSystemService($service->id);
if (!$systemService) {
continue;
}
$request = $module->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/servers/' . (int) $systemService->server_id);
if ($request->getRequestInfo('http_code') != 200) {
continue;
}
$serverData = json_decode($data, true);
if (!isset($serverData['data'])) {
continue;
}
$server = $serverData['data'];
$update = [];
// Disk usage (WHMCS expects MB)
if (isset($server['usage']['storage']['used'])) {
$update['diskused'] = round($server['usage']['storage']['used'] / 1048576);
}
if (isset($server['settings']['resources']['storage'])) {
$update['disklimit'] = (int) $server['settings']['resources']['storage'] * 1024;
}
// Bandwidth usage (WHMCS expects MB)
if (isset($server['usage']['traffic']['used'])) {
$update['bwused'] = round($server['usage']['traffic']['used'] / 1048576);
}
if (isset($server['settings']['resources']['traffic'])) {
$trafficGB = (int) $server['settings']['resources']['traffic'];
$update['bwlimit'] = $trafficGB > 0 ? $trafficGB * 1024 : 0;
}
if (!empty($update)) {
$update['lastupdate'] = date('Y-m-d H:i:s');
\WHMCS\Database\Capsule::table('tblhosting')
->where('id', $service->id)
->update($update);
}
// Self-service auto top-off
$product = \WHMCS\Database\Capsule::table('tblproducts')
->where('id', $service->packageid)
->first();
if ($product) {
$threshold = (float) ($product->configoption5 ?? 0);
$topOffAmount = (float) ($product->configoption6 ?? 0);
if ($threshold > 0 && $topOffAmount > 0) {
$usageData = $module->getSelfServiceUsage($service->id);
if ($usageData) {
$usageInner = $usageData['data'] ?? $usageData;
$credit = $usageInner['credit'] ?? $usageInner['balance'] ?? null;
if ($credit !== null && (float) $credit < $threshold) {
$module->addSelfServiceCredit($service->id, $topOffAmount, 'Auto top-off');
\WHMCS\Module\Server\VirtFusionDirect\Log::insert(
'UsageUpdate:autoTopOff',
['serviceId' => $service->id, 'credit' => $credit, 'threshold' => $threshold],
['amount' => $topOffAmount]
);
}
}
}
}
} catch (\Exception $e) {
// Log but continue processing other services
\WHMCS\Module\Server\VirtFusionDirect\Log::insert('UsageUpdate:service:' . $service->id, [], $e->getMessage());
continue;
}
}
return 'success';
} catch (\Exception $e) {
return 'Usage update failed: ' . $e->getMessage();
}
}

View File

@@ -26,17 +26,17 @@ switch ($vf->validateAction(true)) {
$whmcsService = Database::getWhmcsService((int)$_GET['serviceID']);
if (!$whmcsService) {
$vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 404);
}
if ($whmcsService->domainstatus == 'Pending' || $whmcsService->domainstatus == 'Terminated' || $whmcsService->domainstatus == 'Cancelled' || $whmcsService->domainstatus == 'Fraud') {
$vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 400);
}
$data = $vf->fetchServerData((int)$_GET['serviceID']);
if (!$data) {
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 502);
}
@@ -58,12 +58,21 @@ switch ($vf->validateAction(true)) {
$service = Database::getSystemService((int)$_GET['serviceID']);
if (!$service) {
$vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 404);
}
$whmcsService = Database::getWhmcsService((int)$_GET['serviceID']);
if (!$whmcsService) {
$vf->output(['success' => false, 'errors' => 'WHMCS service not found'], true, true, 404);
}
$cp = $vf->getCP($whmcsService->server);
if (!$cp) {
$vf->output(['success' => false, 'errors' => 'Control server not found'], true, true, 500);
}
$request = $vf->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/users/' . $whmcsService->userid . '/byExtRelation');
@@ -72,7 +81,7 @@ switch ($vf->validateAction(true)) {
$vf->output(['success' => true, 'url' => $cp['base_url'], 'user' => json_decode($data, true)['data']], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Received HTTP code ' . $request->getRequestInfo('http_code')], true, true, 200);
$vf->output(['success' => false, 'errors' => 'Received HTTP code ' . $request->getRequestInfo('http_code')], true, true, 502);
}
break;
@@ -80,6 +89,5 @@ switch ($vf->validateAction(true)) {
default:
/** No valid action was specified **/
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}

View File

@@ -9,103 +9,392 @@ $vf = new Module();
$vf->isAuthenticated();
switch ($vf->validateAction(true)) {
$action = $vf->validateAction(true);
switch ($action) {
/**
*
* Reset Password.
*
*/
case 'resetPassword':
if ($vf->validateServiceID(true)) {
$client = $vf->validateUserOwnsService((int)$_GET['serviceID']);
$serviceID = $vf->validateServiceID(true);
$client = $vf->validateUserOwnsService($serviceID);
if (!$client) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$data = $vf->resetUserPassword((int)$_GET['serviceID'], $client);
$data = $vf->resetUserPassword($serviceID, $client);
if ($data) {
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'error'], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
break;
/**
*
* Get server information.
*
*/
case 'serverData':
if ($vf->validateServiceID(true)) {
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService((int)$_GET['serviceID'])) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 200);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$data = $vf->fetchServerData((int)$_GET['serviceID']);
$data = $vf->fetchServerData($serviceID);
if ($data) {
(new Module())->updateWhmcsServiceParamsOnServerObject((int)$_GET['serviceID'], $data);
(new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'error'], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
break;
/**
*
* Login as server owner.
*
*/
case 'loginAsServerOwner':
if ($vf->validateServiceID(true)) {
/**
* A client can't log in as any user. Ownership should be validated.
*/
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService((int)$_GET['serviceID'])) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 200);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$token = $vf->fetchLoginTokens((int)$_GET['serviceID']);
$token = $vf->fetchLoginTokens($serviceID);
if ($token) {
/**
* A valid token/url was received.
*/
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
break;
/**
* Failed to get the token from the control panel or the service ID doesn't exist.
* Power management actions: boot, shutdown, restart, poweroff
*/
$vf->output(['success' => false, 'errors' => 'token request error'], true, true, 200);
case 'powerAction':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
if (!in_array($powerAction, $allowedActions, true)) {
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
break;
}
$result = $vf->serverPowerAction($serviceID, $powerAction);
if ($result) {
$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);
break;
/**
* Rebuild/reinstall server with new OS.
*/
case 'rebuild':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
if ($osId <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
break;
}
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
if ($result) {
$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);
break;
/**
* Rename server.
*/
case 'rename':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
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;
}
$result = $vf->renameServer($serviceID, $newName);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
break;
/**
* Get available OS templates for rebuild.
*/
case 'osTemplates':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$templates = $vf->fetchOsTemplates($serviceID);
if ($templates !== false) {
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
break;
// =================================================================
// Server Password Reset
// =================================================================
/**
* Reset server root password.
*/
case 'resetServerPassword':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->resetServerPassword($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
break;
// =================================================================
// Backup Listing
// =================================================================
/**
* Get server backups.
*/
case 'backups':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getServerBackups($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve backups'], true, true, 500);
break;
// =================================================================
// Traffic Statistics
// =================================================================
/**
* Get traffic statistics for a server.
*/
case 'trafficStats':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getTrafficStats($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve traffic statistics'], true, true, 500);
break;
// =================================================================
// VNC Console
// =================================================================
/**
* Get VNC console URL.
*/
case 'vnc':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getVncConsole($serviceID);
if ($result !== false) {
$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);
break;
/**
* Toggle VNC on/off.
*/
case 'toggleVnc':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
$result = $vf->toggleVnc($serviceID, $enabled);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Failed to toggle VNC'], true, true, 500);
break;
// =================================================================
// 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;
default:
/**
*
* No valid action was specified.
*
*/
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 200);
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}

View File

@@ -1,5 +1,7 @@
<?php
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\User\User;
if (!defined("WHMCS")) {
@@ -7,226 +9,539 @@ if (!defined("WHMCS")) {
}
/**
* You'll need to configure the following constrants in your configuration.php file.
* This is only temporary and will be replaced with pulling the token from the database in the future.
* Shopping Cart Validation Hook
*
* const VIRTFUSION_API_URL = "https://your-virtfusion-url.com/api/v1";
* Validates that an operating system has been selected before checkout
* for all VirtFusion products in the cart.
*/
add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
$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')
->where('id', $pid)
->where('servertype', 'VirtFusionDirect')
->first();
if (!$dbProduct) {
continue;
}
// Check if Initial Operating System custom field has a value
if (isset($product['customfields']) && is_array($product['customfields'])) {
$osSelected = false;
$customFields = \WHMCS\Database\Capsule::table('tblcustomfields')
->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;
}
}
if (!$osSelected) {
$errors[] = 'Please select an Operating System for your VPS order.';
}
}
}
return $errors;
});
/**
* Client Area Footer Output Hook
*
* You can create a token in the VirtFusion control panel under System > API.
*
* const VIRT_TOKEN = "your-virtfusion-token";
* Dynamically converts hidden text fields for OS templates and SSH keys
* into dropdown selects populated from the VirtFusion API.
* Works with all WHMCS themes by using vanilla JavaScript and standard form-control classes.
*/
/**
* If the constants are not defined, return null to prevent errors.
*/
if (!defined("VIRTFUSION_API_URL") || !defined("VIRT_TOKEN")) {
return null;
}
if (!function_exists('fetchPackageId')) {
/**
* @param string $packageName
* @return int|null
* @throws JsonException
*/
function fetchPackageId(string $packageName): ?int
{
$url = sprintf("%s/packages", VIRTFUSION_API_URL);
$packages = makeRequest($url);
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
return $package['id'];
}
}
return null;
}
}
if (!function_exists('fetchTemplates')) {
/**
* @param int $serverPackageId
* @return array|null
* @throws JsonException
*/
function fetchTemplates(int $serverPackageId): ?array
{
$url = sprintf("%s/media/templates/fromServerPackageSpec/%d", VIRTFUSION_API_URL, $serverPackageId);
return makeRequest($url);
}
}
if (!function_exists('custom_os_templates_hook')) {
/**
* @param array $vars
* @return array|null[]
*/
function custom_os_templates_hook(array $vars): array
{
try {
$serverPackageId = fetchPackageId($vars['productinfo']['name']); // Replace with the appropriate server package ID
if ($serverPackageId === null) {
return [
'templates' => null,
];
}
$templates = fetchTemplates($serverPackageId);
// Assign the generated dropdown menu to a Smarty template variable
return [
'templates' => $templates,
];
} catch (JsonException $e) {
return [
'templates' => null,
];
}
}
}
if (!function_exists('get_vf_user_details')) {
/**
* @param int $id
* @return array
* @throws JsonException
*/
function get_vf_user_details(int $id): ?array {
$url = sprintf("%s/users/%s/byExtRelation", VIRTFUSION_API_URL, $id);
$response = makeRequest($url);
if (isset($response['msg']) && $response['msg'] === "ext_relation_id not found") {
return null;
}
return $response['data'];
}
}
if (!function_exists('makeRequest')) {
/**
* @param string $url
* @return mixed
* @throws JsonException
* @throws Exception
*/
function makeRequest(string $url): array
{
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array(
sprintf("Authorization: Bearer %s", VIRT_TOKEN)
)
));
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
throw new Exception("cURL Error: " . $err);
}
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}
if (!function_exists('get_users_ssh_keys')) {
/**
* @throws JsonException
*/
function get_users_ssh_keys(?User $user): ?array {
if (is_null($user)) {
return null;
}
$vfUser = get_vf_user_details($user['id']);
$url = sprintf("%s/ssh_keys/user/%s", VIRTFUSION_API_URL, $vfUser['id']);
$response = makeRequest($url);
return $response;
}
}
if (!function_exists('add_hook_os_templates')) {
/**
* @param array $vars
* @return array|null
* @throws JsonException
*/
function add_hook_os_templates(array $vars): ?array
{
add_hook('ClientAreaFooterOutput', 1, function ($vars) {
if (!isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
return null;
}
$templates_data = custom_os_templates_hook($vars)['templates'];
try {
$cs = new ConfigureService();
$templates_data = $cs->fetchTemplates(
$cs->fetchPackageByDbId($vars['productinfo']['pid']) ?? $cs->fetchPackageId($vars['productinfo']['name'])
);
if (empty($templates_data)) {
return null;
}
$dropdownOptions = [];
foreach ($templates_data['data'] as $osCategory) {
foreach ($osCategory['templates'] as $template) {
$optionValue = $template['id'];
$optionLabel = $template['name'] . " " . $template['version'] . " " . $template['variant'];
$dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
}
}
// Sort dropdownOptions alphabetically by the 'name' key
usort($dropdownOptions, function ($a, $b) {
return strcmp($a['name'], $b['name']);
});
$osTemplates = [
'id' => 'os_template',
'optionname' => 'Initial Operating System',
'optiontype' => 1,
'options' => $dropdownOptions,
'selectedvalue' => ''
$galleryData = [
'baseUrl' => '',
'categories' => \WHMCS\Module\Server\VirtFusionDirect\Module::groupOsTemplates($templates_data['data'] ?? [], true),
];
$sshKeys = get_users_ssh_keys($vars['loggedinuser']);
$sshKeysOptions = [
'id' => 'ssh_key',
'optionname' => 'Initial SSH Key',
'optiontype' => 1,
'options' => array_map(function ($sshKey) {
$sshKeys = [];
$sshKeysOptions = [];
if (isset($vars['loggedinuser']) && $vars['loggedinuser']) {
$sshKeysData = $cs->getUserSshKeys($vars['loggedinuser']);
if ($sshKeysData && isset($sshKeysData['data'])) {
$sshKeysOptions = array_values(array_filter(array_map(function ($sshKey) {
if ($sshKey['enabled'] === false) {
return null;
}
return [
'id' => $sshKey['id'],
'name' => $sshKey['name']
];
}, $sshKeys['data'] ?? []),
'selectedvalue' => ''
];
$configurableoptions = $vars['configurableoptions'];
array_push(
$configurableoptions,
$osTemplates,
$sshKeysOptions
);
return [
'configurableoptions' => $configurableoptions,
'name' => htmlspecialchars($sshKey['name'], ENT_QUOTES, 'UTF-8')
];
}, $sshKeysData['data'])));
}
}
add_hook('ClientAreaPageCart', 1, 'add_hook_os_templates');
$osID = array_values(array_filter(array_map(function ($option) {
if ($option['textid'] === 'initialoperatingsystem') {
return $option['id'];
}
}, $vars['customfields'] ?? [])));
$sshID = array_values(array_filter(array_map(function ($option) {
if ($option['textid'] === 'initialsshkey') {
return $option['id'];
}
}, $vars['customfields'] ?? [])));
$osFieldId = $osID[0] ?? null;
$sshFieldId = $sshID[0] ?? null;
if ($osFieldId === null) {
return null;
}
$systemUrl = Database::getSystemUrl();
return "
<link href=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/css/module.css?v=20260319\" rel=\"stylesheet\">
<script src=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/js/keygen.js?v=20260207\"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
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 osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
var sshInputField = " . ($sshFieldId !== null ? "document.querySelector('[name=\"customfield[" . (int) $sshFieldId . "]\"]')" : "null") . ";
var sshInputLabel = " . ($sshFieldId !== null ? "document.querySelector('[for=\"customfield" . (int) $sshFieldId . "\"]')" : "null") . ";
if (!osInputField) return;
// Brand color map (must match vfOsBrandColors in module.js)
var brandColors = {
'ubuntu':'#E95420','debian':'#A81D33','rocky':'#10B981','centos':'#932279',
'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';
}
// Build gallery container
var galleryWrap = document.createElement('div');
galleryWrap.style.marginTop = '8px';
var searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'form-control vf-os-search';
searchInput.placeholder = 'Search templates...';
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';
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';
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';
});
});
// Validation: red border if no selection on form submit
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(galleryWrap, osInputField.nextSibling);
osInputField.style.display = 'none';
// Handle SSH keys
if (sshInputField) {
// Create the paste-key textarea (hidden initially if keys exist)
var sshPasteContainer = document.createElement('div');
sshPasteContainer.setAttribute('id', 'vf-ssh-paste-container');
sshPasteContainer.style.display = 'none';
sshPasteContainer.style.marginTop = '8px';
var pasteLabel = document.createElement('label');
pasteLabel.textContent = 'Paste your SSH public key:';
pasteLabel.style.display = 'block';
pasteLabel.style.marginBottom = '4px';
var pasteArea = document.createElement('textarea');
pasteArea.className = 'form-control';
pasteArea.setAttribute('id', 'vf-ssh-paste');
pasteArea.setAttribute('rows', '3');
pasteArea.setAttribute('placeholder', 'ssh-rsa AAAA... or ssh-ed25519 AAAA...');
pasteArea.addEventListener('input', function() {
sshInputField.value = this.value.trim();
});
sshPasteContainer.appendChild(pasteLabel);
sshPasteContainer.appendChild(pasteArea);
// Generate key button
var generateBtn = document.createElement('button');
generateBtn.type = 'button';
generateBtn.className = 'btn btn-outline-secondary btn-sm';
generateBtn.textContent = 'Generate a new key';
generateBtn.style.marginTop = '8px';
// Private key panel (hidden initially)
var privKeyPanel = document.createElement('div');
privKeyPanel.setAttribute('id', 'vf-privkey-panel');
privKeyPanel.style.display = 'none';
privKeyPanel.style.marginTop = '12px';
privKeyPanel.style.border = '2px solid #dc3545';
privKeyPanel.style.borderRadius = '6px';
privKeyPanel.style.padding = '12px';
var privKeyWarning = document.createElement('div');
privKeyWarning.style.color = '#dc3545';
privKeyWarning.style.fontWeight = 'bold';
privKeyWarning.style.marginBottom = '8px';
privKeyWarning.textContent = 'Private Key — Save This Now! It will not be shown again.';
var privKeyArea = document.createElement('textarea');
privKeyArea.className = 'form-control';
privKeyArea.setAttribute('rows', '6');
privKeyArea.setAttribute('readonly', 'readonly');
privKeyArea.style.fontFamily = 'monospace';
privKeyArea.style.fontSize = '12px';
privKeyArea.style.marginBottom = '8px';
var privKeyBtnRow = document.createElement('div');
privKeyBtnRow.style.display = 'flex';
privKeyBtnRow.style.gap = '8px';
privKeyBtnRow.style.alignItems = 'center';
privKeyBtnRow.style.flexWrap = 'wrap';
var downloadBtn = document.createElement('button');
downloadBtn.type = 'button';
downloadBtn.className = 'btn btn-primary btn-sm';
downloadBtn.textContent = 'Download';
var copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn btn-default btn-secondary btn-sm';
copyBtn.textContent = 'Copy to Clipboard';
var pubKeyConfirm = document.createElement('span');
pubKeyConfirm.style.color = '#28a745';
pubKeyConfirm.style.fontWeight = 'bold';
pubKeyConfirm.textContent = 'Public key set automatically.';
privKeyBtnRow.appendChild(downloadBtn);
privKeyBtnRow.appendChild(copyBtn);
privKeyBtnRow.appendChild(pubKeyConfirm);
privKeyPanel.appendChild(privKeyWarning);
privKeyPanel.appendChild(privKeyArea);
privKeyPanel.appendChild(privKeyBtnRow);
downloadBtn.addEventListener('click', function() {
vfDownloadFile('id_ed25519', privKeyArea.value);
});
copyBtn.addEventListener('click', function() {
navigator.clipboard.writeText(privKeyArea.value).then(function() {
copyBtn.textContent = 'Copied!';
setTimeout(function() { copyBtn.textContent = 'Copy to Clipboard'; }, 2000);
});
});
// Error message for unsupported browsers
var genErrorMsg = document.createElement('div');
genErrorMsg.style.display = 'none';
genErrorMsg.style.marginTop = '8px';
genErrorMsg.style.color = '#dc3545';
genErrorMsg.textContent = 'Your browser does not support Ed25519 key generation. Please paste your public key manually.';
generateBtn.addEventListener('click', async function() {
generateBtn.disabled = true;
generateBtn.textContent = 'Generating...';
try {
var keys = await vfGenerateSSHKey();
var sshSelect = document.getElementById('vf-ssh-select');
if (sshSelect) {
sshSelect.value = '__new__';
sshPasteContainer.style.display = 'block';
}
pasteArea.value = keys.publicKey;
sshInputField.value = keys.publicKey;
privKeyArea.value = keys.privateKey;
privKeyPanel.style.display = 'block';
genErrorMsg.style.display = 'none';
} catch (e) {
genErrorMsg.style.display = 'block';
privKeyPanel.style.display = 'none';
} finally {
generateBtn.disabled = false;
generateBtn.textContent = 'Generate a new key';
}
});
if (sshKeys.length > 0) {
var sshSelect = document.createElement('select');
sshSelect.className = 'form-control';
sshSelect.setAttribute('id', 'vf-ssh-select');
var sshDefaultOption = document.createElement('option');
sshDefaultOption.value = '';
sshDefaultOption.text = '-- No SSH Key (Optional) --';
sshSelect.appendChild(sshDefaultOption);
sshKeys.forEach(function(sshkey) {
var option = document.createElement('option');
option.value = sshkey.id;
option.text = sshkey.name;
sshSelect.appendChild(option);
});
// Add new key option
var addNewOption = document.createElement('option');
addNewOption.value = '__new__';
addNewOption.text = 'Add new key...';
sshSelect.appendChild(addNewOption);
sshSelect.addEventListener('change', function() {
if (this.value === '__new__') {
sshPasteContainer.style.display = 'block';
sshInputField.value = '';
} else {
sshPasteContainer.style.display = 'none';
document.getElementById('vf-ssh-paste').value = '';
sshInputField.value = this.value;
}
});
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling);
sshSelect.parentNode.insertBefore(sshPasteContainer, sshSelect.nextSibling);
sshPasteContainer.parentNode.insertBefore(generateBtn, sshPasteContainer.nextSibling);
generateBtn.parentNode.insertBefore(genErrorMsg, generateBtn.nextSibling);
genErrorMsg.parentNode.insertBefore(privKeyPanel, genErrorMsg.nextSibling);
sshInputField.style.display = 'none';
} else {
// No existing keys — show the paste textarea directly
sshPasteContainer.style.display = 'block';
sshInputField.parentNode.insertBefore(sshPasteContainer, sshInputField.nextSibling);
sshPasteContainer.parentNode.insertBefore(generateBtn, sshPasteContainer.nextSibling);
generateBtn.parentNode.insertBefore(genErrorMsg, generateBtn.nextSibling);
genErrorMsg.parentNode.insertBefore(privKeyPanel, genErrorMsg.nextSibling);
sshInputField.style.display = 'none';
}
}
// Slider UI: enhance known configurable option selects with range sliders
var sliderResourceNames = ['Memory', 'CPU Cores', 'Storage', 'Bandwidth', 'Inbound Network Speed', 'Outbound Network Speed'];
var sliderUnits = {
'Memory': 'MB', 'CPU Cores': 'Core(s)', 'Storage': 'GB',
'Bandwidth': 'GB', 'Inbound Network Speed': 'Mbps', 'Outbound Network Speed': 'Mbps'
};
var configSelects = document.querySelectorAll('select[name^=\"configoption[\"]');
configSelects.forEach(function(sel) {
// Find the label for this select
var label = null;
var labelEl = sel.closest('.form-group, .row');
if (labelEl) {
label = labelEl.querySelector('label');
}
if (!label) return;
var labelText = label.textContent.trim();
var matchedResource = null;
sliderResourceNames.forEach(function(name) {
if (labelText.indexOf(name) !== -1) {
matchedResource = name;
}
});
if (!matchedResource) return;
var options = [];
for (var i = 0; i < sel.options.length; i++) {
options.push({
value: sel.options[i].value,
label: sel.options[i].text
});
}
if (options.length < 2) return;
var unit = sliderUnits[matchedResource] || '';
// Create slider container
var container = document.createElement('div');
container.className = 'vf-slider-container';
var valueDisplay = document.createElement('div');
valueDisplay.className = 'vf-slider-value';
valueDisplay.textContent = options[sel.selectedIndex || 0].label + (unit ? ' ' + unit : '');
var slider = document.createElement('input');
slider.type = 'range';
slider.className = 'vf-slider form-range';
slider.min = '0';
slider.max = String(options.length - 1);
slider.step = '1';
slider.value = String(sel.selectedIndex || 0);
slider.addEventListener('input', function() {
var idx = parseInt(this.value);
sel.selectedIndex = idx;
valueDisplay.textContent = options[idx].label + (unit ? ' ' + unit : '');
// Trigger change event on hidden select for WHMCS pricing
var evt = new Event('change', { bubbles: true });
sel.dispatchEvent(evt);
});
container.appendChild(valueDisplay);
container.appendChild(slider);
sel.parentNode.insertBefore(container, sel.nextSibling);
sel.style.display = 'none';
});
});
</script>
";
} catch (\Throwable $e) {
// Silently fail - don't break the checkout page
return null;
}
});

View File

@@ -7,6 +7,7 @@ class AdminHTML
public static function options($systemUrl, $serviceId)
{
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
return <<<EOT
<button onclick="impersonateServerOwner('${serviceId}', '${systemUrl}')" type="button" class="btn btn-primary">Impersonate Server Owner</button>
<span class="text-info">&nbsp;&nbsp;A valid VirtFusion admin session in the same browser is required for this functionality to work.</span>
@@ -15,6 +16,7 @@ EOT;
public static function serverObject($serverObject)
{
$serverObject = htmlspecialchars($serverObject, ENT_QUOTES, 'UTF-8');
return <<<EOT
<textarea class="form-control" name="modulefields[1]" rows="10" style="width: 100%" disabled>${serverObject}</textarea>
EOT;
@@ -31,9 +33,10 @@ EOT;
public static function serverInfo($systemUrl, $serviceId)
{
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
return <<<EOT
<link href="${systemUrl}modules/servers/VirtFusionDirect/templates/css/module.css" rel="stylesheet">
<script src="${systemUrl}modules/servers/VirtFusionDirect/templates/js/module.js"></script>
<link href="${systemUrl}modules/servers/VirtFusionDirect/templates/css/module.css?v=20260207" rel="stylesheet">
<script src="${systemUrl}modules/servers/VirtFusionDirect/templates/js/module.js?v=20260207"></script>
<div id="vf-loader" class="vf-loader">
<div id="vf-loading"></div>
</div>

View File

@@ -0,0 +1,196 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect;
class Cache
{
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 (race condition safe)
$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($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);
}
}
/**
* Delete all cache keys matching a pattern.
*
* @param string $pattern Glob pattern (e.g., "os:*")
*/
public static function forgetPattern($pattern)
{
$redis = self::redis();
if ($redis) {
try {
$keys = $redis->keys(self::PREFIX . $pattern);
if (!empty($keys)) {
$redis->del($keys);
}
} catch (\Exception $e) {
// Continue to file cleanup
}
}
// File cache: can only clear all files for pattern matches
// Since file names are md5 hashed, we can't match patterns.
// For non-Redis, TTL expiry handles cleanup naturally.
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect;
use JsonException;
use WHMCS\Database\Capsule as DB;
use WHMCS\User\User;
class ConfigureService extends Module
{
/**
* @var array|false $cp
*/
private array|bool $cp;
public function __construct()
{
parent::__construct();
$this->cp = $this->getCP(false, true);
}
/**
* @param string $packageName
* @return int|null
* @throws JsonException
*/
public function fetchPackageId(string $packageName): ?int
{
$cacheKey = 'pkg_name:' . md5($packageName);
$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/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;
}
/**
* @param int $productId
* @return int|null
*/
public function fetchPackageByDbId(int $productId): ?int
{
$product = DB::table('tblproducts')->where('id', $productId)->first();
if (is_null($product)) {
return null;
}
return (int)$product->configoption2;
}
/**
* @param int $serverPackageId
* @return array|null
* @throws JsonException
*/
public function fetchTemplates(?int $serverPackageId): ?array
{
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;
}
/**
* @param User|null $user
* @return array|null
* @throws JsonException
*/
public function getUserSshKeys(?User $user): ?array
{
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);
}
/**
* @param int $id
* @return array|null
* @throws JsonException
*/
public function getVFUserDetails(int $id): ?array
{
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
$response = $this->decodeResponseFromJson($request->get(
sprintf("%s/users/%d/byExtRelation", $this->cp['url'], $id)
));
return isset($response['msg']) && $response['msg'] === "ext_relation_id not found" ? null : $response['data'];
}
/**
* @param int $id
* @param array $vars
* @param int|null $vfUserId VirtFusion user ID (for creating SSH keys from raw public key)
* @return bool
*/
public function initServerBuild(int $id, array $vars, ?int $vfUserId = null): bool
{
if (!$this->cp) return false;
$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);
}
/**
* Create an SSH key for a VirtFusion user from a raw public key string.
*
* @param int $userId VirtFusion user ID
* @param string $publicKey Raw SSH public key (ssh-rsa ..., ssh-ed25519 ..., etc.)
* @return int|null Created key ID or null on failure
*/
public function createUserSshKey(int $userId, string $publicKey): ?int
{
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
$keyData = [
'userId' => $userId,
'name' => 'WHMCS-' . date('Y-m-d'),
'publicKey' => trim($publicKey),
];
$request->addOption(CURLOPT_POSTFIELDS, json_encode($keyData));
$response = $request->post($this->cp['url'] . '/ssh_keys');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
$data = json_decode($response, true);
return $data['data']['id'] ?? null;
}
return null;
}
}

View File

@@ -8,12 +8,14 @@ class Curl
private $data;
private $customOptions = [];
private $defaultOptions = [
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => 'CURL',
CURLOPT_USERAGENT => 'VirtFusion-WHMCS/2.0',
CURLOPT_HEADER => false,
CURLOPT_NOBODY => false,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
];
@@ -24,7 +26,7 @@ class Curl
public function useCookies()
{
$cookiesFile = tempnam('/tmp', 'virtfusion_cookies');
$cookiesFile = tempnam(sys_get_temp_dir(), 'virtfusion_cookies');
$this->defaultOptions[CURLOPT_COOKIEFILE] = $cookiesFile;
$this->defaultOptions[CURLOPT_COOKIEJAR] = $cookiesFile;
}
@@ -57,6 +59,15 @@ class Curl
return $this->send('PUT', $url);
}
/**
* @param null $url
* @return bool|string|void
*/
public function patch($url = null)
{
return $this->send('PATCH', $url);
}
/**
* @param $method
* @param $url
@@ -66,7 +77,7 @@ class Curl
{
if ($url === null) {
if (!isset($this->customOptions[CURLOPT_URL]) || empty($this->customOptions[CURLOPT_URL])) {
exit('empty url');
throw new \RuntimeException('Curl: empty URL provided');
}
}
$this->addOption(CURLOPT_CUSTOMREQUEST, $method);
@@ -84,6 +95,12 @@ class Curl
$response = curl_exec($this->ch);
$this->data['info'] = curl_getinfo($this->ch);
if ($response === false) {
$this->data['info']['curl_error'] = curl_error($this->ch);
$this->data['info']['curl_errno'] = curl_errno($this->ch);
}
if (isset($this->customOptions[CURLOPT_HEADER]) && $this->customOptions[CURLOPT_HEADER]) {
$this->data['info']['request_header'] = trim($this->data['info']['request_header']);
$this->processHeaders($response);

View File

@@ -54,6 +54,7 @@ class Database
public static function getSystemUrl()
{
$url = DB::table('tblconfiguration')->where('setting', '=', 'SystemURL')->first();
if (!$url) return '';
return $url->value;
}

View File

@@ -2,46 +2,45 @@
namespace WHMCS\Module\Server\VirtFusionDirect;
class Module
{
public function __construct()
{
error_reporting(0);
Database::schema();
}
/**
* @param bool $exitOnError
* @return mixed
* @return string
*/
public function validateAction($exitOnError = true)
{
if (!isset($_GET['action'])) {
$this->output(['errors' => 'no action specified'], true, $exitOnError, 200);
$this->output(['success' => false, 'errors' => 'no action specified'], true, $exitOnError, 400);
}
return $_GET['action'];
return preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['action']);
}
/**
* @param bool $exitOnError
* @return mixed
* @return int
*/
public function validateServiceID($exitOnError = true)
{
if (!isset($_GET['serviceID'])) {
$this->output(['errors' => 'no serviceID specified'], true, $exitOnError, 200);
if (!isset($_GET['serviceID']) || !is_numeric($_GET['serviceID'])) {
$this->output(['success' => false, 'errors' => 'no valid serviceID specified'], true, $exitOnError, 400);
}
return $_GET['serviceID'];
return (int) $_GET['serviceID'];
}
/**
* @param $serviceID
* @param int $serviceID
* @param bool $exitOnError
* @return bool
* @return int|false
*/
public function validateUserOwnsService($serviceID, $exitOnError = true)
{
$serviceID = (int) $serviceID;
$currentUser = new \WHMCS\Authentication\CurrentUser;
$client = $currentUser->client();
@@ -57,25 +56,49 @@ class Module
}
/**
* @param $serviceID
* Resolve service context: system service, WHMCS service, control panel, and curl client.
* Returns false if any lookup fails.
*
* @param int $serviceID
* @return array{service: object, whmcsService: object, cp: array, request: Curl}|false
*/
protected function resolveServiceContext($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if (!$service) return false;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
return [
'service' => $service,
'whmcsService' => $whmcsService,
'cp' => $cp,
'request' => $this->initCurl($cp['token']),
'serverId' => (int) $service->server_id,
];
}
/**
* @param int $serviceID
* @return false|string
*/
public function fetchLoginTokens($serviceID)
{
$service = Database::getSystemService($serviceID);
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
$data = $ctx['request']->post($ctx['cp']['url'] . '/users/' . (int) $ctx['whmcsService']->userid . '/serverAuthenticationTokens/' . $ctx['serverId']);
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/users/' . $whmcsService->userid . '/serverAuthenticationTokens/' . $service->server_id);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == '200') {
if ($ctx['request']->getRequestInfo('http_code') == '200') {
$data = json_decode($data);
return $cp['base_url'] . $data->data->authentication->endpoint_complete;
if (isset($data->data->authentication->endpoint_complete)) {
return $ctx['cp']['base_url'] . $data->data->authentication->endpoint_complete;
}
}
return false;
@@ -117,39 +140,451 @@ class Module
public function fetchServerData($serviceID)
{
$service = Database::getSystemService($serviceID);
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId']);
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
if ($ctx['request']->getRequestInfo('http_code') == '200') {
return json_decode($data);
}
return false;
}
/**
* Execute a power action on a server.
*
* @param int $serviceID
* @param string $action One of: boot, shutdown, restart, poweroff
* @return object|false
*/
public function serverPowerAction($serviceID, $action)
{
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
if (!in_array($action, $allowedActions, true)) {
return false;
}
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/power/' . $action);
Log::insert(__FUNCTION__ . ':' . $action, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
return false;
}
/**
* Rebuild/reinstall a server with a new OS.
*
* @param int $serviceID
* @param int $osId Operating system template ID
* @param string|null $hostname Optional new hostname
* @return object|false
*/
public function rebuildServer($serviceID, $osId, $hostname = null)
{
$osId = (int) $osId;
if ($osId <= 0) return false;
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$buildData = ['operatingSystemId' => $osId, 'email' => true];
if ($hostname !== null && $hostname !== '') {
$buildData['hostname'] = $hostname;
}
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode($buildData));
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/build');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
Cache::forgetPattern('backups:' . $ctx['serverId']);
return json_decode($data) ?: (object) ['success' => true];
}
return false;
}
/**
* Rename a server.
*
* @param int $serviceID
* @param string $newName
* @return bool
*/
public function renameServer($serviceID, $newName)
{
$newName = trim($newName);
if (empty($newName) || strlen($newName) > 255) return false;
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
$data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
return ($httpCode == 200 || $httpCode == 204);
}
/**
* Fetch available OS templates for a server's package.
*
* @param int $serviceID
* @return array|false
*/
public function fetchOsTemplates($serviceID)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$product = \WHMCS\Database\Capsule::table('tblproducts')->where('id', $ctx['whmcsService']->packageid)->first();
if (!$product || !$product->configoption2) return false;
$cacheKey = 'os:' . (int) $product->configoption2;
$cached = Cache::get($cacheKey);
if ($cached !== null) return $cached;
$data = $ctx['request']->get($ctx['cp']['url'] . '/media/templates/fromServerPackageSpec/' . (int) $product->configoption2);
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
if ($ctx['request']->getRequestInfo('http_code') == '200') {
$templates = json_decode($data, true);
$baseUrl = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
$result = [
'baseUrl' => $baseUrl,
'categories' => self::groupOsTemplates($templates['data'] ?? []),
];
Cache::set($cacheKey, $result, 600);
return $result;
}
return false;
}
/**
* Group OS template data into categories. Categories with only 1 template
* are merged into an "Other" category.
*
* @param array $data Raw template data from VirtFusion API
* @param bool $htmlEscape Whether to escape names for HTML output
* @return array
*/
public static function groupOsTemplates(array $data, bool $htmlEscape = false): array
{
$categories = [];
$otherTemplates = [];
$esc = fn($v) => $htmlEscape ? htmlspecialchars($v, ENT_QUOTES, 'UTF-8') : $v;
foreach ($data as $osCategory) {
$catTemplates = [];
foreach ($osCategory['templates'] as $template) {
$catTemplates[] = [
'id' => $template['id'],
'name' => $esc($template['name']),
'version' => $esc($template['version'] ?? ''),
'variant' => $esc($template['variant'] ?? ''),
'icon' => $template['icon'] ?? null,
'eol' => $template['eol'] ?? false,
'type' => $template['type'] ?? '',
'description' => $esc($template['description'] ?? ''),
];
}
if (count($catTemplates) <= 1) {
$otherTemplates = array_merge($otherTemplates, $catTemplates);
} else {
$categories[] = [
'name' => $esc($osCategory['name'] ?? 'Unknown'),
'icon' => $osCategory['icon'] ?? null,
'templates' => $catTemplates,
];
}
}
if (!empty($otherTemplates)) {
$categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates];
}
return $categories;
}
// =========================================================================
// Traffic Statistics
// =========================================================================
/**
* Get traffic statistics for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getTrafficStats($serviceID)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$cacheKey = 'traffic:' . $ctx['serverId'];
$cached = Cache::get($cacheKey);
if ($cached !== null) return $cached;
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/traffic');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
if ($ctx['request']->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 120);
return $result;
}
return false;
}
// =========================================================================
// IP Address Management
// =========================================================================
/**
* Add an IPv4 address to a server.
*
* @param int $serviceID
* @return object|false
*/
public function addIPv4($serviceID)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/ipv4');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data) ?: (object) ['success' => true];
}
return false;
}
// =========================================================================
// Backup Management
// =========================================================================
/**
* Get backup list for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getServerBackups($serviceID)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$cacheKey = 'backups:' . $ctx['serverId'];
$cached = Cache::get($cacheKey);
if ($cached !== null) return $cached;
$data = $ctx['request']->get($ctx['cp']['url'] . '/backups/server/' . $ctx['serverId']);
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
if ($ctx['request']->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 120);
return $result;
}
return false;
}
/**
* Assign a backup plan to a server.
*
* @param int $serviceID
* @param int $planId Backup plan ID (0 to remove)
* @return object|false
*/
public function assignBackupPlan($serviceID, $planId)
{
$planId = (int) $planId;
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['planId' => $planId]));
$endpoint = $ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/backup/plan';
$data = $planId > 0 ? $ctx['request']->post($endpoint) : $ctx['request']->delete($endpoint);
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
return false;
}
// =========================================================================
// VNC Console
// =========================================================================
/**
* Get VNC console connection details for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getVncConsole($serviceID)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/vnc');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
if ($ctx['request']->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
return false;
}
/**
* Toggle VNC on/off for a server.
*
* @param int $serviceID
* @param bool $enabled
* @return array|false
*/
public function toggleVnc($serviceID, $enabled)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['enabled' => (bool) $enabled]));
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/vnc');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data, true) ?: ['success' => true];
}
return false;
}
// =========================================================================
// Resource Modification
// =========================================================================
/**
* Modify a server resource (memory, cpuCores, or traffic).
*
* @param int $serviceID
* @param string $resource One of: memory, cpuCores, traffic
* @param int $value New value for the resource
* @return object|false
*/
public function modifyResource($serviceID, $resource, $value)
{
$allowedResources = ['memory', 'cpuCores', 'traffic'];
if (!in_array($resource, $allowedResources, true)) return false;
$value = (int) $value;
if ($value < 0) return false;
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode([$resource => $value]));
$data = $ctx['request']->put($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/modify/' . $resource);
Log::insert(__FUNCTION__ . ':' . $resource, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
return false;
}
// =========================================================================
// Dry Run Validation
// =========================================================================
/**
* Validate server creation parameters without actually creating a server.
*
* @param array $options Server creation options
* @param int $serverId WHMCS server ID for API credentials
* @return array ['valid' => bool, 'errors' => array]
*/
public function validateServerCreation($options, $serverId)
{
$cp = $this->getCP($serverId, !$serverId);
if (!$cp) {
return ['valid' => false, 'errors' => ['No control server found']];
}
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
$cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/servers/' . $service->server_id);
$request->addOption(CURLOPT_POSTFIELDS, json_encode($options));
$data = $request->post($cp['url'] . '/servers?dryRun=true');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == '200') {
return json_decode($data);
$httpCode = $request->getRequestInfo('http_code');
$response = json_decode($data, true);
if ($httpCode == 200 || $httpCode == 201) {
return ['valid' => true, 'errors' => []];
}
$errors = [];
if (isset($response['errors']) && is_array($response['errors'])) {
$errors = $response['errors'];
} elseif (isset($response['msg'])) {
$errors = [$response['msg']];
} else {
$errors = ['Validation failed with HTTP ' . $httpCode];
}
return ['valid' => false, 'errors' => $errors];
}
/**
* Reset the server's root password.
*
* @param int $serviceID
* @return array|false
*/
public function resetServerPassword($serviceID)
{
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/resetPassword');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
$httpCode = $ctx['request']->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data, true);
}
return false;
}
public function resetUserPassword($serviceID, $clientID)
{
$service = Database::getSystemService($serviceID);
$clientID = (int) $clientID;
$ctx = $this->resolveServiceContext($serviceID);
if (!$ctx) return false;
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
$cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/users/' . $clientID . '/byExtRelation/resetPassword');
$data = $ctx['request']->post($ctx['cp']['url'] . '/users/' . $clientID . '/byExtRelation/resetPassword');
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == '201') {
if ($ctx['request']->getRequestInfo('http_code') == '201') {
return json_decode($data);
}
}
return false;
}
@@ -201,7 +636,7 @@ class Module
return true;
}
$this->output(['errors' => 'unauthenticated'], true, true, 200);
$this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
}
/**
@@ -213,7 +648,7 @@ class Module
return true;
}
$this->output(['errors' => 'unauthenticated'], true, true, 200);
$this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
}
/**
@@ -232,4 +667,147 @@ class Module
return $curl;
}
// =========================================================================
// Self Service — Credit & Usage
// =========================================================================
/**
* Get self-service usage data for a WHMCS client.
*
* @param int $serviceID
* @return array|false
*/
public function getSelfServiceUsage($serviceID)
{
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/selfService/usage/byUserExtRelationId/' . (int) $whmcsService->userid);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
return false;
}
/**
* Get self-service billing report for a WHMCS client.
*
* @param int $serviceID
* @return array|false
*/
public function getSelfServiceReport($serviceID)
{
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/selfService/report/byUserExtRelationId/' . (int) $whmcsService->userid);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
return false;
}
/**
* Add self-service credit for a WHMCS client.
*
* @param int $serviceID
* @param float $tokens Amount of credit tokens to add
* @param string $reference Reference text for the transaction
* @return array|false
*/
public function addSelfServiceCredit($serviceID, $tokens, $reference = '')
{
$serviceID = (int) $serviceID;
$tokens = (float) $tokens;
if ($tokens <= 0) {
return false;
}
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode([
'tokens' => $tokens,
'reference_1' => $reference ?: 'WHMCS Top-up',
'reference_2' => 'Service #' . $serviceID,
]));
$data = $request->post($cp['url'] . '/selfService/credit/byUserExtRelationId/' . (int) $whmcsService->userid);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data, true);
}
return false;
}
/**
* Get available self-service currencies.
*
* @param int $serviceID
* @return array|false
*/
public function getSelfServiceCurrencies($serviceID)
{
$cacheKey = 'ss_currencies';
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/selfService/currencies');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 1800);
return $result;
}
return false;
}
/**
* Decodes a response from JSON into an associative array.
*
* @param string $response
*
* @return array
* @throws \JsonException
*/
public function decodeResponseFromJson(string $response): array
{
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}

View File

@@ -23,88 +23,82 @@ class ModuleFunctions extends Module
try {
/**
*
* If the service exists in the custom table, Cancel the create account action.
*
* If the service exists in the custom table, cancel the create account action.
*/
if (Database::checkSystemService($params['serviceid'])) {
return 'Service already exists. You must run a termination first.';
}
/**
*
* If no VirtFusionDirect control server exists, cancel the create account action.
*
*/
$server = $params['serverid'] ?: false;
$cp = $this->getCP($server, $server ? false : true);
$cp = $this->getCP($server, !$server);
if (!$cp) {
return 'No Control server found.';
return 'No Control server found. Please ensure a VirtFusion server is configured in WHMCS.';
}
Log::insert(__FUNCTION__, $params, []);
/**
*
* Does a user account in VirtFusion match this account (byExtRelationId) in WHMCS.
*
*/
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/users/' . $params['userid'] . '/byExtRelation');
$data = $request->get($cp['url'] . '/users/' . (int) $params['userid'] . '/byExtRelation');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
switch ($request->getRequestInfo('http_code')) {
case 200:
/**
*
* A user with relation ID exists in VirtFusion. We can provision under that account.
*
*/
break;
case 404:
/**
*
* A user doesn't exist in VirtFusion. We should attempt to create one.
*
*/
$user = Database::getUser($params['userid']);
if (!$user) {
return 'WHMCS user not found for ID ' . (int) $params['userid'];
}
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(
[
$userData = [
"name" => $user->firstname . ' ' . $user->lastname,
"email" => $user->email,
"extRelationId" => $user->id
]
));
"extRelationId" => $user->id,
];
// Enable self-service billing if configured
$selfServiceMode = (int) ($params['configoption4'] ?? 0);
if ($selfServiceMode > 0) {
$userData['selfService'] = $selfServiceMode;
$userData['selfServiceHourlyCredit'] = in_array($selfServiceMode, [1, 3]);
}
$request->addOption(CURLOPT_POSTFIELDS, json_encode($userData));
$data = $request->post($cp['url'] . '/users');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') !== 201) {
return 'Unable to create user.';
return 'Unable to create user in VirtFusion. API returned HTTP ' . $request->getRequestInfo('http_code');
}
break;
default:
return 'Error processing user account.';
break;
return 'Error processing user account. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
$data = json_decode($data);
/**
*
* A user is available. We can now attempt to create a server.
*
*/
$configOptionDefaultNaming = [
@@ -124,26 +118,27 @@ class ModuleFunctions extends Module
$configOptionCustomNaming = [];
if (file_exists(ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php')) {
$configOptionCustomNaming = require_once ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php';
$configOptionCustomNaming = require ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php';
}
$options = [
"packageId" => $params['configoption2'],
"packageId" => (int) $params['configoption2'],
"userId" => $data->data->id,
"hypervisorId" => $params['configoption1'],
"ipv4" => $params['configoption3'],
"hypervisorId" => (int) $params['configoption1'],
"ipv4" => (int) $params['configoption3'],
];
if (array_key_exists('configoptions', $params)) {
foreach ($configOptionDefaultNaming as $key => $option) {
$currentOption = array_key_exists($key, $configOptionCustomNaming) ? $configOptionCustomNaming[$key] : $option;
if (array_key_exists($currentOption, $params['configoptions'])) {
// If the option key is "Memory" and the value is less than 1024, we need to convert it to MB
$value = $params['configoptions'][$currentOption];
// If the option key is "Memory" and the value is less than 1024, convert to MB
// VirtFusion expects memory in MB.
if ($currentOption === 'Memory' && $params['configoptions'][$currentOption] < 1024) {
$options[$key] = $params['configoptions'][$currentOption] * 1024;
if ($key === 'memory' && is_numeric($value) && $value < 1024) {
$options[$key] = (int) ($value * 1024);
} else {
$options[$key] = $params['configoptions'][$currentOption];
$options[$key] = is_numeric($value) ? (int) $value : $value;
}
}
}
@@ -163,17 +158,20 @@ class ModuleFunctions extends Module
Database::systemOnServerCreate($params['serviceid'], $data);
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
/**
*
* Server was created successfully.
*
*/
// If the server is created successfully, we can initialize the server build.
$cs = new ConfigureService();
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
$cs->initServerBuild($data->data->id, $params, $vfUserId);
return 'success';
} else {
if ($data->errors[0]) {
if (isset($data->errors) && is_array($data->errors) && isset($data->errors[0])) {
return $data->errors[0];
}
return 'Unknown error.';
if (isset($data->msg)) {
return $data->msg;
}
return 'Server creation failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
@@ -181,6 +179,74 @@ class ModuleFunctions extends Module
}
}
/**
* Allows changing of the package of a server
*
* @param $params
* @return string
*/
public function changePackage($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->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);
}
}
}
return 'success';
}
return 'Service not found in module database.';
}
/**
*
* TERMINATE SERVER
@@ -197,11 +263,13 @@ class ModuleFunctions extends Module
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->delete($cp['url'] . '/servers/' . $service->server_id);
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id);
$data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
@@ -212,27 +280,24 @@ class ModuleFunctions extends Module
Database::deleteSystemService($params['serviceid']);
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
return 'success';
break;
case 404:
if (property_exists($data, 'msg')) {
if (isset($data->msg)) {
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']);
return 'success';
} else {
return '404 was returned from the web service with the msg property but doesn\'t contain appropriate data to process a termination.';
return 'VirtFusion returned 404: ' . $data->msg;
}
} else {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
return 'VirtFusion returned 404 without details. The API may be unavailable.';
}
break;
default:
return 'Termination request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
break;
return 'Termination request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
}
return 'Service not found. Termination routine has already been run?';
return 'Service not found in module database. Has termination already been run?';
}
/**
@@ -251,10 +316,13 @@ class ModuleFunctions extends Module
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/' . $service->server_id . '/suspend');
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/suspend');
$data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
@@ -263,32 +331,29 @@ class ModuleFunctions extends Module
case 204:
return 'success';
break;
case 404:
if (property_exists($data, 'msg')) {
if (isset($data->msg)) {
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']);
return 'success';
} else {
return '404 was returned from the web service with the msg property but doesn\'t contain appropriate data to process a suspension.';
return 'VirtFusion returned 404: ' . $data->msg;
}
} else {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
return 'VirtFusion returned 404 without details. The API may be unavailable.';
}
break;
case 423:
if (property_exists($data, 'msg')) {
if (isset($data->msg)) {
return $data->msg;
}
return 'The server is currently locked. Please try again later.';
default:
return 'Suspend request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
break;
return 'Suspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
}
return 'Service not found.';
return 'Service not found in module database.';
}
function updateServerObject($params)
@@ -298,10 +363,13 @@ class ModuleFunctions extends Module
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/' . $service->server_id);
$data = $request->get($cp['url'] . '/servers/' . (int) $service->server_id);
$data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
@@ -314,13 +382,11 @@ class ModuleFunctions extends Module
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
return 'success';
break;
default:
return 'Request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
break;
return 'Request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
}
return 'Service not found.';
return 'Service not found in module database.';
}
@@ -330,10 +396,13 @@ class ModuleFunctions extends Module
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/' . $service->server_id . '/unsuspend');
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/unsuspend');
$data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
@@ -342,31 +411,29 @@ class ModuleFunctions extends Module
case 204:
return 'success';
break;
case 404:
if (property_exists($data, 'msg')) {
if (isset($data->msg)) {
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']);
return 'success';
} else {
return '404 was returned from the web service with the msg property but doesn\'t contain appropriate data to process an unsuspension.';
return 'VirtFusion returned 404: ' . $data->msg;
}
} else {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
return 'VirtFusion returned 404 without details. The API may be unavailable.';
}
break;
case 423:
if (property_exists($data, 'msg')) {
if (isset($data->msg)) {
return $data->msg;
}
return 'The server is currently locked. Please try again later.';
default:
return 'Unsuspend request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
break;
return 'Unsuspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
}
return 'Service not found';
return 'Service not found in module database.';
}
public function adminServicesTabFields($params)
@@ -396,12 +463,57 @@ class ModuleFunctions extends Module
public function adminServicesTabFieldsSave($params)
{
if ($_POST['modulefields'][0] == '') {
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);
}
}
}
Database::updateSystemServiceServerId($params['serviceid'], $_POST['modulefields'][0]);
/**
* Validate server creation parameters via dry run.
*
* @param array $params WHMCS service params
* @return string 'success' or error message
*/
public function validateServerConfig($params)
{
try {
$server = $params['serverid'] ?: false;
$cp = $this->getCP($server, !$server);
if (!$cp) {
return 'No Control server found.';
}
$options = [
"packageId" => (int) $params['configoption2'],
"hypervisorId" => (int) $params['configoption1'],
"ipv4" => (int) $params['configoption3'],
];
// We need a userId for dry run - use the service owner
if (isset($params['userid'])) {
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/users/' . (int) $params['userid'] . '/byExtRelation');
if ($request->getRequestInfo('http_code') == 200) {
$userData = json_decode($data);
$options['userId'] = $userData->data->id;
}
}
$result = $this->validateServerCreation($options, $params['serverid']);
if ($result['valid']) {
return 'success';
}
return 'Validation failed: ' . implode(', ', $result['errors']);
} catch (\Exception $e) {
return 'Validation error: ' . $e->getMessage();
}
}
@@ -419,6 +531,7 @@ class ModuleFunctions extends Module
'systemURL' => Database::getSystemUrl(),
'serviceStatus' => $params['status'],
'serverHostname' => $serverHostname,
'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
],
];
} catch (\Throwable $e) {

View File

@@ -8,41 +8,72 @@ class ServerResource
{
$server = json_decode(json_encode($data->data), true);
$traffic = '';
$traffic = '-';
if ($server['settings']['resources']['traffic']) {
if (isset($server['settings']['resources']['traffic'])) {
if ($server['settings']['resources']['traffic'] > 0) {
$traffic = $server['settings']['resources']['traffic'] . ' GB';
} else {
$traffic = 'Unlimited';
}
}
$trafficUsed = '-';
if (isset($server['usage']['traffic']['used'])) {
$trafficUsed = round($server['usage']['traffic']['used'] / 1073741824, 2) . ' GB';
} elseif (isset($server['settings']['resources']['traffic']) && $server['settings']['resources']['traffic'] > 0) {
$trafficUsed = '0 GB';
}
$data = [
'name' => $server['name'] ?: '-',
'hostname' => $server['hostname'] ?: '-',
'memory' => $server['settings']['resources']['memory'] . ' MB',
'memory' => isset($server['settings']['resources']['memory']) ? $server['settings']['resources']['memory'] . ' MB' : '-',
'traffic' => $traffic,
'storage' => $server['settings']['resources']['storage'] . ' GB',
'cpu' => $server['settings']['resources']['cpuCores'] . ' Core(s)',
'trafficUsed' => $trafficUsed,
'storage' => isset($server['settings']['resources']['storage']) ? $server['settings']['resources']['storage'] . ' GB' : '-',
'cpu' => isset($server['settings']['resources']['cpuCores']) ? $server['settings']['resources']['cpuCores'] . ' Core(s)' : '-',
'status' => isset($server['state']) ? $server['state'] : 'unknown',
'powerStatus' => isset($server['hypervisor']['settings']['state']) ? $server['hypervisor']['settings']['state'] : 'unknown',
'username' => isset($server['owner']['email']) ? $server['owner']['email'] : '',
'password' => '',
'primaryNetwork' => [
'ipv4' => ['-'],
'ipv4Unformatted' => [],
'ipv6' => ['-'],
'ipv6Unformatted' => [],
]
'mac' => '-',
],
'networkSpeed' => [
'inbound' => isset($server['settings']['resources']['networkSpeedInbound']) ? $server['settings']['resources']['networkSpeedInbound'] . ' Mbps' : '-',
'outbound' => isset($server['settings']['resources']['networkSpeedOutbound']) ? $server['settings']['resources']['networkSpeedOutbound'] . ' Mbps' : '-',
],
'vncEnabled' => isset($server['vnc']['enabled']) ? (bool) $server['vnc']['enabled'] : false,
'memoryRaw' => isset($server['settings']['resources']['memory']) ? (int) $server['settings']['resources']['memory'] : 0,
'cpuRaw' => isset($server['settings']['resources']['cpuCores']) ? (int) $server['settings']['resources']['cpuCores'] : 0,
'storageRaw' => isset($server['settings']['resources']['storage']) ? (int) $server['settings']['resources']['storage'] : 0,
'trafficRaw' => isset($server['settings']['resources']['traffic']) ? (int) $server['settings']['resources']['traffic'] : 0,
'trafficUsedRaw' => isset($server['usage']['traffic']['used']) ? round($server['usage']['traffic']['used'] / 1073741824, 2) : 0,
'networkSpeedInboundRaw' => isset($server['settings']['resources']['networkSpeedInbound']) ? (int) $server['settings']['resources']['networkSpeedInbound'] : 0,
'networkSpeedOutboundRaw' => isset($server['settings']['resources']['networkSpeedOutbound']) ? (int) $server['settings']['resources']['networkSpeedOutbound'] : 0,
];
if (array_key_exists('network', $server)) {
if (array_key_exists('interfaces', $server['network'])) {
if (count($server['network']['interfaces'])) {
if (count($server['network']['interfaces'][0]['ipv4'])) {
if (isset($server['network']['interfaces'][0]['mac'])) {
$data['primaryNetwork']['mac'] = $server['network']['interfaces'][0]['mac'];
}
if (isset($server['network']['interfaces'][0]['ipv4']) && count($server['network']['interfaces'][0]['ipv4'])) {
$data['primaryNetwork']['ipv4'] = [];
foreach ($server['network']['interfaces'][0]['ipv4'] as $ip) {
$data['primaryNetwork']['ipv4'][] = $ip['address'];
}
}
if (count($server['network']['interfaces'][0]['ipv6'])) {
if (isset($server['network']['interfaces'][0]['ipv6']) && count($server['network']['interfaces'][0]['ipv6'])) {
$data['primaryNetwork']['ipv6'] = [];
foreach ($server['network']['interfaces'][0]['ipv6'] as $ip) {
$data['primaryNetwork']['ipv6'][] = $ip['subnet'] . '/' . $ip['cidr'];

View File

@@ -1 +1,475 @@
.vf-bold{font-weight:800}.vf-small{font-size:.9rem}.vf-button{font-size:.8rem;padding:.95rem 1.5rem;font-weight:600}.vf-button-small{font-size:.8rem;padding:.75rem 1.3rem;font-weight:500}.vf-spinner-margin{margin-right:7px}.vf-badge{font-size:.8rem;padding:.5rem .9rem;text-transform:uppercase;font-weight:800}.vf-badge-active{background-color:rgba(32,177,0,.12);color:#276900;border-radius:6px}.vf-badge-awaiting{background-color:rgba(177,89,0,.12);color:#692000;border-radius:6px}#vf-login-button-spinner{display:none}#vf-password-reset-button-spinner{display:none}#vf-password-reset-error{display:none}#vf-password-reset-success{display:none}#vf-login-error{display:none}#vf-server-info{display:none}#vf-server-info-error{display:none}#vf-server-info-loader{min-height:136px}#vf-loading{display:inline-block;width:30px;height:30px;border:3px solid rgba(225,224,224,.3);border-radius:50%;border-top-color:#0e151a;animation:vf-spin 1s ease-in-out infinite;-webkit-animation:vf-spin 1s ease-in-out infinite}.vf-loader{margin:30px}@keyframes vf-spin{to{transform:rotate(360deg)}}@-webkit-keyframes vf-spin{to{transform:rotate(360deg)}}#vf-server-info-error{margin:10px}
/* VirtFusion Direct Provisioning Module Styles */
/* Typography */
.vf-bold {
font-weight: 800;
}
.vf-small {
font-size: 0.9rem;
}
/* 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 {
margin-right: 7px;
}
/* Status Badges */
.vf-badge {
font-size: 0.75rem;
padding: 0.35rem 0.75rem;
text-transform: uppercase;
font-weight: 700;
border-radius: 6px;
display: inline-block;
}
.vf-badge-active {
background-color: rgba(32, 177, 0, 0.12);
color: #276900;
}
.vf-badge-awaiting {
background-color: rgba(177, 89, 0, 0.12);
color: #692000;
}
.vf-badge-suspended {
background-color: rgba(220, 53, 69, 0.12);
color: #721c24;
}
/* Power Management */
.vf-power-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.vf-btn-power {
min-width: 100px;
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
padding: 0.5rem 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
}
/* Hidden elements (initial state) */
#vf-login-button-spinner {
display: none;
}
#vf-password-reset-button-spinner {
display: none;
}
#vf-password-reset-error {
display: none;
}
#vf-password-reset-success {
display: none;
}
#vf-login-error {
display: none;
}
#vf-server-info {
display: none;
}
#vf-server-info-error {
display: none;
}
#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 */
#vf-server-info-loader {
min-height: 136px;
}
#vf-loading {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid rgba(225, 224, 224, 0.3);
border-radius: 50%;
border-top-color: #0e151a;
animation: vf-spin 1s ease-in-out infinite;
-webkit-animation: vf-spin 1s ease-in-out infinite;
}
.vf-loader {
margin: 30px;
}
@keyframes vf-spin {
to {
transform: rotate(360deg);
}
}
@-webkit-keyframes vf-spin {
to {
transform: rotate(360deg);
}
}
/* Error message spacing */
#vf-server-info-error {
margin: 10px;
}
/* Network / IP Management */
.vf-ip-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.vf-ip-address {
font-family: monospace;
font-size: 0.9rem;
}
/* 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;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
}
.vf-os-category-arrow {
margin-left: auto;
font-size: 0.7rem;
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;
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;
}
.vf-os-icon img {
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 */
.vf-resource-item .progress {
background-color: rgba(0,0,0,0.08);
border-radius: 4px;
}
/* Order form slider UI */
.vf-slider-container {
padding: 8px 0;
}
.vf-slider-value {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 4px;
text-align: center;
}
.vf-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #ddd;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.vf-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #337ab7;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.vf-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #337ab7;
cursor: pointer;
border: 2px solid #fff;
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 */
@media (max-width: 768px) {
.vf-power-buttons {
flex-direction: column;
}
.vf-btn-power {
width: 100%;
}
.vf-ip-row {
flex-wrap: wrap;
}
}

View File

@@ -1 +1,11 @@
<div class="alert alert-danger"><p>Oops! Something went wrong.</p></div><p>Please go back and try again.</p><p>If the problem persists, please contact support.</p>
<div class="panel card panel-default mb-3">
<div class="panel-heading card-header">
<h3 class="panel-title card-title m-0">Error</h3>
</div>
<div class="panel-body card-body p-4">
<div class="alert alert-danger mb-0">
<p><strong>Something went wrong.</strong></p>
<p class="mb-0">Please go back and try again. If the problem persists, please contact support.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,124 @@
/**
* VirtFusion SSH Ed25519 Key Generator
*
* Client-side Ed25519 keypair generation using Web Crypto API.
* Produces OpenSSH-format public and private keys.
*/
function vfConcatArrays() {
var total = 0;
for (var i = 0; i < arguments.length; i++) total += arguments[i].length;
var result = new Uint8Array(total);
var offset = 0;
for (var i = 0; i < arguments.length; i++) {
result.set(arguments[i], offset);
offset += arguments[i].length;
}
return result;
}
function vfSshEncodeUint32(value) {
return new Uint8Array([
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
(value >>> 8) & 0xff,
value & 0xff
]);
}
function vfSshEncodeString(data) {
if (typeof data === 'string') {
data = new TextEncoder().encode(data);
}
return vfConcatArrays(vfSshEncodeUint32(data.length), data);
}
function vfArrayToBase64(uint8Array) {
var binary = '';
for (var i = 0; i < uint8Array.length; i++) {
binary += String.fromCharCode(uint8Array[i]);
}
return btoa(binary);
}
function vfEncodeSSHPublicKey(pubKeyBytes) {
var blob = vfConcatArrays(
vfSshEncodeString('ssh-ed25519'),
vfSshEncodeString(pubKeyBytes)
);
return 'ssh-ed25519 ' + vfArrayToBase64(blob);
}
function vfEncodeSSHPrivateKey(seed, pubKeyBytes) {
var keyType = vfSshEncodeString('ssh-ed25519');
var pubBlob = vfConcatArrays(keyType, vfSshEncodeString(pubKeyBytes));
var checkInt = crypto.getRandomValues(new Uint8Array(4));
var privKey = vfConcatArrays(seed, pubKeyBytes); // 64 bytes: seed || pubkey
var privateSection = vfConcatArrays(
checkInt,
checkInt,
vfSshEncodeString('ssh-ed25519'),
vfSshEncodeString(pubKeyBytes),
vfSshEncodeString(privKey),
vfSshEncodeString('') // empty comment
);
// Pad to 8-byte boundary with 1,2,3,4,5...
var padLen = 8 - (privateSection.length % 8);
if (padLen < 8) {
var padding = new Uint8Array(padLen);
for (var i = 0; i < padLen; i++) padding[i] = i + 1;
privateSection = vfConcatArrays(privateSection, padding);
}
var authMagic = new TextEncoder().encode('openssh-key-v1\0');
var body = vfConcatArrays(
authMagic,
vfSshEncodeString('none'), // cipher
vfSshEncodeString('none'), // kdf
vfSshEncodeString(''), // kdf options
vfSshEncodeUint32(1), // number of keys
vfSshEncodeString(pubBlob),
vfSshEncodeString(privateSection)
);
var b64 = vfArrayToBase64(body);
var lines = ['-----BEGIN OPENSSH PRIVATE KEY-----'];
for (var i = 0; i < b64.length; i += 70) {
lines.push(b64.substring(i, i + 70));
}
lines.push('-----END OPENSSH PRIVATE KEY-----');
lines.push('');
return lines.join('\n');
}
async function vfGenerateSSHKey() {
var keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
var pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
var pubKeyBytes = new Uint8Array(pubRaw);
// PKCS#8 for Ed25519 is exactly 48 bytes; bytes 16-47 are the 32-byte seed
var privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
var privBytes = new Uint8Array(privPkcs8);
var seed = privBytes.slice(16, 48);
return {
publicKey: vfEncodeSSHPublicKey(pubKeyBytes),
privateKey: vfEncodeSSHPrivateKey(seed, pubKeyBytes)
};
}
function vfDownloadFile(filename, content) {
var blob = new Blob([content], { type: 'application/octet-stream' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Generate API endpoint documentation from PHP source files
# Usage: bash scripts/generate-endpoint-doc.sh > docs/API-ENDPOINTS.md
MODULE_DIR="modules/servers/VirtFusionDirect"
echo "# VirtFusion WHMCS Module — API Endpoints"
echo ""
echo "Auto-generated from source code. Do not edit manually."
echo ""
echo "| Endpoint Pattern | HTTP Method | PHP File | Function |"
echo "|---|---|---|---|"
# Extract API URL patterns from PHP files
grep -rn "->get\|->post\|->put\|->patch\|->delete" "$MODULE_DIR/lib/" 2>/dev/null | \
grep -oP "(?<=>)(get|post|put|patch|delete)\(.*?'[^']*'" | \
while IFS= read -r line; do
method=$(echo "$line" | grep -oP "^(get|post|put|patch|delete)" | tr '[:lower:]' '[:upper:]')
url=$(echo "$line" | grep -oP "'[^']*'" | tr -d "'")
echo "| \`$url\` | $method | - | - |"
done
echo ""
echo "## Client Endpoints (client.php)"
echo ""
echo "| Action | Description |"
echo "|---|---|"
grep -n "case '" "$MODULE_DIR/client.php" 2>/dev/null | \
while IFS= read -r line; do
action=$(echo "$line" | grep -oP "case '\K[^']+")
echo "| \`$action\` | - |"
done