diff --git a/.github/workflows/api-sync-check.yml b/.github/workflows/api-sync-check.yml
new file mode 100644
index 0000000..cd0a851
--- /dev/null
+++ b/.github/workflows/api-sync-check.yml
@@ -0,0 +1,65 @@
+name: VirtFusion API Change Detection
+
+on:
+ schedule:
+ - cron: '0 9 * * 1' # Monday 9am UTC
+ workflow_dispatch:
+
+jobs:
+ check-api:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Download current API spec
+ run: curl -sSL -o /tmp/openapi-current.yaml https://docs.virtfusion.com/api/openapi.yaml
+
+ - name: Compare with baseline
+ id: diff
+ run: |
+ if [ ! -f docs/openapi-baseline.yaml ]; then
+ echo "No baseline found — creating initial baseline"
+ cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml
+ echo "changed=initial" >> "$GITHUB_OUTPUT"
+ elif ! diff -q docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /dev/null 2>&1; then
+ echo "API spec has changed"
+ diff docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /tmp/api-diff.txt || true
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "No changes detected"
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Create issue on change
+ if: steps.diff.outputs.changed == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const diff = fs.readFileSync('/tmp/api-diff.txt', 'utf8').substring(0, 60000);
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `VirtFusion API spec changed (${new Date().toISOString().split('T')[0]})`,
+ body: `The VirtFusion OpenAPI spec has been updated.\n\nDiff
\n\n\`\`\`diff\n${diff}\n\`\`\`\n \n\nReview the changes and update the module if needed.`,
+ labels: ['api-sync']
+ });
+
+ - name: Update baseline and create PR
+ if: steps.diff.outputs.changed == 'true' || steps.diff.outputs.changed == 'initial'
+ run: |
+ cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ BRANCH="api-sync/$(date +%Y-%m-%d)"
+ git checkout -b "$BRANCH"
+ git add docs/openapi-baseline.yaml
+ git commit -m "chore: update VirtFusion API baseline spec"
+ git push origin "$BRANCH"
+ gh pr create --title "chore: update VirtFusion API baseline" --body "Automated update of the VirtFusion OpenAPI baseline spec." --base main
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index df0e616..51edfb1 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -33,6 +33,17 @@ jobs:
# GITHUB_TOKEN is required for authentication
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Generate cache busting version hashes
+ run: |
+ CSS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/css/module.css | cut -c1-8)
+ JS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/js/module.js | cut -c1-8)
+ echo "{\"css\":\"$CSS_HASH\",\"js\":\"$JS_HASH\"}" > modules/servers/VirtFusionDirect/templates/version.json
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add modules/servers/VirtFusionDirect/templates/version.json
+ git diff --cached --quiet || git commit -m "chore: update asset version hashes [skip ci]"
+ git push || true
+
# To make this work, you must follow the Conventional Commits specification.
# Examples:
# - fix: correct a typo in the documentation
diff --git a/.gitignore b/.gitignore
index 57f1cb2..1320326 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-/.idea/
\ No newline at end of file
+/.idea/
+/.superpowers/
\ No newline at end of file
diff --git a/docs/openapi-baseline.yaml b/docs/openapi-baseline.yaml
new file mode 100644
index 0000000..de4aaf3
--- /dev/null
+++ b/docs/openapi-baseline.yaml
@@ -0,0 +1,7 @@
+# VirtFusion OpenAPI Baseline
+# This file will be auto-populated by the api-sync-check workflow
+# on first run. Do not edit manually.
+openapi: "3.0.0"
+info:
+ title: VirtFusion API Baseline Placeholder
+ version: "0.0.0"
diff --git a/modules/servers/VirtFusionDirect/client.php b/modules/servers/VirtFusionDirect/client.php
index c081b53..224aeff 100644
--- a/modules/servers/VirtFusionDirect/client.php
+++ b/modules/servers/VirtFusionDirect/client.php
@@ -23,12 +23,14 @@ switch ($action) {
if (!$client) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$data = $vf->resetUserPassword($serviceID, $client);
if ($data) {
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
@@ -43,6 +45,7 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$data = $vf->fetchServerData($serviceID);
@@ -50,6 +53,7 @@ switch ($action) {
if ($data) {
(new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
@@ -64,12 +68,14 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$token = $vf->fetchLoginTokens($serviceID);
if ($token) {
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
@@ -84,19 +90,22 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
- $powerAction = isset($_GET['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_GET['powerAction']) : '';
+ $powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
if (!in_array($powerAction, $allowedActions, true)) {
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
+ break;
}
$result = $vf->serverPowerAction($serviceID, $powerAction);
if ($result) {
$vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500);
@@ -111,19 +120,22 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
- $osId = isset($_GET['osId']) ? (int) $_GET['osId'] : 0;
- $hostname = isset($_GET['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_GET['hostname']) : null;
+ $osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
+ $hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
if ($osId <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
+ break;
}
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Server rebuild failed. The server may be locked or unavailable.'], true, true, 500);
@@ -138,19 +150,21 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
- $newName = isset($_GET['name']) ? trim($_GET['name']) : '';
- $newName = htmlspecialchars($newName, ENT_QUOTES, 'UTF-8');
+ $newName = isset($_POST['name']) ? trim($_POST['name']) : '';
- if (empty($newName) || strlen($newName) > 255) {
+ if (empty($newName) || strlen($newName) > 63 || !preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) {
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
+ break;
}
$result = $vf->renameServer($serviceID, $newName);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
@@ -165,44 +179,95 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$templates = $vf->fetchOsTemplates($serviceID);
if ($templates !== false) {
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
break;
// =================================================================
- // IP Address Management
+ // Server Password Reset
// =================================================================
/**
- * Remove an IPv4 address.
+ * Reset server root password.
*/
- case 'removeIPv4':
+ case 'resetServerPassword':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
- $ipAddress = isset($_GET['ip']) ? trim($_GET['ip']) : '';
- if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
- $vf->output(['success' => false, 'errors' => 'Invalid IPv4 address'], true, true, 400);
+ $result = $vf->resetServerPassword($serviceID);
+
+ if ($result !== false) {
+ $vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
}
- $result = $vf->removeIPv4($serviceID, $ipAddress);
+ $vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
+ break;
- if ($result) {
- $vf->output(['success' => true, 'data' => ['message' => 'IPv4 address removed successfully']], true, true, 200);
+ // =================================================================
+ // Backup Listing
+ // =================================================================
+
+ /**
+ * Get server backups.
+ */
+ case 'backups':
+
+ $serviceID = $vf->validateServiceID(true);
+
+ if (!$vf->validateUserOwnsService($serviceID)) {
+ $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
- $vf->output(['success' => false, 'errors' => 'Failed to remove IPv4 address'], true, true, 500);
+ $result = $vf->getServerBackups($serviceID);
+
+ if ($result !== false) {
+ $vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
+ }
+
+ $vf->output(['success' => false, 'errors' => 'Unable to retrieve backups'], true, true, 500);
+ break;
+
+ // =================================================================
+ // Traffic Statistics
+ // =================================================================
+
+ /**
+ * Get traffic statistics for a server.
+ */
+ case 'trafficStats':
+
+ $serviceID = $vf->validateServiceID(true);
+
+ if (!$vf->validateUserOwnsService($serviceID)) {
+ $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
+ }
+
+ $result = $vf->getTrafficStats($serviceID);
+
+ if ($result !== false) {
+ $vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
+ }
+
+ $vf->output(['success' => false, 'errors' => 'Unable to retrieve traffic statistics'], true, true, 500);
break;
// =================================================================
@@ -218,17 +283,42 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$result = $vf->getVncConsole($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
break;
+ /**
+ * Toggle VNC on/off.
+ */
+ case 'toggleVnc':
+
+ $serviceID = $vf->validateServiceID(true);
+
+ if (!$vf->validateUserOwnsService($serviceID)) {
+ $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
+ }
+
+ $enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
+ $result = $vf->toggleVnc($serviceID, $enabled);
+
+ if ($result !== false) {
+ $vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
+ }
+
+ $vf->output(['success' => false, 'errors' => 'Failed to toggle VNC'], true, true, 500);
+ break;
+
// =================================================================
// Self Service — Credit & Usage
// =================================================================
@@ -242,12 +332,14 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$result = $vf->getSelfServiceUsage($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500);
@@ -262,12 +354,14 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
$result = $vf->getSelfServiceReport($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
@@ -282,17 +376,20 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
+ break;
}
- $tokens = isset($_GET['tokens']) ? (float) $_GET['tokens'] : 0;
+ $tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0;
if ($tokens <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
+ break;
}
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
+ break;
}
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
diff --git a/modules/servers/VirtFusionDirect/hooks.php b/modules/servers/VirtFusionDirect/hooks.php
index 70be304..5faf246 100644
--- a/modules/servers/VirtFusionDirect/hooks.php
+++ b/modules/servers/VirtFusionDirect/hooks.php
@@ -86,19 +86,46 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
return null;
}
- $dropdownOptions = [];
-
- foreach ($templates_data['data'] as $osCategory) {
- foreach ($osCategory['templates'] as $template) {
- $optionValue = $template['id'];
- $optionLabel = htmlspecialchars($template['name'] . " " . $template['version'] . " " . $template['variant'], ENT_QUOTES, 'UTF-8');
- $dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
- }
+ $baseUrl = '';
+ $firstServer = \WHMCS\Database\Capsule::table('tblservers')
+ ->where('type', 'VirtFusionDirect')
+ ->where('disabled', 0)
+ ->first();
+ if ($firstServer) {
+ $baseUrl = rtrim('https://' . $firstServer->hostname, '/');
}
- usort($dropdownOptions, function ($a, $b) {
- return strcmp($a['name'], $b['name']);
- });
+ $categories = [];
+ $otherTemplates = [];
+
+ foreach ($templates_data['data'] as $osCategory) {
+ $catTemplates = [];
+ foreach ($osCategory['templates'] as $template) {
+ $catTemplates[] = [
+ 'id' => $template['id'],
+ 'name' => htmlspecialchars($template['name'], ENT_QUOTES, 'UTF-8'),
+ 'version' => htmlspecialchars($template['version'] ?? '', ENT_QUOTES, 'UTF-8'),
+ 'variant' => htmlspecialchars($template['variant'] ?? '', ENT_QUOTES, 'UTF-8'),
+ 'icon' => $template['icon'] ?? null,
+ 'eol' => $template['eol'] ?? false,
+ 'description' => htmlspecialchars($template['description'] ?? '', ENT_QUOTES, 'UTF-8'),
+ ];
+ }
+ if (count($catTemplates) <= 1) {
+ $otherTemplates = array_merge($otherTemplates, $catTemplates);
+ } else {
+ $categories[] = [
+ 'name' => htmlspecialchars($osCategory['name'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'),
+ 'icon' => $osCategory['icon'] ?? null,
+ 'templates' => $catTemplates,
+ ];
+ }
+ }
+ if (!empty($otherTemplates)) {
+ $categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates];
+ }
+
+ $galleryData = ['baseUrl' => $baseUrl, 'categories' => $categories];
$sshKeys = [];
$sshKeysOptions = [];
@@ -139,10 +166,11 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
$systemUrl = Database::getSystemUrl();
return "
+
+
+
{if $serviceStatus eq 'Active'}
@@ -12,9 +12,27 @@
+
-
@@ -27,7 +45,15 @@
Hostname:
@@ -136,6 +162,27 @@
{/if}
+
+
+
+
Reset the server's root password. The new password will be copied to your clipboard automatically.
+
+
+
+
+
Backups
+
+
+
+
+
@@ -150,16 +197,16 @@
Warning: Rebuilding your server will erase all data on the server and reinstall the operating system. This action cannot be undone.
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -246,10 +308,37 @@
Access your server's console directly in your browser. The server must be running for VNC access.
-
+
+
+
+
+
+
+
+
+
+ Copied!
+
+
+
diff --git a/scripts/generate-endpoint-doc.sh b/scripts/generate-endpoint-doc.sh
new file mode 100755
index 0000000..a57ae22
--- /dev/null
+++ b/scripts/generate-endpoint-doc.sh
@@ -0,0 +1,33 @@
+#!/bin/bash
+# Generate API endpoint documentation from PHP source files
+# Usage: bash scripts/generate-endpoint-doc.sh > docs/API-ENDPOINTS.md
+
+MODULE_DIR="modules/servers/VirtFusionDirect"
+
+echo "# VirtFusion WHMCS Module — API Endpoints"
+echo ""
+echo "Auto-generated from source code. Do not edit manually."
+echo ""
+echo "| Endpoint Pattern | HTTP Method | PHP File | Function |"
+echo "|---|---|---|---|"
+
+# Extract API URL patterns from PHP files
+grep -rn "->get\|->post\|->put\|->patch\|->delete" "$MODULE_DIR/lib/" 2>/dev/null | \
+ grep -oP "(?<=>)(get|post|put|patch|delete)\(.*?'[^']*'" | \
+ while IFS= read -r line; do
+ method=$(echo "$line" | grep -oP "^(get|post|put|patch|delete)" | tr '[:lower:]' '[:upper:]')
+ url=$(echo "$line" | grep -oP "'[^']*'" | tr -d "'")
+ echo "| \`$url\` | $method | - | - |"
+ done
+
+echo ""
+echo "## Client Endpoints (client.php)"
+echo ""
+echo "| Action | Description |"
+echo "|---|---|"
+
+grep -n "case '" "$MODULE_DIR/client.php" 2>/dev/null | \
+ while IFS= read -r line; do
+ action=$(echo "$line" | grep -oP "case '\K[^']+")
+ echo "| \`$action\` | - |"
+ done