Skip to content

About

ServiceRadar plugin SDK for Rust

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

serviceradar-sdk-rust

ServiceRadar plugin SDK for Rust and WebAssembly.

Source of truth: https://github.com/carverauto/serviceradar-sdk-rust

Overview

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.v1 payloads
  • Run overrides that let an action leave time-bounded state for later scheduled runs, and emit_ocsf_event for OCSF events from any entrypoint
  • Host-proxied unary gRPC calls
  • HTTP response headers (envelope mode) with Retry-After parsing
  • 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.

Install

cargo add serviceradar-sdk-rust

Example

use 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",
            )))
    });
}

Examples

  • clock-check
  • http-check
  • tcp-check
  • udp-check
  • rtsp-check
  • widgets-check
  • northbound-actions

Signal display contracts

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.

HTTP response headers

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-insensitive

Unary gRPC

sdk::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.

Credential broker grants

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.

Plugin Manifest

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 encodes

SignalSchemaContribution::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, and outputs are checked against core's allowlists.
  • signal_type must be event or log; payload_kind must be ocsf_event or otel_log.
  • Schema and display-contract versions must be semver.
  • Bundle paths must be relative .json files 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.

Advisory Feed Producers

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.

Source-native local development

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-password
use 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-secret
let (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 --examples

Build WebAssembly examples:

rustup target add wasm32-wasip1
cargo build --examples --target wasm32-wasip1

Plugins 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.

Run Overrides

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.

Device Discovery

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.

Verification

Run the unit tests:

cargo test

The repository CI runs fmt, clippy, tests, native example builds, wasm example builds, and cargo publish --dry-run.

Release

Crate publishing is automated in GitHub Actions. To publish a release:

  1. Update version in Cargo.toml.
  2. Push the commit to main.
  3. 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.

About

ServiceRadar plugin SDK for Rust

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages