Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion clients/ddm-admin-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use either::Either;
use omicron_common::address::BOOTSTRAP_PREFIX;
use omicron_common::address::BOOTSTRAP_SLED_SUBNET_PREFIX_LENGTH;
use omicron_common::address::DDMD_PORT;
use omicron_common::http_client::internal_http_client_builder;
use oxnet::Ipv6Net;
use sled_hardware_types::underlay::BootstrapInterface;
use slog::Logger;
Expand Down Expand Up @@ -54,7 +55,7 @@ impl Client {
let log =
log.new(slog::o!("DdmAdminClient" => SocketAddr::V6(ddmd_addr)));

let inner = reqwest::ClientBuilder::new()
let inner = internal_http_client_builder()
.connect_timeout(dur)
.timeout(dur)
.build()?;
Expand Down
9 changes: 7 additions & 2 deletions cockroach-admin/src/http_entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use dropshot::HttpResponseOk;
use dropshot::HttpResponseUpdatedNoContent;
use dropshot::RequestContext;
use dropshot::TypedBody;
use omicron_common::http_client::internal_http_client_builder;
use slog::info;
use slog_error_chain::InlineErrorChain;
use std::sync::Arc;
Expand Down Expand Up @@ -86,7 +87,9 @@ impl CockroachAdminApi for CockroachAdminImpl {
ctx.cockroach_cli().cockroach_http_address();
let url = format!("http://{}/_status/vars", cockroach_http_address);

let client = reqwest::Client::new();
let client = internal_http_client_builder()
.build()
.expect("building a client without TLS roots does no I/O");

@smklein smklein Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

let response = client.get(&url).send().await.map_err(|e| {
HttpError::for_internal_error(format!(
"Failed to proxy to CockroachDB: {}",
Expand Down Expand Up @@ -128,7 +131,9 @@ impl CockroachAdminApi for CockroachAdminImpl {
ctx.cockroach_cli().cockroach_http_address();
let url = format!("http://{}/_status/nodes", cockroach_http_address);

let client = reqwest::Client::new();
let client = internal_http_client_builder()
.build()
.expect("building a client without TLS roots does no I/O");
let response = client.get(&url).send().await.map_err(|e| {
HttpError::for_internal_error(format!(
"Failed to proxy to CockroachDB: {}",
Expand Down
1 change: 1 addition & 0 deletions cockroach-metrics/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ chrono.workspace = true
cockroach-admin-client.workspace = true
cockroach-admin-types.workspace = true
futures.workspace = true
omicron-common.workspace = true
parallel-task-set.workspace = true
reqwest.workspace = true
serde_json.workspace = true
Expand Down
3 changes: 2 additions & 1 deletion cockroach-metrics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use chrono::{DateTime, Utc};
use cockroach_admin_client::Client;
use cockroach_admin_types::node::InternalNodeId;
use futures::stream::{FuturesUnordered, StreamExt};
use omicron_common::http_client::internal_http_client_builder;
use parallel_task_set::ParallelTaskSet;
use serde::{Deserialize, Serialize};
use slog::{Logger, debug, warn};
Expand All @@ -36,7 +37,7 @@ impl CockroachAdminClient {
///
/// "timeout" is used as both a connection and request timeout duration.
fn new(log: Logger, address: SocketAddr, timeout: Duration) -> Self {
let reqwest_client = reqwest::ClientBuilder::new()
let reqwest_client = internal_http_client_builder()
.connect_timeout(timeout)
.timeout(timeout)
.build()
Expand Down
97 changes: 97 additions & 0 deletions common/src/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Construction of HTTP clients for talking to other control plane services.
//!
//! 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>.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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([]).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, good call, moved in b62a484


use std::time::Duration;

/// The connect and request timeout applied by a progenitor-generated
/// `Client::new`.
const PROGENITOR_DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);

/// Returns a [`reqwest::ClientBuilder`] for plain-HTTP communication with
/// other control plane services.
///
/// The resulting client trusts no TLS root certificates: requests to `https`
/// URLs will fail certificate verification. Clients that need to speak TLS
/// (e.g., to services outside the rack) should not use this builder.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in b62a484

pub fn internal_http_client_builder() -> reqwest::ClientBuilder {
reqwest::ClientBuilder::new().tls_certs_only([])
}

/// Returns a [`reqwest::Client`] for plain-HTTP communication with other
/// control plane services, configured with the same timeouts as a
/// progenitor-generated `Client::new`.
///
/// Pass this to a generated client's `new_with_client` in place of calling its
/// `new`, which builds a client that loads TLS root certificates from disk.
pub fn internal_http_client() -> reqwest::Client {
internal_http_client_builder()
.connect_timeout(PROGENITOR_DEFAULT_TIMEOUT)
.timeout(PROGENITOR_DEFAULT_TIMEOUT)
Comment on lines +47 to +48

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

.build()
.expect("building a client without TLS roots does no I/O")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
.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",
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated in b62a484

}

#[cfg(test)]
mod test {
use super::*;
use std::fs::File;

// 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() {

@smklein smklein Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is a variation of what I described in oxidecomputer/progenitor#1440 (comment) , which is a regression test against #10871

// Lower the soft limit on open files, so that we don't need to open
// very many to run out.
let mut limit = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
// SAFETY: `limit` is a valid, writable `rlimit`.
let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
assert_eq!(rc, 0, "getrlimit: {}", std::io::Error::last_os_error());
limit.rlim_cur = limit.rlim_cur.min(64);
// SAFETY: `limit` is a valid `rlimit`.
let rc = unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) };
assert_eq!(rc, 0, "setrlimit: {}", std::io::Error::last_os_error());

let mut held = Vec::new();
let err = loop {
match File::open("/dev/null") {
Ok(f) => held.push(f),
Err(e) => break e,
}
};
assert_eq!(
err.raw_os_error(),
Some(libc::EMFILE),
"expected to run out of file descriptors, got: {err}"
);

// Panics if the client cannot be built.
let _ = internal_http_client();

// On these platforms, reqwest's default client loads TLS roots from
// disk, so it should fail to build. If this starts succeeding, the
// test above no longer demonstrates anything about TLS roots.
//
// This check is skipped on macOS, where building the default client
// does no I/O: the platform verifier consults the system trust store
// only when verifying a certificate.
#[cfg(any(target_os = "linux", target_os = "illumos"))]
reqwest::ClientBuilder::new().build().expect_err(
"expected a default reqwest client to fail to build without \
file descriptors (has reqwest stopped loading TLS roots when \
building a client?)",
);

drop(held);
}
}
1 change: 1 addition & 0 deletions common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod api;
pub mod backoff;
pub mod cmd;
pub mod disk;
pub mod http_client;
pub mod policy;
pub mod resolvable_files;
pub mod snake_case_result;
Expand Down
3 changes: 2 additions & 1 deletion installinator/src/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use omicron_common::http_client::internal_http_client_builder;
use std::{net::SocketAddr, time::Duration};

use anyhow::{Context, Result, bail};
Expand Down Expand Up @@ -118,7 +119,7 @@ impl ArtifactClient {
// by the fetch loop. (Why is the read timeout handled by the fetch
// loop? So that it can also apply to the mock peer backend, and logic
// shared across both.)
let client = reqwest::ClientBuilder::new()
let client = internal_http_client_builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(5 * 60))
.build()
Expand Down
22 changes: 19 additions & 3 deletions nexus/inventory/src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ use nexus_types::inventory::SpType;
use nexus_types::inventory::TimeSync;
use omicron_cockroach_metrics::CockroachClusterAdminClient;
use omicron_common::address::NTP_ADMIN_PORT;
use omicron_common::http_client::internal_http_client;
use omicron_common::http_client::internal_http_client_builder;
use omicron_uuid_kinds::OmicronZoneUuid;
use parallel_task_set::ParallelTaskSet;
use sled_agent_types::disk::M2Slot;
Expand Down Expand Up @@ -528,7 +530,14 @@ impl<'a> Collector<'a> {
let url = format!("http://{addr}");
let log = self.log.new(o!("ntp_admin_url" => url.clone()));

(cfg.id, ntp_admin_client::Client::new(&url, log))
(
cfg.id,
ntp_admin_client::Client::new_with_client(
&url,
internal_http_client(),
log,
),
)
})
.collect();

Expand Down Expand Up @@ -657,7 +666,14 @@ impl<'a> Collector<'a> {
let url = format!("http://{http_address}");
let log = self.log.new(o!("internal_dns_url" => url.clone()));

(cfg.id, dns_service_client::Client::new(&url, log))
(
cfg.id,
dns_service_client::Client::new_with_client(
&url,
internal_http_client(),
log,
),
)
})
.collect();

Expand Down Expand Up @@ -709,7 +725,7 @@ async fn collect_one_sled_agent(
"sled_agent_url" => url
);

let reqwest_client = reqwest::ClientBuilder::new()
let reqwest_client = internal_http_client_builder()
.connect_timeout(SLED_AGENT_TIMEOUT)
.timeout(SLED_AGENT_TIMEOUT)
.build()
Expand Down
8 changes: 6 additions & 2 deletions nexus/metrics-producer-gc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use nexus_db_queries::db::identity::Asset;
use nexus_db_queries::db::model::OximeterInfo;
use nexus_db_queries::db::model::ProducerEndpoint;
use omicron_common::api::external::Error as DbError;
use omicron_common::http_client::internal_http_client;
use oximeter_client::Client as OximeterClient;
use slog::Logger;
use slog::info;
Expand Down Expand Up @@ -173,8 +174,11 @@ impl ExpiredProducers {
let client_log =
opctx.log.new(o!("oximeter-collector" => info.id.to_string()));
let address = SocketAddr::new(info.ip.ip(), *info.port);
let client =
OximeterClient::new(&format!("http://{address}"), client_log);
let client = OximeterClient::new_with_client(
&format!("http://{address}"),
internal_http_client(),
client_log,
);
entry.insert(client);
}

Expand Down
7 changes: 6 additions & 1 deletion nexus/mgs-updates/src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! Makes artifact contents available for use in updates

use futures::TryStreamExt;
use omicron_common::http_client::internal_http_client;
use sha2::{Digest, Sha256};
use slog::o;
use std::sync::atomic::AtomicUsize;
Expand Down Expand Up @@ -91,7 +92,11 @@ impl ArtifactCache {
let addr = addresses[idx % addresses.len()];
let url = format!("http://{}", addr.address);
let log = self.log.new(o!("repo_depot_url" => url.clone()));
Ok(repo_depot_client::Client::new(&url, log))
Ok(repo_depot_client::Client::new_with_client(
&url,
internal_http_client(),
log,
))
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion nexus/mgs-updates/src/driver_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use nexus_types::deployment::PendingMgsUpdateDetails;
use nexus_types::internal_api::views::UpdateAttemptStatus;
use nexus_types::internal_api::views::UpdateCompletedHow;
use nexus_types::inventory::SpType;
use omicron_common::http_client::internal_http_client_builder;
use omicron_uuid_kinds::GenericUuid;
use omicron_uuid_kinds::SpUpdateUuid;
use qorb::resolver::AllBackends;
Expand Down Expand Up @@ -204,7 +205,7 @@ fn make_mgs_clients(backends: &AllBackends, log: &slog::Logger) -> MgsClients {
// because we've seen sidecars take 20+ seconds to reset). We should
// therefore wait at least as long as its timeouts; we'll add a buffer
// here to leave plenty of time for minor weather between us and MGS.
let client = reqwest::ClientBuilder::new()
let client = internal_http_client_builder()
.connect_timeout(Duration::from_secs(75))
.timeout(Duration::from_secs(75))
.build()
Expand Down
8 changes: 6 additions & 2 deletions nexus/mgs-updates/src/host_phase1_updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ use gateway_client::types::SpComponentFirmwareSlot;
use nexus_types::deployment::PendingMgsUpdate;
use nexus_types::deployment::PendingMgsUpdateHostPhase1Details;
use nexus_types::inventory::SpType;
use omicron_common::http_client::internal_http_client;
use sled_agent_client::Client as SledAgentClient;
use sled_agent_types::disk::M2Slot;
use sled_agent_types::inventory::BootPartitionContents;
Expand Down Expand Up @@ -345,8 +346,11 @@ impl ReconfiguratorHostPhase1Updater {
log: &Logger,
) -> Result<BootPartitionContents, PrecheckError> {
let address = self.details.sled_agent_address;
let sled_agent =
SledAgentClient::new(&format!("http://{address}"), log.clone());
let sled_agent = SledAgentClient::new_with_client(
&format!("http://{address}"),
internal_http_client(),
log.clone(),
);
let sled_inventory = sled_agent
.inventory()
.await
Expand Down
7 changes: 6 additions & 1 deletion nexus/networking/src/gateway_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use omicron_common::backoff::{self, BackoffError};
use omicron_common::http_client::internal_http_client;
use omicron_uuid_kinds::RackUuid;
use parallel_task_set::ParallelTaskSet;
use slog::{Logger, o};
Expand Down Expand Up @@ -31,7 +32,11 @@ impl GatewayClient {
let url = format!("http://{addr}");
let log = log
.new(o!("gateway_url" => url.clone(), "addr" => addr.to_string()));
let client = gateway_client::Client::new(&url, log);
let client = gateway_client::Client::new_with_client(
&url,
internal_http_client(),
log,
);
GatewayClient { addr, client }
}

Expand Down
3 changes: 2 additions & 1 deletion nexus/networking/src/sled_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use nexus_db_queries::context::OpContext;
use nexus_db_queries::db::DataStore;
use omicron_common::api::external::Error;
use omicron_common::api::external::LookupResult;
use omicron_common::http_client::internal_http_client_builder;
use omicron_uuid_kinds::RackUuid;
use omicron_uuid_kinds::SledUuid;
use sled_agent_client::Client as SledAgentClient;
Expand All @@ -29,7 +30,7 @@ pub fn sled_lookup<'a>(

pub fn default_reqwest_client_builder() -> reqwest::ClientBuilder {
let dur = std::time::Duration::from_secs(60);
reqwest::ClientBuilder::new().connect_timeout(dur).timeout(dur)
internal_http_client_builder().connect_timeout(dur).timeout(dur)
}

pub async fn sled_client(
Expand Down
Loading
Loading