Skip to content

Commit a0ec3d5

Browse files
Simplify the host detection logic and move aws specific code into endpoints.rs
Also use from_env() to create the profile in all cases, just specifying the profile name Signed-off-by: Anil Kulkarni <anil@terminal.space>
1 parent 9e305fd commit a0ec3d5

3 files changed

Lines changed: 58 additions & 120 deletions

File tree

crates/nono-proxy/src/aws/endpoints.rs

Lines changed: 41 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,12 @@
1-
//! AWS hostname parsing: service-code and region extraction.
1+
//! AWS endpoint parsing: extract the SigV4 region and service from an `amazonaws.com` hostname.
22
//!
3-
//! ## Canonical AWS hostname shapes
3+
//! Supported hostname shapes:
4+
//! - Regional: `{service}.{region}.amazonaws.com`
5+
//! - Global: `{service}.amazonaws.com`
6+
//! - FIPS: `{service}-fips.{region}.amazonaws.com` (`-fips` is stripped)
47
//!
5-
//! ```text
6-
//! Regional: {service}.{region}.amazonaws.com (4 dot-segments)
7-
//! Global: {service}.amazonaws.com (3 dot-segments)
8-
//! FIPS: {service}-fips.{region}.amazonaws.com (4 dot-segments, service ends in "-fips")
9-
//! Dual-stack:{service}.{region}.api.aws (different TLD — not handled here)
10-
//! ```
11-
//!
12-
//! `api.aws` dual-stack endpoints are out of scope for auto-detection; users
13-
//! on those endpoints supply explicit `aws_auth.region` and `aws_auth.service`
14-
//! overrides in their config.
15-
//!
16-
//! ## Region code pattern
17-
//!
18-
//! Standard regions match `^[a-z]{2,3}-[a-z]+-\d+$`. GovCloud and ISO
19-
//! partition regions have an extra word between the geo prefix and direction
20-
//! segment (`us-gov-east-1`, `us-iso-east-1`, `us-isob-east-1`), so the
21-
//! effective pattern is `^[a-z]{2,3}-(?:[a-z]+-)?[a-z]+-\d+$`. This covers
22-
//! all current regions including newer geo prefixes (`il-`, `mx-`, `me-`,
23-
//! `af-`) and high-index AZ suffixes (`ap-southeast-7`).
24-
//!
25-
//! ## Service-code table
26-
//!
27-
//! The service segment is not always the SigV4 signing name. AWS uses
28-
//! irregular host prefixes (e.g. `bedrock-runtime` signs as `bedrock`,
29-
//! `email` signs as `ses`). The table maps the canonical segment (with any
30-
//! `-fips` suffix stripped) to the signing name.
8+
//! `api.aws` dual-stack endpoints are not supported; supply explicit
9+
//! `aws_auth.region` and `aws_auth.service` config values for those.
3110
3211
use std::collections::HashMap;
3312
use std::sync::OnceLock;
@@ -43,7 +22,8 @@ fn region_re() -> &'static Regex {
4322
}
4423

4524
fn service_map() -> &'static HashMap<&'static str, &'static str> {
46-
// Maps from the URL hostname path (key) to the AWS service name (value)
25+
// Maps hostname service segment to SigV4 signing name. Some segments differ
26+
// from the signing name (e.g. "bedrock-runtime" signs as "bedrock", "email" as "ses").
4727
static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
4828
MAP.get_or_init(|| {
4929
HashMap::from([
@@ -73,32 +53,18 @@ fn service_map() -> &'static HashMap<&'static str, &'static str> {
7353
})
7454
}
7555

76-
/// Parsed components of an AWS URL
56+
/// Parsed components of an `amazonaws.com` hostname.
7757
#[derive(Debug, PartialEq, Eq)]
7858
pub struct AwsUrlParts<'a> {
79-
/// Service-code segment from the hostname, with any `-fips` suffix
80-
/// already stripped (e.g. `"bedrock-runtime"`, `"iam"`).
59+
/// Service segment with any `-fips` suffix stripped (e.g. `"bedrock-runtime"`, `"iam"`).
8160
pub normalized_service: &'a str,
82-
/// Region code if present (e.g. `"us-east-1"`), or `None` for
83-
/// global-only endpoints.
61+
/// Region code, or `None` for global endpoints.
8462
pub region: Option<&'a str>,
8563
}
8664

8765
impl<'a> AwsUrlParts<'a> {
88-
/// Parse a hostname into its service segment and optional region.
89-
///
90-
/// Accepts only `amazonaws.com` endpoints in the two canonical shapes:
91-
/// - Regional (4 segments): `{service}.{region}.amazonaws.com`
92-
/// - Global (3 segments): `{service}.amazonaws.com`
93-
///
94-
/// A `-fips` suffix on the service segment is stripped before returning so
95-
/// callers always receive the canonical segment name (e.g. `"bedrock-runtime"`
96-
/// for both `bedrock-runtime.us-east-1.amazonaws.com` and
97-
/// `bedrock-runtime-fips.us-east-1.amazonaws.com`).
98-
///
99-
/// Returns `None` for non-`amazonaws.com` hosts (including `api.aws`
100-
/// dual-stack endpoints), wrong segment counts, and region strings that do
101-
/// not match the regex
66+
/// Parse an `amazonaws.com` hostname into its service and region components.
67+
/// Returns `None` for unrecognised shapes or invalid region codes.
10268
#[must_use]
10369
pub fn parse(host: &'a str) -> Option<Self> {
10470
match host.split('.').collect::<Vec<_>>().as_slice() {
@@ -119,12 +85,8 @@ impl<'a> AwsUrlParts<'a> {
11985
}
12086
}
12187

122-
/// Return the SigV4 signing service name for this endpoint.
123-
///
124-
/// The `normalized_service` segment (with any `-fips` suffix already
125-
/// stripped) is looked up in the service table. Returns `None` for
126-
/// unrecognised service segments; callers should fall back to an explicit
127-
/// `aws_auth.service` config value or emit a warning.
88+
/// Look up the SigV4 signing service name for this endpoint.
89+
/// Returns `None` for unrecognised service segments.
12890
#[must_use]
12991
pub fn signing_service(&self) -> Option<&'static str> {
13092
service_map().get(self.normalized_service).copied()
@@ -133,25 +95,33 @@ impl<'a> AwsUrlParts<'a> {
13395

13496
/// Resolve the SigV4 `(region, service)` pair for an AWS route.
13597
///
136-
/// Explicit config values in `aws_auth` take precedence over auto-detection.
137-
/// When either is absent the `upstream_host` is parsed as an `amazonaws.com`
138-
/// endpoint; if the hostname is not recognised and a value cannot be inferred,
139-
/// returns `None` after emitting a `warn!` that names the missing field and
140-
/// how to fix it.
141-
///
142-
/// Returns `Some((region, service))` on success, `None` when the route should
143-
/// be skipped (the warning has already been emitted).
98+
/// Config values in `aws_auth` take precedence. When either is absent, the
99+
/// host of `upstream` is parsed as an `amazonaws.com` endpoint to fill in the
100+
/// missing value. Returns `None` and emits a warning if resolution fails.
144101
#[must_use]
145102
pub fn resolve_signing_params(
146103
prefix: &str,
147104
aws_auth: &crate::config::AwsAuthConfig,
148-
upstream_host: &str,
105+
upstream: &str,
149106
) -> Option<(String, String)> {
150-
// When either region or service is missing from config, parse the upstream
151-
// host once and derive both from it. If both are explicit we skip the parse
152-
// entirely.
107+
let upstream_host: String;
153108
let parsed_host = if aws_auth.region.is_none() || aws_auth.service.is_none() {
154-
let h = AwsUrlParts::parse(upstream_host);
109+
upstream_host = match url::Url::parse(upstream)
110+
.ok()
111+
.and_then(|u| u.host_str().map(str::to_owned))
112+
{
113+
Some(h) => h,
114+
None => {
115+
warn!(
116+
"AWS route '{}': upstream '{}' is not a valid URL with a \
117+
host component; fix the upstream or set aws_auth.region \
118+
and aws_auth.service explicitly — skipping this route.",
119+
prefix, upstream
120+
);
121+
return None;
122+
}
123+
};
124+
let h = AwsUrlParts::parse(&upstream_host);
155125
if h.is_none() {
156126
warn!(
157127
"AWS route '{}': upstream host '{}' is not a recognised \
@@ -166,7 +136,7 @@ pub fn resolve_signing_params(
166136
None
167137
};
168138

169-
// Region: explicit override wins; otherwise take from parsed host.
139+
// Region: explicit config wins; otherwise derive from the parsed host.
170140
let region = match aws_auth.region.as_deref() {
171141
Some(r) => r.to_owned(),
172142
None => match parsed_host.as_ref().and_then(|h| h.region) {
@@ -176,15 +146,14 @@ pub fn resolve_signing_params(
176146
"AWS route '{}': could not determine region from upstream \
177147
host '{}' (global endpoint?); set aws_auth.region \
178148
explicitly — skipping this route.",
179-
prefix, upstream_host
149+
prefix, upstream
180150
);
181151
return None;
182152
}
183153
},
184154
};
185155

186-
// Service: explicit override wins; otherwise look up signing name from the
187-
// parsed host's service segment.
156+
// Service: explicit config wins; otherwise look up from the parsed host.
188157
let service = match aws_auth.service.as_deref() {
189158
Some(s) => s.to_owned(),
190159
None => {
@@ -199,7 +168,7 @@ pub fn resolve_signing_params(
199168
"AWS route '{}': service segment '{}' from upstream \
200169
host '{}' is not in the signing-name table; set \
201170
aws_auth.service explicitly — skipping this route.",
202-
prefix, seg, upstream_host
171+
prefix, seg, upstream
203172
);
204173
return None;
205174
}

crates/nono-proxy/src/aws/route.rs

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
88
use std::collections::HashMap;
99

10-
use aws_config::BehaviorVersion;
11-
use aws_config::profile::ProfileFileCredentialsProvider;
1210
use aws_credential_types::provider::SharedCredentialsProvider;
1311
use tracing::debug;
1412

@@ -131,42 +129,24 @@ impl AwsRouteTable {
131129
}
132130
}
133131

134-
/// Build a fresh `SharedCredentialsProvider` for the given profile.
135-
///
136-
/// - `Some(name)`: uses `ProfileFileCredentialsProvider` for the named profile.
137-
/// - `None`: uses the SDK default credential chain.
132+
/// Build a `SharedCredentialsProvider` for the given profile, or the default
133+
/// credential chain if `profile` is `None`.
138134
async fn new_provider(profile: Option<&str>) -> Result<SharedCredentialsProvider, String> {
139-
// Behavior version is pinned to avoid silent breaking changes from latest().
140-
let behavior = BehaviorVersion::v2026_01_12();
135+
debug!("aws::route: building provider for profile={:?}", profile);
141136

142-
let provider = match profile {
143-
Some(name) => {
144-
debug!(
145-
"aws::route: building ProfileFileCredentialsProvider for profile={:?}",
146-
name
147-
);
148-
SharedCredentialsProvider::new(
149-
ProfileFileCredentialsProvider::builder()
150-
.profile_name(name)
151-
.build(),
152-
)
153-
}
154-
None => {
155-
debug!("aws::route: building default credential chain provider");
156-
let sdk_config = aws_config::from_env()
157-
.behavior_version(behavior)
158-
.load()
159-
.await;
160-
sdk_config
161-
.credentials_provider()
162-
.ok_or_else(|| {
163-
"default AWS credential chain yielded no provider; \
164-
set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY or configure an AWS profile"
165-
.to_string()
166-
})?
167-
.clone()
168-
}
169-
};
137+
let mut loader = aws_config::from_env();
138+
if let Some(name) = profile {
139+
loader = loader.profile_name(name);
140+
}
170141

142+
let sdk_config = loader.load().await;
143+
let provider = sdk_config
144+
.credentials_provider()
145+
.ok_or_else(|| {
146+
"default AWS credential chain yielded no provider; \
147+
set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY or configure an AWS profile"
148+
.to_string()
149+
})?
150+
.clone();
171151
Ok(provider)
172152
}

crates/nono-proxy/src/credential.rs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,6 @@ impl CredentialStore {
301301
}
302302
}
303303
} else if let Some(ref aws_auth) = route.aws_auth {
304-
let upstream_host = extract_host(&route.upstream);
305304
debug!(
306305
"aws credential load: prefix='{}' upstream='{}' \
307306
explicit_region={:?} explicit_service={:?} profile={:?}",
@@ -315,7 +314,7 @@ impl CredentialStore {
315314
let Some((region, service)) = crate::aws::endpoints::resolve_signing_params(
316315
&normalized_prefix,
317316
aws_auth,
318-
&upstream_host,
317+
&route.upstream,
319318
) else {
320319
continue;
321320
};
@@ -438,16 +437,6 @@ const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
438437

439438
const KEYRING_TIMEOUT_HINT: &str = " Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.";
440439

441-
/// Extract the hostname from a URL string, returning an empty string on parse
442-
/// failure. Used to derive the AWS region and service from the upstream URL at
443-
/// credential load time.
444-
fn extract_host(url: &str) -> String {
445-
url::Url::parse(url)
446-
.ok()
447-
.and_then(|u| u.host_str().map(|h| h.to_string()))
448-
.unwrap_or_default()
449-
}
450-
451440
/// Redact a credential reference for safe display in warnings.
452441
///
453442
/// Delegates to the appropriate URI-specific redaction helper so that

0 commit comments

Comments
 (0)