Writing a Server Module
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
The installation package ships 47 server modules. None of them is a sample to copy: the walkthrough below builds each part, and the example shows a complete provisioning method.
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.
new is never used for a module.
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.
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()
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);
}
}
| Surface | Entry point in your class | Reached from |
|---|---|---|
| Server settings form | config.php fields, then test_connect() | Admin, Servers, Manage Server |
| Product settings | product_configuration(), save_product_configuration() | Product detail, Module tab |
| Provisioning | create(), suspend(), unsuspend(), cancel() | Order approval, admin actions, the queue |
| Client area dashboard | dashboard_data() | Service detail in the client area |
| Client area tools | tool_data(), tool_action() | The tool sidebar, see the tools article |
| Metered billing | metrics_usage() or metrics_usage_bulk() | The usage collection cron |
| Import | list() | 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.
define_server_info(), thentest_connect(): add the server and prove the credentials.product_configuration()andsave_product_configuration(), plus the callable that fills the plan dropdown.create(), thensuspend(),unsuspend(),cancel().change_password()andupgrade().list(),dashboard_data(), the single sign on methods, metrics.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.
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 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.
- Read the plan from
creation_info, the limits throughget_limit(), the addon answers fromaddon_params, the order form answers fromrequirement_params. - Call the provider. Let the client throw on an API error, or throw yourself with a translated message.
- 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.
// 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. A resource card whose usage you cannot read sends 'used' => null, never 0:
// a missing or zero used prints "0 / N". In a product limit 0 means no allowance; only
// empty, -1 or 'unlimited' means unlimited (disk and traffic are stored as 'unlimited').
// The client overview remembers a read that throws or returns [] for two minutes and lets one
// request read at a time, even with $dashboard_cache off. The first visitor still waits for your
// HTTP timeout, so give the API client a short connect timeout.
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;
// Optional. Runs for the admin management tab AND the client overview (theme and client
// API), so both see the same cards. Hide power tools or add live card rows here, with no
// remote call: the client page pays for it on every view.
public function configure_dashboard_tools(array $data): void;
// TLS: never switch verification off. The API client reads the record's switch:
// $verify = \ServerModule::tls_verify($server); then curl_tls_options($verify) or
// stream_tls_options($verify). A client without that call hides the switch from the form.
public static function tls_verify(array $server): bool;
public static function curl_tls_options(bool $verify): array;
public static function stream_tls_options(bool $verify): array;
public static function tls_flag_supported(string $type): bool;
public static function requires_access_hash(string $type): bool;
public function client_sso_allowed(): bool;
public function activity_action_label(string $action): 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;
| Method | Required | Called when | Status afterwards |
|---|---|---|---|
| define_server_info | yes | Every instantiation | none |
| test_connect | yes | Test Connection on the server form | none |
| product_configuration | yes | Product detail, Module tab | none |
| create | yes | Order approved, or Recreate in admin | active |
| suspend | yes | Overdue invoice, or manual suspend | suspended |
| unsuspend | yes | Payment received, or manual unsuspend | active |
| cancel | yes | Cancellation processed | cancelled |
| change_password | yes | Account password changed | unchanged |
| upgrade | yes | Plan change on the same module | unchanged |
| renew | optional | Renewal invoice paid | unchanged |
| change_limits, reset_limits | optional | Provider allows per account limit overrides | unchanged |
| addon_* | optional | An addon on the service changes state | addon only |
| list | optional | Admin opens Import Accounts; implementing it makes that screen appear | none |
| metric_enable, metric_disable | optional | Metered billing with provider side limit overrides. Runs when a metric is switched on or off and when the cap of an enabled metric changes: a new cap calls metric_enable again, so write the limit idempotently; a cap of 0 calls metric_disable, back to the plan limit. It runs before the change is saved, so throwing cancels the change. | none |
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: the account identity, so user, the encrypted password, home_dir, the provider side id.
login. Credentials the client area shows or the single sign on uses.
creation_info, the same bag the product form writes into. Record what was actually provisioned.
'inprocess' or 'waiting' keeps the service out of active state while asynchronous provisioning finishes.
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.
fields from your config.
save_options() to persist mid method; returning an array does the same at the end.
disk_limit, bandwidth_limit, email_limit, database_limit, addons_limit, subdomain_limit, ftp_limit, park_limit, max_email_per_hour.
addon-params.
requirement-params.
[$username, $adopt]. The account is adopted only when no other live service names it and this service made it or it serves the domain. Otherwise the helper generates a new name for the domain. It ships with the 5.0-beta.4 core and the 5.0-beta.3 fix package; a module that also runs on an older core checks it with method_exists() first.
config.account_claim. Write it after the panel has made the account, and return it with config.
config.user. It returns verdict, account, domain and account_domain. A matching config.account_claim answers own without a panel call. Do not override it; override the probe below.
['own', $served] when the account serves $domain, ['foreign', $served] when it serves another domain, and ['unknown', ''] when the panel cannot say. An exception counts as unknown. Cancel and terminate stop on foreign and unknown, suspend only on foreign, and nothing is sent to the panel. The base answers n/a, which skips the check. It ships with the 5.0-beta.4 core and the 5.0-beta.3 fix package.
curl_tls_options() or stream_tls_options() and never set the options by hand.
tls_verify() has no switch to offer, so the server form hides it.
config.php sets require-access-hash. The panel and the admin API then refuse a server record without an access hash; use-access-hash alone accepts a password too. It ships with the 5.0-beta.4 core. On 5.0-beta.3 the option only marks the form field as required; the core does not check it on save.
system/module/al-action/*) are shown un-snaked; override the method to map them to your own language file.
The Core Side of the Call
public static function run_module(int|array $service, string $action, array $params = []): mixed;
// $capability names a domain capability ('dns_records', 'nameservers' …); given one, a
// provider other than the registrar may answer it. Without it the hook is never asked.
public static function instance_module(int|array $service, string $capability = ''): ?object;
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
terminate() is missing the core tries cancel(), then cancelled().
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.
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);
// An account under the name is adopted only when it is this service's own;
// otherwise the helper picks a new name for the domain.
[$username, $adopt] = $this->provision_username(
$username,
fn (string $user): bool => $this->api->account_exists($user),
fn (string $user): bool => $this->api->account_domain($user) === $domain,
fn (): string => 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;
if (!$adopt) {
$this->api->call('accounts', $parameters, 'POST');
// Claim the account only after the panel has made it, and persist at once for a retry.
$this->options['config']['user'] = $username;
$this->options['config']['password'] = $this->encode_str($password);
$this->options['config']['account_claim'] = $this->account_claim($username);
$this->save_options();
}
return [
'config' => [
'user' => $username,
'password' => $this->encode_str($password),
'home_dir' => '/home/' . $username,
'account_claim' => $this->account_claim($username),
],
// Not a reserved key, so it is written to the option root.
'ip' => $this->server['ip'] ?? '',
];
}
// 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
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.
A failure after the account exists is retried from the top. Never skip creation because an account exists under the name. Two domains can derive the same name, and the account may belong to another customer. Let provision_username() decide, and persist the credentials and the claim with save_options() as soon as the panel has made the account.
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.
Approval and switch fields post the strings "0" and "1", and empty("0") is true. Write (int) ($data['key'] ?? 0) === 1 instead.
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.
Related Articles
Спасибо за отзыв!
Наша служба поддержки на связи круглосуточно по всему, чего вы не нашли выше.