Skip to main content
Version: Next

Licensing

Licensing in Scadex is per component. The core, the SDK and every module have their own LICENSE file and terms, so open and proprietary components sit side by side on one gateway. This page is for module vendors who sell a module and for administrators who install a licence grant.

Terms per component​

A module's terms are its LICENSE file and the license field of its module.json, an SPDX expression such as Apache-2.0 or a LicenseRef-* for proprietary terms. To change a module's terms, replace the file and update the field. The four first-party modules ship as LicenseRef-Scadex-Proprietary. The Modules page shows each module's licence in its Overview.

Enforced licences​

A module that must be paid for sets requiresLicense: true in module.json, or RequiresLicense = true in its code manifest so it cannot be edited out (set both; see below). The gateway then loads it only as a signed package with a valid signed grant, and reports one of these states:

StateWhen
runningNo licence is required, or a valid signature and grant are installed
unlicensedA licence is required and no valid grant was found
unsignedA licence is required and the package has no module.sig
tamperedThe package has a module.sig that does not verify: a key the gateway does not trust, or a file changed, missing or not on the signed list

In the last three the module's code is not started and its routes do not exist (they answer 404). The gateway and the other modules carry on.

Grants​

A grant is a file signed with the scadex.io private key. It names a customer, one module and optionally an expiry, and may carry feature flags:

public sealed record LicenseGrant(
string CustomerId,
string ModuleId,
DateTimeOffset IssuedAt,
DateTimeOffset? ExpiresAt = null,
IReadOnlyList<string>? FeatureFlags = null);

Gateways trust the public keys compiled in from src/Scadex.Core/Licensing/trusted-keys.txt, so a customer cannot sign their own grant. Grants are not tied to one gateway and cannot be revoked.

Issuing a grant​

The LicenseTool (tools/Scadex.LicenseTool) has four commands: keygen --out <private-key.pem>, pubkey --key <private-key.pem>, issue and sign-module. Run it from the repository root; the private key stays off every gateway:

docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -v "$HOME/.config/scadex:/keys:ro" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0.403 dotnet run --project tools/Scadex.LicenseTool -- \
issue --key /keys/license-signing-key.pem --customer acme --module io.scadex.driver.mqtt \
--expires 2027-12-31 --out .local/acme-mqtt.license

--expires yyyy-MM-dd and --features a,b are optional.

Installing a grant​

Copy the file into the gateway's /data/licenses directory and restart:

docker compose cp .local/acme-mqtt.license gateway:/data/licenses/
docker compose restart gateway

Expiry​

Expiry is checked when a module loads, not while it runs, so a grant that lapses never stops a running plant mid-process. It takes effect at the next restart, including an unplanned one, with no grace period. A grant is valid through the whole of its --expires date.

Signing a package​

A package that requires a licence must also be signed, so that its grant covers the code it was issued for. sign-module writes module.sig beside module.json: the module id and the SHA-256 of every file in the folder, sorted by relative path and signed with the same key as the grants. Sign after publishing, because any later change to the folder invalidates the signature:

scripts/stage-modules.sh Release .local/modules mymodule
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -v "$HOME/.config/scadex:/keys:ro" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0.403 dotnet run --project tools/Scadex.LicenseTool -- \
sign-module --key /keys/license-signing-key.pem --dir .local/modules/com.acme.mymodule

The gateway checks a package before loading any of its code when module.json requires a licence or the folder holds a module.sig, so an open module can be signed too and is then held to it. Everything in the folder counts, hidden files included, and a symbolic link anywhere in it is refused. A folder that fails the check does not replace a built-in module with the same id.

note

If only the code manifest declares RequiresLicense, the gateway cannot know until it has loaded the assembly, created the module and read its manifest. An unsigned package is then refused and unloaded: its constructor has run, but it is never started and has no routes. Declare the requirement in module.json too, so the signature covers that file and nothing runs first.

What a paid module should check itself​

IModuleContext.License is the valid grant installed for the module's id, or null when there is none. A paid module should refuse to start without its own:

public ValueTask StartAsync(IModuleContext context, CancellationToken cancellationToken)
{
if (context.License?.ModuleId != Manifest.Id)
throw new InvalidOperationException("This module needs a licence.");
// ...
}

This matters because the gateway only enforces what a package declares. A wrapper module can declare that it needs no licence, carry no signature, and load a paid module's DLL under its own id. The gateway then gives the wrapper's context no grant for the paid module, and the check above fails. FeatureFlags on the grant are available for a module to gate its own features; the gateway does not act on them.

What this does not stop​

This raises the bar for bypassing a licence; it is not copy protection.

  • The code runs on the customer's machine, where it can be patched. Patching the gateway to skip these checks, or the module to skip its own, defeats them and needs no key.
  • A wrapper that goes further than the case above can implement IModuleContext itself and hand the paid module a grant it made up, since the module does not re-verify the context's grant. That takes writing code against the SDK rather than editing a file.
  • Files are verified when the gateway starts, not while it runs. Someone able to change them between the check and the load, or afterwards for assemblies loaded later, is not caught. Write access to the module folder is already admin access.

Only never shipping the binary to unlicensed customers closes this. Distributing signed artifacts through a private registry is on the roadmap and not available yet.