diff --git a/CHANGELOG.md b/CHANGELOG.md index 4076028..6a0f5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.].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 diff --git a/README.md b/README.md index 5ffb09d..e4dfaf2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. | diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 43a7921..a53577f 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 8fa4ada..d9310ff 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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 | 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] diff --git a/examples/config.toml b/examples/config.toml index dda9460..0eb031d 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -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. diff --git a/src/config.rs b/src/config.rs index b3d9de8..47f3c74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -269,6 +269,16 @@ pub struct ProfileConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub verify_tls: Option, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_secs: Option, diff --git a/src/netbox/client.rs b/src/netbox/client.rs index 1438c7c..35306c4 100644 --- a/src/netbox/client.rs +++ b/src/netbox/client.rs @@ -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 @@ -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/`). @@ -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 {