rustnetconf 0.12.0

An async-first NETCONF 1.0/1.1 client library for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# rustnetconf

[![crates.io — rustnetconf](https://img.shields.io/crates/v/rustnetconf.svg?label=rustnetconf)](https://crates.io/crates/rustnetconf)
[![crates.io — rustnetconf-cli](https://img.shields.io/crates/v/rustnetconf-cli.svg?label=rustnetconf-cli)](https://crates.io/crates/rustnetconf-cli)
[![crates.io — rustnetconf-yang](https://img.shields.io/crates/v/rustnetconf-yang.svg?label=rustnetconf-yang)](https://crates.io/crates/rustnetconf-yang)
[![CI](https://github.com/fastrevmd-lab/rustnetconf/actions/workflows/ci.yml/badge.svg)](https://github.com/fastrevmd-lab/rustnetconf/actions/workflows/ci.yml)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)

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](https://tokio.rs), [russh](https://crates.io/crates/russh), and [rustls](https://crates.io/crates/rustls) — pure Rust, no OpenSSL, no libssh2.

> **Latest release — [v0.12.0](https://github.com/fastrevmd-lab/rustnetconf/releases/tag/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.1.
> See [What's New in v0.12.0](#whats-new-in-v0120) 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-keyscan` → `KnownHosts(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-005** — `apply` 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#type` → `type_` 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.

```bash
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

```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

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

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

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

### Fetch running config

```rust
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)

```rust
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`)

```rust
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`

```rust
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.

```rust
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)

```rust
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

```rust
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 support** — `ProxyJump` (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 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

### 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:

```rust
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:

```bash
# 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.

```bash
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 verification** — `HostKeyVerification` 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 warnings** — `danger_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](ARCHITECTURE.md) for the codebase design and [TODOS.md](TODOS.md) for tracked work items.