Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbe40c52 |
72
CHANGELOG.md
72
CHANGELOG.md
@@ -2,6 +2,78 @@
|
||||
|
||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||
|
||||
## [1.5.0] - 2026-04-28
|
||||
|
||||
> **Tested against:** WHMCS 9.0.3 and VirtFusion v7.0.0 Build 9.
|
||||
|
||||
### Features
|
||||
|
||||
- **In-page section navigation in the WHMCS Actions sidebar.** Customers viewing a VirtFusion service get an "On This Page" group in the sidebar with jump-links to every visible panel (Overview, Traffic, Live Stats, Power, Manage, Rebuild, Reverse DNS, Resources, Billing & Usage, Billing Overview). Rendered server-side via a `ClientAreaPrimarySidebar` hook that's gated to productdetails pages for VF services only — no impact on other modules. Each link smooth-scrolls to its target via a delegated click handler. Conditionally-shown panels (Live Stats when remoteState is unavailable, Reverse DNS when PowerDNS isn't configured, Resources/Self-Service before their data loads) auto-hide their corresponding sidebar links so customers don't see dead jumps. VNC is intentionally excluded — its panel is the first thing on the page already. Theme-agnostic — WHMCS handles sidebar rendering for Six, Twenty-One, Lagom, etc.
|
||||
|
||||
- **Traffic chart panel showing the last 12 months of monthly aggregates.** New panel between Server Overview and Power Management, sourced from `/servers/{id}/traffic`'s `monthly[]` array. Renders side-by-side rx (blue) and tx (green) bars per month with month-or-month/year labels at the bottom and a centered legend with breathing room. Current period's used/limit/remaining tile sits below the chart. Replaces the previously broken in-resources chart that read non-existent `data.entries` / `data.used` paths and silently never rendered for any service since it was first added.
|
||||
|
||||
- **Live Stats panel — CPU, memory, disk I/O with 30s auto-refresh.** Surfaces `remoteState.cpu`, `remoteState.memory.{actual,unused}`, and `remoteState.disk.vda.{rd.bytes,wr.bytes}` from VirtFusion's libvirt introspection. CPU and memory render as colored progress bars with `bg-warning` at 75% and `bg-danger` at 90%. Disk I/O shows cumulative bytes since boot. Refresh polls `serverData` every 30s while the panel is visible AND the page has focus — pauses on `visibilitychange` to avoid hammering libvirt when the customer alt-tabs away. Hidden entirely when the upstream call returns no remoteState block.
|
||||
|
||||
- **Filesystem usage in the Resources panel.** Per-mount usage rows sourced from qemu-guest-agent's fsinfo block (requires `qemu-guest-agent` installed on the VM). Pseudo-FS (proc/sysfs/devtmpfs/tmpfs/cgroup/etc.) plus `/boot*` and `/run*` are filtered out — only customer-meaningful mounts show. Each row has a progress bar with the same 75%/90% warning thresholds. Hidden when the agent isn't running, with a one-line hint that customers can install qemu-guest-agent to populate the section.
|
||||
|
||||
- **Server Overview meta bar — Location, OS, lifetime.** Top of the panel now shows three chips: data-center location with country flag emoji (from `hypervisor.group.{name,icon}`, country codes mapped via Regional Indicator Symbols), the OS template name preferring the qemu-agent's pretty name when available (with kernel version on hover), and a relative "Created N days ago" chip with the absolute timestamp on hover.
|
||||
|
||||
- **Hypervisor maintenance banner.** Yellow alert at the very top of the page when `hypervisor.maintenance=true`. Prepares the customer to expect "operation may be unavailable" errors so they don't open support tickets for what's actually known maintenance.
|
||||
|
||||
- **"Mask Sensitive" toggle for screenshot-safe viewing.** Button on the Server Overview meta bar. When active, masks IPv4 (keeps first two octets — `205.186.•••.•••`), IPv6 (keeps first two hextets — `2602:2f3:••••::•`), hostnames (keeps first char of each dot-separated label — `m•••.e••••••.c••`). Covers the Server Name input, Hostname row, IPv4/IPv6 cells in Server Overview, AND in the Reverse DNS panel rows. Input fields mask via CSS `text-security: disc` (preserves the underlying value for editing — focus reveals); text cells via attribute swap with the original cached on `data-vf-ip-original`. State persists in `sessionStorage` so screenshots taken across page refreshes stay masked.
|
||||
|
||||
- **Per-IP copy buttons in Server Overview cells.** Each IPv4 / IPv6 address renders as a row with a copy button (replaces the standalone Network panel that duplicated this information).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Critical: UsageUpdate cron silently never recorded any usage.** `VirtFusionDirect_UsageUpdate()` was reading `server.usage.traffic.used` and `server.usage.storage.used` — neither path exists in any VirtFusion API response, so `tblhosting.bwused` and `diskused` stayed at 0 forever for every service. Bandwidth now sourced from `/servers/{id}/traffic`'s `monthly[0].total` (canonical billing-period bytes); disk usage from `/servers/{id}?remoteState=true`'s `agent.fsinfo[]` summed (best-effort — only updates when qemu-guest-agent is running). Limits (`settings.resources.{storage,traffic}`) were already correct and untouched. Verified on live data: usage columns now populate correctly, unblocking suspend-on-overage rules and customer-facing usage bars.
|
||||
|
||||
- **Critical: WHMCS 9 multi-service order short-circuit.** WHMCS 9 added a batch order-acceptance loop that terminates as soon as the order leaves Pending status — calling `localAPI('AcceptOrder')` mid-batch (which our `AfterModuleCreate` hook did to advance the order) caused subsequent VF services in the same order to be skipped, leaving them Active in `tblhosting` but with no `mod_virtfusion_direct` row and no actual VPS in VirtFusion. Fix: defer the auto-accept until every VF service in the order has been provisioned. The hook fires once per service, so the last one to complete sees no unprovisioned siblings and triggers the actual accept. WHMCS 8 was unaffected (its loop ignored order status mid-batch); deferring there is harmless.
|
||||
|
||||
- **Service detail page returned generic 500s for orphaned services.** When a service exists in `tblhosting` but has no row (or NULL `server_id`) in `mod_virtfusion_direct` — e.g., the multi-service order bug above, or a failed CreateAccount — every client.php endpoint silently bailed via `resolveServiceContext()` and the customer saw a string of generic 500s with no actionable message. Added `Module::requireProvisionedService()` helper that emits a single clean 409 ("Server has not been provisioned yet. Please contact support if this is unexpected.") on the first endpoint call. Wired into all 17 client.php cases after `validateUserOwnsService()` so customers see one clear message instead of six broken sections.
|
||||
|
||||
- **Traffic display in Server Overview showed `- / Unlimited` even for servers with measured usage.** Same root cause as the UsageUpdate bug: `ServerResource::process()` read the non-existent `usage.traffic.used` path. Fixed by having `Module::fetchServerData()` make a secondary call to `/servers/{id}/traffic` and merge the current period's total bytes onto the server object as `trafficUsedBytes`; ServerResource reads from that stable field. Also renamed "Unlimited" → "Unmetered" everywhere — limit=0 means no cap, but traffic is still tracked per period, so Unmetered is more accurate.
|
||||
|
||||
- **VNC viewer popup didn't render — wrong URL pattern.** The `/vnc/?token=...` URL VirtFusion returns is the raw WebSocket endpoint and rejects HTTP GET with 405. The actual noVNC viewer is a tiny HTML shell that loads `<vfBaseUrl>/vnc/vnc.js`. Fixed by serving the shell ourselves from a same-origin authenticated PHP route (`client.php?action=vncViewer`) — see the Security section below for the full shape.
|
||||
|
||||
- **VNC toggle was lying and was removed.** The PHP `Module::toggleVnc()` was POSTing `{enabled: bool}` to VirtFusion when the API parameter is actually `vnc: bool` — silent no-op. Even after fixing the param, the API's `vnc.enabled` response field stayed `false` regardless of toggle state, and the wss endpoint accepts connections regardless of the panel toggle (per VirtFusion's current implementation, the toggle only manages a firewall flag that's currently broken at their panel level). Toggle removed entirely; VNC is treated as always-available, gated by WHMCS session + service ownership at our layer.
|
||||
|
||||
- **OS gallery's "Other" category icon was hardcoded to a generic SVG.** Forced override in `Module::groupOsTemplates()` and matching branches in `module.js` and `hooks.php` were nulling out the icon VirtFusion provides for the category. Reverted to use the upstream icon — applies in both the client-area Rebuild gallery and the checkout-side OS picker. Singleton-collected templates (those merged into a synthetic "Other" bucket) inherit the icon from VF's "Other" category if one was present in the source data.
|
||||
|
||||
- **Save button squished on the Server Name rename row.** Single flex row with 6px gap and a 200px max-width input was packing the randomise + save buttons into too-narrow columns on mid-width viewports. Wrapped the inputs in a flex-wrap container with explicit min-widths so all three controls stay readable down to mobile.
|
||||
|
||||
- **Server rename was force-lowercasing the input.** Customer typed "VPS-01", got "vps-01" back. Both client and server validation enforced an RFC-1123-style hostname regex, but VirtFusion's `name` field is a display label that accepts any printable string up to 63 chars. Validation relaxed to non-empty + length cap + reject control characters. Mid-flight, the input field, Save, and Randomise buttons all freeze together so customers can't double-submit or edit while the rename is in flight.
|
||||
|
||||
- **Server rename hit the wrong VirtFusion endpoint** (and silently treated success as failure). The PHP module was using `PATCH /servers/{id}/name`, which VirtFusion v7 returns 404 for — the path moved to `PUT /servers/{id}/modify/name` (consistent with `/modify/memory`, `/modify/cpu`, etc.). Even after fixing the URL, success was treated as failure because v7 returns HTTP 201 (Created) on rename and our success whitelist only accepted 200/204. Fixed both: switched to PUT + new path, added 201 to the whitelist.
|
||||
|
||||
### Security
|
||||
|
||||
- **CSRF protection added to every destructive client.php action.** `rebuild`, `resetPassword`, `resetServerPassword`, `powerAction`, `rename`, `selfServiceAddCredit`, `toggleVnc`, and `vncViewer` now require `requirePost()` + `requireSameOrigin()`. Closes a class of attacks where a malicious page (or compromised ad slot) could embed a hidden form targeting `client.php?serviceID=X&action=rebuild` and destroy the customer's data on the next page load they made while logged in. Previously only `rdnsUpdate` carried these gates.
|
||||
|
||||
- **Open-redirect defence on SSO.** `Module::fetchLoginTokens()` now validates that the URL constructed from VirtFusion's `endpoint_complete` resolves to the same hostname as the configured VirtFusion panel before returning it for redirect. Defends against a hostname-mismatched response (compromised VF panel, tampered `tblservers` row, etc.) being used to phish customers.
|
||||
|
||||
- **VNC viewer popup served from a same-origin authenticated POST route.** Click → hidden form-submit (POST) to `client.php?action=vncViewer` → `requirePost()` + `requireSameOrigin()` + `isAuthenticated()` + `validateUserOwnsService()` + `requireProvisionedService()` → POST `/vnc {vnc:true}` to rotate the wss token → return `text/html` with the noVNC shell embedded. The wss URL never appears in any URL bar, browser history, or shareable link — it lives only in the HTTP response body delivered to a logged-in session. Each open rotates the token, so any prior credential exposure is short-lived. Response carries `X-Frame-Options: DENY`, `Cache-Control: no-store, private`, and a strict `Content-Security-Policy` (`default-src 'none'`, `script-src 'self' <vf-panel>`, `connect-src wss://<vf-panel> <vf-panel>`, `frame-ancestors 'none'`) so the viewer cannot be re-hosted, embedded, or opened in a way that loads scripts from anywhere outside our WHMCS host or the configured VirtFusion panel.
|
||||
|
||||
- **Per-action rate limiting** on destructive endpoints via the new `Module::requireRateLimit()` helper (Cache-backed, Redis-or-filesystem). Limits: `rebuild` 60 s, `resetPassword` / `resetServerPassword` 30 s, `powerAction` 10 s, `vncViewer` / `toggleVnc` / `selfServiceAddCredit` 5 s — all per-(action, serviceID). Defence against runaway browser scripts and accidental double-submits hammering the VirtFusion API. Returns 429 with a clear "Too many requests" message instead of letting the request through.
|
||||
|
||||
- **IP and hostname masking covers screenshot-sensitive cells.** See the Mask Sensitive feature above — reduces leakage when customers screen-share or attach screenshots for support.
|
||||
|
||||
- **IPv6 examples in the Reverse DNS placeholder + comment switched to the IANA documentation prefix `2001:db8::/32`** (RFC 3849), eliminating an inadvertent reference to a deployer-specific IPv6 block that previously appeared in customer-facing UI.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Network panel** (full removal) — duplicated Server Overview's IP rows. Per-IP copy buttons moved into the Overview cells via `vfRenderIpCells()`.
|
||||
- **Network Speed row** from the Resources panel — VirtFusion's `inAverage`/`inPeak`/`inBurst` and matching out-fields all return 0 in our setup (network speed isn't capped at the package level), so the row was always empty.
|
||||
- **VNC enable/disable toggle** — see Bug Fixes above.
|
||||
|
||||
### Internal
|
||||
|
||||
- **`Module::fetchServerData()` now passes `?remoteState=true`** on the upstream call so the response includes live CPU/memory, disk I/O counters, and qemu-agent fsinfo. Adds one libvirt round-trip per page load on the hypervisor side; revisit caching if hypervisor load becomes a concern.
|
||||
- **`ServerResource::process()`** exposes new fields: `osName`, `osPretty`, `osKernel`, `osDistro`, `osIcon`, `location`, `locationIcon`, `hypervisorMaintenance`, `createdAt`, `builtAt`, plus a nested `live.{state, cpu, memoryActualKB, memoryUnusedKB, memoryAvailableKB, memoryRssKB, diskRdBytes, diskWrBytes, filesystems[]}` block.
|
||||
- **`Module::toggleVnc()`** corrected to post `{vnc: bool}` (the actual API parameter) instead of `{enabled: bool}` (silent no-op). Also returns `baseUrl` alongside the API envelope so the new vncViewer route can build the wss URL without re-deriving it.
|
||||
- **`Module::getVncConsole()`** also returns `baseUrl` for the same reason.
|
||||
- **Panel margins tightened** from `mb-3` (16px) to `mb-2` (8px) across all 11 client-area panels for a tighter visual rhythm post-additions.
|
||||
|
||||
## [1.4.4] - 2026-04-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -77,16 +77,31 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene
|
||||
|
||||
### 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/overview.tpl`** — Smarty template for client area. Panel order top-down: Hypervisor maintenance banner → VNC Console (button only) → Server Overview (with location/OS/lifetime meta chips, Mask Sensitive toggle, IP rows with copy buttons, Login to Control Panel footer) → Traffic chart (12-month aggregates) → Live Stats (CPU/memory/disk I/O, 30s refresh) → Power Management → Manage (password reset + backups) → Rebuild (OS gallery) → Reverse DNS (when PowerDNS enabled) → Resources (with filesystem usage when qemu-agent reports it) → Self-Service Billing (when configoption4>0) → Billing Overview.
|
||||
- **`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, monthly bars + centered legend), `vfRenderIpCells()` (IP-row + copy button), `vfRenderLiveStats()` / `vfRenderFilesystems()` (remoteState surfaces), `vfMaskString()` / `vfApplyIpMask()` / `vfToggleIpMask()` (Mask Sensitive — IPv4 keeps first 2 octets, IPv6 keeps first 2 hextets, hostnames keep first char per dot-label; inputs masked via `text-security: disc`), `vfBuildSectionNav()` (toggles sidebar Jump-To items based on visible-panel state).
|
||||
- **`templates/js/keygen.js`** — Client-side SSH Ed25519 key generator using Web Crypto API (loaded on checkout page)
|
||||
- **`templates/css/module.css`** — Cross-theme styles with Bootstrap 3/4/5 dual class support (`panel card`, `panel-body card-body`)
|
||||
- **`templates/css/module.css`** — Cross-theme styles with Bootstrap 3/4/5 dual class support (`panel card`, `panel-body card-body`). Panel margins are `mb-2` (8px) for tight stacking; `.vf-panel-grid` provides side-by-side panel layouts when used.
|
||||
|
||||
### Sidebar Integration
|
||||
|
||||
- **WHMCS Actions sidebar** receives an "On This Page" group via `ClientAreaPrimarySidebar` hook (in `hooks.php`), gated to productdetails for VF services only. Each entry carries `data-vf-target=<panel-id>` so the document-level smooth-scroll click handler in `module.js` picks it up regardless of theme markup. Visibility per entry is toggled by `vfBuildSectionNav()` after page load — works whether the theme renders sidebar items in `<li>` wrappers (Six) or as bare `<a>` elements (Twenty-One).
|
||||
- **`ServiceSingleSignOnLabel` metadata** is set to "Login to VirtFusion Panel" but the auto-render of this in the WHMCS 9 sidebar is unreliable. The reliable surface is the inline "Login to Control Panel" button in the Server Overview footer, which calls `vfLoginAsServerOwner()` to fetch a one-shot SSO URL via `Module::fetchLoginTokens()` and opens it in a new tab.
|
||||
|
||||
### VNC viewer security model
|
||||
|
||||
- Customer clicks "Open Console" → `window.open('client.php?action=vncViewer&serviceID=X')`.
|
||||
- The route checks `isAuthenticated()` + `validateUserOwnsService()` + `requireProvisionedService()`, then POSTs to `/servers/{id}/vnc {vnc:true}` to rotate the wss token, returning `text/html` with the noVNC shell embedded (hidden `#con` / `#pass` / `#server-name` inputs + `<script src="{vfBaseUrl}/vnc/vnc.js">`). Headers: `X-Frame-Options: DENY`, `Cache-Control: no-store, private`.
|
||||
- The wss URL never appears in any URL the customer can copy or share. Each open rotates the token, so any prior credential exposure is short-lived. Other customers landing on the popup URL get a 403 from the ownership check.
|
||||
- The popup HTML is delivered with a strict CSP (`default-src 'none'`, `script-src` restricted to the WHMCS host + the VirtFusion panel origin, `connect-src` restricted to the VF wss endpoint + panel) and `X-Frame-Options: DENY` so the viewer cannot be re-hosted or embedded.
|
||||
|
||||
### 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
|
||||
- **VNC enable/disable toggle** — Removed in 1.5.0. VirtFusion's POST `/vnc {vnc:false}` only manipulates a firewall flag that's currently broken at the panel level; the wss endpoint accepts WebSocket upgrades regardless. Toggle was misleading. VNC is treated as always-available, gated by WHMCS session + service ownership at our layer.
|
||||
- **Standalone Network panel** — Removed in 1.5.0. Duplicated Server Overview's IP rows. Per-IP copy buttons moved into the Overview cells via `vfRenderIpCells()`.
|
||||
- **Network Speed row** in the Resources panel — Removed in 1.5.0. VirtFusion's `inAverage` / `outAverage` etc. all return 0 in our setup; the row was always empty.
|
||||
|
||||
### Data Flow: Server Creation
|
||||
|
||||
@@ -180,9 +195,13 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
|
||||
## VirtFusion API Compatibility
|
||||
|
||||
- **API reference (OpenAPI spec):** https://docs.virtfusion.com/api/openapi.yaml
|
||||
- **Tested against:** VirtFusion v7.0.0 Build 9 (current production target as of 2026-04-28)
|
||||
- **Base features:** VirtFusion v1.7.3+
|
||||
- **VNC console:** v6.1.0+
|
||||
- **VNC console:** v6.1.0+ — POST `/servers/{id}/vnc` with `{vnc: bool}` (NOT `{enabled: bool}` — the latter is a silent no-op). Response includes `data.vnc.{ip, port, password, wss.{token, url}, enabled}`. The `enabled` field is unreliable in v7.0.0 — it tracks "active session" rather than panel-toggle state, and the wss endpoint accepts WebSocket upgrades regardless of the toggle. Treat VNC as always-available and gate it at the WHMCS-session layer.
|
||||
- **noVNC viewer:** the wss URL (`{baseUrl}/vnc/?token=<uuid>`) is the raw WebSocket endpoint; loading it as HTTP returns 405. The HTML viewer is assembled by the panel: an HTML shell with hidden `#con` (wss URL), `#pass` (VNC password), `#server-name` inputs, plus `<script src="{baseUrl}/vnc/vnc.js">`. The module reproduces this shell server-side via `client.php?action=vncViewer` (see Security model below).
|
||||
- **Resource modification:** v6.2.0+
|
||||
- **Live state introspection:** `?remoteState=true` query param returns `remoteState.{state, cpu, memory.{actual,unused,available,rss}, disk.vda.{rd.bytes,wr.bytes}, agent.fsinfo[]}`. fsinfo only populated when qemu-guest-agent is running on the guest. Heavier than the bare `/servers/{id}` call (libvirt round-trip on the hypervisor).
|
||||
- **Traffic history:** `/servers/{id}/traffic` returns `data.monthly[]` aggregates only — VirtFusion does NOT expose daily granularity. The `monthly[i].{rx, tx, total}` are bytes; `limit` is GB (0 = unmetered). The server fetch's `traffic.public.currentPeriod` only exposes the period window (start/end/limit), NOT the byte counter.
|
||||
- **Self-service billing:** Requires self-service feature enabled in VirtFusion
|
||||
- **OS icon path:** `{baseUrl}/img/logo/{icon_filename}` (public, no auth required)
|
||||
|
||||
@@ -210,7 +229,18 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
|
||||
|
||||
## WHMCS Compatibility
|
||||
|
||||
- WHMCS 8.x+ (tested 8.0–8.10)
|
||||
- PHP 8.0+ with cURL extension
|
||||
- WHMCS 8.x and 9.x supported
|
||||
- **Tested against:** WHMCS 9.0.3 (current production target as of 2026-04-28); broadly compatible with 8.10 and earlier 8.x releases
|
||||
- PHP 8.2+ required for WHMCS 9.x; PHP 8.0+ for WHMCS 8.x (matches the WHMCS minimums for each major)
|
||||
- cURL extension required
|
||||
|
||||
### WHMCS 9 caveats
|
||||
|
||||
- Batch order acceptance terminates as soon as the order leaves Pending status. The module's `AfterModuleCreate` hook defers `localAPI('AcceptOrder')` until every VirtFusionDirect service in the order has a `mod_virtfusion_direct.server_id` row, so multi-VPS orders provision cleanly. WHMCS 8 was not affected (its loop ignored order status mid-batch).
|
||||
- Email template rendering requires a properly-configured Storage backend (System Settings → Storage Settings). If the storage config has no `region` set (common with S3-compatible providers like Storj), template-based emails silently fail before SMTP is even contacted, while custom-message admin emails (e.g. cron activity reports) keep working. Setting any non-empty region value (e.g. `us-east-1`, `auto`, `US1` for Storj) restores templates.
|
||||
|
||||
### Module debug logging
|
||||
|
||||
`Log::insert()` wraps `logModuleCall()`, which only writes to `tblmodulelog` when **Module Debug Logging** is enabled (WHMCS Admin → Utilities → Logs → Module Log → Activate Module Debug Logging). When off, calls succeed silently and nothing lands in the table. Enable it before relying on the log table for diagnostics — or invoke module methods directly via `php -r` from the CLI for one-off testing.
|
||||
- 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
|
||||
|
||||
86
README.md
86
README.md
@@ -37,10 +37,10 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co
|
||||
|
||||
| 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 |
|
||||
| **VirtFusion** | v1.7.3+ | Tested against **v7.0.0 Build 9** (current production target). v6.1.0+ required for VNC console; v6.2.0+ for resource modification. |
|
||||
| **WHMCS** | 8.x or 9.x | Tested against **WHMCS 9.0.3** (current production target); broadly compatible with 8.10 and earlier 8.x releases. |
|
||||
| **PHP** | 8.2+ for WHMCS 9.x; 8.0+ for WHMCS 8.x | With cURL extension enabled. |
|
||||
| **SSL** | Valid certificate | Required on the VirtFusion panel. |
|
||||
|
||||
You also need a VirtFusion API token with the following permissions:
|
||||
- Server management (create, read, update, delete, power, build)
|
||||
@@ -59,17 +59,23 @@ You also need a VirtFusion API token with the following permissions:
|
||||
- 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
|
||||
- **Server Overview** - Real-time server info (hostname, IPs, resources) with status badge, plus location flag, OS template name, and "Created N days ago" lifetime chips
|
||||
- **VNC Console** - Browser-based console access via a popup window. Loads VirtFusion's noVNC viewer through a same-origin authenticated route (`client.php?action=vncViewer`), session-gated and ownership-validated; wss token rotates on every open and never appears in any URL
|
||||
- **Hypervisor Maintenance Banner** - Yellow alert at the very top of the page when the hypervisor is in maintenance, so customers know to expect transient errors
|
||||
- **Traffic Chart** - Last 12 months of bandwidth usage (rx + tx) as side-by-side monthly bars, plus current period used/limit/remaining tile
|
||||
- **Live Stats** - CPU, memory, and disk I/O sourced from VirtFusion's libvirt introspection, auto-refreshing every 30 s while the panel is visible
|
||||
- **Filesystem Usage** - Per-mount usage rows from qemu-guest-agent (when installed on the VM), with progress bars and warning thresholds
|
||||
- **Power Management** - Start, restart, graceful shutdown, and force power off
|
||||
- **Control Panel SSO** - One-click login to VirtFusion panel
|
||||
- **Control Panel SSO** - One-click login to VirtFusion panel from the Server Overview footer
|
||||
- **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
|
||||
- **IP Management** - IPv4 + IPv6 listed inline in the Server Overview cells, each with a per-address copy button
|
||||
- **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)
|
||||
- **Mask Sensitive (Screenshot Mode)** - Toggle in the Server Overview meta bar that masks IPs (keeps subnet visible: `205.186.•••.•••`), IPv6 (keeps prefix), hostnames, and the Server Name + Reverse DNS hostname inputs. Useful for support screenshots and screen-shares; state persists across page refreshes via `sessionStorage`
|
||||
- **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
|
||||
- **In-Page Section Navigation** - "On This Page" group injected into the WHMCS Actions sidebar with smooth-scroll jump-links to every visible panel; auto-hides links for hidden panels (e.g. Live Stats when remoteState is unavailable, Reverse DNS when PowerDNS isn't configured)
|
||||
|
||||
### Admin Area
|
||||
- **Test Connection** - Verify API connectivity from WHMCS
|
||||
@@ -392,11 +398,23 @@ Optional. Activate the `VirtFusionDns` addon module to let the provisioning modu
|
||||
|
||||
### Server Overview
|
||||
Displays real-time server information fetched from VirtFusion:
|
||||
- Server name and hostname
|
||||
- Server name (editable inline) and hostname
|
||||
- **Meta chips:** data-center location with country flag, OS template name (with kernel version on hover when qemu-guest-agent is installed), and "Created N days ago" lifetime tag
|
||||
- **Mask Sensitive toggle** — masks IPs (last two octets for v4, post-prefix for v6), hostnames, and Server Name input via CSS `text-security: disc`. State persists across refreshes via `sessionStorage` for screen-share / screenshot workflows
|
||||
- Memory, CPU cores, storage allocation
|
||||
- IPv4 and IPv6 addresses
|
||||
- Traffic usage vs. allocation
|
||||
- IPv4 and IPv6 addresses, each rendered with its own copy button
|
||||
- Traffic usage vs. allocation (e.g. "0.04 GB / Unmetered" or "234 GB / 6 TB")
|
||||
- Server status badge (Active, Suspended, etc.)
|
||||
- **"Login to Control Panel"** footer button — opens VirtFusion in a new tab via one-shot SSO
|
||||
|
||||
### Hypervisor Maintenance Banner
|
||||
Yellow alert injected at the very top of the page when `hypervisor.maintenance=true` in the VirtFusion API response. Sets the customer's expectation that operations may be temporarily unavailable, reducing support tickets for known maintenance windows.
|
||||
|
||||
### Traffic Chart
|
||||
A canvas chart between Server Overview and Power Management showing the last 12 months of bandwidth usage as side-by-side rx/tx bars. Sourced from VirtFusion's `/servers/{id}/traffic` endpoint (monthly aggregates only — VirtFusion does not expose daily granularity). Tile below the chart shows the current period's used / limit / remaining.
|
||||
|
||||
### Live Stats
|
||||
CPU, memory, and disk I/O sourced from VirtFusion's libvirt introspection (`?remoteState=true`). CPU and memory render as colored progress bars with warning thresholds (75% / 90%). Disk I/O shows cumulative bytes since boot. Auto-refreshes every 30 s while the panel is visible AND the page has focus — pauses on `visibilitychange` so it doesn't hammer libvirt when the customer alt-tabs away.
|
||||
|
||||
### Power Management
|
||||
Four power control buttons:
|
||||
@@ -405,14 +423,19 @@ Four power control buttons:
|
||||
- **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
|
||||
- Single "Open Console" button at the very top of the page (no toggle — see Known Issues for why)
|
||||
- Opens a 1024×768 popup window with VirtFusion's noVNC viewer
|
||||
- The popup is served by `client.php?action=vncViewer` — same-origin, session-required, ownership-validated
|
||||
- The wss token rotates on every open; previously-leaked tokens become invalid
|
||||
- Requires VirtFusion v6.1.0+ and the server must be running
|
||||
- Opens in a new browser window/tab
|
||||
|
||||
### Resources Panel
|
||||
- Memory, CPU, storage, traffic limits with usage bars where applicable
|
||||
- **Filesystem Usage** section (when qemu-guest-agent is installed in the VM) — per-mount progress bars with warning thresholds; pseudo-FS (`proc`, `sysfs`, `/boot`, `/run`, etc.) filtered out
|
||||
|
||||
### In-Page Section Navigation
|
||||
"On This Page" group injected into the WHMCS Actions sidebar via the `ClientAreaPrimarySidebar` hook. Smooth-scrolls to each section on click. Auto-hides links for panels that aren't rendered on this particular service (e.g. Live Stats when no remoteState data, Reverse DNS when PowerDNS isn't configured). Theme-agnostic — works in Six, Twenty-One, Lagom, etc.
|
||||
|
||||
### Server Rebuild
|
||||
- Select from available OS templates (filtered by server package)
|
||||
@@ -533,7 +556,7 @@ WHMCS automatically loads theme-specific templates when they exist. Copy the ori
|
||||
| `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}/vnc` | Rotate VNC credentials (`{vnc:true}`) — called by the secure viewer route on every popup open |
|
||||
| `POST` | `/servers/{id}/resetPassword` | Reset server root password |
|
||||
|
||||
### Advanced
|
||||
@@ -627,8 +650,9 @@ This data appears in the WHMCS client area and admin product details.
|
||||
|
||||
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
|
||||
3. Popup blockers may prevent the console window from opening — allow popups for the WHMCS host
|
||||
4. Module routes the popup through `client.php?action=vncViewer` (same-origin, session-required). If the popup redirects to the WHMCS login page, your client session has expired — log in again and retry
|
||||
5. The VirtFusion panel toggle for VNC enable/disable is currently broken (only changes a firewall flag that doesn't propagate); the module does not surface this toggle. VNC is treated as always-available and gated by WHMCS session + service ownership
|
||||
|
||||
### UsageUpdate Not Syncing
|
||||
|
||||
@@ -639,19 +663,27 @@ This data appears in the WHMCS client area and admin product details.
|
||||
|
||||
## 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.
|
||||
1. **VNC Console** - Requires VirtFusion v6.1.0+. Earlier versions do not expose a VNC API endpoint.
|
||||
|
||||
2. **Resource Modification** - Memory and CPU modification requires VirtFusion v6.2.0+. Traffic modification requires v6.0.0+. Backup management requires v4.3.0+.
|
||||
2. **VirtFusion VNC enable/disable toggle is non-functional** - The VirtFusion panel's VNC toggle currently only manipulates a firewall flag that doesn't actually gate the wss endpoint, and the API's `vnc.enabled` response field tracks "active session" rather than feature state. The module therefore does not expose a toggle; VNC is treated as always-available and access is gated by WHMCS session + service ownership at our layer.
|
||||
|
||||
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".
|
||||
3. **Resource Modification** - Memory and CPU modification requires VirtFusion v6.2.0+. Traffic modification requires v6.0.0+. Backup management requires v4.3.0+.
|
||||
|
||||
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.
|
||||
4. **IPv6 Display** - IPv6 subnet display depends on the VirtFusion installation having IPv6 pools configured. If no IPv6 is assigned, the IPv6 cell shows "-".
|
||||
|
||||
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.
|
||||
5. **Filesystem Usage requires qemu-guest-agent** - The Filesystem Usage section in the Resources panel reads from `remoteState.agent.fsinfo`, which is populated only when `qemu-guest-agent` is installed and running inside the VM. Builds without the agent simply hide the section.
|
||||
|
||||
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.
|
||||
6. **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.
|
||||
|
||||
7. **Concurrent API Calls** - The module makes individual API calls for each feature panel on the client area page. If the VirtFusion API is slow, the page may take longer to fully load. All panels load asynchronously to minimize perceived delay.
|
||||
7. **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.
|
||||
|
||||
8. **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.
|
||||
|
||||
9. **Concurrent API Calls** - The module makes individual API calls for each feature panel on the client area page. The Server Overview fetch now passes `?remoteState=true` so it includes a libvirt round-trip on the hypervisor side. At low volume this is fine; at high concurrency, watch hypervisor CPU.
|
||||
|
||||
10. **Module Debug Logging is off by default** - `tblmodulelog` (visible in **Utilities → Logs → Module Log**) only receives entries when **Module Debug Logging** is enabled at WHMCS Admin → Utilities → Logs → Module Log → Activate Module Debug Logging. Without that toggle, the module's `Log::insert()` calls succeed silently and nothing is recorded. Turn it on before opening a support ticket about a module call so we have logs to inspect.
|
||||
|
||||
11. **Email template attachment storage configuration (WHMCS 9)** - WHMCS 9 added an email-template-attachments asset type that requires a properly-configured Storage backend (System Settings → Storage Settings) — including a non-empty `region` field even for S3-compatible providers like Storj that don't use regions. Misconfiguration causes ALL template-based emails (Order Confirmation, Welcome, etc.) to silently fail before SMTP is contacted, while custom-message admin emails (cron activity reports) keep working. Setting any non-empty region (`us-east-1`, `auto`, `US1`) restores templates. Independent of this module.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -358,12 +358,20 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
||||
foreach ($services as $service) {
|
||||
try {
|
||||
$systemService = Database::getSystemService($service->id);
|
||||
if (! $systemService) {
|
||||
if (! $systemService || empty($systemService->server_id)) {
|
||||
// No VirtFusion server linked to this WHMCS service yet —
|
||||
// either provisioning hasn't happened or it failed mid-create.
|
||||
// Skipping is correct: there is nothing to read usage from.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch server settings (limits + storage profile) with remoteState=true
|
||||
// so the qemu-agent fsinfo block is included for disk usage. The agent
|
||||
// is best-effort — guests without qemu-agent installed will have no
|
||||
// fsinfo, in which case we simply skip the diskused write rather than
|
||||
// zeroing it.
|
||||
$request = $module->initCurl($cp['token']);
|
||||
$data = $request->get($cp['url'] . '/servers/' . (int) $systemService->server_id);
|
||||
$data = $request->get($cp['url'] . '/servers/' . (int) $systemService->server_id . '?remoteState=true');
|
||||
|
||||
if ($request->getRequestInfo('http_code') != 200) {
|
||||
continue;
|
||||
@@ -377,19 +385,43 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
||||
$server = $serverData['data'];
|
||||
$update = [];
|
||||
|
||||
// Disk usage (WHMCS expects MB)
|
||||
if (isset($server['usage']['storage']['used'])) {
|
||||
$update['diskused'] = round($server['usage']['storage']['used'] / 1048576);
|
||||
// Disk usage (WHMCS expects MB) — derived from qemu-agent fsinfo when
|
||||
// available. Sum across all reported filesystems (root + any extra
|
||||
// mounts) and convert bytes -> MB. If the agent isn't running we get
|
||||
// no fsinfo entries and leave diskused untouched.
|
||||
$fsinfo = $server['remoteState']['agent']['fsinfo'] ?? null;
|
||||
if (is_array($fsinfo) && $fsinfo !== []) {
|
||||
$diskUsedBytes = 0;
|
||||
foreach ($fsinfo as $fs) {
|
||||
if (isset($fs['used-bytes']) && is_numeric($fs['used-bytes'])) {
|
||||
$diskUsedBytes += (int) $fs['used-bytes'];
|
||||
}
|
||||
}
|
||||
if ($diskUsedBytes > 0) {
|
||||
$update['diskused'] = (int) round($diskUsedBytes / 1048576);
|
||||
}
|
||||
}
|
||||
if (isset($server['settings']['resources']['storage'])) {
|
||||
// settings.resources.storage is in GB; WHMCS disklimit is MB.
|
||||
$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);
|
||||
// Bandwidth usage (WHMCS expects MB) — fetched from the dedicated
|
||||
// /servers/{id}/traffic endpoint, which is the canonical source for
|
||||
// billing-period totals. The /servers/{id} response only exposes the
|
||||
// current period's window (start/end/limit), not the byte counter.
|
||||
$trafficRequest = $module->initCurl($cp['token']);
|
||||
$trafficData = $trafficRequest->get($cp['url'] . '/servers/' . (int) $systemService->server_id . '/traffic');
|
||||
if ($trafficRequest->getRequestInfo('http_code') == 200) {
|
||||
$trafficJson = json_decode($trafficData, true);
|
||||
$currentPeriod = $trafficJson['data']['monthly'][0] ?? null;
|
||||
if (is_array($currentPeriod) && isset($currentPeriod['total']) && is_numeric($currentPeriod['total'])) {
|
||||
$update['bwused'] = (int) round($currentPeriod['total'] / 1048576);
|
||||
}
|
||||
}
|
||||
if (isset($server['settings']['resources']['traffic'])) {
|
||||
// settings.resources.traffic is in GB; 0 means unlimited, which
|
||||
// WHMCS represents the same way (0 bwlimit = no cap).
|
||||
$trafficGB = (int) $server['settings']['resources']['traffic'];
|
||||
$update['bwlimit'] = $trafficGB > 0 ? $trafficGB * 1024 : 0;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ require dirname(__DIR__, 3) . '/init.php';
|
||||
* 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;
|
||||
@@ -74,6 +75,13 @@ try {
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -82,6 +90,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('resetPassword:' . $serviceID, 30);
|
||||
|
||||
$data = $vf->resetUserPassword($serviceID, $client);
|
||||
|
||||
if ($data) {
|
||||
@@ -104,6 +115,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$data = $vf->fetchServerData($serviceID);
|
||||
|
||||
if ($data) {
|
||||
@@ -127,6 +140,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$token = $vf->fetchLoginTokens($serviceID);
|
||||
|
||||
if ($token) {
|
||||
@@ -142,6 +157,12 @@ try {
|
||||
*/
|
||||
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)) {
|
||||
@@ -149,6 +170,9 @@ try {
|
||||
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'];
|
||||
|
||||
@@ -172,6 +196,13 @@ try {
|
||||
*/
|
||||
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)) {
|
||||
@@ -179,6 +210,9 @@ try {
|
||||
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;
|
||||
|
||||
@@ -202,6 +236,10 @@ try {
|
||||
*/
|
||||
case 'rename':
|
||||
|
||||
// Mutation: anti-CSRF. No rate limit — name changes are cheap.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -209,9 +247,14 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$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 "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;
|
||||
}
|
||||
@@ -238,6 +281,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$templates = $vf->fetchOsTemplates($serviceID);
|
||||
|
||||
if ($templates !== false) {
|
||||
@@ -257,6 +302,11 @@ try {
|
||||
*/
|
||||
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)) {
|
||||
@@ -264,6 +314,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('resetServerPassword:' . $serviceID, 30);
|
||||
|
||||
$result = $vf->resetServerPassword($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -290,6 +343,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getServerBackups($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -316,6 +371,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getTrafficStats($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -342,6 +399,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getVncConsole($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -353,9 +412,36 @@ try {
|
||||
break;
|
||||
|
||||
/**
|
||||
* Toggle VNC on/off.
|
||||
* 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 'toggleVnc':
|
||||
case 'vncViewer':
|
||||
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
@@ -364,6 +450,110 @@ try {
|
||||
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 '<!DOCTYPE html><html><head><title>VNC Console</title></head><body style="font-family:sans-serif;padding:40px;text-align:center;color:#aaa;background:#111;">Unable to obtain VNC credentials. The server may be powered off.</body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Drill the response shape the same way module.js used to —
|
||||
// wrapper.data.vnc holds the credentials; wrapper.baseUrl is
|
||||
// added by Module::toggleVnc / Module::getVncConsole.
|
||||
$apiRoot = isset($vncData['data']) ? $vncData['data'] : $vncData;
|
||||
$vnc = $apiRoot['vnc'] ?? [];
|
||||
$baseUrl = $vncData['baseUrl'] ?? '';
|
||||
$wssPath = $vnc['wss']['url'] ?? '';
|
||||
$password = $vnc['password'] ?? '';
|
||||
|
||||
if ($baseUrl === '' || $wssPath === '') {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html><head><title>VNC Console</title></head><body style="font-family:sans-serif;padding:40px;text-align:center;color:#aaa;background:#111;">VNC credentials missing from the API response.</body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Look up the server name for the popup title (best-effort —
|
||||
// doesn't gate rendering).
|
||||
$serverName = '';
|
||||
|
||||
try {
|
||||
$hosting = Capsule::table('tblhosting')->where('id', $serviceID)->first(['domain']);
|
||||
$serverName = $hosting && $hosting->domain ? (string) $hosting->domain : '';
|
||||
} catch (Throwable $e) { /* non-fatal */
|
||||
}
|
||||
|
||||
$vfHost = preg_replace('~^https?://~', '', rtrim($baseUrl, '/'));
|
||||
$vncJsSrc = $baseUrl . '/vnc/vnc.js';
|
||||
|
||||
$esc = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
// Don't let the page be embedded by other origins or cached
|
||||
// intermediaries — the rotated token must not stick around.
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, private');
|
||||
header('Pragma: no-cache');
|
||||
// CSP — only the VirtFusion panel can serve scripts (vnc.js bundle)
|
||||
// and only the wss endpoint on that host accepts our WebSocket.
|
||||
// Self is needed for the inline script that runs the noVNC bundle.
|
||||
header("Content-Security-Policy: default-src 'none'; script-src 'self' " . $baseUrl . '; connect-src wss://' . $vfHost . ' ' . $baseUrl . "; img-src 'self' data: " . $baseUrl . "; style-src 'self' 'unsafe-inline'; frame-ancestors 'none';");
|
||||
?><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>VNC — <?= $esc($serverName) ?></title>
|
||||
<style>html,body{margin:0;padding:0;background:#000;height:100%;font-family:sans-serif;color:#aaa;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<input type="hidden" id="con" value="wss://<?= $esc($vfHost . $wssPath) ?>">
|
||||
<input type="hidden" id="pass" value="<?= $esc($password) ?>">
|
||||
<input type="hidden" id="server-name" value="<?= $esc($serverName) ?>">
|
||||
<div id="noVNC_container" style="position:fixed;inset:0;"></div>
|
||||
<script src="<?= $esc($vncJsSrc) ?>"></script>
|
||||
</body>
|
||||
</html><?php
|
||||
exit;
|
||||
break;
|
||||
|
||||
/**
|
||||
* Toggle VNC on/off.
|
||||
*
|
||||
* Dead path as of 1.5.0 (UI no longer exposes a toggle — see
|
||||
* VNC notes in CLAUDE.md). Kept for backwards-compat in case any
|
||||
* out-of-tree caller invokes it; gated as if it were live so
|
||||
* leaving it here doesn't widen the attack surface.
|
||||
*/
|
||||
case 'toggleVnc':
|
||||
|
||||
$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('toggleVnc:' . $serviceID, 5);
|
||||
|
||||
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
|
||||
$result = $vf->toggleVnc($serviceID, $enabled);
|
||||
|
||||
@@ -391,6 +581,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getSelfServiceUsage($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -413,6 +605,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getSelfServiceReport($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -428,6 +622,13 @@ try {
|
||||
*/
|
||||
case 'selfServiceAddCredit':
|
||||
|
||||
// Money-affecting mutation: anti-CSRF + 5 s rate limit so a
|
||||
// hostile script can't accidentally trigger duplicate charges
|
||||
// by spamming credit-adds. The actual amount is also validated
|
||||
// and money-bound on the WHMCS side, but defence-in-depth.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -435,6 +636,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('selfServiceAddCredit:' . $serviceID, 5);
|
||||
|
||||
$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);
|
||||
@@ -471,6 +675,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
// Reads are permitted for Active + Suspended (a suspended user can still see their rDNS);
|
||||
// Terminated/Pending/Cancelled/Fraud return a clear 400 upfront.
|
||||
$vf->requireServiceStatus($serviceID, ['Active', 'Suspended']);
|
||||
@@ -511,6 +717,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
// Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS.
|
||||
$vf->requireServiceStatus($serviceID, ['Active']);
|
||||
|
||||
|
||||
@@ -144,16 +144,46 @@ add_hook('AfterModuleCreate', 1, function ($vars) {
|
||||
if ($orderId > 0) {
|
||||
$order = Capsule::table('tblorders')->where('id', $orderId)->first();
|
||||
if ($order && strcasecmp((string) $order->status, 'Pending') === 0) {
|
||||
$resp = localAPI('AcceptOrder', [
|
||||
'orderid' => $orderId,
|
||||
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
||||
'sendemail' => true,
|
||||
]);
|
||||
Log::insert(
|
||||
'AutoAcceptOrder',
|
||||
['orderid' => $orderId, 'serviceid' => $serviceId],
|
||||
$resp,
|
||||
);
|
||||
// WHMCS 9 regression guard: WHMCS 9's batch order-acceptance
|
||||
// loop terminates once the order leaves Pending status.
|
||||
// Calling AcceptOrder after the first sibling completes
|
||||
// therefore short-circuits provisioning of the rest of the
|
||||
// order's services — they end up Active in tblhosting with
|
||||
// no mod_virtfusion_direct row and no server in VirtFusion.
|
||||
// Defer the AcceptOrder until every VF service in this
|
||||
// order has provisioned; the hook fires once per service,
|
||||
// so the last one to complete will see no unprovisioned
|
||||
// siblings and trigger the accept. WHMCS 8 wasn't affected
|
||||
// (its loop ignored order status mid-batch), but deferring
|
||||
// there is harmless — same end state, just later timing.
|
||||
$unprovisionedSiblings = Capsule::table('tblhosting AS h')
|
||||
->join('tblproducts AS p', 'h.packageid', '=', 'p.id')
|
||||
->leftJoin('mod_virtfusion_direct AS m', 'h.id', '=', 'm.service_id')
|
||||
->where('h.orderid', $orderId)
|
||||
->where('h.id', '!=', $serviceId)
|
||||
->where('p.servertype', 'VirtFusionDirect')
|
||||
->where('h.domainstatus', 'Pending')
|
||||
->whereNull('m.server_id')
|
||||
->count();
|
||||
|
||||
if ($unprovisionedSiblings > 0) {
|
||||
Log::insert(
|
||||
'AutoAcceptOrder:deferred',
|
||||
['orderid' => $orderId, 'serviceid' => $serviceId, 'unprovisioned_siblings' => $unprovisionedSiblings],
|
||||
'Order has more VirtFusionDirect services awaiting provisioning; AcceptOrder will fire after the last one',
|
||||
);
|
||||
} else {
|
||||
$resp = localAPI('AcceptOrder', [
|
||||
'orderid' => $orderId,
|
||||
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
||||
'sendemail' => true,
|
||||
]);
|
||||
Log::insert(
|
||||
'AutoAcceptOrder',
|
||||
['orderid' => $orderId, 'serviceid' => $serviceId],
|
||||
$resp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,10 +475,10 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
||||
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 {
|
||||
// No icon (synthetic singletons bucket, etc.) — fall back
|
||||
// to brand-color circle with the first letter, matching
|
||||
// the client-area renderer.
|
||||
catIcon.style.background = catColor;
|
||||
catIcon.textContent = (cat.name || '?')[0].toUpperCase();
|
||||
}
|
||||
@@ -821,3 +851,78 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject a "On This Page" jump-link group into the client area sidebar
|
||||
* when the customer is viewing a VirtFusionDirect product details page.
|
||||
*
|
||||
* Replaces the previous inline horizontal nav strip — sidebar placement
|
||||
* keeps the links visible while scrolling the long product details page.
|
||||
*
|
||||
* Static rendering: every known section anchor is added regardless of
|
||||
* whether its panel is visible. JS (vfBuildSectionNav in module.js) walks
|
||||
* the rendered links post-load and hides the parent <li> for any target
|
||||
* panel that isn't visible (Resources/VNC/Self-Service when their data
|
||||
* hasn't loaded; rDNS when PowerDNS isn't enabled at the template level).
|
||||
*
|
||||
* Filtered to productdetails for VF services so we don't pollute the
|
||||
* sidebar on unrelated pages or non-VF service detail pages.
|
||||
*/
|
||||
add_hook('ClientAreaPrimarySidebar', 1, function ($primarySidebar) {
|
||||
try {
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$serviceId = (int) ($_REQUEST['id'] ?? 0);
|
||||
if ($action !== 'productdetails' || $serviceId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify this is a VirtFusionDirect service before adding our links.
|
||||
$isVf = Capsule::table('tblhosting AS h')
|
||||
->join('tblproducts AS p', 'h.packageid', '=', 'p.id')
|
||||
->where('h.id', $serviceId)
|
||||
->where('p.servertype', 'VirtFusionDirect')
|
||||
->exists();
|
||||
if (! $isVf) {
|
||||
return;
|
||||
}
|
||||
|
||||
// High order pushes us below the standard "Manage Product" entries.
|
||||
$jump = $primarySidebar->addChild('VfJumpTo', [
|
||||
'label' => 'On This Page',
|
||||
'order' => 80,
|
||||
]);
|
||||
|
||||
// VNC deliberately excluded — its panel sits at the very top of
|
||||
// the page, so a sidebar jump-link would just scroll the customer
|
||||
// past everything else they care about. The other entries are
|
||||
// ordered to match the page's vertical flow.
|
||||
$items = [
|
||||
['Overview', 'vf-sec-overview'],
|
||||
['Traffic', 'vf-sec-traffic'],
|
||||
['Live Stats', 'vf-sec-livestats'],
|
||||
['Power', 'vf-sec-power'],
|
||||
['Manage', 'vf-sec-manage'],
|
||||
['Rebuild', 'vf-sec-rebuild'],
|
||||
['Reverse DNS', 'vf-sec-rdns'],
|
||||
['Resources', 'vf-resources-panel'],
|
||||
['Billing & Usage', 'vf-selfservice-panel'],
|
||||
['Billing Overview', 'vf-sec-billing'],
|
||||
];
|
||||
|
||||
foreach ($items as $i => $item) {
|
||||
$child = $jump->addChild('vfsec-' . $item[1], [
|
||||
'label' => $item[0],
|
||||
'uri' => '#' . $item[1],
|
||||
'order' => ($i + 1) * 10,
|
||||
]);
|
||||
// data-vf-target lets the smooth-scroll handler in module.js find
|
||||
// these links generically (same selector covers both inline and
|
||||
// sidebar nav). Class is duplicated for legacy CSS that may key
|
||||
// on .vf-nav-link.
|
||||
$child->setAttribute('data-vf-target', $item[1]);
|
||||
$child->setClass('vf-nav-link');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Silent failure — sidebar customisation must never break the page.
|
||||
}
|
||||
});
|
||||
|
||||
@@ -191,7 +191,21 @@ class Module
|
||||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||||
$data = json_decode($data);
|
||||
if (isset($data->data->authentication->endpoint_complete)) {
|
||||
return $ctx['cp']['base_url'] . $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,14 +288,46 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId']);
|
||||
// ?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 json_decode($data);
|
||||
if ($ctx['request']->getRequestInfo('http_code') != '200') {
|
||||
return false;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
@@ -402,12 +448,17 @@ class Module
|
||||
}
|
||||
}
|
||||
|
||||
// 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']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
|
||||
$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');
|
||||
$success = $httpCode == 200 || $httpCode == 204;
|
||||
// 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
|
||||
@@ -507,19 +558,29 @@ class Module
|
||||
}
|
||||
|
||||
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' => ($catName === 'Other') ? null : ($osCategory['icon'] ?? null),
|
||||
'icon' => $osCategory['icon'] ?? null,
|
||||
'templates' => $catTemplates,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($otherTemplates)) {
|
||||
$categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates];
|
||||
$categories[] = ['name' => 'Other', 'icon' => $otherIcon ?? null, 'templates' => $otherTemplates];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
@@ -631,7 +692,19 @@ class Module
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
if ($ctx['request']->getRequestInfo('http_code') == 200) {
|
||||
return json_decode($data, true);
|
||||
$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;
|
||||
@@ -657,13 +730,25 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['enabled' => (bool) $enabled]));
|
||||
// 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) {
|
||||
return json_decode($data, true) ?: ['success' => true];
|
||||
$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;
|
||||
@@ -1049,6 +1134,39 @@ class Module
|
||||
$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.
|
||||
*
|
||||
@@ -1089,6 +1207,45 @@ class Module
|
||||
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.
|
||||
|
||||
@@ -64,15 +64,23 @@ class ServerResource
|
||||
if ($server['settings']['resources']['traffic'] > 0) {
|
||||
$traffic = $server['settings']['resources']['traffic'] . ' GB';
|
||||
} else {
|
||||
$traffic = 'Unlimited';
|
||||
// limit=0 in VirtFusion means "no cap on this period". We
|
||||
// surface that as "Unmetered" rather than "Unlimited" — limits
|
||||
// exist (the period still rolls over monthly, traffic is still
|
||||
// counted), the customer just isn't billed for overage.
|
||||
$traffic = 'Unmetered';
|
||||
}
|
||||
}
|
||||
|
||||
// trafficUsedBytes is merged onto the response by Module::fetchServerData()
|
||||
// from the dedicated /servers/{id}/traffic endpoint. Reading it directly
|
||||
// (rather than the non-existent server.usage.traffic.used path that we
|
||||
// historically referenced) is what unblocks the "X GB / Unmetered" display
|
||||
// for unmetered plans — there IS usage to show even when there's no cap.
|
||||
$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';
|
||||
if (isset($server['trafficUsedBytes']) && is_numeric($server['trafficUsedBytes'])) {
|
||||
$bytes = (int) $server['trafficUsedBytes'];
|
||||
$trafficUsed = ($bytes > 0 ? round($bytes / 1073741824, 2) : 0) . ' GB';
|
||||
}
|
||||
|
||||
$data = [
|
||||
@@ -94,18 +102,55 @@ class ServerResource
|
||||
'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,
|
||||
'trafficUsedRaw' => isset($server['trafficUsedBytes']) ? round((int) $server['trafficUsedBytes'] / 1073741824, 2) : 0,
|
||||
|
||||
// -- Identity / catalog ---------------------------------------
|
||||
// os.templateName is always present; qemuAgent.os.* only when
|
||||
// qemu-guest-agent is installed and running on the guest. Both
|
||||
// are surfaced; the template chooses which to emphasise.
|
||||
'osName' => $server['os']['templateName'] ?? '-',
|
||||
'osPretty' => $server['qemuAgent']['os']['pretty-name'] ?? null,
|
||||
'osKernel' => $server['qemuAgent']['os']['kernel-release'] ?? null,
|
||||
'osDistro' => $server['qemuAgent']['os']['id'] ?? null,
|
||||
'osIcon' => $server['qemuAgent']['os']['img'] ?? null,
|
||||
|
||||
// -- Data center / hypervisor ---------------------------------
|
||||
'location' => $server['hypervisor']['group']['name'] ?? '-',
|
||||
'locationIcon' => $server['hypervisor']['group']['icon'] ?? null,
|
||||
'hypervisorMaintenance' => (bool) ($server['hypervisor']['maintenance'] ?? false),
|
||||
|
||||
// -- Server lifetime ------------------------------------------
|
||||
'createdAt' => $server['created'] ?? null,
|
||||
'builtAt' => $server['built'] ?? null,
|
||||
|
||||
// -- Live state (requires ?remoteState=true on the upstream call) -
|
||||
// Fields default to null when the live block is absent — happens
|
||||
// when remoteState wasn't requested or the hypervisor couldn't
|
||||
// reach libvirt at fetch time. Templates must isset()-guard each.
|
||||
'live' => [
|
||||
'state' => $server['remoteState']['state'] ?? null,
|
||||
'cpu' => isset($server['remoteState']['cpu']) ? (float) $server['remoteState']['cpu'] : null,
|
||||
// memory.* values are kilobytes (libvirt convention).
|
||||
'memoryActualKB' => isset($server['remoteState']['memory']['actual']) ? (int) $server['remoteState']['memory']['actual'] : null,
|
||||
'memoryUnusedKB' => isset($server['remoteState']['memory']['unused']) ? (int) $server['remoteState']['memory']['unused'] : null,
|
||||
'memoryAvailableKB' => isset($server['remoteState']['memory']['available']) ? (int) $server['remoteState']['memory']['available'] : null,
|
||||
'memoryRssKB' => isset($server['remoteState']['memory']['rss']) ? (int) $server['remoteState']['memory']['rss'] : null,
|
||||
// disk.{drive}.{rd,wr,fl}.{reqs,bytes,times} — surfacing the
|
||||
// primary drive (vda) cumulative byte counters. JS can derive
|
||||
// throughput rates from successive samples.
|
||||
'diskRdBytes' => isset($server['remoteState']['disk']['vda']['rd.bytes']) ? (int) $server['remoteState']['disk']['vda']['rd.bytes'] : null,
|
||||
'diskWrBytes' => isset($server['remoteState']['disk']['vda']['wr.bytes']) ? (int) $server['remoteState']['disk']['vda']['wr.bytes'] : null,
|
||||
// Filesystems: only present when qemu-guest-agent is running
|
||||
// inside the VM. Each entry is normalised to {name, mountpoint,
|
||||
// type, usedBytes, totalBytes}; pseudo-FS (devtmpfs, proc, sys)
|
||||
// are filtered out — only real mounts the customer cares about.
|
||||
'filesystems' => self::extractFilesystems($server['remoteState']['agent']['fsinfo'] ?? null),
|
||||
],
|
||||
];
|
||||
|
||||
if (array_key_exists('network', $server)) {
|
||||
@@ -140,4 +185,67 @@ class ServerResource
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the qemu-guest-agent fsinfo array into customer-facing rows.
|
||||
*
|
||||
* Only "real" filesystems are returned — pseudo-FS like proc/sysfs/devtmpfs
|
||||
* have no meaning in a usage context. Returned entries are sorted with the
|
||||
* root mount first so the most relevant row leads in the UI.
|
||||
*
|
||||
* @param array|null $fsinfo remoteState.agent.fsinfo from the API
|
||||
* @return array List of {name, mountpoint, type, usedBytes, totalBytes}
|
||||
*/
|
||||
private static function extractFilesystems($fsinfo): array
|
||||
{
|
||||
if (! is_array($fsinfo) || $fsinfo === []) {
|
||||
return [];
|
||||
}
|
||||
// Filesystems we never want to show — they're kernel/runtime, not user storage.
|
||||
$skipTypes = ['proc', 'sysfs', 'devtmpfs', 'devpts', 'tmpfs', 'cgroup', 'cgroup2',
|
||||
'pstore', 'bpf', 'mqueue', 'debugfs', 'tracefs', 'securityfs',
|
||||
'configfs', 'fusectl', 'autofs', 'hugetlbfs', 'rpc_pipefs',
|
||||
'binfmt_misc', 'overlay', 'squashfs', 'ramfs', 'fuse.gvfsd-fuse',
|
||||
'efivarfs', 'selinuxfs'];
|
||||
$rows = [];
|
||||
foreach ($fsinfo as $fs) {
|
||||
if (! is_array($fs)) {
|
||||
continue;
|
||||
}
|
||||
$type = $fs['type'] ?? '';
|
||||
if (in_array($type, $skipTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
$mount = $fs['mountpoint'] ?? '';
|
||||
// Skip /boot* and /run* — useful in monitoring tools but noisy on
|
||||
// a customer-facing dashboard. Customers care about the root and
|
||||
// any data mounts.
|
||||
if ($mount === '/boot' || str_starts_with($mount, '/boot/')) {
|
||||
continue;
|
||||
}
|
||||
if ($mount === '/run' || str_starts_with($mount, '/run/')) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'name' => (string) ($fs['name'] ?? '-'),
|
||||
'mountpoint' => (string) $mount,
|
||||
'type' => (string) $type,
|
||||
'usedBytes' => isset($fs['used-bytes']) ? (int) $fs['used-bytes'] : 0,
|
||||
'totalBytes' => isset($fs['total-bytes']) ? (int) $fs['total-bytes'] : 0,
|
||||
];
|
||||
}
|
||||
// Root mount first; everything else by mountpoint alphabetical.
|
||||
usort($rows, function ($a, $b) {
|
||||
if ($a['mountpoint'] === '/') {
|
||||
return -1;
|
||||
}
|
||||
if ($b['mountpoint'] === '/') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return strcmp($a['mountpoint'], $b['mountpoint']);
|
||||
});
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,3 +574,205 @@
|
||||
.vf-rdns-subnet-form { padding-left: 0; }
|
||||
.vf-rdns-subnet-inputs { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ---------------- In-page Section Nav ---------------- */
|
||||
.vf-section-nav-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.vf-section-nav-body::before {
|
||||
content: "Jump to:";
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-right: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vf-nav-link {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #d6d8db;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.vf-nav-link:hover,
|
||||
.vf-nav-link:focus {
|
||||
background: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.vf-section-nav-body::before { display: block; width: 100%; margin-bottom: 4px; }
|
||||
}
|
||||
|
||||
/* ---------------- Server Overview meta bar ---------------- */
|
||||
.vf-overview-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e6e8eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vf-meta-chip {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #d6d8db;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vf-meta-chip-muted {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
.vf-mask-ips-btn {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.vf-mask-ips-btn { margin-left: 0; width: 100%; }
|
||||
}
|
||||
|
||||
/* ---------------- Live Stats panel ---------------- */
|
||||
.vf-live-bar {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
background: #e9ecef;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.vf-live-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
transition: width 0.5s ease, background 0.3s ease;
|
||||
}
|
||||
.vf-live-bar-fill.bg-warning {
|
||||
background: linear-gradient(90deg, #ffc107, #fd7e14);
|
||||
}
|
||||
.vf-live-bar-fill.bg-danger {
|
||||
background: linear-gradient(90deg, #dc3545, #c82333);
|
||||
}
|
||||
.vf-livestats-updated {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* ---------------- Filesystem rows ---------------- */
|
||||
.vf-fs-row .progress {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.vf-fs-row .progress-bar {
|
||||
background-color: #337ab7;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.vf-fs-row .progress-bar.bg-warning { background-color: #ffc107 !important; }
|
||||
.vf-fs-row .progress-bar.bg-danger { background-color: #dc3545 !important; }
|
||||
|
||||
/* ---------------- Layout: side-by-side panel grid ---------------- */
|
||||
/*
|
||||
* Used to lay out compact panels (Traffic + Live Stats) side-by-side on
|
||||
* wide screens. CSS Grid with auto-fit handles the case where one panel is
|
||||
* display:none (e.g. Live Stats hidden when remoteState is unavailable) —
|
||||
* the visible panel fills the row.
|
||||
*/
|
||||
.vf-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.vf-panel-grid > .panel,
|
||||
.vf-panel-grid > .card {
|
||||
margin-bottom: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Server Overview rename row ---------------- */
|
||||
/*
|
||||
* Was previously a single flex row that squished the Save button on
|
||||
* narrower viewports. Wrap-on-overflow + min-widths keep buttons readable
|
||||
* regardless of cell width.
|
||||
*/
|
||||
.vf-rename-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.vf-rename-input-field {
|
||||
flex: 1 1 160px;
|
||||
min-width: 140px;
|
||||
max-width: 240px;
|
||||
}
|
||||
.vf-rename-btn-randomise,
|
||||
.vf-rename-btn-save {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
min-width: 38px;
|
||||
}
|
||||
.vf-rename-btn-save {
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* ---------------- IP cell rows (Server Overview) ---------------- */
|
||||
/*
|
||||
* Each IPv4/IPv6 address renders as a compact row: address span + copy
|
||||
* button. Replaces the standalone Network panel; the per-address copy
|
||||
* affordance moved here.
|
||||
*/
|
||||
.vf-ip-cell-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vf-ip-cell-row .vf-ip-address {
|
||||
word-break: break-all;
|
||||
}
|
||||
.vf-ip-cell-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Sensitive-input masking ---------------- */
|
||||
/*
|
||||
* Companion to the JS-based IP text masking. When body.vf-mask-active is
|
||||
* set, render the value of any input.vf-sensitive as discs so the actual
|
||||
* characters don't leak into a screenshot. Hover/focus restores the real
|
||||
* value for editing — the customer can still see what they're typing.
|
||||
*
|
||||
* `text-security` is widely supported under the -webkit- prefix (Chrome,
|
||||
* Edge, Safari) and as the unprefixed property in modern Firefox. Falls
|
||||
* through to `-webkit-text-security: disc` everywhere else; if a browser
|
||||
* truly doesn't honour it the screenshot mask just isn't applied to the
|
||||
* input field — the IP cells still mask, so the customer's worst case is
|
||||
* an unmasked rDNS hostname (failsafe-soft, not security-critical).
|
||||
*/
|
||||
body.vf-mask-active input.vf-sensitive {
|
||||
-webkit-text-security: disc;
|
||||
text-security: disc;
|
||||
font-family: text-security-disc, sans-serif;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
body.vf-mask-active input.vf-sensitive:focus,
|
||||
body.vf-mask-active input.vf-sensitive:hover {
|
||||
-webkit-text-security: none;
|
||||
text-security: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,39 @@
|
||||
|
||||
{if $serviceStatus eq 'Active'}
|
||||
|
||||
{* Hypervisor maintenance banner — populated by vfServerData. Hidden by
|
||||
default; surfaces only when hypervisor.maintenance=true so the customer
|
||||
knows operations may be unavailable. *}
|
||||
<div id="vf-maintenance-banner" class="alert alert-warning mb-3" style="display:none;">
|
||||
<strong>Hypervisor maintenance.</strong>
|
||||
Your server's hypervisor is currently in maintenance. Some operations may be temporarily unavailable.
|
||||
</div>
|
||||
|
||||
{* VNC Console — placed at the very top so it's the first action the
|
||||
customer reaches. No toggle (VirtFusion's VNC enable/disable was a
|
||||
broken firewall flag), no IP/port/password panel — just the button.
|
||||
Click → noVNC popup. *}
|
||||
<div id="vf-vnc-panel" class="panel card panel-default mb-2">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">VNC Console</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
||||
<p class="mb-3">Access your server's console directly in your browser. The server must be running for VNC access.</p>
|
||||
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary d-flex align-items-center">
|
||||
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||
Open Console
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Section navigation moved to the WHMCS Actions sidebar via the
|
||||
ClientAreaPrimarySidebar hook in hooks.php. The sidebar version stays
|
||||
visible while scrolling, which the inline strip never could. JS still
|
||||
walks the rendered links and hides ones whose target panels are hidden. *}
|
||||
|
||||
{* Server Overview Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-overview" class="panel card panel-default mb-2" data-vf-nav-label="Overview">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">
|
||||
Server Overview
|
||||
@@ -39,6 +70,20 @@
|
||||
<div id="vf-server-info-error">
|
||||
<div class="alert alert-warning mb-0">Information unavailable. Try again later.</div>
|
||||
</div>
|
||||
|
||||
{* Top meta bar — populated by JS once server data loads. Holds the
|
||||
data-center chip (flag + city), OS chip, lifetime chip, and the
|
||||
Mask IPs toggle. The toggle stays visible on every overview load
|
||||
regardless of which other chips have data. *}
|
||||
<div id="vf-overview-meta" class="vf-overview-meta mb-3" style="display:none;">
|
||||
<span id="vf-data-location" class="vf-meta-chip" style="display:none;"></span>
|
||||
<span id="vf-data-os" class="vf-meta-chip" style="display:none;"></span>
|
||||
<span id="vf-data-created" class="vf-meta-chip vf-meta-chip-muted" style="display:none;"></span>
|
||||
<button id="vf-mask-ips-btn" type="button" class="btn btn-sm btn-outline-secondary vf-mask-ips-btn" onclick="vfToggleIpMask()" title="Hide IPs and rDNS hostnames for screenshots">
|
||||
<span id="vf-mask-ips-label">Mask Sensitive</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="vf-server-info" class="row mb-2">
|
||||
<div class="col-12">
|
||||
<div class="row">
|
||||
@@ -46,10 +91,10 @@
|
||||
<div class="row p-1">
|
||||
<div class="col-xs-4 col-4 text-right vf-bold">Name:</div>
|
||||
<div class="col-xs-8 col-8">
|
||||
<div class="d-flex" style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="text" id="vf-rename-input" class="form-control form-control-sm" maxlength="63" style="max-width:200px;" placeholder="Server name">
|
||||
<button id="vf-randomise-btn" onclick="vfShowNameDropdown('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-outline-secondary" title="Randomise">↻</button>
|
||||
<button id="vf-rename-save" onclick="vfRenameServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-primary">Save</button>
|
||||
<div class="vf-rename-row">
|
||||
<input type="text" id="vf-rename-input" class="form-control form-control-sm vf-rename-input-field vf-sensitive" maxlength="63" placeholder="Server name">
|
||||
<button id="vf-randomise-btn" onclick="vfShowNameDropdown('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-outline-secondary vf-rename-btn-randomise" title="Randomise">↻</button>
|
||||
<button id="vf-rename-save" onclick="vfRenameServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-primary vf-rename-btn-save">Save</button>
|
||||
</div>
|
||||
<div id="vf-name-dropdown" style="display:none;"></div>
|
||||
<div id="vf-rename-alert" class="mt-1" style="display:none;"></div>
|
||||
@@ -57,7 +102,7 @@
|
||||
</div>
|
||||
<div class="row p-1">
|
||||
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
|
||||
<div class="col-xs-8 col-8" id="vf-data-server-hostname"></div>
|
||||
<div class="col-xs-8 col-8 vf-sensitive" id="vf-data-server-hostname"></div>
|
||||
</div>
|
||||
<div class="row p-1">
|
||||
<div class="col-xs-4 col-4 text-right vf-bold">Memory:</div>
|
||||
@@ -93,11 +138,99 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Server Overview footer — Login to Control Panel SSO. Was briefly
|
||||
moved to the WHMCS Actions sidebar via _CustomActions, but the
|
||||
sidebar dispatch path didn't carry the SSO redirect through cleanly
|
||||
in this WHMCS 9 install. Inline button is reliable: vfLoginAsServerOwner
|
||||
opens a new tab and navigates it to the upstream SSO URL fetched
|
||||
via fetchLoginTokens. *}
|
||||
<div id="vf-overview-footer" class="vf-overview-footer mt-3 pt-3" style="border-top:1px solid #e6e8eb;">
|
||||
<div id="vf-login-error" class="alert alert-danger" style="display:none;"></div>
|
||||
<button id="vf-login-button" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',true)" type="button" class="btn btn-primary d-flex align-items-center">
|
||||
<span id="vf-login-button-spinner" class="spinner-border spinner-border-sm text-light vf-spinner-margin" style="display:none;"></span>
|
||||
Login to Control Panel
|
||||
</button>
|
||||
<p class="mb-0 mt-2 vf-small text-muted">Opens VirtFusion in a new tab. Trouble? <a href="#" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',false); return false;">Open in this tab instead</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Traffic Panel — last N months of monthly aggregates from VF. Renders
|
||||
full-width (own row) — side-by-side with Live Stats was tested and felt
|
||||
too cramped. *}
|
||||
<div id="vf-sec-traffic" class="panel card panel-default mb-2" style="display:none;" data-vf-nav-label="Traffic">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Traffic</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-traffic-chart-section">
|
||||
<canvas id="vf-traffic-chart" style="width:100%; height:240px;"></canvas>
|
||||
<div class="row mt-3 text-center">
|
||||
<div class="col-4"><small class="text-muted">This Period Used</small><div id="vf-traffic-used" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Period Limit</small><div id="vf-traffic-limit" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Remaining</small><div id="vf-traffic-remaining" class="vf-bold">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
if (typeof vfLoadTrafficStats === 'function') {
|
||||
vfLoadTrafficStats('{$serviceid}', '{$systemURL}');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Live Stats Panel — CPU, memory, disk I/O sourced from VirtFusion's
|
||||
?remoteState=true introspection (libvirt + qemu-agent). Hidden by default;
|
||||
surfaces only when the upstream call returns a remoteState block. Auto-
|
||||
refreshes every 30s; refresh stops when the panel scrolls out of view to
|
||||
keep hypervisor load proportional to actual customer attention. *}
|
||||
<div id="vf-sec-livestats" class="panel card panel-default mb-2" style="display:none;" data-vf-nav-label="Live Stats">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">
|
||||
Live Stats
|
||||
<small class="text-muted vf-livestats-updated" id="vf-live-updated" style="float:right; font-size:11px; font-weight:normal;"></small>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="vf-bold mb-2">CPU</div>
|
||||
<div class="vf-live-gauge">
|
||||
<div class="vf-live-bar"><div id="vf-live-cpu-bar" class="vf-live-bar-fill" style="width:0%;"></div></div>
|
||||
<div class="d-flex justify-content-between vf-small mt-1">
|
||||
<span id="vf-live-cpu-pct">-</span>
|
||||
<span class="text-muted">load</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="vf-bold mb-2">Memory</div>
|
||||
<div class="vf-live-gauge">
|
||||
<div class="vf-live-bar"><div id="vf-live-mem-bar" class="vf-live-bar-fill" style="width:0%;"></div></div>
|
||||
<div class="d-flex justify-content-between vf-small mt-1">
|
||||
<span id="vf-live-mem-text">-</span>
|
||||
<span id="vf-live-mem-pct" class="text-muted">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="vf-bold mb-2">Disk I/O <small class="text-muted">(since boot)</small></div>
|
||||
<div class="d-flex justify-content-between vf-small">
|
||||
<span class="text-muted">Read</span>
|
||||
<span id="vf-live-disk-rd">-</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between vf-small mt-1">
|
||||
<span class="text-muted">Write</span>
|
||||
<span id="vf-live-disk-wr">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Power Management Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-power" class="panel card panel-default mb-2" data-vf-nav-label="Power">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Power Management</h3>
|
||||
</div>
|
||||
@@ -129,23 +262,16 @@
|
||||
</div>
|
||||
|
||||
{* Manage Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-manage" class="panel card panel-default mb-2" data-vf-nav-label="Manage">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Manage</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="vf-login-error" class="alert alert-danger"></div>
|
||||
<p>Manage your server via our dedicated control panel. You will be automatically authenticated and the control panel will open in a new window.</p>
|
||||
<button id="vf-login-button" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',true)" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
|
||||
<div id="vf-login-button-spinner" class="spinner-border spinner-border-sm text-light vf-spinner-margin"></div>
|
||||
Open Control Panel
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p class="mb-0 pt-3 vf-small">Having trouble opening the control panel in a new window? <a href="#" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',false); return false;">Click here</a> to open in this window.</p>
|
||||
</div>
|
||||
{* Inline "Open Control Panel" button removed — WHMCS already
|
||||
surfaces this in the Actions sidebar via the module's
|
||||
ServiceSingleSignOnLabel ("Login to VirtFusion Panel").
|
||||
Keeping both was a duplicate. *}
|
||||
{if $serverHostname}
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
@@ -188,7 +314,7 @@
|
||||
</div>
|
||||
|
||||
{* Rebuild Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-rebuild" class="panel card panel-default mb-2" data-vf-nav-label="Rebuild">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Rebuild Server</h3>
|
||||
</div>
|
||||
@@ -215,31 +341,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Network Management Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Network</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-network-alert" class="alert" style="display: none;"></div>
|
||||
<div id="vf-network-content" style="display: none;">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h5 class="vf-bold">IPv4 Addresses</h5>
|
||||
<div id="vf-ipv4-list" class="mb-2"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5 class="vf-bold">IPv6 Subnets</h5>
|
||||
<div id="vf-ipv6-list" class="mb-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{* The standalone Network panel was removed — its IP list duplicated the
|
||||
Server Overview's IPv4/IPv6 rows. The unique value (per-IP copy buttons)
|
||||
was folded into the Overview cells via vfRenderIpCells in module.js. *}
|
||||
|
||||
{if $rdnsEnabled}
|
||||
{* Reverse DNS Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-rdns" class="panel card panel-default mb-2" data-vf-nav-label="Reverse DNS">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Reverse DNS</h3>
|
||||
</div>
|
||||
@@ -260,7 +368,7 @@
|
||||
{/if}
|
||||
|
||||
{* Resources Panel — populated by JS after server data loads *}
|
||||
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div id="vf-resources-panel" class="panel card panel-default mb-2" style="display: none;" data-vf-nav-label="Resources">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Resources</h3>
|
||||
</div>
|
||||
@@ -296,77 +404,34 @@
|
||||
<div id="vf-res-traffic-bar" class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vf-resource-item mb-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="vf-bold">Network Speed</span>
|
||||
<span id="vf-res-network-speed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vf-traffic-chart-section" style="display:none;">
|
||||
{* Note: dedicated Traffic panel near the top of the page (vf-sec-traffic)
|
||||
handles the chart + period tile. Resources panel here just lists the
|
||||
configured limits — no chart duplication. Network speed row was
|
||||
removed: VirtFusion's API returns 0 for inAverage/inPeak/inBurst
|
||||
when speed isn't capped at the package level, which is the
|
||||
common case for our setup — there's nothing useful to show. *}
|
||||
|
||||
{* Filesystem usage — only renders when qemu-guest-agent is running on
|
||||
the guest. vfRenderFilesystems() shows or hides the section based
|
||||
on whether remoteState.agent.fsinfo came back populated. *}
|
||||
<div id="vf-fs-section" class="mt-4" style="display:none;">
|
||||
<hr>
|
||||
<h5 class="vf-bold mb-2">Traffic Usage</h5>
|
||||
<canvas id="vf-traffic-chart" style="width:100%; height:200px;"></canvas>
|
||||
<div class="row mt-2 text-center">
|
||||
<div class="col-4"><small class="text-muted">Used</small><div id="vf-traffic-used" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Limit</small><div id="vf-traffic-limit" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Remaining</small><div id="vf-traffic-remaining" class="vf-bold">-</div></div>
|
||||
</div>
|
||||
<h5 class="vf-bold mb-3">Filesystem Usage</h5>
|
||||
<div id="vf-fs-container"></div>
|
||||
<p class="vf-small text-muted mt-2 mb-0">Reported by qemu-guest-agent inside the VM. Install <code>qemu-guest-agent</code> if no filesystems show.</p>
|
||||
</div>
|
||||
<script>
|
||||
if (typeof vfLoadTrafficStats === 'function') {
|
||||
vfLoadTrafficStats('{$serviceid}', '{$systemURL}');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* VNC Console Panel — hidden by default, shown by JS if VNC is enabled *}
|
||||
<div id="vf-vnc-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">VNC Console</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
||||
<p>Access your server's console directly in your browser. The server must be running for VNC access.</p>
|
||||
<div class="d-flex align-items-center mb-3" style="display:flex; gap:12px; align-items:center;">
|
||||
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
|
||||
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||
Open Console
|
||||
</button>
|
||||
<label class="vf-toggle-label mb-0" style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||
<input type="checkbox" id="vf-vnc-toggle" class="vf-toggle-input" onchange="vfToggleVnc('{$serviceid}','{$systemURL}', this.checked)">
|
||||
<span class="vf-toggle-switch"></span>
|
||||
<span class="vf-small">VNC Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="vf-vnc-details" style="display:none;">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="row p-1">
|
||||
<div class="col-4 text-right vf-bold vf-small">IP:</div>
|
||||
<div class="col-8 vf-small" id="vf-vnc-ip">-</div>
|
||||
</div>
|
||||
<div class="row p-1">
|
||||
<div class="col-4 text-right vf-bold vf-small">Port:</div>
|
||||
<div class="col-8 vf-small" id="vf-vnc-port">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="vfCopyVncPassword('{$serviceid}','{$systemURL}')">
|
||||
Copy VNC Password
|
||||
</button>
|
||||
<span id="vf-vnc-copy-confirm" class="text-success vf-small" style="display:none;">Copied!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{* VNC panel relocated to the very top of the page (above Server Overview).
|
||||
See its definition there. This block is intentionally left as a comment
|
||||
marker so future readers know where the panel used to live. *}
|
||||
|
||||
{* Self Service — Billing & Usage Panel *}
|
||||
{if $selfServiceMode > 0}
|
||||
<div id="vf-selfservice-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div id="vf-selfservice-panel" class="panel card panel-default mb-2" style="display: none;" data-vf-nav-label="Billing & Usage">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Billing & Usage</h3>
|
||||
</div>
|
||||
@@ -416,7 +481,7 @@
|
||||
|
||||
{elseif $serviceStatus eq 'Suspended'}
|
||||
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div class="panel card panel-default mb-2">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Service Suspended</h3>
|
||||
</div>
|
||||
@@ -430,7 +495,7 @@
|
||||
{/if}
|
||||
|
||||
{* Billing Overview - Always visible *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-billing" class="panel card panel-default mb-2" data-vf-nav-label="Billing Overview">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Billing Overview</h3>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user