Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
move off yanked `chacha20` 0.10.0 and `spin` 0.9.8.
- Allow `clippy::unused_async_trait_impl` (new in rustc 1.98) on the MCP
`ServerHandler` impl, whose methods are async by the rmcp trait contract.
### Added

- **`ca_bundle` trust anchors for NetBox behind an internal CA.**
`[profiles.<name>].ca_bundle = "/path/ca.pem"` adds certificates to the
bundled Mozilla roots (which contain no private CAs), so an enterprise-issued
NetBox certificate verifies instead of failing with `invalid peer certificate:
UnknownIssuer`. Previously the only escape hatch was `verify_tls = false`,
which disables verification altogether — it would expose the NetBox token to
anyone able to intercept the connection. An unreadable bundle, or one
containing no certificates, is a startup error rather than a silently
unverified connection.

## [0.14.1] - 2026-07-31

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ url = "https://netbox.example.com"
token_env = "NETBOX_TOKEN"
auth_scheme = "auto" # auto | bearer | token
verify_tls = true
# ca_bundle = "/etc/ssl/internal-ca.pem" # extra trust anchors for a private CA
timeout_secs = 15
page_size = 100
exclude_config_context = true
Expand Down Expand Up @@ -632,7 +633,7 @@ deferred; only a raw escape-hatch MCP tool comes later.
| `no config at … — run nbox config init` | First run: just launch `nbox` for the guided wizard, or `nbox profile add …`. |
| `error: authentication failed` (exit 3) | The token is missing/invalid. Check `nbox config token status` for the resolved source; paste a token in the TUI, export `NBOX_TOKEN`, or point `token_env` at a set variable. |
| `403` / permission denied (exit 3) | The token is valid but lacks object permissions, or your NetBox needs `LOGIN_REQUIRED`-aware tokens. Use a token with read access. |
| TLS / certificate error | For a lab with a self-signed cert, set `verify_tls = false` on that profile (never in production). |
| TLS / certificate error | NetBox behind an internal CA? Point `ca_bundle` at that CA's PEM — it adds trust anchors and keeps verification on. Only for a lab you control, `verify_tls = false` disables checking entirely. |
| `operation timed out` on one search endpoint | Transient; nbox already disables stale keep-alive reuse. Retry, or raise `timeout_secs`. |
| `ambiguous` (exit 5) | The reference matched several objects — disambiguate (`--vrf` for an IP/prefix, `--site`/`--group` for a VLAN). |
| NetBox version error | nbox requires NetBox **4.2+**. Check `nbox status`. |
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ url = "https://netbox.example.com"
token_env = "NETBOX_TOKEN"
auth_scheme = "auto" # auto | bearer | token
verify_tls = true
# ca_bundle = "/etc/ssl/internal-ca.pem" # extra trust anchors for a private CA
timeout_secs = 15
page_size = 100
exclude_config_context = true
Expand Down
24 changes: 20 additions & 4 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,27 @@ full schema.

## TLS

### Certificate error against a lab instance
### Certificate error (`invalid peer certificate: UnknownIssuer`)

A self-signed or otherwise untrusted certificate fails verification. For a lab
you control, disable verification on that profile only — never in production,
where it removes the protection TLS provides:
nbox ships the Mozilla root store, which does not contain private CAs. If your
NetBox is served by an internal/enterprise CA, add that CA's certificate rather
than switching verification off — the certificate stays verified:

```toml
[profiles.work]
ca_bundle = "/etc/ssl/internal-ca.pem" # PEM; may hold the whole chain
```

You can usually fetch the CA from the `CA Issuers` URL in the server
certificate:

```bash
openssl s_client -connect netbox.example.com:443 -servername netbox.example.com \
</dev/null 2>/dev/null | openssl x509 -noout -text | grep -A2 "Authority Information Access"
```

For a lab you control, `verify_tls = false` disables verification entirely —
never in production, where an interceptor could steal the NetBox token:

```toml
[profiles.lab]
Expand Down
3 changes: 3 additions & 0 deletions examples/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ vrf = "graphql" # rest | graphql
url = "https://netbox.lab.example.com"
token_env = "NETBOX_LAB_TOKEN"
verify_tls = false # labs with self-signed certs; never use in prod
# NetBox behind an internal/enterprise CA? Add that CA instead of switching
# verification off — this keeps the certificate verified:
# ca_bundle = "/etc/ssl/internal-ca.pem"

# MCP server defaults for `nbox serve` (optional; absent = stdio, no HTTP).
# CLI flags (--http, --http-token, --oidc-issuer, …) override these. See docs/MCP.md.
Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,16 @@ pub struct ProfileConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_tls: Option<bool>,

/// Path to a PEM file of extra trust anchors, for a NetBox served by an
/// internal/enterprise CA. nbox ships the Mozilla root store, which does not
/// include private CAs; this *adds* to it rather than replacing it.
///
/// Prefer this over `verify_tls = false`: it keeps the certificate actually
/// verified. Turning verification off accepts any certificate at all, which
/// would expose the NetBox token to anyone able to intercept the connection.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_bundle: Option<PathBuf>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,

Expand Down
78 changes: 74 additions & 4 deletions src/netbox/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,25 @@ impl NetBoxClient {
.min(timeout.saturating_sub(Duration::from_secs(1)))
.max(Duration::from_secs(1));

let http = reqwest::Client::builder()
// Extra trust anchors for a NetBox behind an internal CA. These are
// *added* to the bundled Mozilla roots, so the certificate still has to
// verify — unlike `verify_tls = false`, which accepts anything and
// would expose the NetBox token to an interceptor.
let extra_roots = match profile.ca_bundle.as_ref() {
Some(path) => {
let pem = std::fs::read(path)
.with_context(|| format!("reading ca_bundle {}", path.display()))?;
let certs = reqwest::Certificate::from_pem_bundle(&pem)
.with_context(|| format!("parsing ca_bundle {}", path.display()))?;
if certs.is_empty() {
anyhow::bail!("ca_bundle {} contains no PEM certificates", path.display());
}
certs
}
None => Vec::new(),
};

let mut builder = reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(connect_timeout)
// NetBox is commonly served by gunicorn *sync* workers, which close
Expand All @@ -71,9 +89,11 @@ impl NetBoxClient {
// concurrency is unaffected — the fan-out still opens up to ~17
// connections at once; they just aren't pooled for the next request.
.pool_max_idle_per_host(0)
.danger_accept_invalid_certs(!verify_tls)
.build()
.context("building the HTTP client")?;
.danger_accept_invalid_certs(!verify_tls);
for cert in extra_roots {
builder = builder.add_root_certificate(cert);
}
let http = builder.build().context("building the HTTP client")?;

// Ensure a trailing slash so `Url::join` preserves any base subpath
// (e.g. NetBox installed under `https://host/netbox/`).
Expand Down Expand Up @@ -767,6 +787,56 @@ mod tests {
assert!(!masked.contains("legacysecret"));
}

/// A throwaway self-signed certificate, only ever parsed — never used to
/// verify a connection — so it cannot expire out from under the tests.
const TEST_CA_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIDETCCAfmgAwIBAgIUBE4mRGKrVup93vp3JL1+HlkVProwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMbmJveC10ZXN0LWNhMCAXDTI2MDkxNTEyNDI1MFoYDzIx\nMjYwODIyMTI0MjUwWjAXMRUwEwYDVQQDDAxuYm94LXRlc3QtY2EwggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDgj6cfzkd1zLJVaUvl44vCI41lkiSXClGF\nijOlqVsJmKfZ7miGpy8+XtBKSFYT/HwH5eSNGx1DUyGfz7kJuW8V2szOIpmjtm1r\n0gb9tz5tauFiZy7YhC+DhW/TTBCfa/gMYl04KY7JAP7SbD2TKpXe9BR2uTRNvQsD\nOMOqmuYF4kKZ5tasi8psnyoZKNB0z3YpxXexm7jTczLAgt01gmi9As5kztehifcP\nZ3ULvwi4cUdqPrh5XMgxoyArHXqibvvjY5XxA8+8D47pyT6US15rUlG+J/tdA7xy\nyryu/vogM40xIcZGLllThHa0tQj6mB6T3CEx9Hvfr7AyIWmLWBJxAgMBAAGjUzBR\nMB0GA1UdDgQWBBQNrAfegK3vwZlptf4x1TaCMfRvQDAfBgNVHSMEGDAWgBQNrAfe\ngK3vwZlptf4x1TaCMfRvQDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA\nA4IBAQDUMjeeSNL4LJRgkALKXky0d9rW3bkr5O8f9PBTJxUCjFAaArfB1npoWrCO\nq6mSJiz8qgb/7SOUvSKGbP4pfImXwxrhFYW8cx6YhSdkumGAUR3bOPZX7Z3pgF9j\nfjSIPaCb2PPAnTqrWaWVYdYL6TlQp+mUELijELjj7XcZJvZjgbR+RJUaWU7tLCoT\nmxxKPeSzhZnMpunAhd6gDtUJN2OETymjQgLSQOEqkglmXJfqF3QHOjxm2HQh3+XL\nP2vBMn4XFT4ZnmWkByUzb3jkvfvqudpeL52fb0u2f5pqYPqSLdodAFG4V6VElMtS\nNb59Pncu5uWcg6EI/8hTQil7hzCl\n-----END CERTIFICATE-----";

fn profile_with_ca(path: &std::path::Path) -> ProfileConfig {
ProfileConfig {
url: "https://netbox.internal".into(),
ca_bundle: Some(path.to_path_buf()),
..Default::default()
}
}

#[test]
fn ca_bundle_adds_trust_anchors() {
// An internal CA is added to the bundled roots; verification stays on.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ca.pem");
std::fs::write(&path, TEST_CA_PEM).unwrap();
assert!(NetBoxClient::new(&profile_with_ca(&path), None).is_ok());
}

#[test]
fn ca_bundle_that_cannot_be_read_is_a_clear_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.pem");
let err = NetBoxClient::new(&profile_with_ca(&missing), None)
.err()
.expect("a ca_bundle path that isn't there must not be ignored");
let msg = format!("{err:#}");
assert!(msg.contains("ca_bundle"), "{msg}");
assert!(msg.contains("nope.pem"), "{msg}");
}

#[test]
fn ca_bundle_without_certificates_is_rejected() {
// Fail loudly rather than build a client with no extra roots: the
// alternative is a confusing `UnknownIssuer` at the first request, far
// from the typo that caused it.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.pem");
std::fs::write(&path, "# no certificates here\n").unwrap();
let err = NetBoxClient::new(&profile_with_ca(&path), None)
.err()
.expect("an empty ca_bundle is a configuration mistake");
assert!(
format!("{err:#}").contains("no PEM certificates"),
"{err:#}"
);
}

#[test]
fn base_url_join_preserves_subpath() {
let profile = ProfileConfig {
Expand Down