13 Commits

8 changed files with 387 additions and 247 deletions

View File

@@ -1,22 +0,0 @@
---
name: Lint Code Base
on: [ workflow_call ]
jobs:
build:
name: Lint Code Base
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
statuses: write
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Lint Code Base
uses: super-linter/super-linter@v5
env:
VALIDATE_ALL_CODEBASE: false
DEFAULT_BRANCH: main
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@@ -5,11 +5,8 @@ on:
branches: branches:
- main - main
jobs: jobs:
linter:
uses: ./.github/workflows/linter.yml
publish-release: publish-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: linter
steps: steps:
- name: Publish Release - name: Publish Release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1

View File

@@ -1,33 +1,90 @@
[![GitHub Super-Linter](https://github.com/EZSCALE/virtfusion-whmcs-module/actions/workflows/linter/badge.svg)](https://github.com/marketplace/actions/super-linter) # VirtFusion Direct Provisioning Module for WHMCS
[![GitHub Super-Linter](https://github.com/EZSCALE/virtfusion-whmcs-module/actions/workflows/publish-release.yml/badge.svg)](https://github.com/EZSCALE/virtfusion-whmcs-module/actions)
![GitHub](https://img.shields.io/github/license/EZSCALE/virtfusion-whmcs-module) ![GitHub](https://img.shields.io/github/license/EZSCALE/virtfusion-whmcs-module)
![GitHub issues](https://img.shields.io/github/issues/EZSCALE/virtfusion-whmcs-module) ![GitHub issues](https://img.shields.io/github/issues/EZSCALE/virtfusion-whmcs-module)
![GitHub pull requests](https://img.shields.io/github/issues-pr/EZSCALE/virtfusion-whmcs-module) ![GitHub pull requests](https://img.shields.io/github/issues-pr/EZSCALE/virtfusion-whmcs-module)
# VirtFusion Direct Provisioning Module for WHMCS This module requires VirtFusion v1.7.3 or higher as this is what it's based on. Please refer to the
official [documentation](https://docs.virtfusion.com/integrations/whmcs).
This module requires VirtFusion v1.7.3 or higher as this is what it's based on. Please refer to the official [documenataion](https://docs.virtfusion.com/integrations/whmcs).
## Installation ## Installation
1. Download the latest release from the [releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases) page. 1. Download the latest release from the [releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases) page.
2. Extract the contents of the archive and upload the modules folder to your WHMCS installation directory. 2. Extract the contents of the archive and upload the modules folder to your WHMCS installation directory.
## Configuration ## :heavy_exclamation_mark: Important Notes :heavy_exclamation_mark:
You'll need to configure the following constraints in your `configuration.php` file. You must create two custom fields in WHMCS for this module to work. You need to configure the following custom fields on
This is only temporary and will be replaced with pulling the token from the database in the future. each product you want to use this module with.
```php | Field Name | Field Type | Description | Validation | Select Options | Admin Only | Required Field | Show on Order Form | Show on Invoice |
// VirtFusion API URL |--------------------------|------------|--------------------------|-------------|----------------|------------|----------------|--------------------|-----------------|
const VIRTFUSION_API_URL = "https://your-virtfusion-url.com/api/v1"; | Initial Operating System | Text Box | Set to whatever you want | Leave Blank | Leave Blank | :x: | :x: | :white_check_mark: | :x: |
| Initial SSH Key | Text Box | Set to whatever you want | Leave Blank | Leave Blank | :x: | :x: | :white_check_mark: | :x: |
// VirtFusion API Token You can run this SQL query to create the custom fields:
const VIRT_TOKEN = "your-virtfusion-token";
```sql
-- Insert records for Initial Operating System if they don't already exist
INSERT INTO tblcustomfields
(type, relid, fieldname, fieldtype, description, fieldoptions, regexpr, adminonly, required, showorder, showinvoice,
sortorder, created_at, updated_at)
SELECT 'product',
id,
'Initial Operating System',
'text',
'',
'',
'',
'',
'',
'on',
'',
0,
UTC_TIMESTAMP(),
UTC_TIMESTAMP()
FROM tblproducts
WHERE servertype = 'VirtFusionDirect'
AND NOT EXISTS (SELECT 1
FROM tblcustomfields
WHERE fieldname = 'Initial Operating System'
AND relid = tblproducts.id);
-- Insert records for Initial SSH Key if they don't already exist
INSERT INTO tblcustomfields
(type, relid, fieldname, fieldtype, description, fieldoptions, regexpr, adminonly, required, showorder, showinvoice,
sortorder, created_at, updated_at)
SELECT 'product',
id,
'Initial SSH Key',
'text',
'',
'',
'',
'',
'',
'on',
'',
0,
UTC_TIMESTAMP(),
UTC_TIMESTAMP()
FROM tblproducts
WHERE servertype = 'VirtFusionDirect'
AND NOT EXISTS (SELECT 1
FROM tblcustomfields
WHERE fieldname = 'Initial SSH Key'
AND relid = tblproducts.id);
``` ```
## What does this module change? ## What does this module change?
This module changes the following things: This module changes the following things:
- Adds configurable options to the product configuration page to allow the user to select the operating system and add - Adds configurable options to the product configuration page to allow the user to select the operating system and add
a ssh key to the initial deployment. an ssh key to the initial deployment.
## TODO
- [ ] Add post checkout checks to ensure the user has selected an operating system and added a ssh key.

View File

@@ -83,9 +83,16 @@ function VirtFusionDirect_updateServerObject(array $params)
return (new ModuleFunctions())->updateServerObject($params); return (new ModuleFunctions())->updateServerObject($params);
} }
/**
* Allows changing of the package of a server
*
* @author https://github.com/BlinkohHost/virtfusion-whmcs-module
* @param array $params
* @return string
*/
function VirtFusionDirect_ChangePackage(array $params) function VirtFusionDirect_ChangePackage(array $params)
{ {
return 'success'; return (new ModuleFunctions())->changePackage($params);
} }
function VirtFusionDirect_AdminServicesTabFields(array $params) function VirtFusionDirect_AdminServicesTabFields(array $params)

View File

@@ -1,232 +1,123 @@
<?php <?php
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
use WHMCS\User\User; use WHMCS\User\User;
if (!defined("WHMCS")) { if (!defined("WHMCS")) {
die("This file cannot be accessed directly"); die("This file cannot be accessed directly");
} }
/** add_hook('ClientAreaFooterOutput', 1, function ($vars) {
* You'll need to configure the following constrants in your configuration.php file. if (!isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
* This is only temporary and will be replaced with pulling the token from the database in the future.
*
* const VIRTFUSION_API_URL = "https://your-virtfusion-url.com/api/v1";
*
* You can create a token in the VirtFusion control panel under System > API.
*
* const VIRT_TOKEN = "your-virtfusion-token";
*/
/**
* If the constants are not defined, return null to prevent errors.
*/
if (!defined("VIRTFUSION_API_URL") || !defined("VIRT_TOKEN")) {
return null;
}
if (!function_exists('fetchPackageId')) {
/**
* @param string $packageName
* @return int|null
* @throws JsonException
*/
function fetchPackageId(string $packageName): ?int
{
$url = sprintf("%s/packages", VIRTFUSION_API_URL);
$packages = makeRequest($url);
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
return $package['id'];
}
}
return null; return null;
} }
}
if (!function_exists('fetchTemplates')) { $cs = new ConfigureService();
/**
* @param int $serverPackageId
* @return array|null
* @throws JsonException
*/
function fetchTemplates(int $serverPackageId): ?array
{
$url = sprintf("%s/media/templates/fromServerPackageSpec/%d", VIRTFUSION_API_URL, $serverPackageId);
return makeRequest($url); $templates_data = $cs->fetchTemplates(
$cs->fetchPackageByDbId($vars['productinfo']['pid']) ?? $cs->fetchPackageId($vars['productinfo']['name'])
);
if (empty($templates_data)) {
return null;
} }
}
if (!function_exists('custom_os_templates_hook')) { $dropdownOptions = [];
/**
* @param array $vars
* @return array|null[]
*/
function custom_os_templates_hook(array $vars): array
{
try {
$serverPackageId = fetchPackageId($vars['productinfo']['name']); // Replace with the appropriate server package ID
if ($serverPackageId === null) { foreach ($templates_data['data'] as $osCategory) {
return [ foreach ($osCategory['templates'] as $template) {
'templates' => null, $optionValue = $template['id'];
]; $optionLabel = $template['name'] . " " . $template['version'] . " " . $template['variant'];
} $dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
$templates = fetchTemplates($serverPackageId);
// Assign the generated dropdown menu to a Smarty template variable
return [
'templates' => $templates,
];
} catch (JsonException $e) {
return [
'templates' => null,
];
} }
} }
}
if (!function_exists('get_vf_user_details')) { // Sort dropdownOptions alphabetically by the 'name' key
/** usort($dropdownOptions, function ($a, $b) {
* @param int $id return strcmp($a['name'], $b['name']);
* @return array });
* @throws JsonException
*/
function get_vf_user_details(int $id): ?array {
$url = sprintf("%s/users/%s/byExtRelation", VIRTFUSION_API_URL, $id);
$response = makeRequest($url);
if (isset($response['msg']) && $response['msg'] === "ext_relation_id not found") { $sshKeys = $cs->getUserSshKeys($vars['loggedinuser']);
$sshKeysOptions = array_map(function ($sshKey) {
if ($sshKey['enabled'] === false) {
return null; return null;
} }
return $response['data'];
}
}
if (!function_exists('makeRequest')) {
/**
* @param string $url
* @return mixed
* @throws JsonException
* @throws Exception
*/
function makeRequest(string $url): array
{
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array(
sprintf("Authorization: Bearer %s", VIRT_TOKEN)
)
));
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
throw new Exception("cURL Error: " . $err);
}
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}
if (!function_exists('get_users_ssh_keys')) {
/**
* @throws JsonException
*/
function get_users_ssh_keys(?User $user): ?array {
if (is_null($user)) {
return null;
}
$vfUser = get_vf_user_details($user['id']);
$url = sprintf("%s/ssh_keys/user/%s", VIRTFUSION_API_URL, $vfUser['id']);
$response = makeRequest($url);
return $response;
}
}
if (!function_exists('add_hook_os_templates')) {
/**
* @param array $vars
* @return array|null
* @throws JsonException
*/
function add_hook_os_templates(array $vars): ?array
{
if (!isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
return null;
}
$templates_data = custom_os_templates_hook($vars)['templates'];
if (empty($templates_data)) {
return null;
}
$dropdownOptions = [];
foreach ($templates_data['data'] as $osCategory) {
foreach ($osCategory['templates'] as $template) {
$optionValue = $template['id'];
$optionLabel = $template['name'] . " " . $template['version'] . " " . $template['variant'];
$dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
}
}
// Sort dropdownOptions alphabetically by the 'name' key
usort($dropdownOptions, function ($a, $b) {
return strcmp($a['name'], $b['name']);
});
$osTemplates = [
'id' => 'os_template',
'optionname' => 'Initial Operating System',
'optiontype' => 1,
'options' => $dropdownOptions,
'selectedvalue' => ''
];
$sshKeys = get_users_ssh_keys($vars['loggedinuser']);
$sshKeysOptions = [
'id' => 'ssh_key',
'optionname' => 'Initial SSH Key',
'optiontype' => 1,
'options' => array_map(function ($sshKey) {
if ($sshKey['enabled'] === false) {
return null;
}
return [
'id' => $sshKey['id'],
'name' => $sshKey['name']
];
}, $sshKeys['data'] ?? []),
'selectedvalue' => ''
];
$configurableoptions = $vars['configurableoptions'];
array_push(
$configurableoptions,
$osTemplates,
$sshKeysOptions
);
return [ return [
'configurableoptions' => $configurableoptions, 'id' => $sshKey['id'],
'name' => $sshKey['name']
]; ];
} }, $sshKeys['data'] ?? []);
add_hook('ClientAreaPageCart', 1, 'add_hook_os_templates'); $osID = array_values(array_filter(array_map(function ($option) {
} if ($option['textid'] === 'initialoperatingsystem') {
return $option['id'];
}
}, $vars['customfields'])));
$sshID = array_values(array_filter(array_map(function ($option) {
if ($option['textid'] === 'initialsshkey') {
return $option['id'];
}
}, $vars['customfields'])));
// Construct the JavaScript code
return "
<script>
document.addEventListener('DOMContentLoaded', function() {
var osTemplates = " . json_encode($dropdownOptions) . ";
var sshKeys = " . json_encode($sshKeysOptions) . ";
var osInputField = document.querySelector('[name=\"customfield[" . ($osID[0] ?? null) . "]\"]');
var sshInputField = document.querySelector('[name=\"customfield[" . ($sshID[0] ?? null) . "]\"]');
// Create dropdown options menu, then add it to the DOM then on change, update the regular input.
var osSelect = document.createElement('select');
osSelect.className = 'form-control';
osTemplates.forEach(function(template) {
var option = document.createElement('option');
option.value = template.id;
option.text = template.name;
osSelect.appendChild(option);
});
// Set the default value of the input field to the first option in the dropdown.
osInputField.value = osSelect.options[0].value;
osSelect.addEventListener('change', function() {
osInputField.value = this.value;
console.log(this.value);
});
osInputField.parentNode.insertBefore(osSelect, osInputField.nextSibling);
osInputField.style.display = 'none';
if (sshKeys.length > 0) {
// Create dropdown options menu, then add it to the DOM then on change, update the regular input.
var sshSelect = document.createElement('select');
sshSelect.className = 'form-control';
sshKeys.forEach(function(sshkey) {
var option = document.createElement('option');
option.value = sshkey.id;
option.text = sshkey.name;
sshSelect.appendChild(option);
});
// Set the default value of the input field to the first option in the dropdown.
sshInputField.value = sshSelect.options[0].value;
sshSelect.addEventListener('change', function() {
sshInputField.value = this.value;
});
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling);
sshInputField.style.display = 'none';
} else {
sshInputField.style.display = 'none';
}
});
</script>
";
});

View File

@@ -0,0 +1,153 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect;
use JsonException;
use WHMCS\Database\Capsule as DB;
use WHMCS\User\User;
class ConfigureService extends Module
{
/**
* @var array|false $cp
*/
private array|bool $cp;
public function __construct()
{
parent::__construct();
$this->cp = $this->getCP(false, true);
}
/**
* @param string $packageName
* @return int|null
* @throws JsonException
*/
public function fetchPackageId(string $packageName): ?int
{
$request = $this->initCurl($this->cp['token']);
$response = $request->get(
sprintf("%s/packages", $this->cp['url'])
);
$packages = $this->decodeResponseFromJson($response);
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
return $package['id'];
}
}
return null;
}
/**
* @param int $productId
* @return int|null
*/
public function fetchPackageByDbId(int $productId): ?int
{
$product = DB::table('tblproducts')->where('id', $productId)->first();
if (is_null($product)) {
return null;
}
return (int)$product->configoption2;
}
/**
* @param int $serverPackageId
* @return array|null
* @throws JsonException
*/
public function fetchTemplates(?int $serverPackageId): ?array
{
if (is_null($serverPackageId)) {
return null;
}
$request = $this->initCurl($this->cp['token']);
$response = $request->get(
sprintf("%s/media/templates/fromServerPackageSpec/%d", $this->cp['url'], $serverPackageId)
);
return $this->decodeResponseFromJson($response);
}
/**
* @param User|null $user
* @return array|null
* @throws JsonException
*/
public function getUserSshKeys(?User $user): ?array
{
if (is_null($user)) {
return null;
}
$request = $this->initCurl($this->cp['token']);
$vfUser = $this->getVFUserDetails($user['id']);
$response = $request->get(
sprintf("%s/ssh_keys/user/%d", $this->cp['url'], $vfUser['id'])
);
return $this->decodeResponseFromJson($response);
}
/**
* @param int $id
* @return array|null
* @throws JsonException
*/
public function getVFUserDetails(int $id): ?array
{
$request = $this->initCurl($this->cp['token']);
$response = $this->decodeResponseFromJson($request->get(
sprintf("%s/users/%d/byExtRelation", $this->cp['url'], $id)
));
return isset($response['msg']) && $response['msg'] === "ext_relation_id not found" ? null : $response['data'];
}
/**
* @param int $id
* @param array $vars
* @return bool
*/
public function initServerBuild(int $id, array $vars): bool
{
$request = $this->initCurl($this->cp['token']);
// Generate a random 8 character hostname
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
$inputData = [
"operatingSystemId" => $vars['customfields']['Initial Operating System'],
"name" => $hostname,
"sshKeys" => [
$vars['customfields']['Initial SSH Key']
],
'email' => true
];
if (empty($vars['customfields']['Initial SSH Key'])) {
unset($inputData['sshKeys']);
}
$request->addOption(CURLOPT_POSTFIELDS, json_encode($inputData));
$request->post(
sprintf("%s/servers/%d/build", $this->cp['url'], $id)
);
return true;
}
}

View File

@@ -232,4 +232,17 @@ class Module
return $curl; return $curl;
} }
/**
* Decodes a response from JSON into an associative array.
*
* @param string $response
*
* @return array
* @throws \JsonException
*/
public function decodeResponseFromJson(string $response): array
{
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
} }

View File

@@ -163,6 +163,10 @@ class ModuleFunctions extends Module
Database::systemOnServerCreate($params['serviceid'], $data); Database::systemOnServerCreate($params['serviceid'], $data);
$this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data); $this->updateWhmcsServiceParamsOnServerObject($params['serviceid'], $data);
// If the server is created successfully, we can initialize the server build.
$cs = new ConfigureService();
$cs->initServerBuild($data->data->id, $params);
/** /**
* *
* Server was created successfully. * Server was created successfully.
@@ -181,6 +185,46 @@ class ModuleFunctions extends Module
} }
} }
// This function was implemented by Zander Scott / awildboop of Blinkoh, LLC
// Please read this function thoroughly before use to ensure security & integrity
/**
* Allows changing of the package of a server
*
* @author https://github.com/BlinkohHost/virtfusion-whmcs-module
* @param $params
* @return string
*/
public function changePackage($params)
{
$service = Database::getSystemService($params['serviceid']);
if ($service) {
$whmcsService = Database::getWhmcsService($params['serviceid']);
$cp = $this->getCP($whmcsService->server);
$request = $this->initCurl($cp['token']);
$data = $request->put($cp['url'] . '/servers/' . $service->server_id . '/package/' . $params['configoption2']);
$data = json_decode($data);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
switch ($request->getRequestInfo('http_code')) {
case 204:
return 'success';
case 404:
return '404 was returned from the web service without the msg property. The service may be currently unavailable.';
case 423:
if (property_exists($data, 'msg')) {
return $data->msg;
}
default:
return 'Update package request failed. The web service reported HTTP code ' . $request->getRequestInfo('http_code');
}
}
return 'Service not found.';
}
/** /**
* *
* TERMINATE SERVER * TERMINATE SERVER