A plugin adds a codec, a tracker or a pairing scorer to OpenTrack without changing OpenTrack. One plugin may provide any of the three:
| Kind | What it does | Where it is used |
|---|---|---|
| codec | a frame's bytes to JSON records a mapping reads | a source's codec: {"type": "plugin", "plugin": "<name>", "options": {...}} |
| tracker | plots in, tracks out | a source's tracker stage: {"algorithm": "plugin", "plugin": "<name>", "options": {...}} |
| scorer | evidence whether a report and a system track are the same object | correlation settings: "scorer": {"plugin": "<name>", "options": {...}} |
The interface is wit/plugin.wit, a WebAssembly component interface.
A plugin runs one of two ways, and the engine cannot tell them apart:
- WebAssembly: a component that OpenTrack runs inside its own process with wasmtime. It is compiled to machine code on load and runs at close to native speed. It is sandboxed: it has no files, network or environment unless an operator grants them, and has a memory ceiling and a time limit per call. It is uploaded in Settings → General → Plugins and loaded without a restart. Write it in Rust (sdk/rust) or plain Python (sdk/python).
- External: a program of its own that serves the same interface over a socket. It runs where you start it, with whatever it needs: numpy, scipy, Stone Soup, a GPU, a licensed library. Settings → General → Plugins registers its address and the secret it shares with OpenTrack (see Authentication). The Python SDK serves any plugin this way.
Values cross the interface as JSON, so OpenTrack's schema can grow without a new interface version.
-
Observations (plots, tracks, reports, system track views) are OpenTrack's
Observation:source_id,source_track_keyobserved_at(ISO 8601)position{latitude, longitude, altitude_hae_m}uncertainty{circular_error_m, ellipse: {semi_major_m, semi_minor_m, orientation_deg}}kinematics{course_deg, speed_mps, heading_deg, vertical_rate_mps}classification{domain, affiliation, ...}identifiers,name,callsign,state,track_type,ext
Rust plugins get the typed struct from
ot-core. A tracker's output only needs asource_track_keyand a position; the easiest way is to copy the plot that last updated the track and change those. OpenTrack setssource_idand stampsprovenance.tracker = "<plugin>-<version>". -
Options are what the source or the settings give, starting from the defaults in the manifest.
-
Times are milliseconds since the Unix epoch.
describe returns:
{
"name": "alpha-beta", // lowercase letters, digits, - and _
"version": "1", // recorded with what the plugin produces
"description": "…",
"kinds": ["tracker"], // the SDKs fill this in
"options": [
{"name": "gate_m", "label": "Gate (m)", "type": "number", "default": 100, "unit": "m", "min": 0,
"help": "Plots further than this from a track's prediction start a new track"}
],
"framing": {"type": "delimiter", "delimiter": "\n"} // codecs: how to split a byte stream
}Option types are bool, number (unit, min), choice (choices) and text. The UI draws
a form from them wherever the plugin is picked.
open-decoder(options) starts one stream's decoder. Its state lives between frames. Two calls:
decode(frame, received-at-ms)returns the frame's records.hints()may report what the stream has shown about the sensor ({"revisit_secs", "revisit_source"}). A tracker stage withauto_timingsizes itself by it.
open-tracker(options) starts one source's tracker. Two calls:
push(plot, received-at-ms)hands it each plot.run(now-ms, force)returns the tracks to report now. It is called after every frame and about once a second, and withforceat the end of a stream.
The plugin decides what a scan is and when a track is confirmed. A track ends with a report whose
state is dropped. Keep to OpenTrack's tracker rule: at most unknown affiliation and a domain,
never an identity.
open-scorer(options) starts a scorer. For every report, the engine calls
score(report, candidates) with each system track nearby that could be the same object. Each
candidate carries:
view: the system track as it standskinematic: OpenTrack's own comparison (distance_m,dt_s,sigma_m,d2,dof,ln_lr,pass)
The plugin answers one {"ln_lr", "pass", "evidence"} per candidate. These take the kinematic
comparison's place in the engine's sequential test. The test itself stays the engine's: prior,
m-of-n, pair probability, suggest mode, do-not-pair and splits. So does every decision, which
records the plugin, its version and its evidence. If a scorer fails, the kinematic comparisons
stand, and the failure is logged once.
WebAssembly plugins only; set per plugin in Settings → General → Plugins or PUT /api/v1/plugins/{name}:
| Grant | Default | |
|---|---|---|
memory_mb |
256 | most memory the plugin may grow to |
call_timeout_ms |
5000 | a call running longer is stopped; the instance is not used again |
network |
none | host:port addresses it may connect to |
dirs |
none | {path, guest?, write?}: server directories it can see (read only unless write) |
env |
none | environment variables it sees |
Every decoder, tracker or scorer that is opened gets its own instance, with its own memory, so one stream cannot disturb another. For an external plugin only the call timeout applies: it has what its own process has.
Rust (sdk/rust): implement Codec, Tracker or Scorer and name them in
plugin!:
use opentrack_plugin::{Observation, Tracker, Value, json, plugin};
struct Mine { /* ... */ }
impl Tracker for Mine {
fn open(options: Value) -> Result<Self, String> { /* ... */ }
fn push(&mut self, plot: Observation, received_at_ms: i64) { /* ... */ }
fn run(&mut self, now_ms: i64, force: bool) -> Result<Vec<Observation>, String> { /* ... */ }
}
plugin! {
manifest: json!({"name": "mine", "version": "1", "description": "…", "options": []}),
tracker: Mine,
}cd sdk/rust && cargo build --release --target wasm32-wasip2 # rustup target add wasm32-wasip2The examples are an alpha-beta tracker, a CSV codec and a speed-veto scorer.
Python (sdk/python): subclass Codec, Tracker or Scorer, describe them
in a Plugin, and call main(PLUGIN):
OT_PLUGIN_SECRET=… python mine.py serve --address 127.0.0.1:47300 # external (any packages)
python mine.py build -o mine.wasm # as a component (plain Python; componentize-py)The examples are a domain and course scorer (plain Python, builds as a component), and a Stone Soup GNN tracker (external).
-
Check before adding:
opentrack plugin check mine.wasm(or an address) loads the plugin, opens each kind it provides with its default options, and prints what worked. Settings → General → Plugins has the same check. For an external plugin it reads the secret from--secret-fileorOT_PLUGIN_SECRET. -
Add: Settings → General → Plugins → Add plugin,
POST /api/v1/plugins(the component withContent-Type: application/wasm, or{"address", "secret"}), oropentrack plugin add.- A plugin is loaded before it is stored.
- A new build replaces an old one only when asked (
?replace=true). It keeps its grants and whether it is enabled. - Every role picks up changes within 5 s.
- Every change goes in the decision log, with the component's SHA-256.
-
Delete is refused while a source or the correlation settings use the plugin, unless forced.
-
Benchmark:
scripts/benchmark/bench runscores a plugin tracker against the built-in ones:scripts/benchmark/bench run 'autoferry-*' --plugin mine.wasm \ --set '*.pipeline.tracker={"algorithm":"plugin","plugin":"mine","options":{}}'
JSON lines over TCP (host:port, tcp://host:port) or a Unix socket (unix:/path). Each request
is {"id", "method", "params"}. Each reply is {"id", "result"} or {"id", "error"}.
Every decoder, tracker or scorer OpenTrack opens is one connection. It starts with open, and the
instance lives as long as the connection.
| Method | Params | Result |
|---|---|---|
describe |
the manifest | |
open |
kind, options |
null |
decode |
frame (base64), received_at_ms |
records |
hints |
hints or null | |
push |
plot, received_at_ms |
null |
run |
now_ms, force |
tracks |
score |
report, candidates |
scores |
A reply slower than the plugin's call_timeout_ms fails the call and closes the session.
opentrack_plugin.external in the Python SDK is a complete server, handshake included.
Anything that can reach a plugin's socket could otherwise pose as OpenTrack (and read what it is sent), and anything that can listen on the address could pose as the plugin (and feed OpenTrack tracks or scores). So OpenTrack and every external plugin share a secret, and each connection (the one that reads the manifest, and every decoder, tracker or scorer session) proves both ends hold it before anything else is sent:
→ {"id":1,"method":"hello","params":{"protocol":"opentrack-plugin-v1","challenge":"<Nc>"}}
← {"id":1,"result":{"proof":"<plugin proof>","challenge":"<Np>"}}
→ {"id":2,"method":"verify","params":{"proof":"<OpenTrack proof>"}}
← {"id":2,"result":null}
NcandNpare 32 random bytes each, standard base64. OpenTrack's come from the FIPS module's DRBG.- plugin proof = HMAC-SHA256(secret,
"opentrack-plugin-v1 plugin"‖0x00‖ Nc ‖ Np); OpenTrack proof = HMAC-SHA256(secret,"opentrack-plugin-v1 opentrack"‖0x00‖ Nc ‖ Np). The key is the secret's UTF-8 bytes. Both are checked in constant time. - The plugin proves itself first; OpenTrack answers only once that proof checks, so it never signs anything for a plugin that does not hold the secret. The role labels mean neither side's proof can pass as the other's, so a challenge reflected back fails.
- A plugin answers nothing but
helloandverifyuntil OpenTrack's proof checks, and hangs up if it does not.
OpenTrack refuses a plugin that answers hello with an error, does not answer, or proves the wrong
secret. It logs the failure as an error and records plugin_auth_failed in the audit record, and
the plugin's status in Settings → General → Plugins says why.
The secret. In Add plugin (or a plugin's key button), Generate makes one: 32 random bytes from
the FIPS DRBG, as hex. It is shown that once; give it to the plugin, then add or save. You can
paste your own instead (at least 32 characters), or ${env:NAME} to read it from OpenTrack's
environment when connecting. OpenTrack keeps it in its database with the plugin (it needs the
secret itself to answer the plugin's challenge), like a source's credentials; the API only says
whether one is set, and the configuration export carries it. Start the plugin with it in
OT_PLUGIN_SECRET, or in a file named with --secret-file (not on the command line, where other
accounts can see it).
Without a secret OpenTrack connects only to a unix socket under its data directory (the directory holding its database, links resolved). Only accounts that can write that directory can listen there, so the filesystem already says who the plugin is; OpenTrack skips the handshake. Any other address with no secret is refused before connecting.
The handshake authenticates both ends when a connection opens. It does not encrypt what follows or stop it being changed on the way: keep external plugins on a unix socket, the same host or a network you trust.
A program in another language can serve the protocol too: ot_plugin::handshake (Rust) and
opentrack_plugin.external.proof (Python) compute the proofs.
Upgrading from 0.4.4 or earlier. External plugins had no secret. After the upgrade one without a secret shows status error: no secret… and does not load (unless it is on a unix socket under the data directory). For each: update the plugin to this SDK, Generate a secret for it in Settings → General → Plugins, start the plugin with that secret, then save it. Sources and the correlation scorer that use it pick it up within 5 s.