ServiceRadar plugin SDK for Rust and WebAssembly.
Source of truth: https://github.com/carverauto/serviceradar-sdk-rust
This crate lets you write ServiceRadar plugin checkers in Rust without dealing directly with low-level host ABI calls. It currently includes:
- Host-provided config loading
- Result construction and serialization for
serviceradar.plugin_result.v1 - Host logging
- Host-proxied HTTP, TCP, UDP, WebSocket, and RTSP/RTSPS helpers
- Policy input parsing and validation for
serviceradar.plugin_inputs.v1 - Camera/media helpers and RTSP parsing/depacketization utilities
- Signal schema/display contract references for package-managed logs and events
- Device discovery/enrichment payload helpers for inventory-producing plugins
- Advisory-feed contract builders and gateway-mediated artifact staging helpers
- First-class metric telemetry helpers for canonical
serviceradar.metric.v1payloads - Run overrides that let an action leave time-bounded state for later scheduled runs, and
emit_ocsf_eventfor OCSF events from any entrypoint - Host-proxied unary gRPC calls
- HTTP response headers (envelope mode) with
Retry-Afterparsing - Typed credential broker grants, including OAuth2 client-credentials injection
- Example plugins for HTTP, TCP, UDP, RTSP, widgets, WASI clock, and northbound actions
The Go SDK in serviceradar-sdk-go remains the behavior reference for parity, but this crate aims for an idiomatic Rust interface rather than a line-for-line Go port.
In practice that means the common path uses concrete Rust domain types like PluginResult, Widget, Event, and HttpClient, while Go-specific convenience aliases are intentionally avoided on the public surface.
cargo add serviceradar-sdk-rustuse serviceradar_sdk_rust as sdk;
#[derive(Debug, serde::Deserialize)]
#[serde(default)]
struct Config {
url: String,
warn_ms: f64,
crit_ms: f64,
}
impl Default for Config {
fn default() -> Self {
Self {
url: "https://example.com/health".to_string(),
warn_ms: 0.0,
crit_ms: 0.0,
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn run_check() {
let _ = sdk::execute(|| {
let cfg = sdk::load_config_or_default::<Config>()?;
let response = sdk::HttpClient::default().get(&cfg.url)?;
let latency_ms = response.duration.as_millis() as f64;
let thresholds = sdk::Thresholds::new(cfg.warn_ms, cfg.crit_ms);
Ok(sdk::PluginResult::new()
.with_summary(format!("http {} in {:.0}ms", response.status, latency_ms))
.with_thresholds(latency_ms, thresholds.warn, thresholds.crit)
.with_widget(sdk::Widget::stat_card(
"Latency",
format!("{latency_ms:.0}ms"),
"success",
)))
});
}clock-checkhttp-checktcp-checkudp-checkrtsp-checkwidgets-checknorthbound-actions
When a plugin emits OCSF events or OTEL-style logs that are described by a package manifest, attach the package schema/display reference through the SDK:
let event = sdk::Event::log_activity("camera motion", sdk::Severity::Warning)
.with_signal_schema_ref(&sdk::SignalSchemaRef {
producer_id: "axis-camera".to_string(),
producer_version: "0.1.0".to_string(),
schema_id: "com.carverauto.axis_camera.event_log".to_string(),
schema_version: "1.0.0".to_string(),
display_contract_id: "com.carverauto.axis_camera.event_log.display".to_string(),
display_contract_version: "1.0.0".to_string(),
display_contract: "display/event_log_activity.display.json".to_string(),
signal_type: sdk::SIGNAL_SCHEMA_SIGNAL_TYPE_EVENT.to_string(),
payload_kind: sdk::SIGNAL_SCHEMA_PAYLOAD_KIND_OCSF_EVENT.to_string(),
});The helper writes the ServiceRadar extension metadata under metadata.service_radar.signal_schema.
For first-class telemetry that should be routed independently from the plugin result payload, emit a telemetry batch through the host:
let record = sdk::TelemetryRecord::ocsf_event(event)?
.with_signal_schema_ref(&schema_ref);
sdk::emit_telemetry(
sdk::TelemetryBatch::new(vec![record])
.with_source(sdk::TelemetrySource::new("axis-camera", "front-door")),
)?;emit_telemetry serializes the same JSON host ABI payload as the Go SDK and requires the plugin manifest capability emit_telemetry.
Do not put time-series metrics in serviceradar.plugin_result.v1. Result
metrics are no longer serialized by the SDK and are rejected by current
ServiceRadar agents. Emit canonical metric protobuf batches with
TelemetryRecord::serviceradar_metric_batch instead:
let record = sdk::TelemetryRecord::serviceradar_metric_batch(
"metric-event-1",
sdk::MetricBatch {
resource: sdk::MetricResource {
service_name: "http-check".to_string(),
service_type: "wasm-plugin".to_string(),
..Default::default()
},
ingest_identity: sdk::MetricIngestIdentity {
source: "plugin-metrics".to_string(),
producer_id: "http-check".to_string(),
producer_kind: "wasm-plugin".to_string(),
..Default::default()
},
metrics: vec![sdk::Metric {
name: "http.response_time_ms".to_string(),
metric_type: "plugin".to_string(),
kind: sdk::MetricKind::Gauge,
unit: "ms".to_string(),
points: vec![sdk::MetricPoint {
value: 12.5,
raw_value: "12.5".to_string(),
raw_value_type: sdk::MetricValueType::Double,
observed_at_unix_nano: 123,
..Default::default()
}],
..Default::default()
}],
..Default::default()
},
);
sdk::emit_telemetry(
sdk::TelemetryBatch::new(vec![record])
.with_source(sdk::TelemetrySource::new("http-check", "default")),
)?;TelemetryRecord::serviceradar_metric_batch uses a dependency-free protobuf
encoder so wasm plugins do not need to link a full protobuf runtime. If a plugin
already has encoded protobuf bytes from another generator, use
TelemetryRecord::serviceradar_metrics.
The default response mode, HTTP_RESPONSE_MODE_STATUS_BODY, returns only the
status and body. Set HTTP_RESPONSE_MODE_ENVELOPE to also receive the response
headers; the host joins repeated values with commas.
let response = sdk::HTTP.do_request(
sdk::HttpRequest::get("https://api.example.com/v1/devices")
.with_response_mode(sdk::HTTP_RESPONSE_MODE_ENVELOPE),
)?;
if response.status == 429 {
if let Some(wait) = response.retry_after() { // delta-seconds or HTTP-date
return Ok(sdk::PluginResult::warning(format!("rate limited, retry in {wait:?}")));
}
}
let content_type = response.header("content-type"); // case-insensitivesdk::GRPC.unary makes one unary call through the host's grpc_unary import.
The message is the serialized request protobuf, so the SDK carries no protobuf
runtime; encode and decode with whatever generator suits the plugin.
let request = sdk::GrpcRequest::new("device.example.com", 9200, "/example.v1.Device/Handle")
.with_metadata("x-request-id", "req-0001")
.with_message(request_bytes)
.with_timeout(std::time::Duration::from_secs(5))
.with_transport(sdk::GRPC_TRANSPORT_TLS); // default; GRPC_TRANSPORT_H2C for cleartext
match sdk::GRPC.unary(request) {
Ok(response) => { /* decode response.message */ }
// Completed with a non-OK status; headers and trailers are on the error.
Err(sdk::Error::GrpcStatus(status)) => {
return Ok(sdk::PluginResult::critical(format!("rpc failed: {}", status.code)));
}
Err(err) => return Err(err), // host policy error (sdk::Error::Host)
}GrpcClient::call returns every completed RPC as Ok, non-OK statuses
included, for callers that inspect GrpcResponse::status themselves.
The manifest must declare the grpc_request capability
(sdk::CAPABILITY_GRPC_REQUEST). The host checks the destination against
allowed_domains, allowed_networks and allowed_ports before dialing,
allows h2c only inside allowed_networks, caps responses at 4 MiB, and
rejects reserved and grpc-* metadata keys. Dial failures arrive as status
UNAVAILABLE.
Target contexts carry credential broker grants; the host injects the
credential into matching outbound requests, so the plugin never reads the
secret. The sdk::CREDENTIAL_INJECT_* constants name the accepted inject
types, and CredentialBrokerGrant::allow carries the request scope a grant
covers. OAuth2ClientCredentialsInject builds an oauth2_client_credentials
spec with the exact keys the host reads:
let inject = sdk::OAuth2ClientCredentialsInject::new("auth.example.com", 443, "/oauth2/token")
.with_scope("devices.read")
.inject()?;
// {"type":"oauth2_client_credentials","token_method":"POST","token_host":"auth.example.com",
// "token_port":"443","token_path":"/oauth2/token","field_client_id":"client_id",
// "field_client_secret":"client_secret","fixed_grant_type":"client_credentials",
// "fixed_scope":"devices.read"}field_<credential field> maps a stored credential field to a token form
field; fixed_<form field> sends a literal value. See
fixtures/credential_grant_oauth2_client_credentials.json for a full grant.
PluginManifest builds the plugin.yaml that ServiceRadar core accepts when a
package is uploaded, and validate mirrors core's own rules so a bad manifest
fails at build time instead of at upload time:
use serviceradar_sdk_rust::{
OUTPUTS_PLUGIN_RESULT, PluginManifest, RUNTIME_WASI_PREVIEW1,
SIGNAL_SCHEMA_PAYLOAD_KIND_OCSF_EVENT, SIGNAL_SCHEMA_SIGNAL_TYPE_EVENT,
SignalSchemaContribution,
};
let schema = SignalSchemaContribution::new(
"com.carverauto.security.scan_activity",
"1.0.0",
SIGNAL_SCHEMA_SIGNAL_TYPE_EVENT,
SIGNAL_SCHEMA_PAYLOAD_KIND_OCSF_EVENT,
)
.with_ocsf("1.9.0-dev", 6007, 600_701);
let manifest = PluginManifest {
id: "security-sample".to_string(),
name: "Security Sample".to_string(),
version: "1.0.0".to_string(),
entrypoint: "run_check".to_string(),
runtime: Some(RUNTIME_WASI_PREVIEW1.to_string()),
capabilities: vec!["get_config".to_string(), "submit_result".to_string()],
outputs: OUTPUTS_PLUGIN_RESULT.to_string(),
signal_schemas: vec![schema],
..Default::default()
};
let payload = manifest.serialize()?; // validates, then encodesSignalSchemaContribution::new derives the conventional bundle paths
(schemas/<name>.schema.json, display/<name>.display.json) and the display
contract id/version from the schema id; override any field afterwards.
validate returns every rejection at once as Vec<ManifestValidationError>,
each carrying the offending field path. It mirrors
ServiceRadar.Plugins.Manifest in core, which is strict on purpose:
capabilities,runtime, andoutputsare checked against core's allowlists.signal_typemust beeventorlog;payload_kindmust beocsf_eventorotel_log.- Schema and display-contract versions must be semver.
- Bundle paths must be relative
.jsonfiles and may not traverse directories.
The signal schema field set is closed. Core rejects any key it does not
recognize with signal_schemas[i].<key> is not allowed, so adding a field here
without a matching change in core produces manifests that fail on upload.
This module is kept at parity with serviceradar-sdk-go: both SDKs ship the
same testdata/sdk_generated_security_manifest.json, and each asserts that
equivalent builder calls reproduce it. A change to the manifest contract in one
SDK fails the other's fixture test.
Plugins that produce vulnerability intelligence should emit normalized advisory
batches through the standard plugin result payload. Provider-specific download,
schema validation, pointer JSON handling, archive extraction, and feed-specific
normalization stay inside the plugin. ServiceRadar core consumes only the
generic serviceradar.advisory_feed.contract.v1 contract.
For large feed snapshots, stage the raw or normalized artifact through the gateway-mediated artifact API before submitting the advisory batch:
let mut stream = sdk::ArtifactStream::open(
sdk::ArtifactOpenRequest::new("vulnerability-feeds/example/sha256.json")
.with_content_type("application/json"),
)?;
stream.write(feed_json.as_bytes())?;
let artifact = stream.commit(
sdk::ArtifactCommitRequest::new().with_sha256(feed_sha256),
)?;
let batch = sdk::AdvisoryFeedBatch::new(
"com.example.feed",
sdk::AdvisorySource::new("example", "normalized"),
sdk::AdvisorySnapshot::new(artifact.object_key, artifact.sha256),
)
.with_advisory(
sdk::AdvisoryRecord::new("CVE-2026-1")
.with_cve("CVE-2026-1")
.with_severity("high")
.with_coordinate(
sdk::AffectedCoordinate::purl("pkg:deb/debian/openssl@3.0.13?arch=amd64"),
),
);
let result = sdk::PluginResult::ok("submitted advisory feed")
.with_advisory_feed(batch);
# Ok::<_, sdk::Error>(())Plugins need the advisory-feed:v1 capability to submit advisory batches and
artifact-staging:v1 when using the artifact stream helpers. These APIs are
host and agent-gateway mediated; plugins never receive direct object-store
credentials.
Scheduled feed downloads are declared in the plugin package manifest with
producer_schedules. The platform persists the declaration, renders operator
settings, and dispatches runs through plugin.run_action; the plugin keeps all
provider-specific fetch, checksum, archive, and normalization logic.
let mut payload_template = std::collections::BTreeMap::new();
payload_template.insert("feed_key".to_string(), serde_json::json!("primary"));
let schedule = sdk::ProducerScheduleContract::new(
"daily_advisory_refresh",
"Refresh advisory feed",
"advisory.refresh",
)
.with_cadence(86_400, 3_600, 2_592_000)
.with_jitter_seconds(120)
.with_payload_template(payload_template);Plugins that declare schedules should include producer-schedule:v1 in their
manifest capabilities. The scheduled invocation payload uses
serviceradar.producer_schedule_run.v1.
Native builds expose serviceradar_sdk_rust::local, which installs a local
development host for ordinary config, HTTP, logging, telemetry, and result SDK
calls. This path does not require a Wasm build, signature, registry, or cluster.
LocalInputs reads public config and optional action-invocation JSON from files
or environment variables. It also reads an optional .env file; process
environment values override file values. Credential fields use the
SERVICERADAR_CREDENTIAL_ prefix and remain separate from runtime config:
SERVICERADAR_PLUGIN_CONFIG_FILE=testdata/config.json
SERVICERADAR_PLUGIN_ACTION_FILE=testdata/action.json
SERVICERADAR_CREDENTIAL_USERNAME=local-user
SERVICERADAR_CREDENTIAL_PASSWORD=local-passworduse serviceradar_sdk_rust::local::{
LocalHostOptions, LocalInputOptions, LocalInputs, run_local_host,
};
let inputs = LocalInputs::load(LocalInputOptions::default())?;
let runtime_config = inputs.runtime_config_json()?;
let credentials = inputs.credentials();
let (capture, result) = run_local_host(
LocalHostOptions {
config_json: runtime_config,
http_handler: Some(new_local_broker(credentials)),
..LocalHostOptions::default()
},
run_plugin,
);
result?;Pass grpc_handler to serve grpc_unary calls the same way; without one, gRPC
calls fail with host error -4. The handler is synchronous, so it emulates a
timeout by returning Error::Host with code -6 (the local host also reports
-6 when a failing handler ran past timeout_ms); any other handler error
reaches the plugin as status UNAVAILABLE with its text withheld.
The local host emulates oauth2_client_credentials grants itself. Pass the
grants and the credentials, and a request inside a grant's allow scope reaches
your HTTP handler with Authorization: Bearer <local_oauth2_bearer_token(grant)>,
a synthetic token derived from the grant identity only and identical to the Go
SDK's. A covered request the grant cannot authorize (missing credential field,
outside the inject target, insecure TLS without opt-in) is denied with -2, as
on the agent:
SERVICERADAR_CREDENTIAL_CLIENT_ID=local-client
SERVICERADAR_CREDENTIAL_CLIENT_SECRET=local-client-secretlet (capture, result) = run_local_host(
LocalHostOptions {
config_json: runtime_config,
http_handler: Some(new_local_broker()),
credential_grants: target.credential_grants().to_vec(),
credentials: inputs.credentials(),
..LocalHostOptions::default()
},
run_plugin,
);The caller-supplied HTTP handler is the trusted local host adapter and should enforce production-equivalent endpoint grants, credential injection, redirects, TLS, and response bounds. Credentials must not enter plugin config, action input, logs, or results. Local success confers no production authorization.
Build native examples:
cargo build --examplesBuild WebAssembly examples:
rustup target add wasm32-wasip1
cargo build --examples --target wasm32-wasip1Plugins target wasm32-wasip1 because the agent's runtime provides WASI
preview 1: wall-clock time, the monotonic clock and sleep. On
wasm32-unknown-unknown, Instant::now(), SystemTime::now() and
thread::sleep trap. conformance/runtime loads examples/clock-check into
wazero with the agent's module configuration and fails if the clock or sleep
does not work; CI runs it on every push.
Plugin runs are stateless. An action that needs to change what later scheduled
runs of the same assignment do, such as injecting a demo fault, returns a run
override in its ActionResult. The host delivers it to every later run until it
expires. The action's descriptor must declare max_override_duration_seconds;
the host clamps each override to it and ignores overrides from actions without
it.
// In the action: declare the bound, then set (or end) an override.
let descriptor = sdk::ActionDescriptor::new("inject_fault", "Inject fault", vec![])
.with_max_override_duration(1800);
let result = sdk::ActionResult::succeeded("Injected conveyor jam")
.set_run_override("fault-jam-7", "conveyor_jam", Some("conveyor-7".into()), 600, params)
.end_run_override("fault-saturation-2");
// In a scheduled run: read the delivered overrides.
for o in sdk::run_overrides()? {
if o.expired {
// Emit the resolving event; the host stops delivering after this run submits.
} else if o.active_at(time::OffsetDateTime::now_utc())? {
// Apply the override to this run.
}
}emit_ocsf_event emits a single OCSF event through the telemetry path from a
scheduled run or an action entrypoint, for example the opening event of an
injected fault. It requires the emit_telemetry capability.
The config and action-result wire shapes are pinned by
fixtures/plugin_run_overrides_config.json and
fixtures/northbound_action_result_run_overrides.json.
Plugins can emit serviceradar.device_discovery.v1 envelopes inside the normal
plugin-result payload. Core ingests these records through the device discovery
handler and reconciles them into ocsf_devices.
use serviceradar_sdk_rust as sdk;
let location = sdk::DeviceLocation::at(0.0, 0.0)
.with_site_code("SITE01")
.with_site_name("Example City");
let device = sdk::DiscoveredDevice::named("SITE01-MDF001-WAP001")
.with_serial("SN0000000001")
.with_device_type("access_point")
.with_location(location)
.with_label("site", "SITE01")
.with_metadata("radio_count", 2);
let result = sdk::PluginResult::ok("discovered 1 device").with_device_discovery(
sdk::DeviceDiscovery::new("example-network-map").with_device(device),
);
let payload = result.serialize()?;
# Ok::<_, sdk::Error>(())The discovery structs are public and serde-native, so collectors can also build
them with struct literals or mutate them incrementally with push_device,
add_device_discovery, and Extend while processing streams of discovered
assets.
Run the unit tests:
cargo testThe repository CI runs fmt, clippy, tests, native example builds, wasm example builds, and cargo publish --dry-run.
Crate publishing is automated in GitHub Actions. To publish a release:
- Update
versioninCargo.toml. - Push the commit to
main. - Create and push a matching tag such as
v0.1.5.
The publish workflow runs cargo publish --locked. Configure the GitHub repository secret CARGO_REGISTRY_TOKEN with a crates.io API token before using the release workflow.