Repository navigation
Build internal HTTP clients without loading TLS roots - #11482
Conversation
Building a reqwest client loads the system's TLS root certificates from disk, and fails if none can be read (e.g., when out of file descriptors). Control plane services talk to each other over plain HTTP, so add builders that trust no TLS roots and therefore do no I/O. See #10871.
Progenitor-generated Client::new builds a reqwest client that loads TLS root certificates from disk, and unwraps the result. For clients that talk plain HTTP to other control plane services, build the client with internal_http_client() instead, which does no I/O.
Clients that talk plain HTTP to other control plane services don't need TLS root certificates. Build them with internal_http_client_builder(), so building the client does no I/O.
| let client = reqwest::Client::new(); | ||
| let client = internal_http_client_builder() | ||
| .build() | ||
| .expect("building a client without TLS roots does no I/O"); |
There was a problem hiding this comment.
just as an "FYI":
progenitor was internally doing this unwrap here in request::Client::new (see: oxidecomputer/progenitor#1440) - so although you, a reviewer, might ask "why are we unwrapping now", the answer is: "we did before, and the goal is to keep this PR focused on one thing: plumbing through the tls_certs_only([]) parameter".
Removing the unwrapping is a good idea, but will come separately.
| // This test exhausts the process's file descriptors, so it relies on | ||
| // nextest running each test in its own process. | ||
| #[test] | ||
| fn internal_http_client_builds_without_file_descriptors() { |
There was a problem hiding this comment.
This test is a variation of what I described in oxidecomputer/progenitor#1440 (comment) , which is a regression test against #10871
|
Dumb question I want to ask is whether we want this to live in progenitor, and/or whether it's possible to add a clippy disallowed-methods lint for this. |
When you say "this", are you referring to the "helper to build an HTTP-only API, which passes this no-TLS argument"? I could go either way on it - this is something that we'll be probably removing over time, but just accurately describes the current situation. I'd understand not wanting to push forward the "here's how to bypass HTTP" lever to the forefront of the API. But I do think progenitor should avoid generating a method which can panic based on I/O (hence: oxidecomputer/progenitor#1440 being an issue). Also we can clippy disallowed-method here to dodge each generated |
Yeah, basically, a helper for us. Maybe behind a Cargo feature if we feel nervous about exposing it generally. I bet @ahl has much more sophisticated thoughts here than I do. |
Ah, right. And if we add a new client we'd have to remember to add it there. (Maybe we need a checklist for adding a new API client.) |
hawkw
left a comment
There was a problem hiding this comment.
this looks good to me, with a few minor notes.
| //! | ||
| //! By default, building a [`reqwest::Client`] loads the system's TLS root | ||
| //! certificates from disk, and fails if none can be read (for example, if the | ||
| //! process has run out of file descriptors). Services within the control plane | ||
| //! talk to each other over plain HTTP, so they have no use for those | ||
| //! certificates. | ||
| //! | ||
| //! See <https://github.com/oxidecomputer/omicron/issues/10871>. |
There was a problem hiding this comment.
nit, take it or leave it: i'm not really sure if this makes sense in the module level comments; I think even once we are doing underlay TLS or something, we will probably still want to keep this code around, and we can imagine this module growing other non-reqwest-default configs. Personally, I think that the doc comment here should really be saying that "this is a centralized set of configurations for all internal control plane HTTP clients" and I think this comment belongs on the actual call to tls_certs_only([]).
| /// URLs will fail certificate verification. Clients that need to speak TLS | ||
| /// (e.g., to services outside the rack) should not use this builder. |
There was a problem hiding this comment.
I think we should probably encourage the use of the external_client module in Nexus here, specifically, because it applies its own set of non-default configs that we should probably be using on any HTTP requests we send outside the rack. This includes SSRF protection for cases where the external URL we're talking to is untrusted, which (currently) is all cases where we send external requests, so we should really ensure that we always use that module for such requests for security reasons.
| .connect_timeout(PROGENITOR_DEFAULT_TIMEOUT) | ||
| .timeout(PROGENITOR_DEFAULT_TIMEOUT) |
There was a problem hiding this comment.
it's a bit odd to me that internal_http_client_builder sets the TLS thing but doesn't set the other configs we set by default. it feels like we should basically set all the defaults there and then allow a (hypothetical?) caller that wants to override them override the individual builder settings to get the default connect timeout/timeout settings too?
There was a problem hiding this comment.
I realize that these are the main settings that every caller that currently even uses the builder path instead of just calling this function are currently overwriting, so there isn't any immediate benefit to having the builder function also set them. But, I feel like with these kind of interfaces, in general I feel like it makes more sense for the API that provides a builder to set all the settings that we expect callers to want to potentially clobber, so they're getting the same defaults as the non-builder path for anything they don't clobber.
Also, I did double-check what the builder does, and it just sets the thing again when you call one of these settings multiple times: https://docs.rs/reqwest/latest/src/reqwest/async_impl/client.rs.html#1444-1478
There was a problem hiding this comment.
asdf so the complication here is that, although we can change the timeouts here, we cannot remove them. There are several clients that only have one of the connect/total timeouts set:
| Call site | Purpose | Connect timeout | Total timeout |
|---|---|---|---|
sled-agent/src/artifact_store.rs:161 |
Artifact downloads between sleds | 15s | none |
sled-agent/src/instance.rs:2552 |
Per-instance propolis client | none | none |
sled-agent/src/nexus.rs:38 |
sled-agent → Nexus (via internal DNS) | none | none |
sled-agent/scrimlet-reconcilers/src/handle.rs:115 |
Scrimlet reconcilers (also sets 10s pool idle timeout) | 15s | none |
sled-agent/rack-setup/src/service.rs:347 |
RSS → sled-agent (set sled config) | 60s | none |
sled-agent/rack-setup/src/service.rs:425 |
RSS → sled-agent (wait for reconciliation) | 60s | none |
sled-agent/rack-setup/src/service.rs:961 |
RSS → cockroach-admin (cluster init) | 60s | none |
cockroach-admin/src/http_entrypoints.rs:90 |
Proxy to CockroachDB /_status/vars |
none | none |
cockroach-admin/src/http_entrypoints.rs:134 |
Proxy to CockroachDB /_status/nodes |
none | none |
oximeter/collector/src/collection_task.rs:235 |
Oximeter → metric producers | none | 30s |
If we "set all the defaults" we cannot unset them for these cases. We could set an "arbitrarily high" timeout here instead? But the caller would need to do that explicitly, I think?
There was a problem hiding this comment.
Oh, that's yucky. I didn't look closely enough at the reqwest code to realize that we couldn't set them to None again. Sigh.
I guess the ugly-but-workable solution might be to add a function that takes an &mut ClientBuilder and sets the defaults timeouts from this module on it, so that callers that want to keep the default timeouts but configure some other thing can do that. But we can save that for when it becomes actually necessary.
| .connect_timeout(PROGENITOR_DEFAULT_TIMEOUT) | ||
| .timeout(PROGENITOR_DEFAULT_TIMEOUT) | ||
| .build() | ||
| .expect("building a client without TLS roots does no I/O") |
There was a problem hiding this comment.
IIUC ClientBuilder::build() will also return errors if the builder's config is invalid, so I think we might want to be a little more explicit here and say something like
| .expect("building a client without TLS roots does no I/O") | |
| .expect( | |
| "because building a client without TLS roots does no I/O, it will \ | |
| only return an error in the event of a programmer error", | |
| ) |
- Describe the module as the central configuration for internal HTTP clients, and move the TLS-roots explanation to the tls_certs_only call. - Point callers that talk to services outside the rack at Nexus's external_client module. - Clarify that the remaining expect only fires on programmer error.
Since the upgrade to reqwest 0.13 (#9917), building a
reqwest::Clientloads the system's TLS root certificates from disk. This can fail, as we've seen, when the process is out of file descriptors. Progenitor's generatedClient::newunwraps that result, which is how Nexus panicked in #10871 (see also oxidecomputer/progenitor#1440).Control plane services on the underlay currently talk to each other over plain HTTP, so they have no use for those certificates. This PR adds
omicron_common::http_client::{internal_http_client_builder, internal_http_client}, which trust no TLS roots (tls_certs_only([])) and therefore do no I/O when building a client, and uses them in place of generatedClient::newcalls andreqwest::ClientBuilder::new()for internal clients. Requests tohttpsURLs through these clients fail certificate verification; clients that need TLS (webhooks, IdP metadata) are unchanged.This intentionally keeps the existing
unwrap/expectbehavior; a follow-up PR will propagate client-construction errors instead.Part of #10871.