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_hostshost-key pinning). Now on crates.io:rustnetconf0.12.0 ·rustnetconf-cli0.3.0 ·rustnetconf-yang0.1.2. See What's New in v0.12.0 below for theHostKeyVerification::KnownHostsvariant andknown_hosts_pathinventory key.
| 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) |
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 OpenSSHknown_hosts(5)file on every connect. Supports plain hostnames,[host]:port, wildcards (*/?), CIDR networks, hashed|1|salt|hmac-sha1entries, and@revokedmarkers. 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_pathfield on[devices.*]and[defaults]ininventory.toml. Per-device value wins; setting bothhost_key_fingerprintandknown_hosts_pathon the same device is a hard error. examples/known_hosts.rsdemonstrates thessh-keyscan→KnownHosts(path)workflow with comments on each failure mode.
Breaking changes:
DeviceConfig(the connection-pool config struct) gained a new fieldhost_key_verification: Option<HostKeyVerification>. Existing struct-literal callers must add the field.Nonemeans "use library default" (RejectAllsince v0.11.0).
Quality:
- Live-device integration tests (
integration_vsrx,integration_vendor_pool) are now opt-in viaRUSTNETCONF_TEST_VSRX_HOST— without it, the suite is a clean no-op for contributors without a Junos lab.
Security remediation pass — addresses the seven findings from the internal audit (RNC-SEC-001..006 + CI hardening). Merged via PR #27.
Breaking changes:
ClientBuilderdefaultHostKeyVerificationis nowRejectAll(fail closed). Connections refuse to complete until the caller pins a fingerprint or explicitly opts in toAcceptAll.ProxyJumphops parsed from~/.ssh/configlikewise default toRejectAll.inventory.toml: devicepasswordandkey_passphrasefields now deserialize into aSecretStringnewtype.DebugprintsSecretString(***)and contents zeroize on drop.ClientBuilder::password/.key_passphrasenow acceptOption<Zeroizing<String>>(was plainOption<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-keyfor lab use; otherwisehost_key_fingerprintmust be set per device ininventory.toml. - RNC-SEC-002 — RUSTSEC-2023-0071 (rsa Marvin Attack timing side-channel) risk-accepted via
.cargo/audit.tomlwith reachability analysis and review date 2026-08-01. russh bumped 0.60.2 → 0.60.3. - RNC-SEC-003 — Inventory passwords use the new
SecretStringtype with redactedDebugand a customDeserializethat zeroizes on drop. - RNC-SEC-004 — State files always land at
0o600via atomic temp-file +rename(2), even when a pre-existing file had looser permissions..netconfand.netconf/stateare forced to0o700on every call. Stale temp files from a prior crash are cleaned up. - RNC-SEC-005 —
applyandrollbackguarantee candidate-lock cleanup on error. NewClient::release_candidate_lock_best_effort(discard-changes + unlock, swallowing errors) is invoked from extracted*_locked_regionhelpers. - 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.ymlruns build, test (workspace, all features), clippy with-D warnings, rustfmt, andcargo auditon 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#type→type_to match the field-sanitization the generator actually emits.
Breaking changes:
HostKeyVerificationno longer implementsDefault— callers must explicitly choose a host key policySshAuth::PasswordandSshAuth::KeyFile { passphrase }now useZeroizing<String>instead ofString- User-provided XML content (RPC bodies, filters, configs) is now validated for well-formedness before sending
Security fixes:
- Shell injection via ProxyCommand
%h/%psubstitution — values are now shell-escaped - Credentials (passwords, passphrases) zeroized on drop via the
zeroizecrate - XML fragment validation prevents injection through malformed RPC content
- TLS
danger_accept_invalid_certsnow emits a detailed warning about the full scope of the bypass - CLI device names validated to prevent path traversal; state files written with
0600permissions
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_stringin async context replaced withtokio::fs - Unnecessary
Arc<Mutex<>>removed fromSshTransport AtomicU64message counter replaced with plainu64(Session is&mut selfonly)- 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
futuresdependency andquick-xmlserialize feature ErrorTagimplementsstd::str::FromStr;Session::validate()checks:validatecapability- Dependency updates: russh 0.60.2, rustls 0.23.40, tokio 1.52.2, rustls-webpki 0.103.13
| 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 |
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 validationmy-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)
[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 helloSecrets: 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.
[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"] }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(())
}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(())
}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(())
}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(())
}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(())
}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
:interleavebut 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 viadrain_notifications().
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- Async-first — tokio-based, push config to 500 devices concurrently
- SSH + TLS transports — SSH (RFC 6242) by default, TLS (RFC 7589) via
tlsfeature flag - SSH bastion support —
ProxyJump(multi-hop),ProxyCommand(shell-escaped), and OpenSSH~/.ssh/configalias 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 notifications —
create-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 recovery —
lock_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
- Auto-detection from device
<hello>capabilities - Junos — config wrapping, namespace normalization, discard-before-close
- Generic — standard RFC 6241 for any compliant device
- Extensible — implement
VendorProfiletrait for custom vendors
- 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
- Build-time generation from
.yangmodel 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
| 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, .. } |
| 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.
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 */ }
}| 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 |
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
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 cmakeThe 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-
RSA timing sidechannel (RUSTSEC-2023-0071) — The
rsacrate (transitive dependency viarussh → 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 filterrustnetconf::transportlogs.
- Credential zeroization — Passwords and key passphrases use
Zeroizing<String>(via thezeroizecrate) and are securely erased from memory on drop. - SSH host key verification —
HostKeyVerificationmust be set explicitly. TheClientBuilderdefault isRejectAll(fail closed): the SSH handshake fails until the caller pins a fingerprint viaFingerprint("SHA256:...")or explicitly opts in toAcceptAllfor lab use (logs atracing::warn!).ProxyJumphops parsed from~/.ssh/configlikewise default toRejectAlland must be individually configured. In the CLI, sethost_key_fingerprintper device ininventory.toml, or pass--insecure-accept-host-keyfor lab use only. - Shell-escaped ProxyCommand —
%hand%psubstitutions 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 warnings —
danger_accept_invalid_certsemits 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
0600permissions 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.
- RUSTSEC-2023-0071 (Marvin Attack,
rsacrate timing side-channel) is present in the dependency graph viarussh → 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.tomland 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.
- Use Ed25519 SSH keys (not RSA) for device authentication (also mitigates RUSTSEC-2023-0071 above)
- Set
host_key_verification(HostKeyVerification::Fingerprint(...))orHostKeyVerification::KnownHosts(path)in production — the default isRejectAll(fail closed), so the connection will refuse to complete until you choose a policy. For the CLI, set eitherhost_key_fingerprint = "SHA256:..."orknown_hosts_path = "/path/to/known_hosts"per device ininventory.toml(orknown_hosts_pathunder[defaults]for fleet-wide pinning). Seeexamples/known_hosts.rsfor thessh-keyscanworkflow. - 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 fornetconf 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.
| 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 |
MIT OR Apache-2.0
Contributions welcome! See ARCHITECTURE.md for the codebase design and TODOS.md for tracked work items.