# Writing a Server Module

https://dev.wisecp.com/es/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

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](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/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.

```bash
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()
```

```php
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.

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.

```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.

```php
// 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;
```

| 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 | 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**: 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

```php
public static function run_module(int|array $service, string $action, array $params = []): mixed;
public static function instance_module(int|array $service): ?object;
```

```php
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.

```php
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'] ?? '',
    ];
}
```

```php
// 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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle)
- [Server Module Tools](https://dev.wisecp.com/en/server-module-tools)
- [Writing a Product Module](https://dev.wisecp.com/en/writing-a-product-module)
- [Domain Helpers](https://dev.wisecp.com/en/domain-helpers)
