Some checks failed
Publish Release / release (push) Failing after 16s
Major client-area overhaul, WHMCS 9 + VirtFusion v7 compatibility, and a
hardening pass on every destructive client.php endpoint.
Tested against WHMCS 9.0.3 + VirtFusion v7.0.0 Build 9.
Features
- "On This Page" jump-link group injected into the WHMCS Actions sidebar
via ClientAreaPrimarySidebar; auto-hides links for hidden panels.
- Monthly traffic chart (last 12 months) with rx/tx bars and centered
legend; replaces the dead canvas that read non-existent JSON paths.
- Live Stats panel: CPU, memory, disk I/O from remoteState; 30s refresh
while the panel is visible AND the page has focus.
- Filesystem usage rows in the Resources panel from qemu-guest-agent
fsinfo; pseudo-FS filtered out.
- Server Overview meta chips: data-center location with country flag,
OS template/agent name with kernel on hover, "Created N days ago".
- Hypervisor maintenance banner at the top of the page.
- Mask Sensitive screenshot mode: IPv4 keeps first two octets, IPv6
keeps first two hextets, hostnames keep first char per dot-label.
Inputs masked via text-security: disc; covers Server Name + Hostname
+ IP cells + rDNS panel rows.
- Per-IP copy buttons folded into the Server Overview cells (replaces
the deleted standalone Network panel).
- VNC viewer popup served from a same-origin authenticated route
(client.php?action=vncViewer) — POST + requireSameOrigin, rotates
the wss token on every open, X-Frame-Options DENY, strict CSP.
Bug Fixes
- UsageUpdate cron silently no-op'd: read server.usage.traffic.used
which doesn't exist. Bandwidth now from /servers/{id}/traffic;
disk usage from remoteState.agent.fsinfo.
- WHMCS 9 multi-service order short-circuit: AfterModuleCreate's
AcceptOrder fired after the first service and terminated the batch
loop, orphaning siblings. Defer until every VF service in the order
has a server_id.
- Orphaned services produced six generic 500s; new
requireProvisionedService() helper emits one clean 409 with an
actionable message. Wired into all 17 client.php cases.
- Server Overview Traffic showed "- / Unlimited"; now renders real
bytes and "Unmetered" (limit=0 is per-period uncapped, not feature-off).
- Rename endpoint moved to PUT /servers/{id}/modify/name in VF v7
(was 404'ing); response is HTTP 201 not 200/204.
- Rename was force-lowercasing the input; relaxed validation to
preserve case + freeze the input row mid-flight to prevent
double-submits.
- "Other" OS category icon override removed; uses VirtFusion's icon
instead of a hardcoded SVG.
- Save button squish on the rename row fixed via flex-wrap layout.
Security
- CSRF protection (requirePost + requireSameOrigin) added to every
destructive POST: rebuild, resetPassword, resetServerPassword,
powerAction, rename, selfServiceAddCredit, toggleVnc, vncViewer.
Previously only rdnsUpdate had it.
- Open-redirect defence in Module::fetchLoginTokens — refuses to
return a redirect URL whose host doesn't match the configured VF
panel hostname.
- Per-action rate limiting via new Module::requireRateLimit helper
(Cache-backed): rebuild 60s, resetPassword/resetServerPassword 30s,
powerAction 10s, vncViewer/toggleVnc/selfServiceAddCredit 5s.
- vncViewer route delivers strict Content-Security-Policy
(default-src none, script-src self + VF panel, connect-src wss VF
panel, frame-ancestors none).
- IPv6 examples in placeholder/comments switched to the IANA
documentation prefix 2001:db8::/32 (RFC 3849).
Removed
- Network panel (duplicated Server Overview IP rows).
- VNC enable/disable toggle (VF firewall flag is non-functional;
toggle was misleading).
- Network Speed row in Resources panel (always 0 from VF API).
Internal
- Module::fetchServerData now passes ?remoteState=true.
- ServerResource::process exposes osName/osPretty/osKernel/osDistro/
osIcon/location/locationIcon/hypervisorMaintenance/createdAt/
builtAt/live.* fields.
- Module::toggleVnc corrected to send {vnc:bool} (the actual API
param) instead of {enabled:bool} (silent no-op).
- Module::getVncConsole + toggleVnc return baseUrl alongside the
envelope so the viewer route can build the wss URL.
- Panel margins tightened mb-3 → mb-2 across all 11 panels.
1580 lines
58 KiB
PHP
1580 lines
58 KiB
PHP
<?php
|
||
|
||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||
|
||
use WHMCS\Authentication\CurrentUser;
|
||
use WHMCS\Database\Capsule;
|
||
|
||
/**
|
||
* Base class providing VirtFusion API integration, authentication checks, and all
|
||
* server feature methods (power, network, VNC, backup, resource modification,
|
||
* self-service billing, traffic, rename, password reset).
|
||
*
|
||
* INHERITANCE SHAPE
|
||
* -----------------
|
||
* Extended by:
|
||
* - ModuleFunctions — service lifecycle (create, suspend, unsuspend, terminate, change package)
|
||
* - ConfigureService — order-time operations (package/template discovery, server build init)
|
||
*
|
||
* Most business logic lives HERE, not in the subclasses. Subclasses are intentionally
|
||
* thin — they orchestrate sequences of calls to methods defined on this base, which
|
||
* lets us unit-exercise any single feature (e.g. "what happens during rename when
|
||
* the VirtFusion API returns 423?") without standing up a full WHMCS lifecycle.
|
||
*
|
||
* THE resolveServiceContext() PATTERN
|
||
* -----------------------------------
|
||
* Almost every method follows the same preamble: look up the module table row,
|
||
* look up the WHMCS tblhosting row, resolve the control panel credentials, build
|
||
* a Curl client with the bearer token. That preamble is consolidated into
|
||
* resolveServiceContext() which returns everything as an array or false on any
|
||
* missing piece. Every feature method starts with "$ctx = $this->resolveServiceContext($id);
|
||
* if (! $ctx) return false;" and can then use $ctx['request'], $ctx['serverId'], etc.
|
||
*
|
||
* This pattern is the most important abstraction in the module — violating it
|
||
* (e.g. reading tblservers directly in a feature method) leads to drift where
|
||
* some features handle missing servers gracefully and others don't.
|
||
*
|
||
* ENDPOINT OUTPUT CONVENTION
|
||
* --------------------------
|
||
* client.php and admin.php call $this->output() to emit JSON responses. Every
|
||
* output() call in a switch case MUST be followed by a `break` — the module
|
||
* deliberately does NOT rely on exit() inside output() for flow control because
|
||
* that couples the HTTP response format to the control-flow mechanism and makes
|
||
* refactoring fragile.
|
||
*
|
||
* SECURITY HELPERS
|
||
* ----------------
|
||
* Five guards callers compose in front of sensitive actions:
|
||
* - isAuthenticated() — client session required
|
||
* - adminOnly() — admin session required
|
||
* - requirePost() — HTTP method gate (mutations only)
|
||
* - requireSameOrigin() — CSRF origin check
|
||
* - requireServiceStatus() — filter by tblhosting.domainstatus
|
||
*
|
||
* Each exits on failure with the appropriate HTTP status — callers treat them
|
||
* as "throw on failure" style assertions rather than having to check return values.
|
||
*/
|
||
class Module
|
||
{
|
||
/**
|
||
* @var array|false|null Memoised catalogue-level CP connection used by fetchPackage/fetchGroupResources.
|
||
* Resolved via getCP(false, true) — "any available VirtFusion server" — on first use.
|
||
* Kept on the instance so a cron loop recalculating 20 products doesn't hit
|
||
* tblservers 20×N times when N stock helpers are called per product.
|
||
*/
|
||
private $catalogueCp = null;
|
||
|
||
/**
|
||
* Initialises the module and ensures the database schema is up to date.
|
||
*/
|
||
public function __construct()
|
||
{
|
||
Database::schema();
|
||
}
|
||
|
||
/**
|
||
* @param bool $exitOnError
|
||
* @return string
|
||
*/
|
||
public function validateAction($exitOnError = true)
|
||
{
|
||
if (! isset($_GET['action'])) {
|
||
$this->output(['success' => false, 'errors' => 'no action specified'], true, $exitOnError, 400);
|
||
}
|
||
|
||
return preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['action']);
|
||
}
|
||
|
||
/**
|
||
* @param bool $exitOnError
|
||
* @return int
|
||
*/
|
||
public function validateServiceID($exitOnError = true)
|
||
{
|
||
if (! isset($_GET['serviceID']) || ! is_numeric($_GET['serviceID'])) {
|
||
$this->output(['success' => false, 'errors' => 'no valid serviceID specified'], true, $exitOnError, 400);
|
||
}
|
||
|
||
return (int) $_GET['serviceID'];
|
||
}
|
||
|
||
/**
|
||
* @param int $serviceID
|
||
* @param bool $exitOnError
|
||
* @return int|false
|
||
*/
|
||
public function validateUserOwnsService($serviceID, $exitOnError = true)
|
||
{
|
||
$serviceID = (int) $serviceID;
|
||
$currentUser = new CurrentUser;
|
||
$client = $currentUser->client();
|
||
|
||
if (! $client) {
|
||
return false;
|
||
}
|
||
|
||
if (Database::userWhmcsService($serviceID, $client->id)) {
|
||
return $client->id;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Resolve service context: system service, WHMCS service, control panel, and curl client.
|
||
*
|
||
* This is the most-called method in the module. Every feature action begins
|
||
* by calling it, so think of the return value as "everything you need to
|
||
* touch VirtFusion for this service":
|
||
*
|
||
* service — row from mod_virtfusion_direct (has server_id, server_object)
|
||
* whmcsService — row from tblhosting (has server, userid, domain, etc.)
|
||
* cp — ['url', 'base_url', 'token'] for the VirtFusion API
|
||
* request — a fresh Curl instance pre-configured with the bearer token
|
||
* serverId — (int) of service.server_id — used in every URL downstream
|
||
*
|
||
* Returning false on ANY missing piece lets callers write a single
|
||
* "if (! $ctx) return false;" check at the top of each feature method
|
||
* rather than threading nullability through three separate lookups.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array{service: object, whmcsService: object, cp: array, request: Curl, serverId: int}|false
|
||
*/
|
||
protected function resolveServiceContext($serviceID)
|
||
{
|
||
try {
|
||
$serviceID = (int) $serviceID;
|
||
$service = Database::getSystemService($serviceID);
|
||
if (! $service) {
|
||
return false;
|
||
}
|
||
|
||
$whmcsService = Database::getWhmcsService($serviceID);
|
||
if (! $whmcsService) {
|
||
return false;
|
||
}
|
||
|
||
$cp = $this->getCP($whmcsService->server);
|
||
if (! $cp) {
|
||
return false;
|
||
}
|
||
|
||
return [
|
||
'service' => $service,
|
||
'whmcsService' => $whmcsService,
|
||
'cp' => $cp,
|
||
'request' => $this->initCurl($cp['token']),
|
||
'serverId' => (int) $service->server_id,
|
||
];
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param int $serviceID
|
||
* @return false|string
|
||
*/
|
||
public function fetchLoginTokens($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$data = $ctx['request']->post($ctx['cp']['url'] . '/users/' . (int) $ctx['whmcsService']->userid . '/serverAuthenticationTokens/' . $ctx['serverId']);
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||
$data = json_decode($data);
|
||
if (isset($data->data->authentication->endpoint_complete)) {
|
||
$ssoUrl = $ctx['cp']['base_url'] . $data->data->authentication->endpoint_complete;
|
||
// Open-redirect defence: assert the URL we're about to send
|
||
// the customer to is on the configured VirtFusion host. If
|
||
// the API response, the cp_base_url config, or someone
|
||
// tampering with tblservers managed to point us elsewhere,
|
||
// refuse rather than 302 to an arbitrary destination.
|
||
$expectedHost = parse_url($ctx['cp']['base_url'], PHP_URL_HOST);
|
||
$actualHost = parse_url($ssoUrl, PHP_URL_HOST);
|
||
if (! $expectedHost || strcasecmp((string) $expectedHost, (string) $actualHost) !== 0) {
|
||
Log::insert(__FUNCTION__ . ':host_mismatch', ['expected' => $expectedHost, 'actual' => $actualHost], 'SSO redirect rejected');
|
||
|
||
return false;
|
||
}
|
||
|
||
return $ssoUrl;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract IP address and hostname from a VirtFusion server object and persist
|
||
* them to the corresponding tblhosting record (dedicatedip, domain, username,
|
||
* password).
|
||
*
|
||
* @param int $serviceId WHMCS service ID
|
||
* @param object $data Raw server object returned by the VirtFusion API
|
||
* @return void
|
||
*/
|
||
public function updateWhmcsServiceParamsOnServerObject($serviceId, $data)
|
||
{
|
||
try {
|
||
$output = [];
|
||
|
||
$serverResource = (new ServerResource)->process($data);
|
||
|
||
$dedicatedIpv4 = null;
|
||
|
||
if (count($serverResource['primaryNetwork']['ipv4Unformatted'])) {
|
||
$dedicatedIpv4 = $serverResource['primaryNetwork']['ipv4Unformatted'][0];
|
||
}
|
||
|
||
if ($serverResource['hostname'] == '-') {
|
||
if ($serverResource['name'] == '-') {
|
||
$name = '';
|
||
} else {
|
||
$name = $serverResource['name'];
|
||
}
|
||
} else {
|
||
$name = $serverResource['hostname'];
|
||
}
|
||
|
||
$output['tblhosting'] = ['dedicatedip' => $dedicatedIpv4, 'domain' => $name, 'username' => $serverResource['username'], 'password' => $serverResource['password']];
|
||
|
||
Database::updateWhmcsServiceParams($serviceId, $output);
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear the dedicated IP on the tblhosting record when a server is terminated.
|
||
*
|
||
* @param int $serviceId WHMCS service ID
|
||
* @return void
|
||
*/
|
||
public function updateWhmcsServiceParamsOnDestroy($serviceId)
|
||
{
|
||
try {
|
||
$output['tblhosting'] = ['dedicatedip' => null];
|
||
|
||
Database::updateWhmcsServiceParams($serviceId, $output);
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch full server details from the VirtFusion API for a given service.
|
||
*
|
||
* @param int $serviceID WHMCS service ID
|
||
* @return object|false Decoded API response object, or false on failure
|
||
*/
|
||
public function fetchServerData($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
// ?remoteState=true asks VirtFusion to introspect libvirt + (when
|
||
// available) qemu-guest-agent on the guest, returning live CPU/memory
|
||
// gauges, disk I/O counters, and per-mount filesystem usage under
|
||
// remoteState.{cpu,memory,disk,agent.fsinfo}. This is heavier than
|
||
// the bare /servers/{id} call (libvirt round-trip on the hypervisor)
|
||
// — acceptable on the page-load path at our scale; revisit caching
|
||
// if hypervisor load becomes a concern.
|
||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '?remoteState=true');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') != '200') {
|
||
return false;
|
||
}
|
||
|
||
$serverObj = json_decode($data);
|
||
|
||
// Merge billing-period traffic onto the server object. The
|
||
// /servers/{id} response exposes only the period WINDOW
|
||
// (settings.resources.traffic = limit GB; traffic.public.currentPeriod
|
||
// = start/end/limit) — the actual byte counter for the period lives
|
||
// on the dedicated /servers/{id}/traffic endpoint at
|
||
// data.monthly[0].total. We surface it as ->data->trafficUsedBytes
|
||
// so ServerResource (and any future consumer) has one stable path
|
||
// to read from. Non-fatal: if the secondary call fails, the field
|
||
// stays absent and ServerResource falls back to its "-" sentinel.
|
||
try {
|
||
$trafficReq = $this->initCurl($ctx['cp']['token']);
|
||
$trafficResp = $trafficReq->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/traffic');
|
||
if ($trafficReq->getRequestInfo('http_code') == '200') {
|
||
$trafficJson = json_decode($trafficResp);
|
||
$current = $trafficJson->data->monthly[0] ?? null;
|
||
if ($current && isset($current->total) && is_numeric($current->total)) {
|
||
$serverObj->data->trafficUsedBytes = (int) $current->total;
|
||
}
|
||
}
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__ . ':traffic', [], $e->getMessage());
|
||
}
|
||
|
||
return $serverObj;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute a power action on a server.
|
||
*
|
||
* @param int $serviceID
|
||
* @param string $action One of: boot, shutdown, restart, poweroff
|
||
* @return object|false
|
||
*/
|
||
public function serverPowerAction($serviceID, $action)
|
||
{
|
||
try {
|
||
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
|
||
if (! in_array($action, $allowedActions, true)) {
|
||
return false;
|
||
}
|
||
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/power/' . $action);
|
||
Log::insert(__FUNCTION__ . ':' . $action, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||
if ($httpCode == 200 || $httpCode == 204) {
|
||
return json_decode($data) ?: (object) ['success' => true];
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rebuild/reinstall a server with a new OS.
|
||
*
|
||
* @param int $serviceID
|
||
* @param int $osId Operating system template ID
|
||
* @param string|null $hostname Optional new hostname
|
||
* @return object|false
|
||
*/
|
||
public function rebuildServer($serviceID, $osId, $hostname = null)
|
||
{
|
||
try {
|
||
$osId = (int) $osId;
|
||
if ($osId <= 0) {
|
||
return false;
|
||
}
|
||
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$buildData = ['operatingSystemId' => $osId, 'email' => true];
|
||
if ($hostname !== null && $hostname !== '') {
|
||
$buildData['hostname'] = $hostname;
|
||
}
|
||
|
||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode($buildData));
|
||
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/build');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||
if ($httpCode == 200 || $httpCode == 201) {
|
||
Cache::forget('backups:' . $ctx['serverId']);
|
||
|
||
return json_decode($data) ?: (object) ['success' => true];
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rename a server.
|
||
*
|
||
* @param int $serviceID
|
||
* @param string $newName
|
||
* @return bool
|
||
*/
|
||
public function renameServer($serviceID, $newName)
|
||
{
|
||
try {
|
||
$newName = trim($newName);
|
||
if (empty($newName) || strlen($newName) > 255) {
|
||
return false;
|
||
}
|
||
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
// Capture old hostname + server object from stored state so we can sync rDNS
|
||
// after the rename. We read from the cached server_object rather than a fresh
|
||
// fetch; this is the hostname the PTR would be set to (if module-managed).
|
||
$oldHostname = null;
|
||
$serverObject = null;
|
||
if (! empty($ctx['service']->server_object)) {
|
||
$serverObject = json_decode($ctx['service']->server_object, true);
|
||
if (is_array($serverObject)) {
|
||
$oldHostname = PowerDns\PtrManager::extractHostname($serverObject);
|
||
}
|
||
}
|
||
|
||
// VirtFusion v7+ moved this endpoint from PATCH /servers/{id}/name
|
||
// to PUT /servers/{id}/modify/name (consistent with the rest of
|
||
// the /modify/* family). The old path returns 404 on v7 panels.
|
||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
|
||
$data = $ctx['request']->put($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/modify/name');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||
// VF v7 returns 201 (Created) on rename — older versions returned
|
||
// 200/204. Accept all three so we cover the version range.
|
||
$success = $httpCode == 200 || $httpCode == 201 || $httpCode == 204;
|
||
|
||
if ($success && $serverObject !== null && PowerDns\Config::isEnabled()) {
|
||
// Sync PTRs: only records whose current content equals the old hostname
|
||
// will be rewritten; client-customized PTRs are preserved automatically.
|
||
// Non-blocking: rDNS failures log but never fail the rename.
|
||
try {
|
||
(new PowerDns\PtrManager)->syncServer($serverObject, $oldHostname, $newName);
|
||
} catch (\Throwable $e) {
|
||
Log::insert('PowerDns:renameServer', ['serviceID' => $serviceID], $e->getMessage());
|
||
}
|
||
}
|
||
|
||
return $success;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch available OS templates for a server's package.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function fetchOsTemplates($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$product = Capsule::table('tblproducts')->where('id', $ctx['whmcsService']->packageid)->first();
|
||
if (! $product || ! $product->configoption2) {
|
||
return false;
|
||
}
|
||
|
||
$cacheKey = 'os:' . (int) $product->configoption2;
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
return $cached;
|
||
}
|
||
|
||
$data = $ctx['request']->get($ctx['cp']['url'] . '/media/templates/fromServerPackageSpec/' . (int) $product->configoption2);
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||
$templates = json_decode($data, true);
|
||
$baseUrl = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||
|
||
$result = [
|
||
'baseUrl' => $baseUrl,
|
||
'categories' => self::groupOsTemplates($templates['data'] ?? []),
|
||
];
|
||
|
||
Cache::set($cacheKey, $result, 600);
|
||
|
||
return $result;
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Group OS template data into categories. Categories with only 1 template
|
||
* are merged into an "Other" category.
|
||
*
|
||
* @param array $data Raw template data from VirtFusion API
|
||
* @param bool $htmlEscape Whether to escape names for HTML output
|
||
*/
|
||
public static function groupOsTemplates(array $data, bool $htmlEscape = false): array
|
||
{
|
||
$categories = [];
|
||
$otherTemplates = [];
|
||
$esc = fn ($v) => $htmlEscape ? htmlspecialchars($v, ENT_QUOTES, 'UTF-8') : $v;
|
||
|
||
foreach ($data as $osCategory) {
|
||
$catTemplates = [];
|
||
foreach ($osCategory['templates'] as $template) {
|
||
$catTemplates[] = [
|
||
'id' => $template['id'],
|
||
'name' => $esc($template['name']),
|
||
'version' => $esc($template['version'] ?? ''),
|
||
'variant' => $esc($template['variant'] ?? ''),
|
||
'icon' => $template['icon'] ?? null,
|
||
'eol' => $template['eol'] ?? false,
|
||
'type' => $template['type'] ?? '',
|
||
'description' => $esc($template['description'] ?? ''),
|
||
];
|
||
}
|
||
|
||
if (count($catTemplates) <= 1) {
|
||
// Track the "Other"-category icon from VF so the singleton
|
||
// bucket below can reuse it instead of falling back to the
|
||
// generic letter placeholder.
|
||
if (($osCategory['name'] ?? '') === 'Other' && ! isset($otherIcon)) {
|
||
$otherIcon = $osCategory['icon'] ?? null;
|
||
}
|
||
$otherTemplates = array_merge($otherTemplates, $catTemplates);
|
||
} else {
|
||
$catName = $osCategory['name'] ?? 'Unknown';
|
||
// Use VF's category icon as-is for every category, including
|
||
// "Other" — the historic override that forced a generic icon
|
||
// was reverted; whatever the API returns (linux_logo.png in
|
||
// our setup) is the canonical source.
|
||
$categories[] = [
|
||
'name' => $esc($catName),
|
||
'icon' => $osCategory['icon'] ?? null,
|
||
'templates' => $catTemplates,
|
||
];
|
||
}
|
||
}
|
||
|
||
if (! empty($otherTemplates)) {
|
||
$categories[] = ['name' => 'Other', 'icon' => $otherIcon ?? null, 'templates' => $otherTemplates];
|
||
}
|
||
|
||
return $categories;
|
||
}
|
||
|
||
// =========================================================================
|
||
// Traffic Statistics
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Get traffic statistics for a server.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function getTrafficStats($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$cacheKey = 'traffic:' . $ctx['serverId'];
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
return $cached;
|
||
}
|
||
|
||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/traffic');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') == 200) {
|
||
$result = json_decode($data, true);
|
||
Cache::set($cacheKey, $result, 120);
|
||
|
||
return $result;
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Backup Management
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Get backup list for a server.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function getServerBackups($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$cacheKey = 'backups:' . $ctx['serverId'];
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
return $cached;
|
||
}
|
||
|
||
$data = $ctx['request']->get($ctx['cp']['url'] . '/backups/server/' . $ctx['serverId']);
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') == 200) {
|
||
$result = json_decode($data, true);
|
||
Cache::set($cacheKey, $result, 120);
|
||
|
||
return $result;
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// VNC Console
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Get VNC console connection details for a server.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function getVncConsole($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/vnc');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') == 200) {
|
||
$result = json_decode($data, true);
|
||
if (! is_array($result)) {
|
||
return false;
|
||
}
|
||
// The VirtFusion API returns the noVNC viewer path as
|
||
// data.vnc.wss.url (e.g. "/vnc/?token=...") — a relative
|
||
// path. The browser needs the full URL, so we expose the
|
||
// VF base URL alongside the API payload. Same pattern used
|
||
// by fetchOsTemplates so the OS gallery can build full
|
||
// logo URLs.
|
||
$result['baseUrl'] = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||
|
||
return $result;
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle VNC on/off for a server.
|
||
*
|
||
* @param int $serviceID
|
||
* @param bool $enabled
|
||
* @return array|false
|
||
*/
|
||
public function toggleVnc($serviceID, $enabled)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
// Body param is "vnc" — NOT "enabled". The API silently no-ops
|
||
// an unknown key, which is why earlier toggle clicks appeared to
|
||
// do nothing. Confirmed against the official endpoint signature.
|
||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['vnc' => (bool) $enabled]));
|
||
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/vnc');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||
if ($httpCode == 200 || $httpCode == 204) {
|
||
$result = json_decode($data, true) ?: ['success' => true];
|
||
// Mirror getVncConsole() so the JS popup-opener can build the
|
||
// full wss:// URL from data.vnc.wss.url + baseUrl. Without
|
||
// this the response only carries the relative path and the
|
||
// popup goes nowhere.
|
||
if (is_array($result)) {
|
||
$result['baseUrl'] = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Resource Modification
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Modify a server resource (memory, cpuCores, or traffic).
|
||
*
|
||
* @param int $serviceID
|
||
* @param string $resource One of: memory, cpuCores, traffic
|
||
* @param int $value New value for the resource
|
||
* @return object|false
|
||
*/
|
||
public function modifyResource($serviceID, $resource, $value)
|
||
{
|
||
try {
|
||
$allowedResources = ['memory', 'cpuCores', 'traffic'];
|
||
if (! in_array($resource, $allowedResources, true)) {
|
||
return false;
|
||
}
|
||
|
||
$value = (int) $value;
|
||
if ($value < 0) {
|
||
return false;
|
||
}
|
||
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode([$resource => $value]));
|
||
$data = $ctx['request']->put($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/modify/' . $resource);
|
||
Log::insert(__FUNCTION__ . ':' . $resource, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||
if ($httpCode == 200 || $httpCode == 204) {
|
||
return json_decode($data) ?: (object) ['success' => true];
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Dry Run Validation
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Validate server creation parameters without actually creating a server.
|
||
*
|
||
* @param array $options Server creation options
|
||
* @param int $serverId WHMCS server ID for API credentials
|
||
* @return array ['valid' => bool, 'errors' => array]
|
||
*/
|
||
public function validateServerCreation($options, $serverId)
|
||
{
|
||
try {
|
||
$cp = $this->getCP($serverId, ! $serverId);
|
||
if (! $cp) {
|
||
return ['valid' => false, 'errors' => ['No control server found']];
|
||
}
|
||
|
||
$request = $this->initCurl($cp['token']);
|
||
$request->addOption(CURLOPT_POSTFIELDS, json_encode($options));
|
||
$data = $request->post($cp['url'] . '/servers?dryRun=true');
|
||
|
||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||
|
||
$httpCode = $request->getRequestInfo('http_code');
|
||
$response = json_decode($data, true);
|
||
|
||
if ($httpCode == 200 || $httpCode == 201) {
|
||
return ['valid' => true, 'errors' => []];
|
||
}
|
||
|
||
$errors = [];
|
||
if (isset($response['errors']) && is_array($response['errors'])) {
|
||
$errors = $response['errors'];
|
||
} elseif (isset($response['msg'])) {
|
||
$errors = [$response['msg']];
|
||
} else {
|
||
$errors = ['Validation failed with HTTP ' . $httpCode];
|
||
}
|
||
|
||
return ['valid' => false, 'errors' => $errors];
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return ['valid' => false, 'errors' => [$e->getMessage()]];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset the server's root password.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function resetServerPassword($serviceID)
|
||
{
|
||
try {
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/resetPassword');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||
if ($httpCode == 200 || $httpCode == 201) {
|
||
return json_decode($data, true);
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset the VirtFusion panel login password for a user identified by their
|
||
* WHMCS client ID (used as the external relation ID in VirtFusion).
|
||
*
|
||
* @param int $serviceID WHMCS service ID
|
||
* @param int $clientID WHMCS client ID (mapped to VirtFusion external relation ID)
|
||
* @return object|false Decoded API response object, or false on failure
|
||
*/
|
||
public function resetUserPassword($serviceID, $clientID)
|
||
{
|
||
try {
|
||
$clientID = (int) $clientID;
|
||
$ctx = $this->resolveServiceContext($serviceID);
|
||
if (! $ctx) {
|
||
return false;
|
||
}
|
||
|
||
$data = $ctx['request']->post($ctx['cp']['url'] . '/users/' . $clientID . '/byExtRelation/resetPassword');
|
||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||
|
||
if ($ctx['request']->getRequestInfo('http_code') == '201') {
|
||
return json_decode($data);
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send a JSON or raw response to the client and optionally terminate execution.
|
||
*
|
||
* @param mixed $data Response payload; encoded as JSON when $json is true
|
||
* @param bool $json Whether to JSON-encode $data and set the Content-Type header
|
||
* @param bool $exit Whether to call exit() after sending the response
|
||
* @param int $rspCode HTTP status code to send
|
||
*/
|
||
public function output($data, $json = true, $exit = true, $rspCode = 200)
|
||
{
|
||
http_response_code($rspCode);
|
||
|
||
if ($json) {
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
echo json_encode($data);
|
||
} else {
|
||
echo $data;
|
||
}
|
||
|
||
if ($exit) {
|
||
exit();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve a WHMCS server record into an API base URL and decrypted Bearer token.
|
||
*
|
||
* OUTPUT SHAPE
|
||
* ------------
|
||
* url — full API base like "https://vf.example.com/api/v1". Append
|
||
* path components to this for every VirtFusion call.
|
||
* base_url — scheme + host only, "https://vf.example.com". Used for SSO
|
||
* redirects where we need to hit the panel UI, not the API.
|
||
* token — decrypted bearer token. Pass to initCurl() to get an
|
||
* authenticated Curl handle.
|
||
*
|
||
* $any=true is an unusual behaviour: when a WHMCS product doesn't have a
|
||
* specific server pinned (allowed if the module is the only VF module on
|
||
* the install), we fall back to any enabled VirtFusion server. This mostly
|
||
* exists for the "Test Connection" button which doesn't know which server
|
||
* to use until after a successful connection. Normal provisioning always
|
||
* passes a real server ID.
|
||
*
|
||
* The token is stored encrypted in tblservers.password and decrypted here
|
||
* via WHMCS's global decrypt() — the same encryption key used for addon
|
||
* module password fields.
|
||
*
|
||
* @param int|object $server WHMCS server ID or server object
|
||
* @param bool $any When true, fall back to any available server if the given one is not found
|
||
* @return array{url: string, base_url: string, token: string}|false
|
||
*/
|
||
public function getCP($server, $any = false)
|
||
{
|
||
try {
|
||
$cp = Database::getWhmcsServer($server, $any);
|
||
|
||
if ($cp) {
|
||
return [
|
||
'url' => 'https://' . $cp->hostname . '/api/v1',
|
||
'base_url' => 'https://' . $cp->hostname,
|
||
'token' => decrypt($cp->password)];
|
||
}
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Enforce WHMCS admin authentication. Returns true if the current user is an
|
||
* authenticated admin; otherwise sends a 401 JSON response and exits.
|
||
*
|
||
* @return bool|void
|
||
*/
|
||
public function adminOnly()
|
||
{
|
||
if ((new CurrentUser)->isAuthenticatedAdmin()) {
|
||
return true;
|
||
}
|
||
|
||
$this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
|
||
}
|
||
|
||
/**
|
||
* Enforce WHMCS client authentication. Returns true if the current user is an
|
||
* authenticated client; otherwise sends a 401 JSON response and exits.
|
||
*
|
||
* @return bool|void
|
||
*/
|
||
public function isAuthenticated()
|
||
{
|
||
if ((new CurrentUser)->isAuthenticatedUser()) {
|
||
return true;
|
||
}
|
||
|
||
$this->output(['success' => false, 'errors' => 'unauthenticated'], true, true, 401);
|
||
}
|
||
|
||
/**
|
||
* Enforce POST as the HTTP method. Emits a 405 JSON response and exits otherwise.
|
||
*
|
||
* WHY THIS EXISTS
|
||
* ---------------
|
||
* The REST principle says mutations should be POST, and PHP's $_POST / $_GET
|
||
* separation means a mutation that reads from $_POST would fail quietly when
|
||
* called via GET. But "fail quietly" isn't what we want — an attacker probing
|
||
* endpoints via crafted <img src="?action=...&ip=...&ptr=..."> tags shouldn't
|
||
* even reach our input-validation code. This gate kills that path with a 405
|
||
* before any per-endpoint logic runs.
|
||
*
|
||
* Combined with requireSameOrigin() below, this closes the most common
|
||
* cross-site request forgery vectors (form POST, image GET) without needing
|
||
* explicit CSRF tokens threaded through every AJAX call.
|
||
*
|
||
* @return bool|void
|
||
*/
|
||
public function requirePost()
|
||
{
|
||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
|
||
return true;
|
||
}
|
||
|
||
$this->output(['success' => false, 'errors' => 'method not allowed'], true, true, 405);
|
||
}
|
||
|
||
/**
|
||
* Verify the request's Origin/Referer belongs to this WHMCS install.
|
||
*
|
||
* THREAT MODEL
|
||
* ------------
|
||
* A logged-in WHMCS user visits a malicious page. That page makes a POST
|
||
* to our rDNS endpoint; because the session cookie is tied to our domain,
|
||
* the browser attaches it automatically. Without this check, the attacker
|
||
* could silently rewrite the user's PTRs.
|
||
*
|
||
* The defence: browsers attach an Origin header on cross-origin fetch/XHR
|
||
* and a Referer on cross-origin form POST. Those headers carry the
|
||
* attacker's origin, not ours — so we compare them against our own
|
||
* hostname and reject mismatches with a 403.
|
||
*
|
||
* This is NOT a full CSRF token scheme. It defends against the common
|
||
* cross-site-POST and cross-site-form-submit vectors but a same-site XSS
|
||
* that can read the user's DOM could still circumvent it. For that you'd
|
||
* need per-request tokens bound to the session — out of scope for the
|
||
* current module, but the helper stays here ready to be composed with
|
||
* a token check if one's added later.
|
||
*
|
||
* IMPLEMENTATION
|
||
* --------------
|
||
* 1. Collect our "known good" host set from HTTP_HOST (what the browser
|
||
* connected to) plus the SystemURL host from tblconfiguration (what
|
||
* WHMCS thinks its canonical URL is). Behind a reverse proxy these
|
||
* can differ; accepting either closes the false-positive gap.
|
||
* 2. Parse HTTP_ORIGIN and HTTP_REFERER and pull out their host:port.
|
||
* 3. Require at least one of those headers to match.
|
||
*
|
||
* Fails closed: if we can't determine our own host OR if neither Origin
|
||
* nor Referer is present, we reject. A legitimate same-origin AJAX call
|
||
* from the module's own JS always sets Origin (fetch API) or Referer
|
||
* (form submit), so the "both absent" case only happens with scripted
|
||
* non-browser clients — which are exactly who we want to filter out.
|
||
*
|
||
* @return bool|void true on success; emits 403 JSON and exits otherwise
|
||
*/
|
||
public function requireSameOrigin()
|
||
{
|
||
$expected = [];
|
||
|
||
$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
|
||
if ($host !== '') {
|
||
$expected[] = strtolower($host);
|
||
}
|
||
|
||
$systemUrl = Database::getSystemUrl();
|
||
if ($systemUrl) {
|
||
$parsed = parse_url($systemUrl);
|
||
if (! empty($parsed['host'])) {
|
||
$expected[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
|
||
$expected[] = strtolower($parsed['host']);
|
||
}
|
||
}
|
||
$expected = array_unique(array_filter($expected));
|
||
if (empty($expected)) {
|
||
// Can't determine our own host; fail closed rather than silently allow.
|
||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||
}
|
||
|
||
$origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
|
||
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
|
||
|
||
$candidates = [];
|
||
foreach ([$origin, $referer] as $raw) {
|
||
if ($raw === '') {
|
||
continue;
|
||
}
|
||
$parsed = parse_url($raw);
|
||
if (! empty($parsed['host'])) {
|
||
$candidates[] = strtolower($parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''));
|
||
$candidates[] = strtolower($parsed['host']);
|
||
}
|
||
}
|
||
|
||
if (empty($candidates)) {
|
||
$this->output(['success' => false, 'errors' => 'cross-origin check failed (missing origin)'], true, true, 403);
|
||
}
|
||
|
||
foreach ($candidates as $c) {
|
||
if (in_array($c, $expected, true)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
Log::insert('csrf:origin-mismatch', ['origin' => $origin, 'referer' => $referer, 'expected' => $expected], 'cross-origin request rejected');
|
||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||
}
|
||
|
||
/**
|
||
* Per-(serviceID, action) rate limit. Emits 429 if hit; otherwise stamps
|
||
* a token in the cache that expires after $windowSec.
|
||
*
|
||
* Defence-in-depth against:
|
||
* - runaway client scripts hammering destructive actions on the
|
||
* customer's own service (rebuild, power-off, password reset)
|
||
* - cumulative VirtFusion API load from a misbehaving customer
|
||
*
|
||
* Uses the existing Cache class so it inherits Redis (when available)
|
||
* or atomic filesystem fallback. Keys are namespaced under "rl:" to
|
||
* avoid collisions with other Cache users.
|
||
*
|
||
* @param string $key Action/scope identifier (e.g. "power:1234")
|
||
* @param int $windowSec Minimum seconds between calls
|
||
* @return bool|void true if not rate-limited; emits 429 + exits otherwise
|
||
*/
|
||
public function requireRateLimit(string $key, int $windowSec)
|
||
{
|
||
$cacheKey = 'rl:' . $key;
|
||
if (Cache::get($cacheKey) !== null) {
|
||
$this->output(
|
||
['success' => false, 'errors' => 'Too many requests. Please wait a moment and try again.'],
|
||
true,
|
||
true,
|
||
429,
|
||
);
|
||
}
|
||
Cache::set($cacheKey, 1, $windowSec);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Ensure the WHMCS service is in a status where client-initiated writes make sense.
|
||
*
|
||
* tblhosting.domainstatus can be: Active, Suspended, Terminated, Pending,
|
||
* Cancelled, Fraud. Not every action makes sense in every status:
|
||
* - Reads (rdnsList, serverData) usually allow Active + Suspended so a
|
||
* suspended user can still see their current config.
|
||
* - Writes (rdnsUpdate, power, etc.) typically require Active only —
|
||
* mutating a cancelled service's rDNS has no sensible business meaning.
|
||
*
|
||
* Pass the allowed set explicitly per endpoint rather than trying to encode
|
||
* a global policy here. Some endpoints (admin reconcile) don't call this at
|
||
* all because the admin is allowed to touch any service.
|
||
*
|
||
* Fails with 404 if the service doesn't exist, 400 otherwise — keeping the
|
||
* two conditions distinct in the response code helps client-side error
|
||
* handling (a 404 usually means "link is stale", a 400 means "not right now").
|
||
*
|
||
* @param int $serviceID WHMCS service ID
|
||
* @param string[] $allowedStatuses Service statuses that permit the operation
|
||
* @return bool|void true on success; emits 400/404 JSON and exits otherwise
|
||
*/
|
||
public function requireServiceStatus(int $serviceID, array $allowedStatuses = ['Active'])
|
||
{
|
||
$row = Database::getWhmcsService($serviceID);
|
||
if (! $row) {
|
||
$this->output(['success' => false, 'errors' => 'service not found'], true, true, 404);
|
||
}
|
||
if (! in_array((string) $row->domainstatus, $allowedStatuses, true)) {
|
||
$this->output(
|
||
['success' => false, 'errors' => 'service status "' . (string) $row->domainstatus . '" does not permit this action'],
|
||
true,
|
||
true,
|
||
400,
|
||
);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Ensure the WHMCS service has a corresponding VirtFusion server linked.
|
||
*
|
||
* A service can exist in tblhosting (Active, paid for, etc.) without ever
|
||
* having been successfully provisioned in VirtFusion — typically when the
|
||
* order was accepted but the CreateAccount call failed or never ran. In
|
||
* that state, mod_virtfusion_direct has either no row for the service or
|
||
* a row with NULL server_id.
|
||
*
|
||
* Without this guard, every downstream feature method (getTrafficStats,
|
||
* getServerBackups, etc.) silently returns false because resolveServiceContext
|
||
* can't build a valid request, and client.php translates that into a generic
|
||
* "Unable to retrieve X" 500 — which gives the client a broken UI with no
|
||
* indication of the real problem. With the guard, the client gets a single
|
||
* clear 409 explaining the state, and our log shows the unprovisioned
|
||
* lookup attempt instead of N misleading "Unable to..." entries.
|
||
*
|
||
* Returns 409 Conflict because the request is well-formed but the server's
|
||
* current state (no provisioned VF server) prevents fulfilment — the same
|
||
* semantics WHMCS itself uses when a service is in the wrong status.
|
||
*
|
||
* @param int $serviceID WHMCS service ID
|
||
* @return bool|void true on success; emits 409 JSON and exits otherwise
|
||
*/
|
||
public function requireProvisionedService(int $serviceID)
|
||
{
|
||
$row = Database::getSystemService($serviceID);
|
||
if (! $row || empty($row->server_id)) {
|
||
$this->output(
|
||
['success' => false, 'errors' => 'Server has not been provisioned yet. Please contact support if this is unexpected.'],
|
||
true,
|
||
true,
|
||
409,
|
||
);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Create a pre-configured Curl instance with JSON Accept/Content-Type headers
|
||
* and a Bearer token for authenticating against the VirtFusion API.
|
||
*
|
||
* @param string $token VirtFusion API Bearer token
|
||
* @return Curl
|
||
*/
|
||
public function initCurl($token)
|
||
{
|
||
$curl = new Curl;
|
||
|
||
$curl->addOption(CURLOPT_HTTPHEADER, [
|
||
'Accept: application/json',
|
||
'Content-type: application/json; charset=utf-8',
|
||
'authorization: Bearer ' . $token,
|
||
]);
|
||
|
||
return $curl;
|
||
}
|
||
|
||
// =========================================================================
|
||
// Self Service — Credit & Usage
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Get self-service usage data for a WHMCS client.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function getSelfServiceUsage($serviceID)
|
||
{
|
||
try {
|
||
$serviceID = (int) $serviceID;
|
||
$whmcsService = Database::getWhmcsService($serviceID);
|
||
if (! $whmcsService) {
|
||
return false;
|
||
}
|
||
|
||
$cp = $this->getCP($whmcsService->server);
|
||
if (! $cp) {
|
||
return false;
|
||
}
|
||
|
||
$request = $this->initCurl($cp['token']);
|
||
$data = $request->get($cp['url'] . '/selfService/usage/byUserExtRelationId/' . (int) $whmcsService->userid);
|
||
|
||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||
|
||
if ($request->getRequestInfo('http_code') == 200) {
|
||
return json_decode($data, true);
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get self-service billing report for a WHMCS client.
|
||
*
|
||
* @param int $serviceID
|
||
* @return array|false
|
||
*/
|
||
public function getSelfServiceReport($serviceID)
|
||
{
|
||
try {
|
||
$serviceID = (int) $serviceID;
|
||
$whmcsService = Database::getWhmcsService($serviceID);
|
||
if (! $whmcsService) {
|
||
return false;
|
||
}
|
||
|
||
$cp = $this->getCP($whmcsService->server);
|
||
if (! $cp) {
|
||
return false;
|
||
}
|
||
|
||
$request = $this->initCurl($cp['token']);
|
||
$data = $request->get($cp['url'] . '/selfService/report/byUserExtRelationId/' . (int) $whmcsService->userid);
|
||
|
||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||
|
||
if ($request->getRequestInfo('http_code') == 200) {
|
||
return json_decode($data, true);
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add self-service credit for a WHMCS client.
|
||
*
|
||
* @param int $serviceID
|
||
* @param float $tokens Amount of credit tokens to add
|
||
* @param string $reference Reference text for the transaction
|
||
* @return array|false
|
||
*/
|
||
public function addSelfServiceCredit($serviceID, $tokens, $reference = '')
|
||
{
|
||
try {
|
||
$serviceID = (int) $serviceID;
|
||
$tokens = (float) $tokens;
|
||
|
||
if ($tokens <= 0) {
|
||
return false;
|
||
}
|
||
|
||
$whmcsService = Database::getWhmcsService($serviceID);
|
||
if (! $whmcsService) {
|
||
return false;
|
||
}
|
||
|
||
$cp = $this->getCP($whmcsService->server);
|
||
if (! $cp) {
|
||
return false;
|
||
}
|
||
|
||
$request = $this->initCurl($cp['token']);
|
||
$request->addOption(CURLOPT_POSTFIELDS, json_encode([
|
||
'tokens' => $tokens,
|
||
'reference_1' => $reference ?: 'WHMCS Top-up',
|
||
'reference_2' => 'Service #' . $serviceID,
|
||
]));
|
||
$data = $request->post($cp['url'] . '/selfService/credit/byUserExtRelationId/' . (int) $whmcsService->userid);
|
||
|
||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||
|
||
$httpCode = $request->getRequestInfo('http_code');
|
||
if ($httpCode == 200 || $httpCode == 201) {
|
||
return json_decode($data, true);
|
||
}
|
||
|
||
return false;
|
||
} catch (\Exception $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Decodes a response from JSON into an associative array.
|
||
*
|
||
*
|
||
* @throws \JsonException
|
||
*/
|
||
public function decodeResponseFromJson(string $response): array
|
||
{
|
||
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Catalogue helpers — used by StockControl to size the WHMCS inventory from
|
||
// live VirtFusion data. Pre-order code path: CP is resolved via "any
|
||
// available server" since no service context exists yet.
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Resolve the catalogue-level CP (any available VirtFusion server) and memoise.
|
||
*
|
||
* Stock calculations run from a cron loop or product-detail page view — there's
|
||
* no WHMCS service yet, so we can't dereference a specific panel via
|
||
* resolveServiceContext. "Any enabled server" is the correct fallback for read-only
|
||
* catalogue operations (package + hypervisor-group endpoints return the same data
|
||
* from every VirtFusion node on the same cluster).
|
||
*
|
||
* @return array{url: string, base_url: string, token: string}|false
|
||
*/
|
||
private function getCatalogueCp()
|
||
{
|
||
if ($this->catalogueCp === null) {
|
||
$this->catalogueCp = $this->getCP(false, true);
|
||
}
|
||
|
||
return $this->catalogueCp;
|
||
}
|
||
|
||
/**
|
||
* Fetch a VirtFusion package by ID — the authoritative source for "how much RAM,
|
||
* CPU, and disk does one VPS of this product cost?".
|
||
*
|
||
* Return values distinguish confirmed-missing from transient failure:
|
||
* array — package data (fields: memory, cpuCores, primaryStorage, primaryStorageProfile, enabled, …)
|
||
* false — HTTP 404: package has been deleted in VirtFusion. Callers treat as OOS.
|
||
* null — Transient failure (no CP, network error, 5xx, malformed body). Callers must
|
||
* NOT overwrite WHMCS qty on a null — that would zero out inventory during a blip.
|
||
*
|
||
* Success responses are cached 10 min (key "pkg:{id}") since package definitions
|
||
* rarely change; 404 responses get a short 60 s cache so an admin re-creating a
|
||
* deleted package doesn't have to wait ten minutes for stock to pick it up again.
|
||
*
|
||
* @param int $packageId VirtFusion package ID (from tblproducts.configoption2).
|
||
* @return array|false|null
|
||
*/
|
||
public function fetchPackage($packageId)
|
||
{
|
||
try {
|
||
$packageId = (int) $packageId;
|
||
if ($packageId <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$cacheKey = 'pkg:' . $packageId;
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
// Sentinel marker for a previously-confirmed 404.
|
||
if (is_array($cached) && ! empty($cached['__notFound'])) {
|
||
return false;
|
||
}
|
||
|
||
return $cached;
|
||
}
|
||
|
||
$cp = $this->getCatalogueCp();
|
||
if (! $cp) {
|
||
return null;
|
||
}
|
||
|
||
$request = $this->initCurl($cp['token']);
|
||
$data = $request->get($cp['url'] . '/packages/' . $packageId);
|
||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||
|
||
$httpCode = (int) $request->getRequestInfo('http_code');
|
||
|
||
if ($httpCode === 200) {
|
||
$decoded = json_decode($data, true);
|
||
if (is_array($decoded)) {
|
||
$package = $decoded['data'] ?? $decoded;
|
||
if (is_array($package)) {
|
||
Cache::set($cacheKey, $package, 600);
|
||
|
||
return $package;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
if ($httpCode === 404) {
|
||
Cache::set($cacheKey, ['__notFound' => true], 60);
|
||
|
||
return false;
|
||
}
|
||
|
||
return null;
|
||
} catch (\Throwable $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch free/allocated resources for every hypervisor in a group — the live picture
|
||
* of how much headroom remains to place more VPSes.
|
||
*
|
||
* Same tri-state return contract as fetchPackage():
|
||
* array — decoded response with a 'data' array of per-hypervisor resource breakdowns.
|
||
* false — HTTP 404: group has been deleted. Callers may treat as "zero capacity from this group".
|
||
* null — Transient failure. Callers must NOT overwrite WHMCS qty on a null.
|
||
*
|
||
* Cache TTL is 120 s — short enough that customers don't see stale OOS labels for
|
||
* long after capacity frees up, and long enough to amortise the upstream call across
|
||
* bursty product-page traffic. Matches the traffic-stats TTL in getTrafficStats().
|
||
*
|
||
* @param int $groupId VirtFusion hypervisor group ID.
|
||
* @return array|false|null
|
||
*/
|
||
public function fetchGroupResources($groupId)
|
||
{
|
||
try {
|
||
$groupId = (int) $groupId;
|
||
if ($groupId <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$cacheKey = 'grpres:' . $groupId;
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
if (is_array($cached) && ! empty($cached['__notFound'])) {
|
||
return false;
|
||
}
|
||
|
||
return $cached;
|
||
}
|
||
|
||
$cp = $this->getCatalogueCp();
|
||
if (! $cp) {
|
||
return null;
|
||
}
|
||
|
||
$request = $this->initCurl($cp['token']);
|
||
$data = $request->get($cp['url'] . '/compute/hypervisors/groups/' . $groupId . '/resources');
|
||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||
|
||
$httpCode = (int) $request->getRequestInfo('http_code');
|
||
|
||
if ($httpCode === 200) {
|
||
$decoded = json_decode($data, true);
|
||
if (is_array($decoded) && isset($decoded['data']) && is_array($decoded['data'])) {
|
||
Cache::set($cacheKey, $decoded, 120);
|
||
|
||
return $decoded;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
if ($httpCode === 404) {
|
||
Cache::set($cacheKey, ['__notFound' => true], 60);
|
||
|
||
return false;
|
||
}
|
||
|
||
return null;
|
||
} catch (\Throwable $e) {
|
||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||
|
||
return null;
|
||
}
|
||
}
|
||
}
|