Writing a Server Module

8 views Markdown

A server module turns a paid order into a real account on a hosting panel or a cloud provider. The core calls your lifecycle methods by name; what you return is written back onto the service.

Overview

Servers is the largest module type: 50 modules, three of them sandbox archetypes for shared hosting panels, dedicated machines and virtualization.

Your class extends ServerModule, which brings in ModuleBaseTrait. Between them they already hold the server, the service, the product, the buyer, the resolved limits, the addon answers and the tool machinery.

The core never constructs your class: it resolves an instance through the factory and calls the verb by name. A verb you did not implement is skipped, and that is the opt-in mechanism of the whole type.

Services::run_module() The single door: builds the instance, resolves aliases, runs the method, applies the result.
Services::instance_module() Picks the module type from the service, then calls the factory and fills the service and the order. Hosting and server go to Servers.
Modules::getInstance() The canonical factory: config, language, instance cache. new is never used for a module.
ModuleQueue The retrying background runner: same door, then the status the action implies.

Prerequisites

  • Module Anatomy and Module Configuration first; this article covers only the Servers type.
  • A provider account with API access and a server record that passes the connection test.
  • A test product bound to that server, otherwise create() has no plan to read.
  • Failure is reported by throwing, not by returning false.

Structure

One directory per module, named exactly like the class inside it.

layout
coremio/modules/Servers/Acme/
├── Acme.php            the class: extends ServerModule
├── ApiClient.php       your HTTP wrapper, plain new, not a WISECP module
├── config.php          metadata, server form fields, supported cards and tools
├── logo.png            shown in the module picker
├── lang/en.php         $this->lang, one file per language
└── pages/              optional templates rendered with get_page()
class chain
namespace WISECP\Modules\Servers;

use Exception;
use Language;
use ServerModule;

class Acme extends ServerModule
{
    private ApiClient $api;

    // Called by set_server(), which the constructor and set_service() both run,
    // once $this->server is filled and its secrets are decoded.
    protected function define_server_info(array $server = []): void
    {
        include_once __DIR__ . DS . 'ApiClient.php';
        $this->api = new ApiClient($server);
    }
}
SurfaceEntry point in your classReached from
Server settings formconfig.php fields, then test_connect()Admin, Servers, Manage Server
Product settingsproduct_configuration(), save_product_configuration()Product detail, Module tab
Provisioningcreate(), suspend(), unsuspend(), cancel()Order approval, admin actions, the queue
Client area dashboarddashboard_data()Service detail in the client area
Client area toolstool_data(), tool_action()The tool sidebar, see the tools article
Metered billingmetrics_usage() or metrics_usage_bulk()The usage collection cron
Importlist()Admin, import accounts from the panel

Walkthrough

Write the Layers in This Order

Lifecycle first is wasted work. create() reads the buyer's choices out of the service options, and those options exist only once the product form is defined.

  1. define_server_info(), then test_connect(): add the server and prove the credentials.
  2. product_configuration() and save_product_configuration(), plus the callable that fills the plan dropdown.
  3. create(), then suspend(), unsuspend(), cancel().
  4. change_password() and upgrade().
  5. list(), dashboard_data(), the single sign on methods, metrics.
  6. configure_features() and the tools.

Verify each layer against the live provider before moving on: a first failure at the end has six possible causes.

Connecting to the Provider

Credentials come from the server record. config.php decides which fields the form shows, and the base class hands them to you decoded.

config.php
return [
    'name'                => 'Acme Cloud',

    // 'hosting' = domain based accounts, 'server' = VPS or dedicated machines.
    'type'                => 'hosting',

    // A long API token belongs in the access hash field, not in the password field:
    // servers.password is a varchar and an encrypted long token overflows it.
    'use-access-hash'     => true,
    'require-access-hash' => true,

    'use-test-connection' => true,
    'use-port'            => true,
    'not-secure-port'     => 2082,
    'secure-port'         => 2083,

    // Extra fields land in $server['fields']. 'crypt' stores the value encrypted.
    'fields' => [
        'region'    => ['type' => 'text', 'name' => '{lang.region}', 'col_class' => 'col-md-6'],
        'sub_token' => ['type' => 'password', 'name' => '{lang.sub_token}', 'crypt' => true],
    ],

    // Which service option identifies the account on the provider side.
    'service-relationship' => 'domain',
];
The password is already decrypted

The base class round trips $server['password'] in one place, so it always reaches define_server_info() in plain text. Decrypting it again gives the API a mangled secret.

Product Fields and the Plan Dropdown

product_configuration() returns a field descriptor map. What the admin picks is saved as the product's module data, and it reaches your provisioning code as $this->options['creation_info'].

Make the option value the plan name, not the provider's numeric id, and resolve it to an id inside create().

The Lifecycle Body

Every provisioning verb has the same three beats: read what the buyer chose, call the provider, return what to store. Returning an array is how you persist.

  1. Read the plan from creation_info, the limits through get_limit(), the addon answers from addon_params, the order form answers from requirement_params.
  2. Call the provider. Let the client throw on an API error, or throw yourself with a translated message.
  3. Return ['config' => [...]]; the core merges it into the service options and every later verb reads the account back from there.

Reference

Lifecycle Signatures

None is declared abstract on the base class. The core probes each with method_exists() and skips what is absent, so the signature is the contract.

exact signatures
// Setup. define_server_info runs from set_server(), before any other verb.
protected function define_server_info(array $server = []): void;
public function test_connect(): array|bool;
public function configure_features(): void;

// Product and service forms. Both save methods take the values BY REFERENCE.
public function product_configuration(array $data = []): array;
public function save_product_configuration(array &$values): void;
public function service_configuration(): array;
public function save_service_configuration(array &$values): void;

// Provisioning.
public function create(): array|bool;
public function suspend(): bool;
public function unsuspend(): bool;
public function cancel(): bool;
public function renew(): bool;
public function upgrade(array $new_product = []): bool;
public function change_password(string $password): bool;
public function change_limits(array $limits): bool;
public function reset_limits(): array|bool;

// Addons. $addon is one row of the service's addon table.
public function addon_create(array $addon = []): array|bool;
public function addon_suspend(array $addon = []): array|bool;
public function addon_unsuspend(array $addon = []): array|bool;
public function addon_cancel(array $addon = []): array|bool;
public function addon_upgrade(array $addon, array $new_addon): array|bool;

// Client area.
public function dashboard_data(): array;
public function tool_data(string $tool, string $action = 'index', array $params = []): array;
public function tool_action(string $tool, string $action, array $data = []): array;
public function sso_panel_login(): string;
public function sso_root_panel_login(): string;

// Metered billing and import.
public function metrics_usage(): array;
public function metrics_usage_bulk(array $services = []): array;
public function metric_enable(array $metric): void;
public function metric_disable(array $metric): void;
public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int;
MethodRequiredCalled whenStatus afterwards
define_server_infoyesEvery instantiationnone
test_connectyesTest Connection on the server formnone
product_configurationyesProduct detail, Module tabnone
createyesOrder approved, or Recreate in adminactive
suspendyesOverdue invoice, or manual suspendsuspended
unsuspendyesPayment received, or manual unsuspendactive
cancelyesCancellation processedcancelled
change_passwordyesAccount password changedunchanged
upgradeyesPlan change on the same moduleunchanged
renewoptionalRenewal invoice paidunchanged
change_limits, reset_limitsoptionalProvider allows per account limit overridesunchanged
addon_*optionalAn addon on the service changes stateaddon only
listoptionalAdmin opens Import Accounts; implementing it makes that screen appearnone
metric_enable, metric_disableoptionalMetered billing with provider side limit overridesnone

What create() May Return

Return true for a bare success, or an array. Every key is an instruction; the reserved ones are merged into the service options recursively.

config Merged under config: the account identity, so user, the encrypted password, home_dir, the provider side id.
login Merged under login. Credentials the client area shows or the single sign on uses.
creation_info Merged under creation_info, the same bag the product form writes into. Record what was actually provisioned.
options Merged into the option root; the escape hatch for anything else.
status Consumed by the queue and never stored. 'inprocess' or 'waiting' keeps the service out of active state while asynchronous provisioning finishes.
any other key Written to the option root as is: hostname, ip, ftp_info.

The State You Already Have

All of it is filled before your method runs. Do not query for any of it.

$this->server ip, hostname, username, the decrypted password, the access hash and fields from your config.
$this->service, $this->product, $this->user, $this->order Plain arrays: the service row, its product, the buyer, the order.
$this->options and save_options() A live array. Mutate it and call save_options() to persist mid method; returning an array does the same at the end.
get_limit(string $key): mixed One resolved limit, service level overriding product level. Keys: disk_limit, bandwidth_limit, email_limit, database_limit, addons_limit, subdomain_limit, ftp_limit, park_limit, max_email_per_hour.
$this->addon_params, $this->addon_params_by_id Merged totals of the active addons, and the same split per addon row id. Keys from addon-params.
$this->requirement_params The buyer's order form answers, keyed by requirement-params.
encode_str(), decode_str() Encrypt before storing a secret, decrypt before sending it. Never store a panel password in clear.
username_generator(string|int|null $domain): string Static. Derives a panel safe username from the domain; an empty domain yields an empty one, which the provider rejects.

The Core Side of the Call

signature
public static function run_module(int|array $service, string $action, array $params = []): mixed;
public static function instance_module(int|array $service): ?object;
real calls from the core
Services::run_module($id, 'create');                       // no arguments
Services::run_module($id, 'change_password', [$password]); // one positional string
Services::run_module($service, 'upgrade', [$newProduct]);  // the new product array
Services::run_module($service, 'addon_create', [$addon]);  // one addon row
returns null Your class has no such method. The queue records "module method not found" and the action fails.
returns false The module refused; the queue marks the item failed and retries.
terminate resolves to cancel If terminate() is missing the core tries cancel(), then cancelled().
gate:service.module_action Runs before your method. A listener returning a non empty string vetoes the action with that message.
filter:service.module_result Runs on the result before it is applied, by reference, so a listener can rewrite what is stored.
action:service.module_ran Fires after the result is applied, with the service, the instance, the action, the result and the error.

Example

A complete provisioning method and the core code that reads the return value back. The keys you return are the keys the core merges.

the module
public function create(): array|bool
{
    $domain = $this->options['domain'] ?? '';
    if (!$domain) throw new Exception($this->lang['error-domain-required']);

    // Re-provision: reuse the identity from the previous run instead of minting a new one.
    $username = $this->options['config']['user'] ?? '';
    if (!$username) $username = self::username_generator($domain);

    $password = ($this->options['config']['password'] ?? '') !== ''
        ? $this->decode_str($this->options['config']['password'])
        : Utility::generate_hash(12);

    $creation = $this->options['creation_info'] ?? [];

    $parameters = [
        'username'  => $username,
        'password'  => $password,
        'domain'    => $domain,

        // Option values carry the plan NAME, so resolve it on this server.
        'plan_id'   => $this->resolve_plan_id($creation['plan'] ?? ''),

        'disk'      => $this->get_limit('disk_limit'),
        'bandwidth' => $this->get_limit('bandwidth_limit'),

        // A switch posts "0" or "1" as a string; empty("0") is true, so cast instead.
        'shell'     => (int) ($creation['shell_access'] ?? 0) === 1,
    ];

    // Order form answers and addon totals, keyed by the names declared in config.php.
    foreach ($this->requirement_params as $key => $value) $parameters[$key] = $value;
    foreach ($this->addon_params as $key => $value) $parameters[$key] = $value;

    // Retry safety: the queue re-runs this method after a failure.
    if (!$this->api->account_exists($username))
        $this->api->call('accounts', $parameters, 'POST');

    return [
        'config' => [
            'user'     => $username,
            'password' => $this->encode_str($password),
            'home_dir' => '/home/' . $username,
        ],
        // Not a reserved key, so it is written to the option root.
        'ip' => $this->server['ip'] ?? '',
    ];
}
the core reading it back
// Services::apply_module_result, simplified to the part that matters to you.
if (is_array($result)) {
    if (isset($result['config']) && is_array($result['config']))
        $options['config'] = array_replace_recursive($options['config'] ?? [], $result['config']);

    // Anything outside the reserved set lands on the option root.
    $reserved = ['config', 'login', 'creation_info', 'options', 'status'];
    foreach ($result as $rKey => $rVal)
        if (!in_array($rKey, $reserved, true)) $options[$rKey] = $rVal;
}

// ModuleQueue then decides the new service status.
$moduleStatus = is_array($result) ? ($result['status'] ?? null) : null;
$targetStatus = $moduleStatus ?: match ($action) {
    'create', 'unsuspend', 'register' => 'active',
    'suspend'                          => 'suspended',
    'cancel'                           => 'cancelled',
    default                            => null,
};

// And this is what every later verb reads:
$username = $this->options['config']['user'] ?? '';
$password = $this->decode_str($this->options['config']['password'] ?? '');

Pitfalls

Report failure by throwing, not by returning false

Assigning to the error property and returning false is a leftover of the previous generation. cPanel throws in 41 places and assigns in none. Throw a translated exception and the caller surfaces the message.

The queue will run create() twice

A failure after the account exists is retried from the top, and a provider that rejects duplicates then fails forever. Check for the account first, and persist the credentials with save_options() as soon as you have them.

Store the plan name, not the provider id

In a load balanced group the order lands on whichever machine has room, and the same plan carries a different id there. The name is stable: save the name, resolve it at call time.

Never test a switch value with empty()

Approval and switch fields post the strings "0" and "1", and empty("0") is true. Write (int) ($data['key'] ?? 0) === 1 instead.

Do not decrypt the server password yourself

The base class already did it, in one place. A second decryption corrupts the plain text path, and the failure only shows up against a real provider.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.