Skip to content

fastrevmd-lab/rustnetconf

Repository files navigation

rustnetconf

crates.io — rustnetconf crates.io — rustnetconf-cli crates.io — rustnetconf-yang CI License: MIT OR Apache-2.0

A Rust network automation platform: async NETCONF client library, YANG code generation, vendor profiles, connection pooling, and a Terraform-like CLI for declarative network config management.

Built on tokio, russh, and rustls — pure Rust, no OpenSSL, no libssh2.

Latest release — v0.12.0 (OpenSSH known_hosts host-key pinning). Now on crates.io: rustnetconf 0.12.0 · rustnetconf-cli 0.3.0 · rustnetconf-yang 0.1.2. See What's New in v0.12.0 below for the HostKeyVerification::KnownHosts variant and known_hosts_path inventory key.

Workspace

Crate Description
rustnetconf Async NETCONF 1.0/1.1 client library
rustnetconf-yang YANG model code generation (compile-time config validation)
rustnetconf-cli Terraform-like CLI tool (netconf binary)

What's New in v0.12.0

OpenSSH known_hosts-style host-key pinning for fleet operation. Merged via PR #29 (closes #28).

New features:

  • HostKeyVerification::KnownHosts(PathBuf) — verify the server's SHA-256 fingerprint against an OpenSSH known_hosts(5) file on every connect. Supports plain hostnames, [host]:port, wildcards (*/?), CIDR networks, hashed |1|salt|hmac-sha1 entries, and @revoked markers. The file is re-read on every connect — no caching, so external rotation tools are picked up immediately.
  • New structured errors on TransportError: HostKeyMismatch { host, expected, actual }, HostKeyNotInKnownHosts { host, port, path }, HostKeyRevoked { host }.
  • CLI: new optional known_hosts_path field on [devices.*] and [defaults] in inventory.toml. Per-device value wins; setting both host_key_fingerprint and known_hosts_path on the same device is a hard error.
  • examples/known_hosts.rs demonstrates the ssh-keyscanKnownHosts(path) workflow with comments on each failure mode.

Breaking changes:

  • DeviceConfig (the connection-pool config struct) gained a new field host_key_verification: Option<HostKeyVerification>. Existing struct-literal callers must add the field. None means "use library default" (RejectAll since v0.11.0).

Quality:

  • Live-device integration tests (integration_vsrx, integration_vendor_pool) are now opt-in via RUSTNETCONF_TEST_VSRX_HOST — without it, the suite is a clean no-op for contributors without a Junos lab.

What's New in v0.11.0

Security remediation pass — addresses the seven findings from the internal audit (RNC-SEC-001..006 + CI hardening). Merged via PR #27.

Breaking changes:

  • ClientBuilder default HostKeyVerification is now RejectAll (fail closed). Connections refuse to complete until the caller pins a fingerprint or explicitly opts in to AcceptAll. ProxyJump hops parsed from ~/.ssh/config likewise default to RejectAll.
  • inventory.toml: device password and key_passphrase fields now deserialize into a SecretString newtype. Debug prints SecretString(***) and contents zeroize on drop.
  • ClientBuilder::password / .key_passphrase now accept Option<Zeroizing<String>> (was plain Option<String>).

Security fixes:

  • RNC-SEC-001 — SSH host-key verification fails closed by default in both the library and the CLI. New CLI flag --insecure-accept-host-key for lab use; otherwise host_key_fingerprint must be set per device in inventory.toml.
  • RNC-SEC-002 — RUSTSEC-2023-0071 (rsa Marvin Attack timing side-channel) risk-accepted via .cargo/audit.toml with reachability analysis and review date 2026-08-01. russh bumped 0.60.2 → 0.60.3.
  • RNC-SEC-003 — Inventory passwords use the new SecretString type with redacted Debug and a custom Deserialize that zeroizes on drop.
  • RNC-SEC-004 — State files always land at 0o600 via atomic temp-file + rename(2), even when a pre-existing file had looser permissions. .netconf and .netconf/state are forced to 0o700 on every call. Stale temp files from a prior crash are cleaned up.
  • RNC-SEC-005apply and rollback guarantee candidate-lock cleanup on error. New Client::release_candidate_lock_best_effort (discard-changes + unlock, swallowing errors) is invoked from extracted *_locked_region helpers.
  • RNC-SEC-006 — Desired XML is validated for well-formedness before any device connection or candidate lock. Errors name the offending file.

CI hardening:

  • New .github/workflows/ci.yml runs build, test (workspace, all features), clippy with -D warnings, rustfmt, and cargo audit on every push and PR to main.

Quality fixes:

  • YANG codegen: generated use super::*; / use crate::serialize::*; imports now carry #[allow(unused_imports)] so modules without leaf references compile cleanly under -D warnings.
  • YANG codegen test: corrected r#typetype_ to match the field-sanitization the generator actually emits.

What's New in v0.10.0

Breaking changes:

  • HostKeyVerification no longer implements Default — callers must explicitly choose a host key policy
  • SshAuth::Password and SshAuth::KeyFile { passphrase } now use Zeroizing<String> instead of String
  • User-provided XML content (RPC bodies, filters, configs) is now validated for well-formedness before sending

Security fixes:

  • Shell injection via ProxyCommand %h/%p substitution — values are now shell-escaped
  • Credentials (passwords, passphrases) zeroized on drop via the zeroize crate
  • XML fragment validation prevents injection through malformed RPC content
  • TLS danger_accept_invalid_certs now emits a detailed warning about the full scope of the bypass
  • CLI device names validated to prevent path traversal; state files written with 0600 permissions

New features:

  • Configurable RPC timeout (.rpc_timeout(Duration)) — prevents indefinite blocking on unresponsive devices
  • Configurable read buffer size (.max_read_buffer(bytes)) — defaults to 100 MB
  • IPv6 address support — bracket notation ([::1]:830) and bare IPv6 addresses
  • Capability normalization — legacy Junos capability URIs are mapped to standard URIs during session establishment

Quality improvements:

  • Connection pool health checks — dead connections are discarded on checkout and drop instead of being recycled
  • Blocking std::fs::read_to_string in async context replaced with tokio::fs
  • Unnecessary Arc<Mutex<>> removed from SshTransport
  • AtomicU64 message counter replaced with plain u64 (Session is &mut self only)
  • YANG codegen: full container/list XML serialization, complete Rust keyword list, hard error on module load failure
  • CLI: plan summary fixed for non-JSON mode, diff engine compares all list elements
  • Removed unused futures dependency and quick-xml serialize feature
  • ErrorTag implements std::str::FromStr; Session::validate() checks :validate capability
  • Dependency updates: russh 0.60.2, rustls 0.23.40, tokio 1.52.2, rustls-webpki 0.103.13

RFC Support

RFC Feature Status
RFC 6241 Network Configuration Protocol (NETCONF) ✅ supported
RFC 6242 NETCONF over SSH ✅ supported
RFC 7589 NETCONF over TLS ✅ supported (feature flag tls) — needs physical SRX or non-vSRX for TLS test
RFC 5277 Event Notifications ✅ supported — tested on Junos 24.4 vSRX (subscription + capability; interleave limited by device)
RFC 5717 Partial Lock RPC 💡 planned
RFC 8071 NETCONF Call Home 💡 planned
RFC 6243 With-defaults Capability 💡 planned
RFC 6022 YANG Module for NETCONF Monitoring 💡 planned
RFC 8526 NETCONF Extensions for NMDA 💡 planned
RFC 6470 NETCONF Base Notifications 💡 planned
RFC 8040 RESTCONF 💡 planned

CLI Tool — netconf

Declarative network config management. Write desired state as XML files, the CLI diffs against the device and applies changes with confirmed-commit safety.

netconf init                    # Create project skeleton
netconf plan spine-01           # Show what would change (colored diff)
netconf apply spine-01          # Apply with confirmed-commit (auto-revert on timeout)
netconf confirm spine-01        # Make changes permanent
netconf rollback spine-01       # Revert to saved state
netconf get spine-01            # Fetch running config
netconf validate spine-01       # Dry-run validation

Project Structure

my-network/
├── inventory.toml              # Device connection details
├── desired/
│   └── spine-01/
│       ├── interfaces.xml      # Desired interface config
│       └── system.xml          # Desired system config
└── .netconf/state/             # Rollback snapshots (auto-managed)

inventory.toml

[defaults]
confirm_timeout = 60

[devices.spine-01]
host = "10.0.0.1:830"
username = "admin"
key_file = "~/.ssh/id_ed25519"
# vendor auto-detected from device hello

Secrets: inventory.toml may contain plaintext passwords. Prefer key_file or SSH-agent auth where possible. If you must use inline passwords, protect the file with chmod 600 inventory.toml and add it to .gitignore. Passwords are stored in zeroizing memory and redacted from Debug output, but the on-disk file itself is plaintext.

Library — Quick Start

[dependencies]
rustnetconf = { git = "https://github.com/fastrevmd-lab/rustnetconf.git" }
tokio = { version = "1", features = ["full"] }

For TLS transport (RFC 7589), enable the tls feature:

[dependencies]
rustnetconf = { git = "https://github.com/fastrevmd-lab/rustnetconf.git", features = ["tls"] }

Fetch running config

use rustnetconf::{Client, Datastore};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = Client::connect("10.0.0.1:830")
        .username("admin")
        .key_file("~/.ssh/id_ed25519")
        .connect()
        .await?;

    let config = client.get_config(Datastore::Running).await?;
    println!("{config}");

    client.close_session().await?;
    Ok(())
}

Edit config (full round trip)

use rustnetconf::{Client, Datastore, DefaultOperation};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = Client::connect("10.0.0.1:830")
        .username("admin")
        .password("secret")
        .connect()
        .await?;

    client.lock(Datastore::Candidate).await?;

    client.edit_config(Datastore::Candidate)
        .config("<interface><name>ge-0/0/0</name><description>uplink</description></interface>")
        .default_operation(DefaultOperation::Merge)
        .send()
        .await?;

    client.validate(Datastore::Candidate).await?;
    client.commit().await?;
    client.unlock(Datastore::Candidate).await?;

    client.close_session().await?;
    Ok(())
}

Connect through a jump host (ProxyJump)

use rustnetconf::{Client, Datastore};
use rustnetconf::transport::ssh::{JumpHostConfig, SshAuth, HostKeyVerification};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bastion = JumpHostConfig {
        host: "bastion.example.com".into(),
        port: 22,
        username: "jumpuser".into(),
        auth: SshAuth::Agent,
        host_key_verification: HostKeyVerification::AcceptAll,
    };

    let mut client = Client::connect("10.0.0.1:830")
        .username("admin")
        .ssh_agent()
        .jump_hosts(vec![bastion])
        .connect()
        .await?;

    let config = client.get_config(Datastore::Running).await?;
    println!("{config}");
    client.close_session().await?;
    Ok(())
}

Connect using your ~/.ssh/config

use rustnetconf::{Client, Datastore};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Resolves `Host edge-r1` from ~/.ssh/config — picks up HostName, Port,
    // User, IdentityFile, ProxyJump, ProxyCommand. NETCONF default port 830
    // is used when the config doesn't pin Port.
    let mut client = Client::connect_via_ssh_config("edge-r1")?
        .ssh_agent()
        .connect()
        .await?;

    let config = client.get_config(Datastore::Running).await?;
    println!("{config}");
    client.close_session().await?;
    Ok(())
}

Connect over TLS (RFC 7589)

Note: vSRX 24.4 has a known TLS handshake issue where the PKI engine cannot present a self-signed certificate chain. TLS testing requires a physical SRX, MX, or EX device with a CA-signed certificate. The code compiles and passes unit tests but has not been validated against a live TLS-capable device.

use rustnetconf::{Client, TlsConfig, Datastore};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = TlsConfig {
        host: "10.0.0.1".into(),
        ca_cert: Some("ca.pem".into()),
        client_cert: Some("client.pem".into()),
        client_key: Some("client-key.pem".into()),
        ..Default::default()
    };

    let mut client = Client::connect_tls(config).connect().await?;
    let config = client.get_config(Datastore::Running).await?;
    println!("{config}");

    client.close_session().await?;
    Ok(())
}

Event notifications (RFC 5277)

use rustnetconf::{Client, Datastore};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = Client::connect("10.0.0.1:830")
        .username("admin")
        .password("secret")
        .connect()
        .await?;

    // Subscribe to NETCONF event stream
    client.create_subscription(Some("NETCONF"), None, None, None).await?;

    // Block waiting for notifications
    while let Some(notif) = client.recv_notification().await? {
        println!("[{}] {}", notif.event_time, notif.event_xml);
    }

    Ok(())
}

Note: Some devices (e.g., Junos vSRX 24.4) advertise :interleave but do not respond to RPCs on a session with an active subscription. On these devices, use a dedicated session for notifications and a separate session for RPCs. Notifications arriving during RPCs on interleave-capable devices are automatically buffered and available via drain_notifications().

Connection pooling

use rustnetconf::pool::{DevicePool, DeviceConfig};
use rustnetconf::transport::ssh::SshAuth;
use rustnetconf::Datastore;
use zeroize::Zeroizing;

let pool = DevicePool::builder()
    .max_connections(50)
    .add_device("spine-01", DeviceConfig {
        host: "10.0.0.1:830".into(),
        username: "admin".into(),
        auth: SshAuth::KeyFile { path: "~/.ssh/id_ed25519".into(), passphrase: None },
        vendor: None, // auto-detect
    })
    .build();

let mut conn = pool.checkout("spine-01").await?;
let config = conn.get_config(Datastore::Running).await?;
// connection auto-returned to pool on drop

Features

NETCONF Client

  • Async-first — tokio-based, push config to 500 devices concurrently
  • SSH + TLS transports — SSH (RFC 6242) by default, TLS (RFC 7589) via tls feature flag
  • SSH bastion supportProxyJump (multi-hop), ProxyCommand (shell-escaped), and OpenSSH ~/.ssh/config alias resolution
  • NETCONF 1.0 + 1.1 — EOM and chunked framing with auto-negotiation
  • All core RPCs — get, get-config, edit-config, lock/unlock, commit, validate, close/kill-session, discard-changes
  • Confirmed commit — auto-rollback safety net (RFC 6241 §8.4)
  • Event notificationscreate-subscription, inline notification demux, buffered drain/recv API (RFC 5277)
  • RPC timeout — configurable per-session deadline prevents indefinite blocking on unresponsive devices
  • XML fragment validation — user-provided RPC content is validated before insertion to prevent XML injection
  • CommitUnknown detection — distinguishes "commit failed" from "maybe committed, connection lost"
  • Stale lock recoverylock_or_kill_stale() kills crashed sessions holding locks
  • Framing mismatch detection — catches firmware bugs where devices send wrong framing
  • IPv6 support — connect to devices using bracket notation ([::1]:830) or bare IPv6 addresses

Vendor Profiles

  • Auto-detection from device <hello> capabilities
  • Junos — config wrapping, namespace normalization, discard-before-close
  • Generic — standard RFC 6241 for any compliant device
  • Extensible — implement VendorProfile trait for custom vendors

Connection Pool

  • Tokio semaphore-based concurrency limiting
  • Checkout with timeout (no blocking forever)
  • Auto-checkin on drop with health check — dead connections are discarded, not recycled
  • Connection reuse from idle pool

YANG Code Generation

  • Build-time generation from .yang model files via libyang2
  • Typed Rust structs with serde Serialize/Deserialize
  • Full XML serialization — leaves, containers, and lists
  • Correct type mapping (string, bool, uint32, etc.)
  • Complete Rust keyword escaping for YANG node names
  • Bundled IETF models: ietf-interfaces, ietf-ip, ietf-yang-types, ietf-inet-types

Authentication

Method Transport Builder API
Password SSH .password("secret")
Key file SSH .key_file("~/.ssh/id_ed25519")
SSH agent SSH .ssh_agent()
Server-only TLS TLS TlsConfig { ca_cert, .. }
Mutual TLS (mTLS) TLS TlsConfig { client_cert, client_key, .. }

SSH Connection Options

Option Builder API Notes
Direct TCP (default) No proxy
ProxyJump (bastion chain) .jump_hosts(Vec<JumpHostConfig>) Each hop has its own credentials and host-key policy
ProxyCommand .proxy_command("ssh -W %h:%p bastion") %h/%p shell-escaped and substituted; runs under sh -c
~/.ssh/config alias Client::connect_via_ssh_config("alias")? Resolves HostName, Port, User, IdentityFile, ProxyJump, ProxyCommand, Include

jump_hosts and proxy_command are mutually exclusive at connect time.

Error Handling

Layered errors matching the protocol stack:

match result {
    Err(NetconfError::Transport(e)) => { /* SSH/TLS connection issues */ }
    Err(NetconfError::Framing(e))   => { /* Protocol framing errors */ }
    Err(NetconfError::Rpc(e))       => { /* Device rejected RPC (all 7 RFC fields parsed) */ }
    Err(NetconfError::Protocol(e))  => { /* Capability/session errors */ }
    Ok(response) => { /* Success */ }
}

Supported Operations

Operation RFC 6241 Status
get §7.7 Done
get-config §7.1 Done
edit-config §7.2 Done
lock / unlock §7.4-7.5 Done
close-session §7.8 Done
kill-session §7.9 Done
commit §8.4 Done
confirmed-commit §8.4 Done
validate §8.6 Done
discard-changes §8.3 Done

Testing

197 tests across the workspace:

  • Unit tests — framing, RPC serialization, capability parsing, vendor profiles, diff engine, inventory parsing, IPv6 address parsing, XML fragment validation, capability normalization
  • Mock transport tests — session state machine, CommitUnknown detection, lock recovery
  • Integration tests — 32 tests against a live Juniper vSRX including full edit-config round trips, vendor auto-detection, connection pooling, and concurrent sessions

Prerequisites

The rustnetconf-yang subcrate builds libyang2 from source via yang2's bundled feature, which requires cmake. Install it before running workspace-wide tests or clippy:

# Debian/Ubuntu
sudo apt-get install cmake

# macOS
brew install cmake

# Fedora/RHEL
sudo dnf install cmake

The core rustnetconf and rustnetconf-cli crates do not require cmake; cargo test -p rustnetconf works without it.

cargo test --workspace                    # Run all tests (requires cmake)
cargo test --test integration_vsrx        # Run vSRX integration tests only
SKIP_INTEGRATION=1 cargo test             # Skip tests requiring a device

Security

Known Issues

  • RSA timing sidechannel (RUSTSEC-2023-0071) — The rsa crate (transitive dependency via russh → internal-russh-forked-ssh-key → rsa) has a known timing sidechannel that could theoretically allow RSA key recovery. No upstream fix is available. Mitigation: Use Ed25519 or ECDSA keys instead of RSA for SSH authentication.

  • Debug logs may contain file paths — When SSH key file loading fails, the key file path is included in tracing::debug! output. This is not exposed at info/warn/error levels. Mitigation: Disable debug-level logging in production, or filter rustnetconf::transport logs.

Security Features

  • Credential zeroization — Passwords and key passphrases use Zeroizing<String> (via the zeroize crate) and are securely erased from memory on drop.
  • SSH host key verificationHostKeyVerification must be set explicitly. The ClientBuilder default is RejectAll (fail closed): the SSH handshake fails until the caller pins a fingerprint via Fingerprint("SHA256:...") or explicitly opts in to AcceptAll for lab use (logs a tracing::warn!). ProxyJump hops parsed from ~/.ssh/config likewise default to RejectAll and must be individually configured. In the CLI, set host_key_fingerprint per device in inventory.toml, or pass --insecure-accept-host-key for lab use only.
  • Shell-escaped ProxyCommand%h and %p substitutions are shell-escaped to prevent command injection via malicious hostnames.
  • XML fragment validation — All user-provided RPC content is validated for well-formedness before insertion, preventing XML injection.
  • XML attribute escaping — All message-id values are escaped to prevent XML attribute injection.
  • TLS bypass warningsdanger_accept_invalid_certs emits a detailed warning explaining that ALL certificate validation is bypassed (trust chain, signatures, hostname, and expiry).
  • Read buffer limits — Session read buffers default to 100 MB (configurable via .max_read_buffer()) to prevent memory exhaustion.
  • RPC timeout — Configurable via .rpc_timeout() to prevent indefinite blocking on unresponsive devices.
  • CLI input validation — Device names are validated to prevent path traversal; state files are written with 0600 permissions on Unix.
  • Typed error hierarchy — Structured error types (ChannelClosed, SessionExpired, MessageIdMismatch) enable precise error handling without string matching.
  • No unsafe code — The entire codebase uses safe Rust.

Known advisories

  • RUSTSEC-2023-0071 (Marvin Attack, rsa crate timing side-channel) is present in the dependency graph via russh → internal-russh-forked-ssh-key → rsa 0.10.0-rc.16. No fixed upstream release is available yet. The advisory is risk-accepted with rationale in .cargo/audit.toml and CI re-checks on every run. It only matters when an RSA SSH key is used for authentication — Ed25519 and ECDSA paths are unaffected. Use the mitigation in the next section.

Security Best Practices

  • Use Ed25519 SSH keys (not RSA) for device authentication (also mitigates RUSTSEC-2023-0071 above)
  • Set host_key_verification(HostKeyVerification::Fingerprint(...)) or HostKeyVerification::KnownHosts(path) in production — the default is RejectAll (fail closed), so the connection will refuse to complete until you choose a policy. For the CLI, set either host_key_fingerprint = "SHA256:..." or known_hosts_path = "/path/to/known_hosts" per device in inventory.toml (or known_hosts_path under [defaults] for fleet-wide pinning). See examples/known_hosts.rs for the ssh-keyscan workflow.
  • Set .rpc_timeout(Duration::from_secs(30)) to prevent hanging on unresponsive devices
  • Prefer SSH agent auth over inline passwords
  • Store credentials in inventory.toml with restricted file permissions (chmod 600)
  • Run the CLI on trusted management networks with direct device connectivity
  • Use confirmed-commit (the default for netconf apply) so the device auto-reverts if something goes wrong
  • Disable debug-level logging in production environments

To report a security vulnerability, please open an issue on GitHub.

Dependencies

Crate Version Purpose
async-trait 0.1 Async trait support
quick-xml 0.37 XML parsing (NETCONF RPC encode/decode)
russh 0.60 SSH transport (pure Rust, no libssh2)
thiserror 2 Error derive macros
tokio 1 Async runtime
tracing 0.1 Structured logging/tracing
zeroize 1 Secure credential erasure on drop

Optional (behind tls feature):

Crate Version Purpose
rustls 0.23 TLS transport (pure Rust, no OpenSSL)
tokio-rustls 0.26 Async TLS stream adapter
webpki-roots 0.26 Mozilla CA root certificates

Dev-only:

Crate Version Purpose
tokio-test 0.4 Async test utilities
tracing-subscriber 0.3 Log subscriber for tests
tempfile 3 Temporary directories for tests

License

MIT OR Apache-2.0

Contributing

Contributions welcome! See ARCHITECTURE.md for the codebase design and TODOS.md for tracked work items.

About

A Rust network automation platform: async NETCONF client library, YANG code generation, vendor profiles, connection pooling, and a Terraform-like CLI for declarative network config management.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages