Licensing a Module

15 views Markdown

Whether your module refuses to run unlicensed is your decision, and so is the server that decides it.

Overview

WISECP publishes no licence protocol for the products it sells for you. You choose the address, the fields that go with the question, and what an answer means.

Two things are ours. Encryption: mark the files that must not ship readable and the packager encodes them. The carrier is optional, and it remembers the answer for a day.

the whole integration, if you want ours
$answer = \License::remote_check('https://your-site.example/license/check', ['domain' => \Utility::getDomain()]);

if ($answer === null)                    return true;   // could not ask
if (trim($answer['body']) !== 'OK')      return false;  // your server said no

return true;

Your own curl is equally welcome. What matters is where the decision sits: inside a file you marked, so the operator cannot delete it.

Prerequisites

A module that already works Licensing is the last thing you add. Build and test the feature unlicensed, then gate it.
A server of your own that answers Any address you control, in any shape you like. WISECP's own products answer at /license-verify/checking/{token}/{id} with the plain words OK or ERROR.
curl on the customer's host Absent, the carrier returns null rather than failing.

Structure

Four pieces carry the feature; the first three are yours.

PieceWhere it livesWhat it does
Your serveran address you controlAnswers in the shape you chose.
Your checka private method of your module classAsks, reads the answer, decides what it means.
Your gatewhere the paid behaviour startsCalls that method and refuses, degrades or unlocks.
The answer cachecoremio/storage/licensesOne encrypted file per address and question, outside the cache directory.

The cache sits in its own folder on purpose. Admin operations clear the cache with no arguments several times a day. An answer parked there would go with the rest.

Walkthrough

Put the gate where the value is

Gate the thing the customer paid for, not the module's existence. Treat "could not ask" as its own answer: a host with no curl sends exactly that.

the admin surface
/* @wisecp-protected */

private function licensed(): bool
{
    $answer = \License::remote_check('https://your-site.example/license/check', [
        'product' => 'aurora-backup',
        'domain'  => \Utility::getDomain(),
    ]);

    // Could not ask. Your outage is not the customer's fault, so this stays open.
    if ($answer === null) return true;

    return $answer['status'] === 200 && trim($answer['body']) === 'OK';
}

public function adminArea(): array
{
    if (!$this->licensed())
        return [
            'page_title' => $this->lang['name'] ?? 'Aurora Backup',
            'content'    => $this->view('unlicensed.php'),
        ];

    return [
        'page_title' => $this->lang['name'] ?? 'Aurora Backup',
        'content'    => $this->view('index.php'),
    ];
}

Mark every file that must not ship readable

The packager cannot know which file carries your check. Your helper's name is yours and the call appears in one file only. So you declare it. Put the marker on its own line at the top of each file you would not hand over.

the marker
/* @wisecp-protected */      // the module class, hooks.php, a helper — any PHP file

PHP only. Templates are not encoded, whatever engine they use. The engine compiles them to plain PHP on disk first, so a secret written into one is readable there. Keep decisions in PHP.

The marker is a promise: a build that cannot protect a marked file is refused.

Five files at most

A submission may mark up to five files. Past that the release is held back and publishing becomes a priced extra. Mark the file that decides, not everything that touches it. That is usually three: the class, its helper, and the view.

Reference

What the carrier remembers

WindowLengthBehaviour
Disk cache86400 secondsNo network. One encrypted file per address and field set, under coremio/storage/licenses.
Every callnonePass true as the third argument when yesterday's answer will not do.
Unreachablenonenull, and nothing is written. A blip cannot freeze a day of silence into the cache.

The cache file is encrypted and signed with the installation's own secret, and the envelope names the question. A file lifted from another installation and renamed answers for nothing. There is no grace window.

Asking an endpoint of your own

Where the check goes is your decision, and so is what an answer means. This carries the question and holds traffic to one call a day.

public static function remote_check(string $url, array $fields = [], bool $always = false, array $options = []): ?array;
$url The address to ask. Only http and https. Addresses inside the customer's own network are refused before the socket opens: loopback, private ranges, cloud metadata.
$fields Sent as the request body.
$always Left alone, the answer is remembered for a day. Pass true to ask on every call.
$options headers request headers, either 'Name: value' lines or a name => value map. method defaults to POST. timeout in seconds, 1 to 60, default 10.
Returns ['status' => int, 'headers' => array, 'body' => string]. Header names are lower-cased, the body verbatim. null means unreachable, and nothing is cached.

The address, fields, method and headers together are the cache identity. An authenticated call is remembered apart from an anonymous one.

$answer = \License::remote_check(
    'https://your-site.example/license/check',
    ['product' => 'aurora-backup', 'domain' => \Utility::getDomain()],
    false,
    ['headers' => ['Authorization: Bearer ' . $this->vendor_key()]]
);

if ($answer === null)         return true;      // could not ask; your outage, not the customer's fault
if ($answer['status'] !== 200) return false;    // 401, 403, 404 — your server said something else

$data = \Utility::jdecode($answer['body'], true);

return (bool) ($data['valid'] ?? false);

It does not judge the answer or verify a signature

Anything the customer's host resolves your address to can write that answer, status code included. If the answer decides money, sign it on your server and check that signature in the code that reads it.

Example

A complete paid module, gated end to end. The admin screen and the scheduled run ask the same question. A gate on one alone is not a gate.

the module class
<?php
    namespace WISECP\Modules\Addons;

    use AddonModule;
    use Filter;

    /* @wisecp-protected */
    class AuroraBackup extends AddonModule
    {
        public string $version = '1.2';

        /** Your address, your fields, your reading of the answer. */
        private function licensed(): bool
        {
            $answer = \License::remote_check('https://vendor.example/license/check', [
                'product' => 'aurora-backup',
                'domain'  => \Utility::getDomain(),
            ]);

            // Could not ask. Your outage is not the customer's fault.
            if ($answer === null) return true;

            return $answer['status'] === 200 && trim($answer['body']) === 'OK';
        }

        public function adminArea(): array
        {
            $action = Filter::init('REQUEST/action', 'route') ?: 'index';
            if (!$this->licensed()) $action = 'unlicensed';

            return [
                'page_title' => $this->lang['name'] ?? 'Aurora Backup',
                'content'    => $this->view($action . '.php'),
            ];
        }

        /** The scheduled half. A cron run must not act on an expired licence either. */
        public function run_backup(): bool
        {
            if (!$this->licensed())
                throw new \Exception($this->lang['error-not-licensed'] ?? 'Not licensed.');

            // ...
            return true;
        }
    }

The scheduled path is gated too. A gate on the admin screen alone leaves the paid work running for a licence that lapsed months ago.

Pitfalls

Unreachable is not unlicensed

Treating every falsy answer as theft switches your module off at the first network hiccup. Branch on the reason and let grace do its job.

Shipping without a public key makes the check optional

With the field empty an answer is accepted on nonce and clock alone. Anyone who can point your hostname elsewhere answers valid to everything. Fill it in before the first paid release.

A domain left in the settings ships the developer's answer

The domain option overrides the host being asked about and is for local work only. Left filled in a release, every copy asks about your machine and comes back licensed.

A long timeout turns your outage into their outage

Five seconds is already generous for a call that happens once a day. Raise it and every page of the customer's panel waits on your server the moment it goes down.

Test the refusal path, not only the happy one

Point the address at a host that does not exist, then at one that returns an unsigned answer. Look at your module in both states. Every unpaid customer sees the refusal screen.

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.