30 Commits

Author SHA1 Message Date
Prophet731
d253bd44e6 feat: auto-create custom fields, add try/catch coverage, PHPDoc, and Pint formatting
All checks were successful
Publish Release / release (push) Successful in 10s
- Auto-create 'Initial Operating System' and 'Initial SSH Key' custom fields
  via Database::ensureCustomFields() on module load, eliminating the manual
  modify.sql step
- Delete modify.sql (no longer needed)
- Add try/catch blocks around every DB operation and API call across all PHP
  files per CLAUDE.md error handling rules
- Add comprehensive PHPDoc to all classes, methods, and properties
- Set up Laravel Pint (laravel/pint) with Laravel-style preset for consistent
  code formatting across the codebase
- Add git pre-commit hook (hooks/pre-commit) that runs Pint on staged PHP
  files, auto-installed via Composer post-install/post-update scripts
- Simplify README installation to a single copy-paste command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:03:17 -05:00
Prophet731
1ab2ef42a5 chore: full project audit cleanup, dead code removal, and documentation update
Dead code removed:
- Module.php: remove assignBackupPlan(), getSelfServiceCurrencies() (no callers)
- Cache.php: remove forgetPattern() (no callers, no-op on filesystem)
- module.js: remove vfLoadSelfServiceReport() (no UI trigger)

Stale files removed:
- .releaserc.json (orphaned, conflicts with tag-based workflow)
- .github/workflows/api-sync-check.yml (baseline never populated)
- docs/openapi-baseline.yaml (placeholder stub)
- scripts/generate-endpoint-doc.sh (broken grep patterns)

Security fixes:
- AdminHTML: cast $serverId to (int), cast $serviceId to (int)
- admin.php: add explicit break after every output() call, sanitize error msgs

File hygiene:
- Move modify.sql into modules/servers/VirtFusionDirect/ (matches README docs)
- Fix CHANGELOG.md: remove duplicate 1.0.0 entry, clean up mixed git host URLs

Documentation:
- CLAUDE.md: full rewrite with current architecture, Cache class, development
  rules (try/catch, ownership validation, HTTP methods, caching policy)
- README.md: remove stale IPv4 removal references, add new features (traffic,
  backups, VNC toggle, password reset, OS gallery, copy buttons), add Cache.php
  to file structure, remove "Primary IPv4 Protection" known issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:28:58 -05:00
Prophet731
3ca9eb60c3 fix: force generic icon on 'Other' category even when API provides linux_logo.png
VirtFusion API returns an 'Other' category with icon=linux_logo.png by default.
Null out the icon in groupOsTemplates() so the JS SVG fallback renders instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:18:12 -05:00
Prophet731
504d2926a4 fix: use unix timestamp for cache busting, generic server icon for Other category
- Replace hardcoded date version strings with dynamic timestamps:
  overview.tpl uses {$smarty.now}, hooks.php uses time(), AdminHTML uses
  $cacheV = time() in heredoc
- Other category gets a gray server/terminal SVG icon instead of falling
  through to the OS-specific letter badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:15:49 -05:00
Prophet731
64dcce3d0e fix: constrain category header icon images with overflow:hidden and max dimensions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:11:04 -05:00
Prophet731
6694a5e44d fix: constrain OS icon sizing and remove background when image loads
- Add overflow:hidden to .vf-os-card and .vf-os-icon
- Constrain .vf-os-icon img with max-width/max-height:100%
- Only apply brand color background as fallback when image fails to load
- No background color when image is present (clean transparent display)
- Apply same logic to both category headers and template cards
- Update both module.js (rebuild panel) and hooks.php (checkout page)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:10:01 -05:00
Prophet731
6528c8a53a fix: restore OS template icons using correct VirtFusion path /img/logo/
The VirtFusion panel serves OS icons at /img/logo/{icon} not /storage/os/{icon}.
Restore image loading in both rebuild gallery (module.js) and checkout gallery
(hooks.php) with onerror fallback to letter badges. Also restore baseUrl
population in hooks.php for checkout page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:07:12 -05:00
Prophet731
d3d75b4752 fix: remove dead code, update stale versions, tag-based release workflow
Dead code removed:
- Module.php: remove addIPv4() method (no endpoint, feature removed per CLAUDE.md)
- Curl.php: remove useCookies(), setLog() (security risk — wrote tokens to
  web-accessible CURL.log), head(), getHeadersData() — all unused
- module.css: remove .vf-button, .vf-button-small (never referenced in DOM)
- module.css: remove vestigial #vf-data-server-traffic-sep rule
- module.css: merge duplicate #vf-server-info-error declarations
- publish-release.yml: remove dead version.json generation step (nothing reads it)

Fixes:
- AdminHTML.php: update stale cache version strings 20260207 → 20260319
- hooks.php: update stale keygen.js version string
- hooks.php: remove unused `use WHMCS\User\User` import
- ConfigureService.php: remove unused `use JsonException` import
- module.css: fix .vf-os-details class selector → #vf-os-details ID selector
- client.php + admin.php: reuse existing $vf instead of new Module()
- Module.php: use Cache::forget() instead of forgetPattern() for known key
  (forgetPattern is a no-op on filesystem cache fallback)

Workflow:
- Rewrite publish-release.yml: tag-based triggers only (no automatic releases)
- Triggers on push of v* tags, creates GitHub release with auto-generated notes
- Uses softprops/action-gh-release@v2 — compatible with both Gitea and GitHub

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:59:28 -05:00
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
29 changed files with 6438 additions and 910 deletions

View File

@@ -1,19 +1,43 @@
---
name: Publish Release name: Publish Release
on: on:
push: push:
branches: tags:
- main - 'v*'
jobs: jobs:
publish-release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Publish Release - name: Checkout code
uses: ncipollo/release-action@v1 uses: actions/checkout@v4
- name: Extract tag name
id: tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Generate release notes
id: notes
run: |
# Get previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
NOTES=$(git log --pretty=format:"- %s" "$PREV_TAG"..HEAD)
else
NOTES=$(git log --pretty=format:"- %s")
fi
# Write to file for the release body
echo "$NOTES" > /tmp/release-notes.txt
- name: Create release
uses: softprops/action-gh-release@v2
with: with:
token: ${{secrets.GITHUB_TOKEN}} tag_name: ${{ steps.tag.outputs.version }}
name: ${{ steps.tag.outputs.version }}
body_path: /tmp/release-notes.txt
draft: false draft: false
prerelease: false prerelease: false
name: "0.0.${{ github.run_number }}" env:
tag: "0.0.${{ github.run_number }}" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
body: "Release 0.0.${{ github.run_number }}"

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/.idea/ /.idea/
/.superpowers/
/vendor/

95
CHANGELOG.md Normal file
View File

@@ -0,0 +1,95 @@
# Changelog
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
## [1.0.0] - 2026-03-19
### Features
- OS template tile gallery with accordion categories, brand icons, and search
- Inline server rename with friendly name generator
- Traffic statistics canvas chart in resources panel
- Backup listing timeline in manage panel
- VNC enable/disable toggle with connection details and password copy
- Server root password reset with auto-clipboard copy
- Redis-backed API response caching with filesystem fallback
- Skeleton loading, action cooldowns, progress indicators
- Copy-to-clipboard buttons for IP addresses
- Client-side SSH Ed25519 key generator on checkout page
- VNC console support, resources panel, self-service billing
- Configurable option sliders on checkout page
### Bug Fixes
- XSS escaping, null guards, and proper error handling
- All state-mutating operations use POST instead of GET
- Explicit break after all output() calls in client.php
- Server-side regex validation on rename endpoint
- Error messages sanitized (no raw API errors exposed to clients)
### Removed
- Client IP removal capability (IPs managed by VirtFusion)
- IP add buttons (managed by VirtFusion during provisioning)
- Firewall panel (non-functional; managed in VirtFusion admin)
### Infrastructure
- Tag-based release workflow (compatible with Gitea and GitHub)
- Codebase consolidation: resolveServiceContext(), groupOsTemplates(), vfUrl(), vfShowAlert()
## [0.0.18] - 2025-10-01
### 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
- Security policy (SECURITY.md)
- License (GPL v3)

135
CLAUDE.md Normal file
View File

@@ -0,0 +1,135 @@
# 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
## Development Rules
- **Error handling:** Always use try...catch blocks around API calls, database operations, and any code that may throw exceptions. Never let exceptions bubble up unhandled to the user. Log caught exceptions via `Log::insert()`.
- **Ownership validation:** Every client-facing action MUST verify service ownership via `validateUserOwnsService()` before performing any operation. Server IDs must be cross-referenced against the authenticated client to prevent cross-customer data access.
- **Security:** All input must be validated server-side. Never trust client-side validation alone. Cast IDs to `(int)`, validate strings with regex, escape output with `htmlspecialchars()`.
- **Control flow:** Every `$vf->output()` call in switch cases must be followed by `break`. Do not rely on `exit()` inside `output()` for flow control.
- **HTTP methods:** Read-only operations use GET. State-mutating operations (power, rebuild, rename, password reset, credit, VNC toggle) use POST with data in the request body.
- **Caching:** Use the `Cache` class for slow-changing API responses. Never cache real-time data (server status, VNC sessions, login tokens) or mutation responses.
## Release Process
Releases are triggered by pushing a git tag:
```bash
git tag v1.1.0
git push origin v1.1.0
```
The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-generated notes from the commit log. Use **conventional commits** for clear changelogs:
- `fix:` → patch-level change
- `feat:` → feature addition
- `refactor:` → code improvement without behavior change
## Architecture
**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. POST for mutations, GET for reads. |
| `admin.php` | Admin-facing AJAX API — requires WHMCS admin authentication |
| `hooks.php` | WHMCS hooks — checkout validation (OS selection), OS gallery + SSH key UI injection, slider UI for configurable options |
### Core Classes (in `lib/`)
| Class | Role |
|-------|------|
| `Module` | Base class with API integration, auth checks, and all feature methods (power, network, VNC, backup, resource, self-service, traffic, rename, password reset). Contains `resolveServiceContext()` for DRY service lookups and `groupOsTemplates()` for shared OS category logic. |
| `ModuleFunctions` | Extends `Module`. Service lifecycle: create, suspend, unsuspend, terminate, change package, usage updates, client area rendering. |
| `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`. Single-use — each instance makes one request. |
| `Cache` | Two-tier caching: Redis (if `ext-redis` available) with atomic filesystem fallback. TTLs: OS templates 10min, traffic/backups 2min, packages 10min. |
| `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. The `resolveServiceContext()` method provides a standardized way to look up service → WHMCS service → control panel → curl client in a single call, eliminating boilerplate across all API methods.
### Client-Side
- **`templates/overview.tpl`** — Smarty template for client area (server info, power, network, rebuild with OS gallery, resources with traffic chart, VNC toggle, self-service billing, billing overview, backups timeline, server rename, password reset)
- **`templates/js/module.js`** — Vanilla JS + jQuery handling AJAX calls, DOM updates, status badges, power actions, all management UIs. Key helpers: `vfUrl()` (URL builder), `vfShowAlert()` (alert display), `vfRenderOsGallery()` (accordion gallery), `vfDrawTrafficChart()` (canvas chart)
- **`templates/js/keygen.js`** — Client-side SSH Ed25519 key generator using Web Crypto API (loaded on checkout page)
- **`templates/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/remove buttons** — Removed; 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
Custom fields (`Initial Operating System`, `Initial SSH Key`) are auto-created by `Database::ensureCustomFields()` on module load for all products using this module. No manual SQL setup required.
### 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 (except entry points using `init.php`)
- 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 (`(int)`), regex filtering, `filter_var()` for IP addresses
- Output escaping: `htmlspecialchars()` in PHP, `$('<span>').text()` in jQuery, `{escape:'htmlall'}` in Smarty
- SSL verification enabled on all API calls (`CURLOPT_SSL_VERIFYPEER` + `CURLOPT_SSL_VERIFYHOST = 2`)
- Server rename validated both client-side and server-side with RFC 1123 regex
## 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
- **OS icon path:** `{baseUrl}/img/logo/{icon_filename}` (public, no auth required)
## 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
- Redis extension optional (improves caching performance, falls back to filesystem)
- All WHMCS themes supported (Six, Twenty-One, Lagom, custom) via Bootstrap 3/4/5 dual classes

582
README.md
View File

@@ -1,90 +1,524 @@
# VirtFusion Direct Provisioning Module for WHMCS # VirtFusion Direct Provisioning Module for WHMCS
[![GitHub Super-Linter](https://github.com/EZSCALE/virtfusion-whmcs-module/actions/workflows/publish-release.yml/badge.svg)](https://github.com/EZSCALE/virtfusion-whmcs-module/actions) [![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](https://img.shields.io/github/license/EZSCALE/virtfusion-whmcs-module)
![GitHub issues](https://img.shields.io/github/issues/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) ![GitHub pull requests](https://img.shields.io/github/issues-pr/EZSCALE/virtfusion-whmcs-module)
This module requires VirtFusion v1.7.3 or higher as this is what it's based on. Please refer to the A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.com) that enables automated VPS server provisioning, management, and client self-service directly from WHMCS.
official [documentation](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 IPv4 addresses and IPv6 subnets with copy-to-clipboard
- **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
- OS template tile gallery with accordion categories, search, and brand icons
- SSH key selection dropdown for users with saved keys, with option to paste a new public key
- **SSH 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 ## Installation
1. Download the latest release from the [releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases) page. ```bash
2. Extract the contents of the archive and upload the modules folder to your WHMCS installation directory. git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf && rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/ && rm -rf /tmp/vf
## :heavy_exclamation_mark: Important Notes :heavy_exclamation_mark:
You must create two custom fields in WHMCS for this module to work. You need to configure the following custom fields on
each product you want to use this module with.
| Field Name | Field Type | Description | Validation | Select Options | Admin Only | Required Field | Show on Order Form | Show on Invoice |
|--------------------------|------------|--------------------------|-------------|----------------|------------|----------------|--------------------|-----------------|
| Initial Operating System | Text Box | Set to whatever you want | Leave Blank | Leave Blank | :x: | :x: | :white_check_mark: | :x: |
| Initial SSH Key | Text Box | Set to whatever you want | Leave Blank | Leave Blank | :x: | :x: | :white_check_mark: | :x: |
You can run this SQL query to create the custom fields:
```sql
-- 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);
``` ```
Replace `/path/to/whmcs` with your actual WHMCS installation root. The database table, schema migrations, and custom fields are all created automatically on first load.
## What does this module change? Then configure in WHMCS Admin:
This module changes the following things: 1. **Add Server** — Configuration > System Settings > Servers > Add New Server. Set hostname to your VirtFusion panel (e.g. `cp.example.com`), type to "VirtFusion Direct Provisioning", and paste your API token in the Password field. Click **Test Connection** to verify.
2. **Create Product** — Configuration > System Settings > Products/Services. On the Module Settings tab, select "VirtFusion Direct Provisioning", choose your server, and set the Hypervisor Group ID, Package ID, and Default IPv4 count.
- Adds configurable options to the product configuration page to allow the user to select the operating system and add That's it. Hooks activate automatically and custom fields are created on module load.
an ssh key to the initial deployment.
## TODO ## Upgrading
- [ ] Add post checkout checks to ensure the user has selected an operating system and added a ssh key. ```bash
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf && rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ /path/to/whmcs/modules/servers/VirtFusionDirect/ && rm -rf /tmp/vf
```
> **Note:** If you have a custom `config/ConfigOptionMapping.php`, back it up first — `--delete` will remove it. Restore it after upgrading.
If you use theme-overridden templates, review them for any new template variables. Clear the WHMCS template cache after upgrading: **Configuration > System Settings > General Settings > clear template cache**.
## Configuration
### Server Setup
In WHMCS Admin under **Configuration > System Settings > Servers**:
| 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
The module requires two custom fields per product: **Initial Operating System** and **Initial SSH Key**. These are **automatically created** when the module loads — no manual setup required.
The fields are hidden text boxes that are dynamically replaced by dropdown selects via JavaScript hooks on the order form. They are created for every product with the module type set to "VirtFusion Direct Provisioning".
### Module Configuration Options
Each product has three module-specific settings:
| 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
- Copy IP addresses to clipboard with one click
### 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 |
### 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` | `/servers/{id}/traffic` | Traffic statistics |
| `GET` | `/backups/server/{id}` | Backup listing |
| `POST` | `/servers/{id}/vnc` | Toggle VNC on/off |
| `POST` | `/servers/{id}/resetPassword` | Reset server root password |
### Advanced
| 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. **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)
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
Cache.php # Two-tier cache: Redis with filesystem fallback
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,12 +2,12 @@
## Supported Versions ## Supported Versions
The support version of this module with VirtFusion Supported VirtFusion versions:
| Version | Supported | | Version | Supported |
|---------|--------------------| |----------|--------------------|
| > 1.7.3 | :white_check_mark: | | >= 1.7.3 | :white_check_mark: |
| < 1.7.3 | :x: | | < 1.7.3 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability

19
composer.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "ezscale/virtfusion-whmcs-module",
"description": "VirtFusion Direct Provisioning Module for WHMCS",
"type": "whmcs-module",
"license": "GPL-3.0-or-later",
"require-dev": {
"laravel/pint": "^1.0"
},
"scripts": {
"post-install-cmd": [
"cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit"
],
"post-update-cmd": [
"cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit"
],
"lint": "pint",
"lint-test": "pint --test"
}
}

87
composer.lock generated Normal file
View File

@@ -0,0 +1,87 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f6be98eb2bded4b127a92bc0f1e19d93",
"packages": [],
"packages-dev": [
{
"name": "laravel/pint",
"version": "v1.29.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
"php": "^8.2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.94.2",
"illuminate/view": "^12.54.1",
"larastan/larastan": "^3.9.3",
"laravel-zero/framework": "^12.0.5",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.6",
"shipfastlabs/agent-detector": "^1.1.0"
},
"bin": [
"builds/pint"
],
"type": "project",
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Seeders\\": "database/seeders/",
"Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com",
"keywords": [
"dev",
"format",
"formatter",
"lint",
"linter",
"php"
],
"support": {
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2026-03-12T15:51:39+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

26
hooks/pre-commit Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Run Pint on staged PHP files before committing.
# Fixes formatting in-place and re-stages the corrected files.
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -z "$STAGED_PHP" ]; then
exit 0
fi
# Check that Pint is installed
if [ ! -x "./vendor/bin/pint" ]; then
echo "Error: laravel/pint is not installed. Run 'composer install' first."
exit 1
fi
echo "Running Pint on staged PHP files..."
./vendor/bin/pint $STAGED_PHP
# Re-stage any files that Pint modified
for FILE in $STAGED_PHP; do
if [ -f "$FILE" ]; then
git add "$FILE"
fi
done

View File

@@ -1,111 +1,359 @@
<?php <?php
if (!defined("WHMCS")) { if (! defined('WHMCS')) {
die("This file cannot be accessed directly"); exit('This file cannot be accessed directly');
} }
use WHMCS\Database\Capsule;
use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module;
use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions; use WHMCS\Module\Server\VirtFusionDirect\ModuleFunctions;
/**
* Returns module metadata consumed by WHMCS.
*
* @return array
*/
function VirtFusionDirect_MetaData() function VirtFusionDirect_MetaData()
{ {
return [ return [
'DisplayName' => 'VirtFusion Direct Provisioning', 'DisplayName' => 'VirtFusion Direct Provisioning',
'APIVersion' => '1.1', 'APIVersion' => '1.1',
'RequiresServer' => true, 'RequiresServer' => true,
'ServiceSingleSignOnLabel' => false, 'ServiceSingleSignOnLabel' => 'Login to VirtFusion Panel',
'AdminSingleSignOnLabel' => false, 'AdminSingleSignOnLabel' => false,
]; ];
} }
/**
* Returns product configuration options displayed in the WHMCS product editor.
*
* @return array
*/
function VirtFusionDirect_ConfigOptions() function VirtFusionDirect_ConfigOptions()
{ {
return [ return [
"defaultHypervisorGroupId" => [ 'defaultHypervisorGroupId' => [
"FriendlyName" => "Hypervisor Group ID", 'FriendlyName' => 'Hypervisor Group ID',
"Type" => "text", 'Type' => 'text',
"Size" => "20", 'Size' => '20',
"Description" => "The default hypervisor group ID", 'Description' => 'The default hypervisor group ID for server placement.',
"Default" => "1", 'Default' => '1',
], ],
"packageID" => [ 'packageID' => [
"FriendlyName" => "Package ID", 'FriendlyName' => 'Package ID',
"Type" => "text", 'Type' => 'text',
"Size" => "20", 'Size' => '20',
"Description" => "The package ID", 'Description' => 'The VirtFusion package ID that defines server resources.',
"Default" => "1", 'Default' => '1',
], ],
"defaultIPv4" => [ 'defaultIPv4' => [
"FriendlyName" => "Default IPv4", 'FriendlyName' => 'Default IPv4',
"Type" => "dropdown", 'Type' => 'dropdown',
"Options" => "0,1,2,3,4,5,6,7,8,9,10", 'Options' => '0,1,2,3,4,5,6,7,8,9,10',
"Description" => "The default amount of IPv4 addresses to assign to the server.", 'Description' => 'The default number of IPv4 addresses to assign to each server.',
"Default" => "1", '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_AdminCustomButtonArray() function VirtFusionDirect_TestConnection(array $params)
{ {
$buttonarray = array( try {
"Update Server Object" => "updateServerObject", $hostname = trim($params['serverhostname'] ?? '');
); $password = $params['serverpassword'] ?? '';
return $buttonarray;
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()];
}
} }
/** /**
* Returns custom admin action buttons shown on the service management page.
* *
* * @return array Button label => function suffix pairs
*/
function VirtFusionDirect_AdminCustomButtonArray()
{
return [
'Update Server Object' => 'updateServerObject',
'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 * Service functions
*
*/ */
function VirtFusionDirect_CreateAccount(array $params) function VirtFusionDirect_CreateAccount(array $params)
{ {
return (new ModuleFunctions())->createAccount($params); return (new ModuleFunctions)->createAccount($params);
} }
/**
* Suspends the VirtFusion server associated with a WHMCS service.
*
* @param array $params WHMCS module parameters
* @return string 'success' or error message
*/
function VirtFusionDirect_SuspendAccount(array $params) function VirtFusionDirect_SuspendAccount(array $params)
{ {
return (new ModuleFunctions())->suspendAccount($params); return (new ModuleFunctions)->suspendAccount($params);
} }
/**
* Unsuspends the VirtFusion server associated with a WHMCS service.
*
* @param array $params WHMCS module parameters
* @return string 'success' or error message
*/
function VirtFusionDirect_UnsuspendAccount(array $params) function VirtFusionDirect_UnsuspendAccount(array $params)
{ {
return (new ModuleFunctions())->unsuspendAccount($params); return (new ModuleFunctions)->unsuspendAccount($params);
} }
/**
* Terminates (deletes) the VirtFusion server associated with a WHMCS service.
*
* @param array $params WHMCS module parameters
* @return string 'success' or error message
*/
function VirtFusionDirect_TerminateAccount(array $params) function VirtFusionDirect_TerminateAccount(array $params)
{ {
return (new ModuleFunctions())->terminateAccount($params); return (new ModuleFunctions)->terminateAccount($params);
} }
/**
* Admin custom action: refreshes the local server object from the VirtFusion API.
*
* @param array $params WHMCS module parameters
* @return string 'success' or error message
*/
function VirtFusionDirect_updateServerObject(array $params) function VirtFusionDirect_updateServerObject(array $params)
{ {
return (new ModuleFunctions())->updateServerObject($params); return (new ModuleFunctions)->updateServerObject($params);
} }
/** /**
* Allows changing of the package of a server * Allows changing of the package of a server
* *
* @author https://github.com/BlinkohHost/virtfusion-whmcs-module
* @param array $params
* @return string * @return string
*/ */
function VirtFusionDirect_ChangePackage(array $params) function VirtFusionDirect_ChangePackage(array $params)
{ {
return (new ModuleFunctions())->changePackage($params); return (new ModuleFunctions)->changePackage($params);
} }
/**
* Returns HTML fields rendered in the custom admin services tab.
*
* @param array $params WHMCS module parameters
* @return array Field name => HTML value pairs
*/
function VirtFusionDirect_AdminServicesTabFields(array $params) function VirtFusionDirect_AdminServicesTabFields(array $params)
{ {
return (new ModuleFunctions())->adminServicesTabFields($params); return (new ModuleFunctions)->adminServicesTabFields($params);
} }
/**
* Handles saving of custom admin services tab field values.
*
* @param array $params WHMCS module parameters
* @return void
*/
function VirtFusionDirect_AdminServicesTabFieldsSave(array $params) function VirtFusionDirect_AdminServicesTabFieldsSave(array $params)
{ {
(new ModuleFunctions())->adminServicesTabFieldsSave($params); (new ModuleFunctions)->adminServicesTabFieldsSave($params);
} }
/**
* Returns the client area template variables and template name for the service overview page.
*
* @param array $params WHMCS module parameters
* @return array Smarty template variables and 'templatefile' key
*/
function VirtFusionDirect_ClientArea(array $params) function VirtFusionDirect_ClientArea(array $params)
{ {
return (new ModuleFunctions())->clientArea($params); return (new ModuleFunctions)->clientArea($params);
}
/**
* Validates server configuration via dry run without creating the server.
*
* @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 = 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');
Capsule::table('tblhosting')
->where('id', $service->id)
->update($update);
}
// Self-service auto top-off
$product = 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');
Log::insert(
'UsageUpdate:autoTopOff',
['serviceId' => $service->id, 'credit' => $credit, 'threshold' => $threshold],
['amount' => $topOffAmount],
);
}
}
}
}
} catch (Exception $e) {
// Log but continue processing other services
Log::insert('UsageUpdate:service:' . $service->id, [], $e->getMessage());
continue;
}
}
return 'success';
} catch (Exception $e) {
return 'Usage update failed: ' . $e->getMessage();
}
} }

View File

@@ -2,84 +2,97 @@
require dirname(__DIR__, 3) . '/init.php'; require dirname(__DIR__, 3) . '/init.php';
/**
* Admin-facing AJAX API endpoint.
*
* Requires WHMCS admin authentication. Provides server data lookup
* and user impersonation for the admin services tab.
*/
use WHMCS\Module\Server\VirtFusionDirect\Database; use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\Module;
use WHMCS\Module\Server\VirtFusionDirect\ServerResource; use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
$vf = new Module(); $vf = new Module;
$vf->adminOnly(); try {
switch ($vf->validateAction(true)) { $vf->adminOnly();
/** switch ($vf->validateAction(true)) {
*
* Get server information.
*
*/
case 'serverData':
if ($vf->validateServiceID(true)) { /**
* Get server information.
*/
case 'serverData':
/** No need to validate ownership **/ $serviceID = $vf->validateServiceID(true);
$whmcsService = Database::getWhmcsService((int)$_GET['serviceID']); $whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) { if (! $whmcsService) {
$vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 200); $vf->output(['success' => false, 'errors' => 'Service not found.'], true, true, 404);
break;
} }
if ($whmcsService->domainstatus == 'Pending' || $whmcsService->domainstatus == 'Terminated' || $whmcsService->domainstatus == 'Cancelled' || $whmcsService->domainstatus == 'Fraud') { if (in_array($whmcsService->domainstatus, ['Pending', 'Terminated', 'Cancelled', 'Fraud'], true)) {
$vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 200); $vf->output(['success' => false, 'errors' => 'Server is not Active, Suspended or Completed. Not fetching remote data.'], true, true, 400);
break;
} }
$data = $vf->fetchServerData((int)$_GET['serviceID']); $data = $vf->fetchServerData($serviceID);
if (!$data) {
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 200);
if (! $data) {
$vf->output(['success' => false, 'errors' => 'No data returned from VirtFusion.'], true, true, 502);
break;
} }
(new Module())->updateWhmcsServiceParamsOnServerObject((int)$_GET['serviceID'], $data); $vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200); $vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200);
break;
} /**
break; * Impersonate server owner.
*/
case 'impersonateServerOwner':
/** $serviceID = $vf->validateServiceID(true);
*
* Impersonate server owner.
*
*/
case 'impersonateServerOwner':
if ($vf->validateServiceID(true)) { $service = Database::getSystemService($serviceID);
if (! $service) {
$service = Database::getSystemService((int)$_GET['serviceID']); $vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 404);
break;
if (!$service) {
$vf->output(['success' => false, 'errors' => 'Service not found'], true, true, 200);
} }
$whmcsService = Database::getWhmcsService((int)$_GET['serviceID']); $whmcsService = Database::getWhmcsService($serviceID);
if (! $whmcsService) {
$vf->output(['success' => false, 'errors' => 'WHMCS service not found'], true, true, 404);
break;
}
$cp = $vf->getCP($whmcsService->server); $cp = $vf->getCP($whmcsService->server);
$request = $vf->initCurl($cp['token']); if (! $cp) {
$vf->output(['success' => false, 'errors' => 'Control server not found'], true, true, 500);
break;
}
$data = $request->get($cp['url'] . '/users/' . $whmcsService->userid . '/byExtRelation'); $request = $vf->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/users/' . (int) $whmcsService->userid . '/byExtRelation');
if ($request->getRequestInfo('http_code') === 200) { if ($request->getRequestInfo('http_code') === 200) {
$vf->output(['success' => true, 'url' => $cp['base_url'], 'user' => json_decode($data, true)['data']], true, true, 200); $vf->output(['success' => true, 'url' => $cp['base_url'], 'user' => json_decode($data, true)['data']], true, true, 200);
break;
} }
$vf->output(['success' => false, 'errors' => 'Received HTTP code ' . $request->getRequestInfo('http_code')], true, true, 200); $vf->output(['success' => false, 'errors' => 'Unable to fetch user data'], true, true, 502);
break;
} default:
break; $vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}
default: } catch (Exception $e) {
/** No valid action was specified **/ Log::insert('admin.php', [], $e->getMessage());
$vf->output(['success' => false, 'errors' => 'An unexpected error occurred'], true, true, 500);
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 200);
} }

View File

@@ -2,110 +2,414 @@
require dirname(__DIR__, 3) . '/init.php'; require dirname(__DIR__, 3) . '/init.php';
/**
* Client-facing AJAX API endpoint.
*
* Authenticated by WHMCS session + service ownership validation.
* POST for mutations (power, rebuild, rename, credit), GET for reads (serverData, templates, backups).
*/
use WHMCS\Module\Server\VirtFusionDirect\Log;
use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\Module;
use WHMCS\Module\Server\VirtFusionDirect\ServerResource; use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
$vf = new Module(); $vf = new Module;
$vf->isAuthenticated(); try {
switch ($vf->validateAction(true)) { $vf->isAuthenticated();
/** $action = $vf->validateAction(true);
*
* Reset Password.
*
*/
case 'resetPassword':
if ($vf->validateServiceID(true)) { switch ($action) {
$client = $vf->validateUserOwnsService((int)$_GET['serviceID']); /**
* Reset Password.
*/
case 'resetPassword':
if (!$client) { $serviceID = $vf->validateServiceID(true);
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 200); $client = $vf->validateUserOwnsService($serviceID);
if (! $client) {
$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) { if ($data) {
$vf->output(['success' => true, 'data' => $data->data], true, true, 200); $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;
} /**
break; * Get server information.
*/
case 'serverData':
/** $serviceID = $vf->validateServiceID(true);
*
* Get server information.
*
*/
case 'serverData':
if ($vf->validateServiceID(true)) { if (! $vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
if (!$vf->validateUserOwnsService((int)$_GET['serviceID'])) { break;
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 200);
} }
$data = $vf->fetchServerData((int)$_GET['serviceID']); $data = $vf->fetchServerData($serviceID);
if ($data) { if ($data) {
$vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
(new Module())->updateWhmcsServiceParamsOnServerObject((int)$_GET['serviceID'], $data); $vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200);
break;
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
} }
$vf->output(['success' => false, 'errors' => 'error'], true, true, 200); $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
break;
}
break;
/**
*
* Login as server owner.
*
*/
case 'loginAsServerOwner':
if ($vf->validateServiceID(true)) {
/** /**
* A client can't log in as any user. Ownership should be validated. * Login as server owner.
*/ */
case 'loginAsServerOwner':
if (!$vf->validateUserOwnsService((int)$_GET['serviceID'])) { $serviceID = $vf->validateServiceID(true);
$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) { if ($token) {
/**
* A valid token/url was received.
*/
$vf->output(['success' => true, 'token_url' => $token], true, true, 200); $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);
break;
default: if (! $vf->validateUserOwnsService($serviceID)) {
/** $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
* break;
* No valid action was specified. }
*
*/ $powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 200); $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:
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}
} catch (Exception $e) {
Log::insert('client.php', [], $e->getMessage());
$vf->output(['success' => false, 'errors' => 'An unexpected error occurred'], true, true, 500);
} }

View File

@@ -1,7 +1,7 @@
<?php <?php
if (!defined("WHMCS")) { if (! defined('WHMCS')) {
die("This file cannot be accessed directly"); exit('This file cannot be accessed directly');
} }
return [ return [

View File

@@ -1,123 +1,578 @@
<?php <?php
use WHMCS\Database\Capsule;
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService; use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
use WHMCS\User\User; use WHMCS\Module\Server\VirtFusionDirect\Database;
use WHMCS\Module\Server\VirtFusionDirect\Module;
if (!defined("WHMCS")) { if (! defined('WHMCS')) {
die("This file cannot be accessed directly"); exit('This file cannot be accessed directly');
} }
add_hook('ClientAreaFooterOutput', 1, function ($vars) { /**
if (!isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') { * Shopping Cart Validation Hook
return null; *
} * Validates that an operating system has been selected before checkout
* for all VirtFusion products in the cart.
*/
add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
$errors = [];
$cs = new ConfigureService(); try {
if (! isset($_SESSION['cart']['products']) || ! is_array($_SESSION['cart']['products'])) {
$templates_data = $cs->fetchTemplates( return $errors;
$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];
} }
foreach ($_SESSION['cart']['products'] as $key => $product) {
$pid = $product['pid'] ?? null;
if (! $pid) {
continue;
}
$dbProduct = 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 = 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.';
}
}
}
} catch (Exception $e) {
// Don't block checkout on internal errors
} }
// Sort dropdownOptions alphabetically by the 'name' key return $errors;
usort($dropdownOptions, function ($a, $b) { });
return strcmp($a['name'], $b['name']);
});
$sshKeys = $cs->getUserSshKeys($vars['loggedinuser']); /**
$sshKeysOptions = array_map(function ($sshKey) { * Client Area Footer Output Hook
if ($sshKey['enabled'] === false) { *
* 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.
*/
add_hook('ClientAreaFooterOutput', 1, function ($vars) {
if (! isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
return null;
}
try {
$cs = new ConfigureService;
$templates_data = $cs->fetchTemplates(
$cs->fetchPackageByDbId($vars['productinfo']['pid']) ?? $cs->fetchPackageId($vars['productinfo']['name']),
);
if (empty($templates_data)) {
return null; return null;
} }
return [ $vfServer = Capsule::table('tblservers')
'id' => $sshKey['id'], ->where('type', 'VirtFusionDirect')
'name' => $sshKey['name'] ->where('disabled', 0)
->first();
$baseUrl = $vfServer ? rtrim('https://' . $vfServer->hostname, '/') : '';
$galleryData = [
'baseUrl' => $baseUrl,
'categories' => Module::groupOsTemplates($templates_data['data'] ?? [], true),
]; ];
}, $sshKeys['data'] ?? []);
$osID = array_values(array_filter(array_map(function ($option) { $sshKeys = [];
if ($option['textid'] === 'initialoperatingsystem') { $sshKeysOptions = [];
return $option['id']; 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' => htmlspecialchars($sshKey['name'], ENT_QUOTES, 'UTF-8'),
];
}, $sshKeysData['data'])));
}
} }
}, $vars['customfields'])));
$sshID = array_values(array_filter(array_map(function ($option) { $osID = array_values(array_filter(array_map(function ($option) {
if ($option['textid'] === 'initialsshkey') { if ($option['textid'] === 'initialoperatingsystem') {
return $option['id']; 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;
} }
}, $vars['customfields'])));
// Construct the JavaScript code $systemUrl = Database::getSystemUrl();
return "
return '
<link href="' . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . 'modules/servers/VirtFusionDirect/templates/css/module.css?v=' . time() . '" rel="stylesheet">
<script src="' . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . 'modules/servers/VirtFusionDirect/templates/js/keygen.js?v=' . time() . "\"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var osTemplates = " . json_encode($dropdownOptions) . "; 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) . "; 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[" . ($osID[0] ?? null) . "]\"]'); var osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
var sshInputField = document.querySelector('[name=\"customfield[" . ($sshID[0] ?? null) . "]\"]'); var sshInputField = " . ($sshFieldId !== null ? "document.querySelector('[name=\"customfield[" . (int) $sshFieldId . "]\"]')" : 'null') . ';
var sshInputLabel = ' . ($sshFieldId !== null ? "document.querySelector('[for=\"customfield" . (int) $sshFieldId . "\"]')" : 'null') . ";
// Create dropdown options menu, then add it to the DOM then on change, update the regular input. if (!osInputField) return;
var osSelect = document.createElement('select');
osSelect.className = 'form-control';
osTemplates.forEach(function(template) { // Brand color map (must match vfOsBrandColors in module.js)
var option = document.createElement('option'); var brandColors = {
option.value = template.id; 'ubuntu':'#E95420','debian':'#A81D33','rocky':'#10B981','centos':'#932279',
option.text = template.name; 'almalinux':'#0F4266','alma':'#0F4266','windows':'#0078D4','fedora':'#51A2DA',
osSelect.appendChild(option); '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';
if (cat.icon && osGalleryData.baseUrl) {
var catImg = document.createElement('img');
catImg.src = osGalleryData.baseUrl + '/img/logo/' + encodeURIComponent(cat.icon);
catImg.alt = '';
catImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = (cat.name || '?')[0].toUpperCase(); };
catIcon.appendChild(catImg);
} else if (cat.name === 'Other') {
catIcon.style.background = '#6c757d';
catIcon.innerHTML = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"#fff\"><path d=\"M3 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm1 2h8v2H4V4zm0 3h8v1H4V7zm0 2h5v1H4V9z\"/></svg>';
} else {
catIcon.style.background = catColor;
catIcon.textContent = (cat.name || '?')[0].toUpperCase();
}
var catTitle = document.createElement('span');
catTitle.textContent = cat.name + ' (' + cat.templates.length + ')';
var arrow = document.createElement('span');
arrow.className = 'vf-os-category-arrow';
arrow.textContent = ci === 0 ? '\u25BC' : '\u25B6';
header.appendChild(catIcon);
header.appendChild(catTitle);
header.appendChild(arrow);
section.appendChild(header);
var grid = document.createElement('div');
grid.className = 'vf-os-grid';
if (ci !== 0) grid.style.display = 'none';
header.addEventListener('click', function() {
var isOpen = grid.style.display !== 'none';
// Collapse all
galleryContainer.querySelectorAll('.vf-os-grid').forEach(function(g) { g.style.display = 'none'; });
galleryContainer.querySelectorAll('.vf-os-category-arrow').forEach(function(a) { a.textContent = '\u25B6'; });
// Toggle this one
if (!isOpen) {
grid.style.display = '';
arrow.textContent = '\u25BC';
}
});
cat.templates.forEach(function(tpl) {
var fullLabel = tpl.name + (tpl.version ? ' ' + tpl.version : '') + (tpl.variant ? ' ' + tpl.variant : '');
var card = document.createElement('div');
card.className = 'vf-os-card' + (tpl.eol ? ' vf-os-card-eol' : '');
card.setAttribute('data-id', tpl.id);
card.setAttribute('data-search', fullLabel.toLowerCase());
var iconDiv = document.createElement('div');
iconDiv.className = 'vf-os-icon';
if (tpl.icon && osGalleryData.baseUrl) {
var tplImg = document.createElement('img');
tplImg.src = osGalleryData.baseUrl + '/img/logo/' + encodeURIComponent(tpl.icon);
tplImg.alt = '';
tplImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = ''; var s = document.createElement('span'); s.textContent = (tpl.name || '?')[0].toUpperCase(); this.parentNode.appendChild(s); };
iconDiv.appendChild(tplImg);
} else {
iconDiv.style.background = catColor;
var sp = document.createElement('span');
sp.textContent = (tpl.name || '?')[0].toUpperCase();
iconDiv.appendChild(sp);
}
card.appendChild(iconDiv);
var labelDiv = document.createElement('div');
labelDiv.className = 'vf-os-label';
labelDiv.textContent = tpl.name;
card.appendChild(labelDiv);
var verDiv = document.createElement('div');
verDiv.className = 'vf-os-version';
verDiv.textContent = (tpl.version || '') + (tpl.variant ? ' ' + tpl.variant : '');
card.appendChild(verDiv);
if (tpl.eol) {
var eolBadge = document.createElement('span');
eolBadge.className = 'vf-os-eol-badge';
eolBadge.textContent = 'EOL';
card.appendChild(eolBadge);
}
card.addEventListener('click', function() {
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) { c.classList.remove('vf-os-card-selected'); });
card.classList.add('vf-os-card-selected');
osInputField.value = tpl.id;
galleryContainer.style.borderColor = '';
});
grid.appendChild(card);
});
section.appendChild(grid);
galleryContainer.appendChild(section);
});
}
galleryWrap.appendChild(galleryContainer);
// Search handler
searchInput.addEventListener('keyup', function() {
var q = this.value.toLowerCase();
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) {
c.style.display = c.getAttribute('data-search').indexOf(q) !== -1 ? '' : 'none';
});
galleryContainer.querySelectorAll('.vf-os-category').forEach(function(s) {
var cards = s.querySelectorAll('.vf-os-card');
var hasVisible = false;
cards.forEach(function(c) { if (c.style.display !== 'none') hasVisible = true; });
s.style.display = hasVisible ? '' : 'none';
});
}); });
// Set the default value of the input field to the first option in the dropdown. // Validation: red border if no selection on form submit
osInputField.value = osSelect.options[0].value; var form = osInputField.closest('form');
if (form) {
form.addEventListener('submit', function(e) {
if (!osInputField.value) {
galleryContainer.style.border = '2px solid #dc3545';
galleryContainer.style.borderRadius = '8px';
galleryContainer.style.padding = '4px';
galleryContainer.scrollIntoView({behavior: 'smooth', block: 'center'});
}
});
}
osSelect.addEventListener('change', function() { osInputField.parentNode.insertBefore(galleryWrap, osInputField.nextSibling);
osInputField.value = this.value;
console.log(this.value);
});
osInputField.parentNode.insertBefore(osSelect, osInputField.nextSibling);
osInputField.style.display = 'none'; osInputField.style.display = 'none';
if (sshKeys.length > 0) { // Handle SSH keys
// Create dropdown options menu, then add it to the DOM then on change, update the regular input. if (sshInputField) {
var sshSelect = document.createElement('select'); // Create the paste-key textarea (hidden initially if keys exist)
sshSelect.className = 'form-control'; var sshPasteContainer = document.createElement('div');
sshPasteContainer.setAttribute('id', 'vf-ssh-paste-container');
sshPasteContainer.style.display = 'none';
sshPasteContainer.style.marginTop = '8px';
sshKeys.forEach(function(sshkey) { var pasteLabel = document.createElement('label');
var option = document.createElement('option'); pasteLabel.textContent = 'Paste your SSH public key:';
option.value = sshkey.id; pasteLabel.style.display = 'block';
option.text = sshkey.name; pasteLabel.style.marginBottom = '4px';
sshSelect.appendChild(option);
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();
}); });
// Set the default value of the input field to the first option in the dropdown. sshPasteContainer.appendChild(pasteLabel);
sshInputField.value = sshSelect.options[0].value; sshPasteContainer.appendChild(pasteArea);
sshSelect.addEventListener('change', function() { // Generate key button
sshInputField.value = this.value; 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);
}); });
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling); copyBtn.addEventListener('click', function() {
sshInputField.style.display = 'none'; navigator.clipboard.writeText(privKeyArea.value).then(function() {
} else { copyBtn.textContent = 'Copied!';
sshInputField.style.display = 'none'; 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> </script>
"; ";
} catch (Throwable $e) {
// Silently fail - don't break the checkout page
return null;
}
}); });

View File

@@ -2,38 +2,76 @@
namespace WHMCS\Module\Server\VirtFusionDirect; namespace WHMCS\Module\Server\VirtFusionDirect;
/**
* Static methods that generate HTML fragments for the WHMCS admin services tab.
*/
class AdminHTML class AdminHTML
{ {
/**
* Render the "Impersonate Server Owner" button for the admin services tab.
*
* @param string $systemUrl WHMCS system URL
* @param int $serviceId VirtFusion server ID
* @return string HTML button markup
*/
public static function options($systemUrl, $serviceId) public static function options($systemUrl, $serviceId)
{ {
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
return <<<EOT return <<<EOT
<button onclick="impersonateServerOwner('${serviceId}', '${systemUrl}')" type="button" class="btn btn-primary">Impersonate Server Owner</button> <button onclick="impersonateServerOwner('${serviceId}', '${systemUrl}')" type="button" class="btn btn-primary">Impersonate Server Owner</button>
<span class="text-info">&nbsp;&nbsp;A valid VirtFusion admin session in the same browser is required for this functionality to work.</span> <span class="text-info">&nbsp;&nbsp;A valid VirtFusion admin session in the same browser is required for this functionality to work.</span>
EOT; EOT;
} }
/**
* Render a read-only textarea containing the raw VirtFusion server JSON object.
*
* @param string $serverObject JSON-encoded server object from the VirtFusion API
* @return string HTML textarea markup
*/
public static function serverObject($serverObject) public static function serverObject($serverObject)
{ {
$serverObject = htmlspecialchars($serverObject, ENT_QUOTES, 'UTF-8');
return <<<EOT return <<<EOT
<textarea class="form-control" name="modulefields[1]" rows="10" style="width: 100%" disabled>${serverObject}</textarea> <textarea class="form-control" name="modulefields[1]" rows="10" style="width: 100%" disabled>${serverObject}</textarea>
EOT; EOT;
} }
/**
* Render an editable text input for the VirtFusion server ID field.
*
* @param int $serverId Current VirtFusion server ID
* @return string HTML input markup with a warning note
*/
public static function serverId($serverId) public static function serverId($serverId)
{ {
$serverId = (int) $serverId;
return <<<EOT return <<<EOT
<input type="text" class="form-control input-200 input-inline" name="modulefields[0]" size="20" value="${serverId}" /> <input type="text" class="form-control input-200 input-inline" name="modulefields[0]" size="20" value="${serverId}" />
<span class="text-info">&nbsp;&nbsp;Changing the Sever ID manually is not recommended. Alterations to this field are usually handled automatically.</span> <span class="text-info">&nbsp;&nbsp;Changing the Sever ID manually is not recommended. Alterations to this field are usually handled automatically.</span>
EOT; EOT;
} }
/**
* Render the inline server info panel for the admin services tab, including CSS/JS assets.
*
* @param string $systemUrl WHMCS system URL (used to build asset and AJAX URLs)
* @param int $serviceId VirtFusion server ID passed to the JS data-loader
* @return string HTML panel markup with embedded script and asset tags
*/
public static function serverInfo($systemUrl, $serviceId) public static function serverInfo($systemUrl, $serviceId)
{ {
$systemUrl = htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8');
$serviceId = (int) $serviceId;
$cacheV = time();
return <<<EOT return <<<EOT
<link href="${systemUrl}modules/servers/VirtFusionDirect/templates/css/module.css" rel="stylesheet"> <link href="${systemUrl}modules/servers/VirtFusionDirect/templates/css/module.css?v=${cacheV}" rel="stylesheet">
<script src="${systemUrl}modules/servers/VirtFusionDirect/templates/js/module.js"></script> <script src="${systemUrl}modules/servers/VirtFusionDirect/templates/js/module.js?v=${cacheV}"></script>
<div id="vf-loader" class="vf-loader"> <div id="vf-loader" class="vf-loader">
<div id="vf-loading"></div> <div id="vf-loading"></div>
</div> </div>

View File

@@ -0,0 +1,184 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect;
/**
* Two-tier cache: uses Redis when the ext-redis extension is available, with an atomic
* filesystem fallback stored in the system temp directory.
*/
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);
}
}
}

View File

@@ -2,17 +2,33 @@
namespace WHMCS\Module\Server\VirtFusionDirect; namespace WHMCS\Module\Server\VirtFusionDirect;
use JsonException;
use WHMCS\Database\Capsule as DB; use WHMCS\Database\Capsule as DB;
use WHMCS\User\User; use WHMCS\User\User;
/**
* Handles order-time and provisioning-time operations for VirtFusion servers.
*
* Extends Module to provide package discovery, OS template fetching, server build
* initialization, and SSH key retrieval/creation. Used during WHMCS checkout and
* account creation flows rather than ongoing service management.
*/
class ConfigureService extends Module class ConfigureService extends Module
{ {
/** /**
* @var array|false $cp * The first available VirtFusion control panel connection, as returned by
* getCP(). Holds server URL and API token used for all API calls in this
* class. False if no active VirtFusion server is configured in WHMCS.
*
* @var array|false
*/ */
private array|bool $cp; private array|bool $cp;
/**
* Initialize the service configurator with the first available VirtFusion server.
*
* Calls the parent Module constructor then resolves the control panel connection
* so all methods in this class have a ready API endpoint.
*/
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
@@ -20,134 +36,298 @@ class ConfigureService extends Module
} }
/** /**
* @param string $packageName * Find a VirtFusion package ID by its name via the API.
* @return int|null *
* @throws JsonException * Searches the packages list for an enabled package whose name matches
* exactly. Result is cached for 10 minutes. Returns null if not found
* or if no control panel is available.
*
* @param string $packageName Exact package name as configured in VirtFusion.
* @return int|null Package ID, or null if not found.
*/ */
public function fetchPackageId(string $packageName): ?int public function fetchPackageId(string $packageName): ?int
{ {
$request = $this->initCurl($this->cp['token']); try {
$cacheKey = 'pkg_name:' . md5($packageName);
$response = $request->get( $cached = Cache::get($cacheKey);
sprintf("%s/packages", $this->cp['url']) if ($cached !== null) {
); return $cached;
$packages = $this->decodeResponseFromJson($response);
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
return $package['id'];
} }
}
return null; if (! $this->cp) {
return null;
}
$request = $this->initCurl($this->cp['token']);
$response = $request->get(
sprintf('%s/packages', $this->cp['url']),
);
$packages = $this->decodeResponseFromJson($response);
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
Cache::set($cacheKey, $package['id'], 600);
return $package['id'];
}
}
return null;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null;
}
} }
/** /**
* @param int $productId * Get the VirtFusion package ID from a WHMCS product's config option.
* @return int|null *
* Reads configoption2 directly from the tblproducts database record for
* the given WHMCS product ID. Returns null if the product does not exist.
*
* @param int $productId WHMCS product (tblproducts) ID.
* @return int|null VirtFusion package ID, or null if the product is not found.
*/ */
public function fetchPackageByDbId(int $productId): ?int public function fetchPackageByDbId(int $productId): ?int
{ {
$product = DB::table('tblproducts')->where('id', $productId)->first(); try {
$product = DB::table('tblproducts')->where('id', $productId)->first();
if (is_null($product)) {
return null;
}
return (int) $product->configoption2;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
if (is_null($product)) {
return null; return null;
} }
return (int)$product->configoption2;
} }
/** /**
* @param int $serverPackageId * Fetch the available OS templates for a given VirtFusion server package.
* @return array|null *
* @throws JsonException * Queries the VirtFusion API for templates compatible with the specified
* package spec ID. Result is cached for 10 minutes. Returns null if no
* package ID is provided or no control panel is available.
*
* @param int|null $serverPackageId VirtFusion server package spec ID.
* @return array|null Template list from the API, or null on failure.
*/ */
public function fetchTemplates(?int $serverPackageId): ?array public function fetchTemplates(?int $serverPackageId): ?array
{ {
if (is_null($serverPackageId)) { try {
if (is_null($serverPackageId)) {
return null;
}
$cacheKey = 'tpl:' . $serverPackageId;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
if (! $this->cp) {
return null;
}
$request = $this->initCurl($this->cp['token']);
$response = $request->get(
sprintf('%s/media/templates/fromServerPackageSpec/%d', $this->cp['url'], $serverPackageId),
);
$result = $this->decodeResponseFromJson($response);
Cache::set($cacheKey, $result, 600);
return $result;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null; return null;
} }
$request = $this->initCurl($this->cp['token']);
$response = $request->get(
sprintf("%s/media/templates/fromServerPackageSpec/%d", $this->cp['url'], $serverPackageId)
);
return $this->decodeResponseFromJson($response);
} }
/** /**
* @param User|null $user * Get the SSH keys registered for a VirtFusion user.
* @return array|null *
* @throws JsonException * Looks up the VirtFusion account for the given WHMCS user via external
* relation ID, then fetches their SSH key list from the API. Returns null
* if the user is not found in VirtFusion or no control panel is available.
*
* @param User|null $user WHMCS User object.
* @return array|null SSH key list from the API, or null on failure.
*/ */
public function getUserSshKeys(?User $user): ?array public function getUserSshKeys(?User $user): ?array
{ {
if (is_null($user)) { try {
if (is_null($user)) {
return null;
}
if (! $this->cp) {
return null;
}
$request = $this->initCurl($this->cp['token']);
$vfUser = $this->getVFUserDetails($user['id']);
if (! $vfUser) {
return null;
}
$response = $request->get(
sprintf('%s/ssh_keys/user/%d', $this->cp['url'], $vfUser['id']),
);
return $this->decodeResponseFromJson($response);
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null; return null;
} }
$request = $this->initCurl($this->cp['token']);
$vfUser = $this->getVFUserDetails($user['id']);
$response = $request->get(
sprintf("%s/ssh_keys/user/%d", $this->cp['url'], $vfUser['id'])
);
return $this->decodeResponseFromJson($response);
} }
/** /**
* @param int $id * Look up a VirtFusion user by WHMCS external relation ID.
* @return array|null *
* @throws JsonException * Calls the VirtFusion API's byExtRelation endpoint using the WHMCS client
* ID. Returns null if the user does not exist in VirtFusion or no control
* panel is available.
*
* @param int $id WHMCS client ID used as the VirtFusion external relation ID.
* @return array|null VirtFusion user data array, or null if not found.
*/ */
public function getVFUserDetails(int $id): ?array public function getVFUserDetails(int $id): ?array
{ {
$request = $this->initCurl($this->cp['token']); try {
if (! $this->cp) {
return null;
}
$response = $this->decodeResponseFromJson($request->get( $request = $this->initCurl($this->cp['token']);
sprintf("%s/users/%d/byExtRelation", $this->cp['url'], $id)
));
return isset($response['msg']) && $response['msg'] === "ext_relation_id not found" ? null : $response['data']; $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'];
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null;
}
} }
/** /**
* @param int $id * Trigger OS installation on a newly created VirtFusion server.
* @param array $vars *
* @return bool * Posts a build request to the VirtFusion API with the selected OS template
* and optionally an SSH key. If the custom field contains a numeric value it
* is treated as an existing key ID; if it is a raw public key string, the key
* is created first via createUserSshKey(). Returns true on HTTP 200/201.
*
* @param int $id VirtFusion server ID to build.
* @param array $vars WHMCS order vars, including customfields for OS and SSH key.
* @param int|null $vfUserId VirtFusion user ID, required when creating a new SSH key from a raw public key.
* @return bool True if the build request was accepted, false otherwise.
*/ */
public function initServerBuild(int $id, array $vars): bool public function initServerBuild(int $id, array $vars, ?int $vfUserId = null): bool
{ {
$request = $this->initCurl($this->cp['token']); try {
if (! $this->cp) {
return false;
}
// Generate a random 8 character hostname $request = $this->initCurl($this->cp['token']);
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
$inputData = [ // Generate a hostname with sufficient entropy to avoid collisions
"operatingSystemId" => $vars['customfields']['Initial Operating System'], $hostname = 'vps-' . bin2hex(random_bytes(4));
"name" => $hostname,
"sshKeys" => [
$vars['customfields']['Initial SSH Key']
],
'email' => true
];
if (empty($vars['customfields']['Initial SSH Key'])) { $sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
unset($inputData['sshKeys']); $sshKeyId = null;
if (! empty($sshKeyValue)) {
if (is_numeric($sshKeyValue)) {
// Existing SSH key ID
$sshKeyId = (int) $sshKeyValue;
} elseif (preg_match('/^ssh-/', $sshKeyValue) && $vfUserId) {
// Raw public key — create it via API
$sshKeyId = $this->createUserSshKey($vfUserId, $sshKeyValue);
}
}
$inputData = [
'operatingSystemId' => $vars['customfields']['Initial Operating System'] ?? null,
'name' => $hostname,
'email' => true,
];
if ($sshKeyId) {
$inputData['sshKeys'] = [$sshKeyId];
}
$request->addOption(CURLOPT_POSTFIELDS, json_encode($inputData));
$response = $request->post(
sprintf('%s/servers/%d/build', $this->cp['url'], $id),
);
$httpCode = $request->getRequestInfo('http_code');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
return $httpCode == 200 || $httpCode == 201;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return false;
} }
}
$request->addOption(CURLOPT_POSTFIELDS, json_encode($inputData)); /**
* 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
{
try {
if (! $this->cp) {
return null;
}
$request->post( $request = $this->initCurl($this->cp['token']);
sprintf("%s/servers/%d/build", $this->cp['url'], $id)
);
return true; $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;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null;
}
} }
} }

View File

@@ -2,46 +2,44 @@
namespace WHMCS\Module\Server\VirtFusionDirect; namespace WHMCS\Module\Server\VirtFusionDirect;
/**
* HTTP client wrapper with Bearer token auth, SSL verification, and a 30s timeout.
* Single-use — each instance makes one request.
*/
class Curl class Curl
{ {
/** @var resource|\CurlHandle cURL handle */
private $ch; private $ch;
/** @var array Response info and parsed header data collected after exec */
private $data; private $data;
/** @var array User-supplied cURL options that override defaults */
private $customOptions = []; private $customOptions = [];
/** @var array Default cURL options applied to every request */
private $defaultOptions = [ private $defaultOptions = [
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => false, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => 'CURL', CURLOPT_USERAGENT => 'VirtFusion-WHMCS/2.0',
CURLOPT_HEADER => false, CURLOPT_HEADER => false,
CURLOPT_NOBODY => false, CURLOPT_NOBODY => false,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
]; ];
/** Initialise the cURL handle. */
public function __construct() public function __construct()
{ {
$this->ch = curl_init(); $this->ch = curl_init();
} }
public function useCookies()
{
$cookiesFile = tempnam('/tmp', 'virtfusion_cookies');
$this->defaultOptions[CURLOPT_COOKIEFILE] = $cookiesFile;
$this->defaultOptions[CURLOPT_COOKIEJAR] = $cookiesFile;
}
public function setLog()
{
$log = fopen(__DIR__ . '/CURL.log', 'a');
if ($log) {
fwrite($log, str_repeat('=', 80) . PHP_EOL);
$this->addOption(CURLOPT_STDERR, $log);
$this->addOption(CURLOPT_VERBOSE, true);
}
}
/** /**
* @param $name * Set a custom cURL option, overriding the defaults.
* @param $value *
* @param int $name A CURLOPT_* constant
* @param mixed $value The option value
*/ */
public function addOption($name, $value) public function addOption($name, $value)
{ {
@@ -49,8 +47,10 @@ class Curl
} }
/** /**
* @param null $url * Execute a PUT request.
* @return bool|string|void *
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
* @return bool|string Response body, or false on failure
*/ */
public function put($url = null) public function put($url = null)
{ {
@@ -58,15 +58,30 @@ class Curl
} }
/** /**
* @param $method * Execute a PATCH request.
* @param $url *
* @return bool|string|void * @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
* @return bool|string Response body, or false on failure
*/
public function patch($url = null)
{
return $this->send('PATCH', $url);
}
/**
* Set the HTTP method and URL, then execute the request.
*
* @param string $method HTTP method (GET, POST, PUT, PATCH, DELETE)
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
* @return bool|string Response body, or false on failure
*
* @throws \RuntimeException If no URL is available
*/ */
private function send($method, $url) private function send($method, $url)
{ {
if ($url === null) { if ($url === null) {
if (!isset($this->customOptions[CURLOPT_URL]) || empty($this->customOptions[CURLOPT_URL])) { if (! isset($this->customOptions[CURLOPT_URL]) || empty($this->customOptions[CURLOPT_URL])) {
exit('empty url'); throw new \RuntimeException('Curl: empty URL provided');
} }
} }
$this->addOption(CURLOPT_CUSTOMREQUEST, $method); $this->addOption(CURLOPT_CUSTOMREQUEST, $method);
@@ -76,7 +91,9 @@ class Curl
} }
/** /**
* @return bool|string * Apply options, run the cURL handle, collect response info, and close the handle.
*
* @return bool|string Response body, or false on cURL error
*/ */
private function exec() private function exec()
{ {
@@ -84,6 +101,12 @@ class Curl
$response = curl_exec($this->ch); $response = curl_exec($this->ch);
$this->data['info'] = curl_getinfo($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]) { if (isset($this->customOptions[CURLOPT_HEADER]) && $this->customOptions[CURLOPT_HEADER]) {
$this->data['info']['request_header'] = trim($this->data['info']['request_header']); $this->data['info']['request_header'] = trim($this->data['info']['request_header']);
$this->processHeaders($response); $this->processHeaders($response);
@@ -94,6 +117,7 @@ class Curl
return $response; return $response;
} }
/** Merge custom and default cURL options and apply them to the handle. */
private function setOptions() private function setOptions()
{ {
if (isset($this->customOptions[CURLOPT_HEADER]) && $this->customOptions[CURLOPT_HEADER]) { if (isset($this->customOptions[CURLOPT_HEADER]) && $this->customOptions[CURLOPT_HEADER]) {
@@ -105,7 +129,9 @@ class Curl
} }
/** /**
* @param $data * Split a response containing headers into header and body parts and store them.
*
* @param string $data Raw response string (headers + body); replaced with body only
*/ */
private function processHeaders(&$data) private function processHeaders(&$data)
{ {
@@ -116,15 +142,17 @@ class Curl
$tmp = explode("\r\n", $this->data['info']['response_header']); $tmp = explode("\r\n", $this->data['info']['response_header']);
$this->data['data']['Message'] = $tmp[0]; $this->data['data']['Message'] = $tmp[0];
for ($i = 1, $size = count($tmp); $i < $size; ++$i) { for ($i = 1, $size = count($tmp); $i < $size; $i++) {
$string = explode(': ', $tmp[$i], 2); $string = explode(': ', $tmp[$i], 2);
$this->data['data'][$string[0]] = $string[1]; $this->data['data'][$string[0]] = $string[1];
} }
} }
/** /**
* @param null $url * Execute a GET request.
* @return bool|string|void *
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
* @return bool|string Response body, or false on failure
*/ */
public function get($url = null) public function get($url = null)
{ {
@@ -132,8 +160,10 @@ class Curl
} }
/** /**
* @param null $url * Execute a DELETE request.
* @return bool|string|void *
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
* @return bool|string Response body, or false on failure
*/ */
public function delete($url = null) public function delete($url = null)
{ {
@@ -141,8 +171,10 @@ class Curl
} }
/** /**
* @param null $url * Execute a POST request.
* @return bool|string|void *
* @param string|null $url Target URL, or null to use a previously set CURLOPT_URL
* @return bool|string Response body, or false on failure
*/ */
public function post($url = null) public function post($url = null)
{ {
@@ -150,17 +182,10 @@ class Curl
} }
/** /**
* @param null $url * Return curl_getinfo data for the completed request.
* @return bool|string|void *
*/ * @param string|false $param A specific info key to retrieve, or false for the full array
public function head($url = null) * @return mixed|null The requested info value, the full info array, or null if the key is absent
{
return $this->send('HEAD', $url);
}
/**
* @param false $param
* @return mixed|null
*/ */
public function getRequestInfo($param = false) public function getRequestInfo($param = false)
{ {
@@ -172,9 +197,11 @@ class Curl
} }
/** /**
* @param $what * Retrieve a single item from the internal data store by section and key.
* @param $name *
* @return mixed|null * @param string $what Top-level section key (e.g. 'info', 'data')
* @param string $name Item key within that section
* @return mixed|null The stored value, or null if not found
*/ */
private function getDataItem($what, $name) private function getDataItem($what, $name)
{ {
@@ -184,17 +211,4 @@ class Curl
return null; return null;
} }
} }
/**
* @param false $param
* @return mixed|null
*/
public function getHeadersData($param = false)
{
if ($param) {
return $this->getDataItem('data', $param);
}
return $this->data['data'];
}
} }

View File

@@ -4,13 +4,28 @@ namespace WHMCS\Module\Server\VirtFusionDirect;
use WHMCS\Database\Capsule as DB; use WHMCS\Database\Capsule as DB;
/**
* Handles all database operations for the module's custom table (mod_virtfusion_direct)
* and queries against core WHMCS tables (tblhosting, tblclients, tblservers, etc.).
*/
class Database class Database
{ {
const SYSTEM_TABLE = 'mod_virtfusion_direct'; const SYSTEM_TABLE = 'mod_virtfusion_direct';
/** @var bool Tracks whether custom field existence has already been verified this request. */
private static $fieldsChecked = false;
/**
* Creates or migrates the module table schema and ensures custom fields exist.
*
* Creates mod_virtfusion_direct with service_id and server_id columns if absent,
* adds the server_object column if missing, then calls ensureCustomFields().
*
* @return void
*/
public static function schema() public static function schema()
{ {
if (!DB::schema()->hasTable(self::SYSTEM_TABLE)) { if (! DB::schema()->hasTable(self::SYSTEM_TABLE)) {
try { try {
DB::schema()->create(self::SYSTEM_TABLE, function ($table) { DB::schema()->create(self::SYSTEM_TABLE, function ($table) {
$table->unsignedBigInteger('service_id')->nullable()->default(null)->index(); $table->unsignedBigInteger('service_id')->nullable()->default(null)->index();
@@ -22,7 +37,7 @@ class Database
} }
} }
if (!DB::schema()->hasColumn(self::SYSTEM_TABLE, 'server_object')) { if (! DB::schema()->hasColumn(self::SYSTEM_TABLE, 'server_object')) {
try { try {
DB::schema()->table(self::SYSTEM_TABLE, function ($table) { DB::schema()->table(self::SYSTEM_TABLE, function ($table) {
$table->longText('server_object')->nullable()->default(null); $table->longText('server_object')->nullable()->default(null);
@@ -31,91 +46,283 @@ class Database
Log::insert(__FUNCTION__, [], $e->getMessage()); Log::insert(__FUNCTION__, [], $e->getMessage());
} }
} }
self::ensureCustomFields();
} }
/**
* Ensures the "Initial Operating System" and "Initial SSH Key" custom fields exist
* for every VirtFusionDirect product, creating them via upsert if absent.
*
* @return void
*/
public static function ensureCustomFields()
{
if (self::$fieldsChecked) {
return;
}
self::$fieldsChecked = true;
try {
$productIds = DB::table('tblproducts')
->where('servertype', 'VirtFusionDirect')
->pluck('id');
foreach ($productIds as $productId) {
foreach (['Initial Operating System', 'Initial SSH Key'] as $fieldName) {
DB::table('tblcustomfields')->updateOrInsert(
['type' => 'product', 'relid' => $productId, 'fieldname' => $fieldName],
[
'fieldtype' => 'text',
'description' => '',
'fieldoptions' => '',
'regexpr' => '',
'adminonly' => '',
'required' => '',
'showorder' => 'on',
'showinvoice' => '',
'sortorder' => 0,
'updated_at' => DB::raw('UTC_TIMESTAMP()'),
],
);
}
}
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
}
}
/**
* Fetches a VirtFusionDirect server record from tblservers.
*
* When $server is non-zero, returns the matching server by ID.
* When $any is true and $server is 0, returns the first enabled server.
*
* @param int $server WHMCS server ID to look up (0 to skip ID filter).
* @param bool $any If true, fall back to the first active server.
* @return object|false Row object on success, false on failure or not found.
*/
public static function getWhmcsServer(int $server, $any = false) public static function getWhmcsServer(int $server, $any = false)
{ {
if ($server) { try {
return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('id', $server)->first(); if ($server) {
} return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('id', $server)->first();
}
if ($any) { if ($any) {
return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('disabled', 0)->first(); return DB::table('tblservers')->where('type', 'VirtFusionDirect')->where('disabled', 0)->first();
}
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
} }
return false; return false;
} }
/**
* Checks whether a WHMCS service belongs to the given client.
*
* @param int $serviceId WHMCS hosting service ID.
* @param int $userId WHMCS client ID.
* @return bool True if the service is owned by the client, false otherwise.
*/
public static function userWhmcsService(int $serviceId, int $userId) public static function userWhmcsService(int $serviceId, int $userId)
{ {
return DB::table('tblhosting')->where('id', $serviceId)->where('userid', $userId)->exists(); try {
return DB::table('tblhosting')->where('id', $serviceId)->where('userid', $userId)->exists();
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return false;
}
} }
/**
* Returns the WHMCS system URL from tblconfiguration.
*
* @return string The system URL, or an empty string if not found or on error.
*/
public static function getSystemUrl() public static function getSystemUrl()
{ {
$url = DB::table('tblconfiguration')->where('setting', '=', 'SystemURL')->first(); try {
return $url->value; $url = DB::table('tblconfiguration')->where('setting', '=', 'SystemURL')->first();
if (! $url) {
return '';
}
return $url->value;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return '';
}
} }
/**
* Fetches a WHMCS client record by ID.
*
* @param int $id WHMCS client ID.
* @return object|null Row object on success, null on failure or not found.
*/
public static function getUser(int $id) public static function getUser(int $id)
{ {
return DB::table('tblclients')->where('id', $id)->first(); try {
return DB::table('tblclients')->where('id', $id)->first();
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null;
}
} }
/**
* Fetches a WHMCS hosting service record by ID.
*
* @param int $serviceId WHMCS hosting service ID.
* @return object|null Row object on success, null on failure or not found.
*/
public static function getWhmcsService(int $serviceId) public static function getWhmcsService(int $serviceId)
{ {
return DB::table('tblhosting')->where('id', $serviceId)->first(); try {
return DB::table('tblhosting')->where('id', $serviceId)->first();
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null;
}
} }
/**
* Upserts the VirtFusion server ID for a given WHMCS service in the module table.
*
* @param int $serviceId WHMCS hosting service ID.
* @param int $serverId VirtFusion server ID.
* @return void
*/
public static function updateSystemServiceServerId(int $serviceId, int $serverId) public static function updateSystemServiceServerId(int $serviceId, int $serverId)
{ {
try {
DB::table(self::SYSTEM_TABLE)->updateOrInsert( DB::table(self::SYSTEM_TABLE)->updateOrInsert(
[ [
"service_id" => $serviceId 'service_id' => $serviceId,
], ],
[ [
'server_id' => $serverId 'server_id' => $serverId,
] ],
); );
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
}
} }
/**
* Updates one or more WHMCS tables with the provided data for a given service ID.
*
* $data is keyed by table name; each value is an associative array of column => value
* pairs passed to an update() WHERE id = $serviceId.
*
* @param int $serviceId WHMCS hosting service ID.
* @param array $data Map of table name to column-value pairs to update.
* @return void
*/
public static function updateWhmcsServiceParams(int $serviceId, $data) public static function updateWhmcsServiceParams(int $serviceId, $data)
{ {
if (count($data)) { try {
foreach ($data as $key => $items) { if (count($data)) {
DB::table($key)->where('id', $serviceId)->update($items); foreach ($data as $key => $items) {
DB::table($key)->where('id', $serviceId)->update($items);
}
} }
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
} }
} }
/**
* Checks whether a module table record exists for the given service.
*
* @param int $serviceId WHMCS hosting service ID.
* @return bool True if a record exists, false otherwise.
*/
public static function checkSystemService(int $serviceId) public static function checkSystemService(int $serviceId)
{ {
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists(); try {
} return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists();
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
public static function deleteSystemService(int $serviceId) return false;
{
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->delete();
}
public static function updateSystemServiceServerObject(int $serviceId, $data)
{
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
}
public static function systemOnServerCreate(int $serviceId, $data)
{
if (DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists()) {
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
} else {
DB::table(self::SYSTEM_TABLE)->insert(['service_id' => $serviceId, 'server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
} }
} }
public static function getSystemService(int $serviceId) /**
* Deletes the module table record for the given service.
*
* @param int $serviceId WHMCS hosting service ID.
* @return void
*/
public static function deleteSystemService(int $serviceId)
{ {
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->first(); try {
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->delete();
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
}
} }
/**
* Persists the raw VirtFusion server API response as JSON in the module table.
*
* @param int $serviceId WHMCS hosting service ID.
* @param mixed $data Server object from the VirtFusion API (will be JSON-encoded).
* @return void
*/
public static function updateSystemServiceServerObject(int $serviceId, $data)
{
try {
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
}
}
/**
* Inserts or updates the module table record immediately after a VirtFusion server is created.
*
* Stores both the VirtFusion server ID (from $data->data->id) and the full server
* object JSON. Uses update if a record already exists, otherwise inserts.
*
* @param int $serviceId WHMCS hosting service ID.
* @param mixed $data Full API response object from the VirtFusion server creation call.
* @return void
*/
public static function systemOnServerCreate(int $serviceId, $data)
{
try {
if (DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->exists()) {
DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->update(['server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
} else {
DB::table(self::SYSTEM_TABLE)->insert(['service_id' => $serviceId, 'server_id' => $data->data->id, 'server_object' => json_encode($data, JSON_PRETTY_PRINT)]);
}
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
}
}
/**
* Fetches the module table record for the given service.
*
* @param int $serviceId WHMCS hosting service ID.
* @return object|null Row object on success, null on failure or not found.
*/
public static function getSystemService(int $serviceId)
{
try {
return DB::table(self::SYSTEM_TABLE)->where('service_id', $serviceId)->first();
} catch (\Exception $e) {
Log::insert(__FUNCTION__, [], $e->getMessage());
return null;
}
}
} }

View File

@@ -2,22 +2,22 @@
namespace WHMCS\Module\Server\VirtFusionDirect; namespace WHMCS\Module\Server\VirtFusionDirect;
/**
* Thin wrapper around the WHMCS logModuleCall() function for module-level logging.
*/
class Log class Log
{ {
const LOG_MODULE = 'VirtFusionDirect'; const LOG_MODULE = 'VirtFusionDirect';
/**
* Write an entry to the WHMCS module log.
*
* @param string $action Name of the action being logged (e.g. 'CreateAccount')
* @param string|array $requestString Request data sent to the API
* @param string|array $responseData Response data received from the API
*/
public static function insert($action, $requestString, $responseData) public static function insert($action, $requestString, $responseData)
{ {
/**
* Log module call.
*
* @param string $module The name of the module
* @param string $action The name of the action being performed
* @param string|array $requestString The input parameters for the API call
* @param string|array $responseData The response data from the API call
* @param string|array $processedData The resulting data after any post processing (eg. json decode, xml decode, etc...)
* @param array $replaceVars An array of strings for replacement
*/
logModuleCall(self::LOG_MODULE, $action, $requestString, $responseData); logModuleCall(self::LOG_MODULE, $action, $requestString, $responseData);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,12 @@
namespace WHMCS\Module\Server\VirtFusionDirect; namespace WHMCS\Module\Server\VirtFusionDirect;
/**
* Extends Module to handle the WHMCS service lifecycle for VirtFusion servers.
*
* Responsibilities include: provisioning (create, suspend, unsuspend, terminate),
* package changes, usage updates, client area rendering, and admin tab fields.
*/
class ModuleFunctions extends Module class ModuleFunctions extends Module
{ {
public function __construct() public function __construct()
@@ -10,103 +16,96 @@ class ModuleFunctions extends Module
} }
/** /**
* Provision a new VirtFusion server for a WHMCS service.
* *
* CREATE SERVER * Ensures a matching VirtFusion user exists (creating one if needed), then creates
* * the server and triggers the OS build via ConfigureService::initServerBuild().
* Before creating a server, we check to see if a user exists in VirtFusion that matches
* the WHMCS user. If it matches, We move on to create the server, if not, we attempt to
* create a user to assign to the new server.
* *
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/ */
public function createAccount($params) public function createAccount($params)
{ {
try { 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'])) { if (Database::checkSystemService($params['serviceid'])) {
return 'Service already exists. You must run a termination first.'; return 'Service already exists. You must run a termination first.';
} }
/** /**
*
* If no VirtFusionDirect control server exists, cancel the create account action. * If no VirtFusionDirect control server exists, cancel the create account action.
*
*/ */
$server = $params['serverid'] ?: false; $server = $params['serverid'] ?: false;
$cp = $this->getCP($server, $server ? false : true); $cp = $this->getCP($server, ! $server);
if (!$cp) { 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, []); Log::insert(__FUNCTION__, $params, []);
/** /**
*
* Does a user account in VirtFusion match this account (byExtRelationId) in WHMCS. * Does a user account in VirtFusion match this account (byExtRelationId) in WHMCS.
*
*/ */
$request = $this->initCurl($cp['token']); $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); Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
switch ($request->getRequestInfo('http_code')) { switch ($request->getRequestInfo('http_code')) {
case 200: case 200:
/** /**
*
* A user with relation ID exists in VirtFusion. We can provision under that account. * A user with relation ID exists in VirtFusion. We can provision under that account.
*
*/ */
break; break;
case 404: case 404:
/** /**
*
* A user doesn't exist in VirtFusion. We should attempt to create one. * A user doesn't exist in VirtFusion. We should attempt to create one.
*
*/ */
$user = Database::getUser($params['userid']); $user = Database::getUser($params['userid']);
if (! $user) {
return 'WHMCS user not found for ID ' . (int) $params['userid'];
}
$request = $this->initCurl($cp['token']); $request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode( $userData = [
[ 'name' => $user->firstname . ' ' . $user->lastname,
"name" => $user->firstname . ' ' . $user->lastname, 'email' => $user->email,
"email" => $user->email, 'extRelationId' => $user->id,
"extRelationId" => $user->id ];
]
)); // Enable self-service billing if configured
$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'); $data = $request->post($cp['url'] . '/users');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') !== 201) { 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; break;
default: default:
return 'Error processing user account.'; return 'Error processing user account. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
break;
} }
$data = json_decode($data); $data = json_decode($data);
/** /**
*
* A user is available. We can now attempt to create a server. * A user is available. We can now attempt to create a server.
*
*/ */
$configOptionDefaultNaming = [ $configOptionDefaultNaming = [
'ipv4' => 'IPv4', 'ipv4' => 'IPv4',
'packageId' => 'Package', 'packageId' => 'Package',
@@ -124,26 +123,27 @@ class ModuleFunctions extends Module
$configOptionCustomNaming = []; $configOptionCustomNaming = [];
if (file_exists(ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php')) { 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 = [ $options = [
"packageId" => $params['configoption2'], 'packageId' => (int) $params['configoption2'],
"userId" => $data->data->id, 'userId' => $data->data->id,
"hypervisorId" => $params['configoption1'], 'hypervisorId' => (int) $params['configoption1'],
"ipv4" => $params['configoption3'], 'ipv4' => (int) $params['configoption3'],
]; ];
if (array_key_exists('configoptions', $params)) { if (array_key_exists('configoptions', $params)) {
foreach ($configOptionDefaultNaming as $key => $option) { foreach ($configOptionDefaultNaming as $key => $option) {
$currentOption = array_key_exists($key, $configOptionCustomNaming) ? $configOptionCustomNaming[$key] : $option; $currentOption = array_key_exists($key, $configOptionCustomNaming) ? $configOptionCustomNaming[$key] : $option;
if (array_key_exists($currentOption, $params['configoptions'])) { 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. // VirtFusion expects memory in MB.
if ($currentOption === 'Memory' && $params['configoptions'][$currentOption] < 1024) { if ($key === 'memory' && is_numeric($value) && $value < 1024) {
$options[$key] = $params['configoptions'][$currentOption] * 1024; $options[$key] = (int) ($value * 1024);
} else { } else {
$options[$key] = $params['configoptions'][$currentOption]; $options[$key] = is_numeric($value) ? (int) $value : $value;
} }
} }
} }
@@ -164,291 +164,486 @@ class ModuleFunctions extends Module
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data); $this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
// If the server is created successfully, we can initialize the server build. // If the server is created successfully, we can initialize the server build.
$cs = new ConfigureService(); $cs = new ConfigureService;
$cs->initServerBuild($data->data->id, $params); $vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
$cs->initServerBuild($data->data->id, $params, $vfUserId);
/**
*
* Server was created successfully.
*
*/
return 'success'; return 'success';
} else { } else {
if ($data->errors[0]) { if (isset($data->errors) && is_array($data->errors) && isset($data->errors[0])) {
return $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) { } catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage()); Log::insert(__FUNCTION__, $params, $e->getMessage());
return $e->getMessage(); return $e->getMessage();
} }
} }
// This function was implemented by Zander Scott / awildboop of Blinkoh, LLC
// Please read this function thoroughly before use to ensure security & integrity
/** /**
* Allows changing of the package of a server * Change the VirtFusion package assigned to a server and apply resource modifications.
* *
* @author https://github.com/BlinkohHost/virtfusion-whmcs-module * Updates the package via the API, then individually adjusts memory, CPU, and bandwidth
* @param $params * if those configurable options are present.
* @return string *
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/ */
public function changePackage($params) public function changePackage($params)
{ {
$service = Database::getSystemService($params['serviceid']); try {
$service = Database::getSystemService($params['serviceid']);
if ($service) { if ($service) {
$whmcsService = Database::getWhmcsService($params['serviceid']); $whmcsService = Database::getWhmcsService($params['serviceid']);
$cp = $this->getCP($whmcsService->server); if (! $whmcsService) {
$request = $this->initCurl($cp['token']); return 'WHMCS service record not found.';
$data = $request->put($cp['url'] . '/servers/' . $service->server_id . '/package/' . $params['configoption2']); }
$data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); $cp = $this->getCP($whmcsService->server);
if (! $cp) {
return 'No control server found.';
}
switch ($request->getRequestInfo('http_code')) { $request = $this->initCurl($cp['token']);
$data = $request->put($cp['url'] . '/servers/' . (int) $service->server_id . '/package/' . (int) $params['configoption2']);
$data = json_decode($data);
case 204: Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
return 'success';
case 404: switch ($request->getRequestInfo('http_code')) {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
case 423: case 204:
if (property_exists($data, 'msg')) { break;
return $data->msg; 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';
} }
default:
return 'Update package request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code'); 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.';
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
return $e->getMessage();
} }
return 'Service not found.';
} }
/** /**
* Delete a VirtFusion server, applying the default 5-minute grace period before destruction.
* *
* TERMINATE SERVER * On success, removes the service record from the module database and clears WHMCS service fields.
* * If VirtFusion reports the server is already gone (404 + "server not found"), treats it as success.
* When requesting to terminate a server in VirtFusion, we leave it set to
* the default 5-minute delay allowing to un-terminate in VirtFusion if the
* request was done in error.
* *
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/ */
public function terminateAccount($params) public function terminateAccount($params)
{ {
$service = Database::getSystemService($params['serviceid']); try {
$service = Database::getSystemService($params['serviceid']);
if ($service) { if ($service) {
$whmcsService = Database::getWhmcsService($params['serviceid']); $whmcsService = Database::getWhmcsService($params['serviceid']);
if (! $whmcsService) {
return 'WHMCS service record not found.';
}
$cp = $this->getCP($whmcsService->server); $cp = $this->getCP($whmcsService->server);
if (! $cp) {
return 'No control server found.';
}
$request = $this->initCurl($cp['token']); $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); $data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
switch ($request->getRequestInfo('http_code')) { switch ($request->getRequestInfo('http_code')) {
case 204: case 204:
Database::deleteSystemService($params['serviceid']); Database::deleteSystemService($params['serviceid']);
$this->updateWhmcsServiceParamsOnDestroy($params['serviceid']); $this->updateWhmcsServiceParamsOnDestroy($params['serviceid']);
return 'success';
break;
case 404: return 'success';
if (property_exists($data, 'msg')) {
if ($data->msg == 'server not found') { case 404:
Database::deleteSystemService($params['serviceid']); if (isset($data->msg)) {
return 'success'; if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']);
return 'success';
} else {
return 'VirtFusion returned 404: ' . $data->msg;
}
} else { } 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 without details. The API may be unavailable.';
} }
} else {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
}
break;
default: default:
return 'Termination request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code'); return 'Termination request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
break; }
} }
return 'Service not found in module database. Has termination already been run?';
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
return $e->getMessage();
} }
return 'Service not found. Termination routine has already been run?';
} }
/** /**
* Suspend a VirtFusion server, queuing the action if another operation is in progress.
* *
* SUSPEND SERVER * Returns 'success' whether the server is suspended immediately or queued for suspension.
*
* When requesting to suspend a server in VirtFusion it may be delayed if another action
* is being processed. This function will return success if the server is either suspended
* now or has been queued for suspension.
* *
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/ */
public function suspendAccount($params) public function suspendAccount($params)
{ {
$service = Database::getSystemService($params['serviceid']); try {
$service = Database::getSystemService($params['serviceid']);
if ($service) { if ($service) {
$whmcsService = Database::getWhmcsService($params['serviceid']); $whmcsService = Database::getWhmcsService($params['serviceid']);
if (! $whmcsService) {
return 'WHMCS service record not found.';
}
$cp = $this->getCP($whmcsService->server); $cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']); if (! $cp) {
$data = $request->post($cp['url'] . '/servers/' . $service->server_id . '/suspend'); return 'No control server found.';
$data = json_decode($data); }
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); $request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/suspend');
$data = json_decode($data);
switch ($request->getRequestInfo('http_code')) { Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
case 204: switch ($request->getRequestInfo('http_code')) {
return 'success';
break;
case 404: case 204:
if (property_exists($data, 'msg')) { return 'success';
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']); case 404:
return 'success'; if (isset($data->msg)) {
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']);
return 'success';
} else {
return 'VirtFusion returned 404: ' . $data->msg;
}
} else { } else {
return 'VirtFusion returned 404 without details. The API may be unavailable.';
return '404 was returned from the web service with the msg property but doesn\'t contain appropriate data to process a suspension.'; }
case 423:
if (isset($data->msg)) {
return $data->msg;
} }
} else {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
}
break;
case 423:
if (property_exists($data, 'msg')) {
return $data->msg;
}
default: return 'The server is currently locked. Please try again later.';
return 'Suspend request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
break; default:
return 'Suspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
} }
return 'Service not found in module database.';
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
return $e->getMessage();
} }
return 'Service not found.';
} }
function updateServerObject($params) /**
* Refresh the cached server object by fetching fresh data from the VirtFusion API.
*
* Updates both the module database record and the WHMCS service fields (IP, username, etc.).
*
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/
public function updateServerObject($params)
{ {
$service = Database::getSystemService($params['serviceid']); try {
$service = Database::getSystemService($params['serviceid']);
if ($service) { if ($service) {
$whmcsService = Database::getWhmcsService($params['serviceid']); $whmcsService = Database::getWhmcsService($params['serviceid']);
if (! $whmcsService) {
return 'WHMCS service record not found.';
}
$cp = $this->getCP($whmcsService->server); $cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']); if (! $cp) {
$data = $request->get($cp['url'] . '/servers/' . $service->server_id); return 'No control server found.';
$data = json_decode($data); }
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); $request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/servers/' . (int) $service->server_id);
$data = json_decode($data);
switch ($request->getRequestInfo('http_code')) { Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
case 200: switch ($request->getRequestInfo('http_code')) {
Database::updateSystemServiceServerObject($params['serviceid'], $data);
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data); case 200:
Database::updateSystemServiceServerObject($params['serviceid'], $data);
return 'success'; $this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
break;
default: return 'success';
return 'Request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code'); default:
break; return 'Request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
} }
return 'Service not found in module database.';
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
return $e->getMessage();
} }
return 'Service not found.';
} }
/**
* Unsuspend a VirtFusion server, queuing the action if another operation is in progress.
*
* Returns 'success' whether the server is unsuspended immediately or queued for unsuspension.
*
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/
public function unsuspendAccount($params) public function unsuspendAccount($params)
{ {
$service = Database::getSystemService($params['serviceid']); try {
$service = Database::getSystemService($params['serviceid']);
if ($service) { if ($service) {
$whmcsService = Database::getWhmcsService($params['serviceid']); $whmcsService = Database::getWhmcsService($params['serviceid']);
if (! $whmcsService) {
return 'WHMCS service record not found.';
}
$cp = $this->getCP($whmcsService->server); $cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']); if (! $cp) {
$data = $request->post($cp['url'] . '/servers/' . $service->server_id . '/unsuspend'); return 'No control server found.';
$data = json_decode($data); }
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); $request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/unsuspend');
$data = json_decode($data);
switch ($request->getRequestInfo('http_code')) { Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
case 204: switch ($request->getRequestInfo('http_code')) {
return 'success';
break;
case 404: case 204:
if (property_exists($data, 'msg')) { return 'success';
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']); case 404:
return 'success'; if (isset($data->msg)) {
if ($data->msg == 'server not found') {
Database::deleteSystemService($params['serviceid']);
return 'success';
} else {
return 'VirtFusion returned 404: ' . $data->msg;
}
} else { } 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 without details. The API may be unavailable.';
}
case 423:
if (isset($data->msg)) {
return $data->msg;
} }
} else {
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
}
break;
case 423:
if (property_exists($data, 'msg')) {
return $data->msg;
}
default: return 'The server is currently locked. Please try again later.';
return 'Unsuspend request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
break; default:
return 'Unsuspend request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
} }
return 'Service not found in module database.';
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
return $e->getMessage();
} }
return 'Service not found';
} }
/**
* Generate the admin Services tab custom fields for a VirtFusion service.
*
* Returns fields for Server ID (editable), Server Info, Server Object (JSON viewer),
* and Options (action buttons), omitting Options for terminated services.
*
* @param array $params WHMCS service parameters
* @return array Associative array of field label => HTML content
*/
public function adminServicesTabFields($params) public function adminServicesTabFields($params)
{ {
$serverId = ''; try {
$serverObject = ''; $serverId = '';
$serverObject = '';
$service = Database::getSystemService($params['serviceid']); $service = Database::getSystemService($params['serviceid']);
$systemUrl = Database::getSystemUrl(); $systemUrl = Database::getSystemUrl();
if ($service) { if ($service) {
$serverId = $service->server_id; $serverId = $service->server_id;
$serverObject = $service->server_object; $serverObject = $service->server_object;
}
$fields = [
'Server ID' => AdminHTML::serverId($serverId),
'Server Info' => AdminHTML::serverInfo($systemUrl, $params['serviceid']),
'Server Object' => AdminHTML::serverObject($serverObject),
];
if ($params['status'] != 'Terminated') {
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
}
return $fields;
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
return [];
} }
$fields = [
'Server ID' => AdminHTML::serverId($serverId),
'Server Info' => AdminHTML::serverInfo($systemUrl, $params['serviceid']),
'Server Object' => AdminHTML::serverObject($serverObject),
];
if ($params['status'] != 'Terminated') {
$fields['Options'] = AdminHTML::options($systemUrl, $params['serviceid']);
}
return $fields;
} }
/**
* Save the admin Services tab custom fields for a VirtFusion service.
*
* Deletes the module database record if the Server ID field is cleared,
* or updates it with the new integer server ID if a value is provided.
*
* @param array $params WHMCS service parameters
* @return void
*/
public function adminServicesTabFieldsSave($params) public function adminServicesTabFieldsSave($params)
{ {
try {
if ($_POST['modulefields'][0] == '') { if (! isset($_POST['modulefields'][0]) || $_POST['modulefields'][0] === '') {
Database::deleteSystemService($params['serviceid']); Database::deleteSystemService($params['serviceid']);
} else { } else {
$serverId = (int) $_POST['modulefields'][0];
Database::updateSystemServiceServerId($params['serviceid'], $_POST['modulefields'][0]); if ($serverId > 0) {
Database::updateSystemServiceServerId($params['serviceid'], $serverId);
}
}
} catch (\Exception $e) {
Log::insert(__FUNCTION__, $params, $e->getMessage());
} }
} }
/**
* Perform a dry-run server creation to validate the current product configuration.
*
* Used by the WHMCS "Test Connection" button to confirm that the package, hypervisor,
* and IP settings are accepted by the VirtFusion API without creating a server.
*
* @param array $params WHMCS service parameters
* @return string 'success' or an error message
*/
public function validateServerConfig($params)
{
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();
}
}
/**
* Render the client area overview tab for a VirtFusion service.
*
* Returns the template name and variables (system URL, service status, hostname,
* self-service mode) needed by the Smarty overview template. Falls back to an
* error template on any exception.
*
* @param array $params WHMCS service parameters
* @return array Template name and variables for WHMCS to render
*/
public function clientArea($params) public function clientArea($params)
{ {
$serverHostname = null; $serverHostname = null;
@@ -463,6 +658,7 @@ class ModuleFunctions extends Module
'systemURL' => Database::getSystemUrl(), 'systemURL' => Database::getSystemUrl(),
'serviceStatus' => $params['status'], 'serviceStatus' => $params['status'],
'serverHostname' => $serverHostname, 'serverHostname' => $serverHostname,
'selfServiceMode' => (int) ($params['configoption4'] ?? 0),
], ],
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -2,47 +2,87 @@
namespace WHMCS\Module\Server\VirtFusionDirect; namespace WHMCS\Module\Server\VirtFusionDirect;
/**
* Transforms a VirtFusion API server response into a flat key-value array for Smarty templates and admin display.
*/
class ServerResource class ServerResource
{ {
/**
* Normalise a VirtFusion API server response into a flat associative array.
*
* @param object $data VirtFusion API server response object (with a `data` property)
* @return array Flat associative array containing server name, hostname, resources, network info, and usage
*/
public function process($data) public function process($data)
{ {
$server = json_decode(json_encode($data->data), true); $server = json_decode(json_encode($data->data), true);
$traffic = ''; $traffic = '-';
if ($server['settings']['resources']['traffic']) { if (isset($server['settings']['resources']['traffic'])) {
if ($server['settings']['resources']['traffic'] > 0) { if ($server['settings']['resources']['traffic'] > 0) {
$traffic = $server['settings']['resources']['traffic'] . ' GB'; $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 = [ $data = [
'name' => $server['name'] ?: '-', 'name' => $server['name'] ?: '-',
'hostname' => $server['hostname'] ?: '-', 'hostname' => $server['hostname'] ?: '-',
'memory' => $server['settings']['resources']['memory'] . ' MB', 'memory' => isset($server['settings']['resources']['memory']) ? $server['settings']['resources']['memory'] . ' MB' : '-',
'traffic' => $traffic, 'traffic' => $traffic,
'storage' => $server['settings']['resources']['storage'] . ' GB', 'trafficUsed' => $trafficUsed,
'cpu' => $server['settings']['resources']['cpuCores'] . ' Core(s)', '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' => [ 'primaryNetwork' => [
'ipv4' => ['-'], 'ipv4' => ['-'],
'ipv4Unformatted' => [], 'ipv4Unformatted' => [],
'ipv6' => ['-'], 'ipv6' => ['-'],
'ipv6Unformatted' => [], '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('network', $server)) {
if (array_key_exists('interfaces', $server['network'])) { if (array_key_exists('interfaces', $server['network'])) {
if (count($server['network']['interfaces'])) { 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'] = []; $data['primaryNetwork']['ipv4'] = [];
foreach ($server['network']['interfaces'][0]['ipv4'] as $ip) { foreach ($server['network']['interfaces'][0]['ipv4'] as $ip) {
$data['primaryNetwork']['ipv4'][] = $ip['address']; $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'] = []; $data['primaryNetwork']['ipv6'] = [];
foreach ($server['network']['interfaces'][0]['ipv6'] as $ip) { foreach ($server['network']['interfaces'][0]['ipv6'] as $ip) {
$data['primaryNetwork']['ipv6'][] = $ip['subnet'] . '/' . $ip['cidr']; $data['primaryNetwork']['ipv6'][] = $ip['subnet'] . '/' . $ip['cidr'];

View File

@@ -1 +1,473 @@
.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-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;
margin: 10px;
}
/* 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);
}
}
/* 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;
min-width: 28px;
min-height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
overflow: hidden;
}
.vf-os-category-icon img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
.vf-os-category-arrow {
margin-left: auto;
font-size: 0.7rem;
color: #888;
}
.vf-os-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 0;
margin-bottom: 4px;
}
.vf-os-card {
width: 120px;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 10px 8px;
text-align: center;
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
}
.vf-os-card:hover {
border-color: #337ab7;
}
.vf-os-card-selected {
border-color: #337ab7;
background: rgba(51,122,183,0.06);
box-shadow: 0 0 0 1px rgba(51,122,183,0.3);
}
.vf-os-card-eol {
opacity: 0.6;
}
.vf-os-icon {
width: 40px;
height: 40px;
border-radius: 8px;
margin: 0 auto 6px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 1.1rem;
overflow: hidden;
flex-shrink: 0;
}
.vf-os-icon img {
max-width: 100%;
max-height: 100%;
width: 24px;
height: 24px;
object-fit: contain;
}
.vf-os-label {
font-weight: 600;
font-size: 12px;
line-height: 1.2;
}
.vf-os-version {
font-size: 10px;
color: #888;
line-height: 1.2;
}
.vf-os-eol-badge {
display: inline-block;
background: #dc3545;
color: #fff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
margin-top: 3px;
}
#vf-os-details {
border-top: 1px solid #dee2e6;
padding-top: 10px;
}
.vf-os-search {
margin-bottom: 10px;
}
@media (max-width: 768px) {
.vf-os-grid {
gap: 6px;
}
.vf-os-card {
width: calc(50% - 3px);
min-width: 100px;
}
}
/* Resource panel */
.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

24
pint.json Normal file
View File

@@ -0,0 +1,24 @@
{
"preset": "laravel",
"rules": {
"declare_strict_types": false,
"blank_line_before_statement": {
"statements": ["return", "throw", "try"]
},
"concat_space": {
"spacing": "one"
},
"ordered_imports": {
"sort_algorithm": "alpha"
},
"single_quote": true,
"no_unused_imports": true,
"trailing_comma_in_multiline": {
"elements": ["arrays", "arguments"]
}
},
"exclude": [
"vendor",
"templates"
]
}