isAuthenticated() — client session (401 otherwise) * 2. $vf->validateServiceID(true) — numeric coercion + presence * 3. $vf->validateUserOwnsService($id) — the session owns this service (403) * 4. Optional: requireServiceStatus — filter by tblhosting.domainstatus * 5. Optional (mutations): requirePost — HTTP method gate (405) * 6. Optional (mutations): requireSameOrigin — CSRF origin gate (403) * * The helpers are "fail loudly" — they exit on failure rather than returning. * So everything AFTER a guard in a case branch knows the guard passed. * * EVERY $vf->output() FOLLOWED BY break * ------------------------------------- * output() emits a JSON response and exits by default, so in theory `break` * is redundant. In practice we always break explicitly for two reasons: * 1. If someone later passes exit=false to output() the switch would fall * through to the default case and emit a second response body. * 2. Code readers shouldn't have to remember that one function exits. * * RESPONSE SHAPE * -------------- * Success: { success: true, data: { ... } } * Error: { success: false, errors: "human-readable message" } * Status codes match HTTP semantics (200/400/401/403/404/405/429/500/502). * * CATCH-ALL * --------- * The outer try/catch guarantees we never expose a raw PHP stack trace to the * client, even on bugs in our own code. All uncaught exceptions are logged and * the user sees a generic 500. */ use WHMCS\Database\Capsule; use WHMCS\Module\Server\VirtFusionDirect\Log; use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\IpUtil; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager; use WHMCS\Module\Server\VirtFusionDirect\ServerResource; $vf = new Module; try { $vf->isAuthenticated(); $action = $vf->validateAction(true); switch ($action) { /** * Reset Password. */ case 'resetPassword': // Destructive: rotates the customer's VirtFusion login password. // Gated by POST + same-origin (anti-CSRF) and a 30 s rate limit // so a runaway / malicious script can't lock out the customer // by spamming password resets. $vf->requirePost(); $vf->requireSameOrigin(); $serviceID = $vf->validateServiceID(true); $client = $vf->validateUserOwnsService($serviceID); if (! $client) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $vf->requireRateLimit('resetPassword:' . $serviceID, 30); $data = $vf->resetUserPassword($serviceID, $client); if ($data) { $vf->output(['success' => true, 'data' => $data->data], true, true, 200); break; } $vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500); break; /** * Get server information. */ case 'serverData': $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $data = $vf->fetchServerData($serviceID); if ($data) { $vf->updateWhmcsServiceParamsOnServerObject($serviceID, $data); $vf->output(['success' => true, 'data' => (new ServerResource)->process($data)], true, true, 200); break; } $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500); break; /** * Login as server owner. */ case 'loginAsServerOwner': $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $token = $vf->fetchLoginTokens($serviceID); if ($token) { $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; /** * Power management actions: boot, shutdown, restart, poweroff */ case 'powerAction': // Destructive: poweroff/restart can interrupt running workloads. // Anti-CSRF + 10 s rate limit (short — power actions can legitimately // cycle quickly when an admin is testing). $vf->requirePost(); $vf->requireSameOrigin(); $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $vf->requireRateLimit('power:' . $serviceID, 10); $powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : ''; $allowedActions = ['boot', 'shutdown', 'restart', 'poweroff']; if (! in_array($powerAction, $allowedActions, true)) { $vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400); break; } $result = $vf->serverPowerAction($serviceID, $powerAction); if ($result) { $vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200); break; } $vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500); break; /** * Rebuild/reinstall server with new OS. */ case 'rebuild': // Most-destructive client action — wipes the server. Strict // anti-CSRF (a malicious page tricking the customer into // rebuilding their own server destroys data) + 60 s rate limit // (no legitimate flow needs more than one rebuild per minute). $vf->requirePost(); $vf->requireSameOrigin(); $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $vf->requireRateLimit('rebuild:' . $serviceID, 60); $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': // Mutation: anti-CSRF. No rate limit — name changes are cheap. $vf->requirePost(); $vf->requireSameOrigin(); $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $newName = isset($_POST['name']) ? trim($_POST['name']) : ''; // VF "name" is a display label, not a DNS hostname — preserve // case + accept any printable string up to 63 chars. The only // hard rejects are empty, oversized, and control characters. if ($newName === '' || strlen($newName) > 63 || preg_match('/[\x00-\x1F\x7F]/', $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; } $vf->requireProvisionedService($serviceID); $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': // Destructive: rotates the VPS root password. Anti-CSRF + 30 s // rate limit so a hostile script can't lock out the customer. $vf->requirePost(); $vf->requireSameOrigin(); $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); $vf->requireRateLimit('resetServerPassword:' . $serviceID, 30); $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; } $vf->requireProvisionedService($serviceID); $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; } $vf->requireProvisionedService($serviceID); $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; } $vf->requireProvisionedService($serviceID); $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; /** * Render the noVNC viewer HTML page. * * SECURITY MODEL * -------------- * This is the popup target instead of a blob URL — it keeps the * wss token out of any URL the customer can copy/share. The page * is gated by the same client.php protections every other action * uses: * - WHMCS session required (isAuthenticated) * - validateUserOwnsService prevents cross-customer access * (any other customer hitting this URL with their session * gets a 403) * - requireProvisionedService blocks orphan services * * Each request rotates the wss token by POSTing to VirtFusion's * /vnc endpoint with vnc:true — older tokens VirtFusion was * tracking are superseded, so a leaked token from a previous * popup open is no longer usable after the next click. * * Method is POST (not GET) so we can require same-origin and * avoid the GET-with-side-effects anti-pattern. JS opens the * popup via a hidden form-submit (see vfOpenVnc in module.js). * * Output is text/html (NOT the JSON the other actions use), * directly delivered to the popup window. */ case 'vncViewer': $vf->requirePost(); $vf->requireSameOrigin(); $serviceID = $vf->validateServiceID(true); if (! $vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); break; } $vf->requireProvisionedService($serviceID); // 5 s rate limit — protects against runaway-script token rotation // bursts. A legitimate user clicking Open Console twice in a row // (e.g. popup got closed) waits at most 5 s. $vf->requireRateLimit('vncViewer:' . $serviceID, 5); // Rotate credentials by toggling vnc=true (idempotent — VF returns // a fresh token + password on each call). Falls back to a plain // GET if the rotate call fails so the customer still gets a // viewer with the existing creds. $vncData = $vf->toggleVnc($serviceID, true); if ($vncData === false) { $vncData = $vf->getVncConsole($serviceID); } if ($vncData === false) { http_response_code(500); header('Content-Type: text/html; charset=utf-8'); echo '