OpexFlow is commercially licensed. Enforcement is opt-in per build: when
license.enabled is set, the connector validates its license key against the OpexFlow
license worker on startup (see states). Disabled by default, so dev and CI
builds run unenforced.
Configure
license:
enabled: true
server_url: "https://flow.opexcg.com"
key: "${OPEX_LICENSE_KEY}"
product: "opex-flow"
offline_grace_days: 7 # default 7; 0 = no offline toleranceexport OPEX_LICENSE_KEY=XXXX-XXXX-XXXX-XXXX
./target/release/opex-flow config.yamlHow it works
The key. It comes from license.key (config file or ${OPEX_LICENSE_KEY}), or you enter
it in the dashboard (the License panel) and it is stored in license.json in
connector.data_dir (owner-only file mode). If both exist the config value wins; the
status says which one is in use (key_source: "config" | "ui"), and only the last four
characters of the key are ever shown.
Activation. With no cached activation (or a different key) the connector POSTs
{ key, device_id, device_name, version } to /license/activate, which binds the key to this
host (one seat per device; asking again from the same device returns the same activation)
and returns activation_id, plan, expires_at and a signed token. The activation is cached
in license.json, so later starts call /license/validate with it instead of using a new
seat, and a running connector revalidates every 6 hours. If an admin frees the seat
(reason: deactivated) the connector takes a new one automatically when the license has room.
States (GET /api/license/status → state):
| State | Meaning | Data flows |
|---|---|---|
valid |
The worker confirmed the license. | yes |
offline_grace |
The worker is unreachable, but a genuine signed token still covers this device (until offline_valid_until). |
yes |
invalid |
The worker refused (reason: invalid, revoked, suspended, expired, seats_exhausted, deactivated), or it is unreachable and no token covers us (reason: network). |
no |
unlicensed |
license.enabled but no key (or the device was deactivated). |
no |
disabled |
license.enabled: false (or the debug-only skip). |
yes |
invalid / unlicensed keep the API and dashboard up (so a key can be entered) but drop every
record at the dispatcher; sources that can redeliver (NATS) keep their messages.
Revoked, expired or suspended is fatal for a config key, as before: the connector shuts
down gracefully with a non-zero exit. A UI-entered key that is rejected does not shut the
connector down; it stays invalid with data blocked so the key can be replaced. A network
error is never fatal while a valid token covers it.
Offline grace relies on a signed token, not on local files. Every successful
activate/validate response carries an Ed25519-signed token (binds product, activation and
device, with iat/exp, where exp is capped by the license’s own expiry), cached in
license.json. If the worker is unreachable, the connector keeps running only while the
cached token’s signature verifies against the public key embedded in the binary and the
current time is before min(exp, iat + offline_grace_days). Editing the cache file cannot
extend the window. The default is 7 days; offline_grace_days: 0 disables offline operation. An explicit refusal is
never excused by grace.
Panel / API. GET /api/license/status returns { enabled, state, plan, expires_at, offline_valid_until, activation_id_short, device_id_short, key_last4, key_source, last_check_at, reason?, error? } (times are Unix seconds). POST /api/license/activate
{ key } (enter or replace the key; refused with 409 while the key is set in the config
file), POST /api/license/revalidate (check now) and POST /api/license/deactivate (free this
device’s seat and forget the UI key) answer with the new status, or { ok: false, error, reason }
(400 bad input, 409 not allowed here, 422 the worker refused, 502 worker unreachable).
A failed activation never replaces a working license. Replacing a key frees the old key’s seat
on a best-effort basis.
Worker setup. Set the secret LICENSE_SIGNING_KEY (base64 of the PKCS#8 DER Ed25519 private
key) and optionally the var LICENSE_TOKEN_TTL_DAYS (default 30, clamped 1-365). Without the
secret no token is issued and connectors stay online-only. Upgrade order: deploy the worker
with the secret first, then connectors (the first online validation stores a token). To
rotate the key, add the new public key to PUBLIC_KEYS in license.rs, ship connectors,
then switch the worker secret/kid; drop the old key after old tokens expire.
Dev / CI escape hatch
The bypass is environment-only (never in config) and honored only in debug builds (or builds with the dev-license-skip feature); release binaries ignore it:
OPEX_LICENSE_SKIP=1 ./target/debug/opex-flow config.yamlDownloads & the worker
Release binaries are served from the download worker (R2-backed):
GET /latest?platform=linux-aarch64 redirects to the latest artifact (add
&format=json for url, size and SHA-256), and GET /downloads returns the manifest with a
per-platform sha256, label and note. Use platform=linux-x86_64-gnu (glibc) when you want Fanuc’s own FOCAS2 library on
Linux (every build can use the native FOCAS client instead). Every /releases/* response carries an X-Checksum-Sha256 header. Worker scaffolding lives at apps/landing/; see
its README.md for license-server setup (D1 migrations, signing key, Cloudflare Access for /admin) and release publishing.