An async SSH CLI scraper library for network device automation in Rust.
Warning: This library is EXTREMELY experimental and under active development. The API is subject to change without notice. Use in production at your own risk.
Ferrissh provides a high-level async API for interacting with network devices over SSH, heavily inspired by Python's scrapli and netmiko libraries.
- Async/Await - Built on Tokio and russh for efficient async SSH connections
- Multi-Vendor Support - Linux, Juniper JUNOS, Arista EOS, Nokia SR OS, Arrcus ArcOS
- Privilege Management - Automatic navigation between privilege levels
- Config Sessions - RAII-guarded config sessions with commit, abort, diff, validate, and confirmed commit
- ConfD Support - Generic ConfD config session shared by both C-style and J-style CLI vendors
- Interactive Commands - Handle prompts requiring user input (confirmations, passwords)
- Configuration Mode - Automatic privilege escalation for config commands
- Credential Protection - Passwords and passphrases wrapped in
SecretString(viasecrecy), redacted from Debug output - Multi-Channel - Multiple independent PTY shells on a single SSH connection via
Session+Channel - Streaming Output -
send_command_stream()yields normalized output chunks as they arrive, withfutures::Streamadapter. Ideal for large outputs (BGP tables, full configs) - Zero-Copy Responses -
Payloadtype backed by reference-countedByteswith in-place buffer normalization. Cheap clones. - Pattern Matching - Efficient tail-search buffer matching (scrapli-style optimization)
- Data-Driven Platforms - Platforms are pure data (prompts, privilege graphs, failure patterns) with optional extension traits for configuration sessions
Add to your Cargo.toml:
[dependencies]
ferrissh = "0.4"
tokio = { version = "1", features = ["full"] }use ferrissh::{Driver, DriverBuilder, Platform};
#[tokio::main]
async fn main() -> Result<(), ferrissh::Error> {
// Connect to a Linux host
let mut driver = DriverBuilder::new("192.168.1.1")
.username("admin")
.password("secret")
.platform(Platform::Linux)
.build()?;
driver.open().await?;
// Send a command
let response = driver.send_command("uname -a").await?;
println!("{}", response.result);
driver.close().await?;
Ok(())
}| Platform | Enum Variant | Privilege Levels | Config Session |
|---|---|---|---|
| Linux/Unix | Platform::Linux |
user ($), root (#) |
- |
| Juniper JUNOS | Platform::JuniperJunos |
exec (>), configuration (#), shell (%) |
JuniperConfigSession |
| Arista EOS | Platform::AristaEos |
exec (>), privileged (#), configuration ((config)#) |
AristaConfigSession |
| Nokia SR OS | Platform::NokiaSros |
exec (#), configuration ((ex)[]#), MD-CLI and Classic CLI |
NokiaSrosConfigSession |
| Arrcus ArcOS | Platform::ArrcusArcOs |
exec (#), configuration ((config)#), ConfD C-style CLI |
ConfDConfigSession |
Config sessions are RAII-guarded transactions that hold &mut Channel, preventing concurrent use at compile time. The commit() and abort() methods consume the session by value, enforcing single-use.
Ferrissh uses extension traits to express what each vendor's config session supports:
| Trait | Description | Vendors |
|---|---|---|
ConfigSession |
Core trait: send_command, commit, abort, detach |
All |
Diffable |
View uncommitted changes (diff()) |
Juniper, Arista, Nokia, ConfD |
Validatable |
Validate config before commit (validate()) |
Juniper, Arista, Nokia, ConfD |
ConfirmableCommit |
Auto-rollback commit (commit_confirmed(timeout)) |
Juniper, Arista, ConfD |
NamedSession |
Named/isolated config sessions (session_name()) |
Arista |
Each vendor implements only the traits it supports — the type system prevents calling features the vendor doesn't have.
Ferrissh includes a generic ConfDConfigSession that works with any vendor using Tail-f/Cisco ConfD as their management framework. The config session commands (commit, revert, validate, compare running-config, commit confirmed) are identical for both C-style and J-style ConfD CLIs — only the prompts and navigation commands differ, which are defined in each vendor's platform definition.
Arrcus ArcOS uses this generic ConfD session directly. Future ConfD-based vendors can reuse it with just a platform definition — no new config session code needed.
use ferrissh::{Driver, DriverBuilder, Platform};
let mut driver = DriverBuilder::new("router.example.com")
.username("admin")
.password("secret")
.platform(Platform::JuniperJunos)
.build()?;
driver.open().await?;
// Single command
let response = driver.send_command("show version").await?;
println!("{}", response.result);
// Multiple commands
let responses = driver.send_commands(&[
"show interfaces terse",
"show route summary",
"show bgp summary",
]).await?;
for response in responses {
println!("{}", response.result);
}
driver.close().await?;Process large outputs incrementally instead of buffering the entire response:
use ferrissh::Driver;
// Stream a large routing table
let mut stream = driver.send_command_stream("show route").await?;
while let Some(chunk) = stream.next_chunk().await? {
print!("{}", String::from_utf8_lossy(&chunk));
}
// Completion metadata (prompt, elapsed time, failure patterns)
let completion = stream.completion().unwrap();
println!("Completed in {:?}", completion.elapsed);Or use the futures::Stream adapter with StreamExt:
use futures_util::StreamExt;
let stream = driver.send_command_stream("show running-config").await?;
let mut pinned = Box::pin(stream.into_stream());
while let Some(chunk) = pinned.next().await {
let bytes = chunk?;
process_chunk(&bytes);
}use std::path::PathBuf;
let driver = DriverBuilder::new("192.168.1.1")
.username("admin")
.private_key(PathBuf::from("~/.ssh/id_rsa"))
.platform(Platform::Linux)
.build()?;Automatically enter and exit configuration mode:
// send_config handles privilege escalation automatically
let responses = driver.send_config(&[
"set interfaces ge-0/0/0 description 'Uplink'",
"set interfaces ge-0/0/0 unit 0 family inet address 10.0.0.1/30",
]).await?;
// Check for errors
for response in &responses {
if !response.is_success() {
eprintln!("Error: {:?}", response.failure_message);
}
}Config sessions provide RAII-guarded access to device configuration with commit, abort, diff, validate, and confirmed commit support:
use ferrissh::{ConfigSession, Diffable, Validatable, Driver};
use ferrissh::platform::vendors::juniper::JuniperConfigSession;
let mut driver = connect_juniper().await;
// Create a config session (enters config mode)
let mut session = JuniperConfigSession::new(driver.channel().unwrap()).await?;
// Make changes
session.send_command("set interfaces lo0 description 'test'").await?;
// Review pending changes
let diff = session.diff().await?;
println!("Changes:\n{}", diff);
// Validate before committing
let result = session.validate().await?;
if result.valid {
session.commit().await?; // Commits and exits config mode
} else {
session.abort().await?; // Discards changes and exits config mode
}Open multiple independent PTY shells on a single authenticated SSH connection:
use ferrissh::{Driver, DriverBuilder, Platform};
let mut driver = DriverBuilder::new("192.168.1.1")
.username("admin")
.password("secret")
.platform(Platform::Linux)
.build()?;
driver.open().await?;
// Open a second channel on the same SSH connection
let mut ch2 = driver.open_channel().await?;
// Each channel has its own shell, privilege state, and prompt detection
let (r1, r2) = tokio::try_join!(
driver.send_command("hostname"),
ch2.send_command("whoami"),
)?;
ch2.close().await?;
driver.close().await?;For full control, use SessionBuilder to create a session and open channels directly:
use ferrissh::{SessionBuilder, Platform};
let session = SessionBuilder::new("192.168.1.1")
.username("admin")
.password("secret")
.platform(Platform::Linux)
.connect().await?;
let mut ch1 = session.open_channel().await?;
let mut ch2 = session.open_channel().await?;
let (r1, r2) = tokio::try_join!(
ch1.send_command("uname -a"),
ch2.send_command("uptime"),
)?;
ch1.close().await?;
ch2.close().await?;
session.close().await?;Handle commands that require confirmation or input:
use ferrissh::{InteractiveBuilder, InteractiveEvent};
// Using the builder (fluent API)
let events = InteractiveBuilder::new()
.send("reload")
.expect(r"Proceed with reload\? \[confirm\]")?
.send("y")
.expect(r"#")?
.build();
let result = driver.send_interactive(&events).await?;
if !result.is_success() {
eprintln!("Interactive command failed!");
}
// With hidden input (passwords)
let events = InteractiveBuilder::new()
.send("enable")
.expect(r"[Pp]assword:")?
.send_hidden("secret_password") // Won't appear in logs
.expect(r"#")?
.build();// Check current privilege
if let Some(level) = driver.current_privilege() {
println!("Current level: {}", level);
}
// Navigate to a specific privilege level
driver.acquire_privilege("configuration").await?;
// Do configuration work...
driver.send_command("set system host-name new-router").await?;
// Return to operational mode
driver.acquire_privilege("exec").await?;For structured data extraction from CLI output, ferrissh works well with textfsm-rust - a Rust implementation of Google's TextFSM.
use ferrissh::{Driver, DriverBuilder};
use textfsm_rust::Template;
// Define a TextFSM template for parsing `df -h` output
const DF_TEMPLATE: &str = r#"
Value Filesystem (\S+)
Value Size (\S+)
Value Used (\S+)
Value Available (\S+)
Value UsePercent (\d+)
Value MountedOn (\S+)
Start
^Filesystem -> Continue
^${Filesystem}\s+${Size}\s+${Used}\s+${Available}\s+${UsePercent}%\s+${MountedOn} -> Record
"#;
// Run command and parse output
let response = driver.send_command("df -h").await?;
let template = Template::parse_str(DF_TEMPLATE)?;
let mut parser = template.parser();
let records = parser.parse_text_to_dicts(&response.result)?;
// Access structured data
for record in records {
if let Some(pct) = record.get("usepercent") {
if pct.parse::<u32>().unwrap_or(0) > 80 {
println!("Warning: {} is {}% full",
record.get("mountedon").unwrap_or(&String::new()), pct);
}
}
}With the serde feature enabled, parse directly into strongly-typed Rust structs:
[dependencies]
textfsm-rust = { version = "0.3", features = ["serde"] }use serde::Deserialize;
use textfsm_rust::Template;
#[derive(Debug, Deserialize)]
struct DiskUsage {
filesystem: String,
size: String,
used: String,
available: String,
usepercent: String,
mountedon: String,
}
let template = Template::parse_str(DF_TEMPLATE)?;
let mut parser = template.parser();
let disks: Vec<DiskUsage> = parser.parse_text_into(&response.result)?;
for disk in &disks {
println!("{} is {}% full", disk.mountedon, disk.usepercent);
}See the textfsm_parsing example for a complete demonstration with templates for Linux and Juniper commands.
Command responses use the Payload type — a zero-copy wrapper around reference-counted Bytes. It implements Deref<Target = str>, so it works anywhere a &str is expected:
let response = driver.send_command("show version").await?;
// All &str methods work via deref coercion
println!("{}", response.result); // Display
assert!(response.result.contains("JUNOS")); // str::contains
for line in response.result.lines() { // str::lines
println!(" {}", line);
}
let trimmed: &str = response.result.trim(); // str::trim
// Cloning is cheap (reference count increment, no data copy)
let cloned = response.result.clone();
// Convert to owned String when needed
let owned: String = response.result.into_string();The in-place normalization pipeline (linefeed normalization, echo stripping, prompt removal) operates directly on the buffer with SIMD-accelerated byte search via memchr, avoiding intermediate String allocations.
use ferrissh::platform::{PlatformDefinition, PrivilegeLevel, VendorBehavior};
use std::sync::Arc;
// Define privilege levels with prompt patterns
let exec = PrivilegeLevel::new("exec", r"[\w@]+>\s*$")?;
let config = PrivilegeLevel::new("config", r"[\w@]+#\s*$")?
.with_parent("exec")
.with_escalate("configure")
.with_deescalate("exit");
// Create platform definition — pure data, no driver code needed
let platform = PlatformDefinition::new("my_vendor")
.with_privilege(exec)
.with_privilege(config)
.with_default_privilege("exec")
.with_failure_pattern("error:")
.with_on_open_command("terminal length 0")
.with_behavior(Arc::new(MyVendorBehavior));
// Use with driver
let driver = DriverBuilder::new("device.example.com")
.custom_platform(platform)
.username("admin")
.password("secret")
.build()?;The ferrissh/examples/ directory contains several examples demonstrating different features. All examples support both password and SSH key authentication.
| Option | Description |
|---|---|
--host <HOST> |
Target hostname or IP (default: localhost) |
--port <PORT> |
SSH port (default: 22) |
--user <USER> |
Username (default: $USER) |
--password <PASS> |
Password authentication |
--key <PATH> |
Path to SSH private key |
--timeout <SECS> |
Connection timeout (default: 30) |
--help |
Show help message |
Basic example connecting to a Linux host and running commands.
# With password
cargo run --example basic_ls -- --host 192.168.1.10 --user admin --password secret
# With SSH key
cargo run --example basic_ls -- --host myserver --user admin --key ~/.ssh/id_ed25519Demonstrates connecting to Juniper devices and running operational/configuration commands.
# Basic operational commands
cargo run --example juniper -- --host router1 --user admin --password secret
# Include configuration mode demo
cargo run --example juniper -- --host router1 --user admin --key ~/.ssh/id_rsa --show-configDemonstrates connecting to Nokia SR OS devices with auto-detection of MD-CLI vs Classic CLI.
cargo run --example nokia_sros -- --host pe1 --user admin --password adminDemonstrates connecting to Arista EOS switches and running operational commands.
cargo run --example arista_eos -- --host switch1 --user admin --password secretDemonstrates RAII-guarded config sessions with diff, validate, commit, and abort.
# Juniper config session
cargo run --example config_session -- --host router1 --user admin --password secret --platform juniper
# Nokia SR OS config session
cargo run --example config_session -- --host pe1 --user admin --password admin --platform nokiaDemonstrates opening multiple PTY channels on a single SSH connection, both from a driver and from a session directly.
SSH_HOST=myserver SSH_USER=admin SSH_PASS=secret cargo run --example multi_channelShows how to handle commands that require user input or confirmation prompts.
cargo run --example interactive -- --host localhost --user admin --password secretDemonstrates using textfsm-rust to parse CLI output into structured data. Includes templates for common Linux and Juniper commands.
# Parse Linux commands (uname, df, ps)
cargo run --example textfsm_parsing -- \
--host localhost --user admin --key ~/.ssh/id_ed25519 --platform linux
# Parse Juniper commands (show version, show interfaces terse)
cargo run --example textfsm_parsing -- \
--host router1 --user admin --password secret --platform juniperSample output:
--- Parsed Data (TextFSM) ---
[
{
"filesystem": "/dev/nvme1n1p4",
"size": "853G",
"used": "278G",
"available": "532G",
"usepercent": "35",
"mountedon": "/"
}
]
Filesystems with >50% usage:
/sys/firmware/efi/efivars - 52% used (64K of 128K)
Enable debug logging to see detailed SSH and parsing information:
RUST_LOG=debug cargo run --example basic_ls -- --host localhost --user admin --key ~/.ssh/id_rsaLog levels: error, warn, info, debug, trace
- Credential protection - Passwords and key passphrases are stored as
SecretString(from thesecrecycrate), which zeroizes memory on drop.Debugformatting onAuthMethodandSshConfigredacts all secrets. - Input validation - Arista config session names are validated against injection (alphanumeric, hyphens, underscores only, max 63 chars). Builder inputs (host, port) are validated before connection.
- RAII safety - Config session guards only mark themselves as consumed after all operations succeed, ensuring
Dropwarnings fire on partial failures.
- Juniper JUNOS
- Nokia SR OS
- Arista EOS
- Arrcus ArcOS
- RAII-guarded config sessions (commit/abort/detach)
- Diff support
- Validate support
- Confirmed commit support
- Generic ConfD config session (shared by C-style and J-style vendors)
- SSH keepalive configuration
- Connection health checks (
is_alive()) - Multi-channel support (multiple PTY shells per connection)
- Proc macro for defining custom platforms declaratively
- Compile-time privilege graph validation
- Compile-time regex verification for prompt patterns
- Feature-gated
async_ssh2_litebackend
- Streaming output API (
send_command_stream(),futures::Streamadapter)
| Crate | Purpose |
|---|---|
russh |
SSH client library |
ssh-key |
SSH key handling |
tokio |
Async runtime |
bytes |
Zero-copy buffer management (BytesMut/Bytes) |
memchr |
SIMD-accelerated byte search for in-place normalization |
regex |
Pattern matching |
thiserror |
Error handling |
log |
Logging facade |
secrecy |
Credential protection (SecretString with zeroize) |
serde |
Serialization/deserialization |
indexmap |
Deterministic-order maps |
vte |
ANSI escape sequence stripping (reusable parser, zero-alloc) |
futures-core / futures-util |
Stream trait and adapters for streaming API |
Licensed under either of
at your option.
The architecture and design of ferrissh is heavily influenced by scrapli — the data-driven platform definitions, privilege level graph, and pattern buffer optimization are all concepts from scrapli. If you're working in Python, check it out.