Rust Modules
A Rust module compiles to a single binary that the daemon launches by path. The SDK
lives at sdk/rust/hovel and is dependency-free: it
hand-rolls a tiny JSON layer and base64 codec rather than pulling in
serde_json, so the whole crate builds with nothing but the standard
library. Real modules are free to add whatever crates they like. The worked examples
are under modules/examples/rust/. The generated source reference is published at
Rust SDK API.
Rust's standard library already definesResult, so this SDK names the module return typeOutcome. It is the exact equivalent of the Python/GoResult.
The Module trait
pub trait Module {
fn info(&self) -> Info; // → handshake
fn schema(&self) -> Schema; // → schema
fn run(&self, ctx: &mut Context) -> Outcome; // → execute
}
fn main() { hovel::serve(MyModule); }
info and schema are catalog-inspection paths. Keep
them fast, deterministic, offline, and side-effect free. Put target I/O,
payload building, random target-side names, and listener startup in
run or in a Go/Python step provider, not in Rust module metadata.
Stdout is reserved for the SDK's framed JSON-RPC stream. Use
ctx.info and ctx.error for structured logs, artifacts
for captured output, and sessions for interactive streams. Use stderr only for
process-level diagnostics.
Current SDK boundary
The Rust SDK currently implements the base module lifecycle, structured logs,
artifacts, interactive sessions, installed-payload result descriptors, all
optional Mesh methods, all five typed credential-provider methods, and
authenticated TCP/UDP Mesh bridge clients. It does not dispatch the generic
step.* methods or the full payload-provider RPC surface available
in Go.
Use Rust today for standalone survey or exploit modules where a compiled,
dependency-free SDK binary is valuable, including Mesh and credential-aware
providers and modules that return installed-payload descriptors from
run. Use Go for payload-provider discovery, generation,
reconnect, and cleanup RPC. Use Go or Python for capability-native
step.* providers.
TLS, workspace PKI, and Mesh bridges
A normal Rust module can use its chosen TLS crate inside run
without overriding credential methods. Override them only when the module
consumes or transforms assignment-bound workspace-PKI material. The base
execute path does not automatically receive a resolved PKI bundle.
| Provider need | Module method |
|---|---|
| Describe strict slots and capabilities | describe_credential_delivery |
| Consume in-memory bytes or a scoped reference | load_runtime_credential |
| Consume invocation-scoped protected files | load_credential_files |
| Produce one bounded provider-native encoding | encode_credential_material |
| Stamp an artifact or provider-owned deployment | stamp_credential |
Override only the methods advertised by CredentialDeliveryDescriptor;
every other default returns an explicit unsupported error. Secret wrappers redact
Debug, but accessors still expose values to provider code. Keep them
scoped to the call and out of logs, errors, outcomes, and durable config. The
current external path initiates direct runtime certificate DER, public-key SPKI,
and private-key PKCS #8 delivery before Mesh operations. General non-Mesh
delivery and external encoding/stamping initiation remain extension points.
See TLS and Workspace PKI,
Credential Provider Development,
and the generated Rust SDK API.
Connect to an authenticated Mesh bridge
use hovel::{
connect_mesh_bridge, MeshBridgeConnection, MeshBridgeEndpoint,
};
use std::time::Duration;
let endpoint = MeshBridgeEndpoint::from_response(
bridge.local_host,
u16::try_from(bridge.local_port)?,
&bridge.local_network,
bridge.capability,
)?;
match connect_mesh_bridge(&endpoint, Some(Duration::from_secs(5)))? {
MeshBridgeConnection::Tcp(stream) => use_tcp(stream)?,
MeshBridgeConnection::Udp(socket) => use_udp(socket)?,
}
from_response strictly parses the response's
localNetwork, not the provider-defined
request.protocol. The generic connector preserves the concrete TCP
or UDP socket in an enum; specialized helpers are also available and reject a
mismatched endpoint. All helpers validate a canonical numeric loopback address
and send the ephemeral bearer capability before application data. Never log or
persist it. Use a provider task or session when the protocol cannot preserve TCP
stream or UDP datagram semantics. The complete decision map is
Build a Module or Provider.
A survey module
use hovel::json::Value;
use hovel::{Context, Info, Module, ModuleType, Outcome, Requirement, Schema};
struct MockSurvey;
impl Module for MockSurvey {
fn info(&self) -> Info {
Info {
name: "mock-survey-rust".into(),
version: "v0.0.0-example".into(),
module_type: ModuleType::Survey,
summary: "Collect example target facts.".into(),
description: String::new(),
tags: vec!["example".into(), "survey".into(), "rust".into()],
discovery_context: Vec::new(),
}
}
fn schema(&self) -> Schema {
Schema {
target_config: vec![
Requirement::new("target.host", "host", "Target host name or IP address."),
Requirement::new("target.port", "port", "Target TCP port."),
],
..Schema::default()
}
}
fn run(&self, ctx: &mut Context) -> Outcome {
let host = ctx.input_str("target.host", &ctx.target);
let port = ctx.input_str("target.port", "unknown");
ctx.info("connecting to target",
&[("host", Value::from(host.as_str())), ("port", Value::from(port.as_str()))]);
let facts = Value::object(vec![
("host", Value::from(host.as_str())),
("port", Value::from(port.as_str())),
("reachable", Value::from(true)),
]);
Outcome::ok(vec![("facts".into(), facts)])
.with_summary(&format!("example survey reached {host}:{port}"))
}
}
fn main() { hovel::serve(MockSurvey); }
ctx.input_str(key, default) resolves a config value as a string.
ctx.info(message, fields) emits a module/log notification;
fields are (&str, Value) pairs built with the Value::from
conversions.
Findings and artifacts
The Outcome builder chains with with_* methods.
fn run(&self, ctx: &mut Context) -> Outcome {
if ctx.input_str("failure_mode", "") == "execution" {
return Outcome::failed("example exploit failed during execution");
}
Outcome::ok(vec![("target".into(), Value::from(ctx.target.as_str()))])
.with_summary("example exploit completed without target interaction")
.with_finding(Finding::new(
"example exploit path verified", "info",
"exercised orchestration without touching a real target",
))
.with_artifact(Artifact::inline(
"mock-exploit-rust-transcript.txt", "text/plain",
"example module invoked through daemon RPC",
))
}
Prefer Outcome::failed for expected operator-facing failures such
as "not vulnerable" or "missing confirmation". Reserve panics and process
exits for bugs or unrecoverable local failures; otherwise the daemon sees a
broken module rather than a useful exploit result.
Installed payload records
A Rust exploit that installs or observes a durable target-side payload can
return an explicit InstalledPayloadDescriptor. Hovel validates the
descriptor, assigns the operator-facing handle, and persists it as installed
payload inventory. The Rust module still does not own workspace writes.
use hovel::{InstalledPayloadDescriptor, PayloadProviderRecord};
let payload = InstalledPayloadDescriptor::new(
"squatter",
"squatter/windows/x86/xp-sp3/tcp-bind/pe-exe",
ctx.target.as_str(),
)
.with_payload_version("0.1.0")
.with_transport("tcp-bind")
.with_endpoint("10.0.0.42:9101")
.with_supports_reconnect(true)
.with_reconnect(
PayloadProviderRecord::new(
"squatter.reconnect.tcp_bind",
vec![
("host".into(), Value::from("10.0.0.42")),
("port".into(), Value::from(9101_i64)),
],
)
.with_schema_version("1"),
)
.with_cleanup(PayloadProviderRecord::new(
"squatter.cleanup.tcp_bind",
vec![("remotePath".into(), Value::from(r"C:\Windows\Temp\n4x9q2.exe"))],
));
Outcome::ok(vec![("target".into(), Value::from(ctx.target.as_str()))])
.with_summary("payload installed and recorded")
.with_installed_payload(payload)
Interactive sessions
LineShellSession::new takes a prompt, an echo flag, and a command
handler closure. ctx.open_session registers it; the session outlives
run.
fn run(&self, ctx: &mut Context) -> Outcome {
let target = ctx.target.clone();
let shell = LineShellSession::new("mock$ ", true, move |command| match command {
"whoami" => "mock-operator".to_string(),
other => format!("mock-shell: command not found: {other}"),
});
let sref = match ctx.open_session(
Box::new(shell),
SessionOptions::default()
.with_name(&format!("mock shell on {target}"))
.with_capabilities(&["read", "write", "exec", "close"]),
) {
Ok(sref) => sref,
Err(err) => return Outcome::failed(&format!("failed to open session: {err}")),
};
Outcome::ok(vec![("sessionId".into(), Value::from(sref.id.as_str()))])
.with_summary("mock exploit opened an interactive shell session")
}
Test without the daemon
Keep pure module behavior testable through ordinary Rust tests. For protocol
coverage, use the SDK's serve_with helper with an in-memory reader
and writer; that exercises the same Content-Length framing the
daemon uses.
#[test]
fn info_is_a_rust_exploit() {
let info = MockExploit.info();
assert_eq!(info.name, "mock-exploit-rust");
assert_eq!(info.module_type, ModuleType::Exploit);
}
The Rust SDK and in-repo Rust examples now live outside the core Bazel
workspace. Validate them through task sdk:test,
task sdk:ci, task modules:examples:test, and
task modules:examples:ci; the Task-backed targets remain hermetic.
For real exploit work, keep parsers, packet builders, state machines, and result construction behind testable functions. Reserve live target execution for an authorized lab; CI should stay offline and deterministic.
Build, package, and run
Rust examples are part of the modules/ slice after the
partial-checkout split. Use task modules:examples:ci for the
language examples, task modules:ci for the full module slice,
and task release:modules-package for installable archives. These
remain separate from the core-only task build gate while
task ci composes them in a full checkout.
rust_binary(
name = "mock-survey-rust",
srcs = ["src/main.rs"],
edition = "2021",
deps = ["//sdk/rust/hovel"],
)
apiVersion: hovel.dev/v1alpha1
kind: ModulePackage
metadata:
name: mock-survey-rust
version: 0.1.0
moduleType: survey
runtime:
protocol: jsonrpc-stdio
launch:
- selector:
os: linux
arch: amd64
command: ["bin/linux-amd64/mock-survey-rust"]
hovel module install --link "$PWD/modules/examples/rust/mock_survey"
task start
hovel> module installed # ... mock-survey-rust, mock-exploit-rust, mock-exploit-session-rust
That completes the trio: the same three modules in Python, Go, and Rust, all speaking the one wire protocol.