A Rust replacement for Juniper PyEZ
a mechub project — sovereign network-security automation
Unofficial / community project. This repository is an independent, community-driven project. It is not affiliated with, endorsed by, sponsored by, or supported by Hewlett Packard Enterprise or Juniper Networks. "HPE", "Juniper", "SRX", "JUNOS", "Security Director" and "Juniper Mist" are trademarks of their respective owners and are used here only to describe what this software interoperates with. Please direct support and licensing questions about those products to the respective vendors.
Async-first Junos device automation, replacing Juniper PyEZ — built on rustnetconf.
PyEZ is the de facto Python library for Junos automation. It works, but:
- Slow at scale — synchronous, single-threaded. Managing hundreds of devices is painful
- Runtime errors — dynamic typing means bugs surface in production, not at compile time
- No real concurrency — threading is bolted on, not native
rustEZ gives you the same Junos automation capabilities with:
- Native async concurrency — async Rust with tokio enables parallel operations across thousands of devices
- Compile-time safety — typed RPCs, typed facts, typed configs. Wrong RPC? The compiler tells you
- Parallel operations —
tokio::join!makes it trivial to run thousands of device operations in parallel
rustez/ Core library — Device, Facts, Config, RPC, operational data
rustez-cli/ CLI binary — Junos automation from the terminal
rustez-py/ Python bindings via PyO3 — pip install rustez
Built on rustnetconf for NETCONF transport, SSH (via russh), connection pooling, vendor profiles, and event notifications (RFC 5277).
use rustez::{Device, ConfigPayload};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect and gather facts
let mut dev = Device::connect("10.0.0.1")
.username("admin")
.password("secret")
.open()
.await?;
let facts = dev.facts().await?;
println!("{} running Junos {}", facts.hostname, facts.version);
// Push a config change
let mut config = dev.config()?;
config.lock().await?;
config.load(ConfigPayload::Text(
"system { host-name new-hostname; }".into()
)).await?;
if let Some(diff) = config.diff().await? {
println!("Changes:\n{diff}");
config.commit().await?;
}
config.unlock().await?;
// Run an operational RPC
let output = dev.cli("show interfaces terse").await?;
println!("{output}");
dev.close().await?;
Ok(())
}Some Junos platforms limit the number of concurrent NETCONF sessions. Exceeding the limit causes connection resets.
| Platform | Max Concurrent Sessions |
|---|---|
| vSRX | 3 |
| SRX (branch) | 3 |
| MX / EX / QFX | 8+ (varies by model) |
When automating multiple operations against the same device, keep your
concurrent connections within these limits. The v0.3 DevicePool will
auto-detect platform personality and enforce the correct ceiling
automatically.
# Gather device facts
rustez facts 10.0.0.1 -u admin --password-file /path/to/password-0600
# Run a show command
rustez rpc 10.0.0.1 "show interfaces terse" -u admin
# Push a config
rustez config apply 10.0.0.1 -f config.set -u adminEvery command accepts --json, emitting a stable envelope on stdout (success)
or stderr (failure). The shape is identical across commands, so a bridge can
parse one structure and branch on ok / error.kind:
rustez facts 10.0.0.1 -u admin --json{
"ok": true,
"command": "facts",
"host": "10.0.0.1",
"data": { "hostname": "vsrx-1", "model": "vSRX", "version": "24.4R1", "...": "..." }
}On failure: {"ok": false, "command": ..., "host": ..., "error": {"kind": "...", "message": "..."}}.
Each error kind maps to a distinct exit code: usage=1, connect=2, auth=3,
rpc=4, load=5, commit=6, rollback=7, internal=8 (success is 0).
Password resolution precedence: --password-file <PATH> (must be a regular,
non-symlink file at mode 0600) → $RUSTEZ_PASSWORD → --key-file <PATH>
(key-based auth) → interactive no-echo prompt (when stdin is a TTY). There is
no --password flag: a password given as a CLI argument is visible to every
other process on the host via ps, and to anyone with shell-history access.
Verification is fail-closed: with no host-key flag, an unknown host key is rejected and the connection fails. Choose one (mutually exclusive):
rustez facts 10.0.0.1 -u admin --host-key-fingerprint SHA256:abc123... # pin a fingerprint
rustez facts 10.0.0.1 -u admin --known-hosts ~/.ssh/known_hosts # verify against a known_hosts file
rustez facts 10.0.0.1 -u admin --accept-any-host-key # trust on first use (lab only)from rustez import Device
async def main():
dev = await Device.connect("10.0.0.1", username="admin", password="secret")
facts = await dev.facts()
print(f"{facts.hostname} running Junos {facts.version}")
await dev.close()PyPI wheels are published for Linux x86_64 only (CPython 3.12; manylinux/musllinux):
| Platform | Wheel | Status |
|---|---|---|
| Linux x86_64 (glibc) | manylinux |
Supported |
| Linux x86_64 (musl/Alpine) | musllinux_1_2 |
Supported |
| Linux aarch64 | — | Not supported |
| macOS / Windows | — | Not supported |
For unsupported platforms, build from source with maturin:
pip install maturin
git clone https://github.com/mechubsec/rustez.git
cd rustez && maturin build --release -m rustez-py/Cargo.toml
pip install target/wheels/*.whlVerified on a real device with all integration tests passing:
| Platform | Junos Version | NETCONF | Tests |
|---|---|---|---|
| vSRX | 24.4R1.9 | 1.0 (EOM) | connect, facts, cli, config load/diff/commit/rollback, RFC 5277 event notifications |
| Phase | Version | Scope |
|---|---|---|
| 1 | v0.1 | Device, Facts, RPC, Config (load/diff/commit/rollback) |
| 2 | v0.2 | Typed operational data (interfaces, routes, ARP, LLDP), CLI |
| 3 | v0.3 | Software management, filesystem, shell, SCP, DevicePool with per-platform session limits |
| 4 | v0.4 | Python bindings via PyO3 |
| 5 | v1.0 | YANG codegen, TUI, config drift detection, 1000+ device scale |
| Feature | PyEZ | rustEZ |
|---|---|---|
| Language | Python | Rust (with Python bindings) |
| Concurrency | Threading (painful) | Async/await (native) |
| Type safety | Runtime errors | Compile-time checks |
| NETCONF library | ncclient | rustnetconf (async, pure Rust) |
| SSH library | paramiko (OpenSSL) | russh (pure Rust) |
| Config templating | Jinja2 | Tera |
| Operational data | YAML Tables/Views | Typed Rust structs (serde) |
| Multi-vendor | No (Junos only) | No (Junos only) |
| Crate | Version | Purpose |
|---|---|---|
| rustnetconf | 0.15 | NETCONF client (SSH transport, RFC 6241/5277) |
| tokio | 1 | Async runtime |
| quick-xml | 0.41 | XML parsing |
| thiserror | 2 | Error derive macros |
| tracing | 0.1 | Structured logging |
| serde | 1 | Serialize derive on Facts and friends |
| serial_test | 3 | Sequential integration tests (dev only) |
| Crate | Version | Purpose |
|---|---|---|
| pyo3 | 0.29 | Python FFI bindings |
| rustez | 0.18.0 | Core library |
| rustnetconf | 0.15 | NETCONF client |
| tokio | 1 | Async runtime |
Python runtime dependency: lxml >= 4.9.0
Last audited: 2026-08-26 via cargo audit — no known advisories across 237
dependencies. cargo audit and cargo deny both run in CI on every PR, with no
advisory ignores configured.
The previously listed RUSTSEC-2023-0071 (Marvin Attack, rsa 0.10.0-rc.16) no
longer applies: rsa is not in the dependency tree at all, having left when
russh reworked its crypto dependencies. cargo audit passes clean without any
--ignore, so the flag that suppressed it has been removed from CI along with
its entry in .cargo/audit-ignored.md — a standing ignore for a crate that is
gone would silently suppress the advisory if the crate ever returned.
One supply-chain item is tracked but not actionable: russh reaches SSH key
handling through ssh-key, which is still on a release-candidate line
(0.7.0-rc.11), pulling prerelease argon2 and blake2 with it. Nothing is
known to be wrong with those versions, and the fix is upstream — this closes
when ssh-key 0.7.0 goes stable, not when russh releases. Tracked at
rustnetconf#64.
Run cargo audit to check for the latest advisories.
Licensed under MIT.
a mechub project · deterministic decides · the model explains · a human approves
github.com/fastrevmd-lab