|
| 1 | +//! AWS hostname parsing: service-code and region extraction. |
| 2 | +//! |
| 3 | +//! ## Canonical AWS hostname shapes |
| 4 | +//! |
| 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. |
| 31 | +
|
| 32 | +use std::collections::HashMap; |
| 33 | +use std::sync::OnceLock; |
| 34 | + |
| 35 | +use regex::Regex; |
| 36 | +use tracing::warn; |
| 37 | + |
| 38 | +fn region_re() -> &'static Regex { |
| 39 | + static RE: OnceLock<Regex> = OnceLock::new(); |
| 40 | + RE.get_or_init(|| { |
| 41 | + Regex::new(r"^[a-z]{2,3}-(?:[a-z]+-)?[a-z]+-\d+$").expect("static regex is valid") |
| 42 | + }) |
| 43 | +} |
| 44 | + |
| 45 | +fn service_map() -> &'static HashMap<&'static str, &'static str> { |
| 46 | + // Maps from the URL hostname path (key) to the AWS service name (value) |
| 47 | + static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new(); |
| 48 | + MAP.get_or_init(|| { |
| 49 | + HashMap::from([ |
| 50 | + // Bedrock: multiple host prefixes, one signing name. |
| 51 | + ("bedrock", "bedrock"), |
| 52 | + ("bedrock-runtime", "bedrock"), |
| 53 | + ("bedrock-agent", "bedrock"), |
| 54 | + ("bedrock-agent-runtime", "bedrock"), |
| 55 | + ("bedrock-data-automation", "bedrock"), |
| 56 | + ("bedrock-data-automation-runtime", "bedrock"), |
| 57 | + // Core services |
| 58 | + ("dynamodb", "dynamodb"), |
| 59 | + ("s3", "s3"), |
| 60 | + ("lambda", "lambda"), |
| 61 | + ("sqs", "sqs"), |
| 62 | + ("sns", "sns"), |
| 63 | + ("logs", "logs"), // CloudWatch Logs |
| 64 | + // API Gateway |
| 65 | + ("execute-api", "execute-api"), |
| 66 | + // Identity / signing |
| 67 | + ("sts", "sts"), |
| 68 | + ("iam", "iam"), |
| 69 | + // Email: host prefix differs from signing name |
| 70 | + ("email", "ses"), |
| 71 | + ("ses", "ses"), |
| 72 | + ]) |
| 73 | + }) |
| 74 | +} |
| 75 | + |
| 76 | +/// Parsed components of an AWS URL |
| 77 | +#[derive(Debug, PartialEq, Eq)] |
| 78 | +pub struct AwsUrlParts<'a> { |
| 79 | + /// Service-code segment from the hostname, with any `-fips` suffix |
| 80 | + /// already stripped (e.g. `"bedrock-runtime"`, `"iam"`). |
| 81 | + pub normalized_service: &'a str, |
| 82 | + /// Region code if present (e.g. `"us-east-1"`), or `None` for |
| 83 | + /// global-only endpoints. |
| 84 | + pub region: Option<&'a str>, |
| 85 | +} |
| 86 | + |
| 87 | +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 |
| 102 | + #[must_use] |
| 103 | + pub fn parse(host: &'a str) -> Option<Self> { |
| 104 | + match host.split('.').collect::<Vec<_>>().as_slice() { |
| 105 | + [service_raw, region, "amazonaws", "com"] => { |
| 106 | + if !region_re().is_match(region) { |
| 107 | + return None; |
| 108 | + } |
| 109 | + Some(AwsUrlParts { |
| 110 | + normalized_service: service_raw.strip_suffix("-fips").unwrap_or(service_raw), |
| 111 | + region: Some(region), |
| 112 | + }) |
| 113 | + } |
| 114 | + [service_raw, "amazonaws", "com"] => Some(AwsUrlParts { |
| 115 | + normalized_service: service_raw.strip_suffix("-fips").unwrap_or(service_raw), |
| 116 | + region: None, |
| 117 | + }), |
| 118 | + _ => None, |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 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. |
| 128 | + #[must_use] |
| 129 | + pub fn signing_service(&self) -> Option<&'static str> { |
| 130 | + service_map().get(self.normalized_service).copied() |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +/// Resolve the SigV4 `(region, service)` pair for an AWS route. |
| 135 | +/// |
| 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). |
| 144 | +#[must_use] |
| 145 | +pub fn resolve_signing_params( |
| 146 | + prefix: &str, |
| 147 | + aws_auth: &crate::config::AwsAuthConfig, |
| 148 | + upstream_host: &str, |
| 149 | +) -> 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. |
| 153 | + let parsed_host = if aws_auth.region.is_none() || aws_auth.service.is_none() { |
| 154 | + let h = AwsUrlParts::parse(upstream_host); |
| 155 | + if h.is_none() { |
| 156 | + warn!( |
| 157 | + "AWS route '{}': upstream host '{}' is not a recognised \ |
| 158 | + amazonaws.com endpoint; set aws_auth.region and \ |
| 159 | + aws_auth.service explicitly — skipping this route.", |
| 160 | + prefix, upstream_host |
| 161 | + ); |
| 162 | + return None; |
| 163 | + } |
| 164 | + h |
| 165 | + } else { |
| 166 | + None |
| 167 | + }; |
| 168 | + |
| 169 | + // Region: explicit override wins; otherwise take from parsed host. |
| 170 | + let region = match aws_auth.region.as_deref() { |
| 171 | + Some(r) => r.to_owned(), |
| 172 | + None => match parsed_host.as_ref().and_then(|h| h.region) { |
| 173 | + Some(r) => r.to_owned(), |
| 174 | + None => { |
| 175 | + warn!( |
| 176 | + "AWS route '{}': could not determine region from upstream \ |
| 177 | + host '{}' (global endpoint?); set aws_auth.region \ |
| 178 | + explicitly — skipping this route.", |
| 179 | + prefix, upstream_host |
| 180 | + ); |
| 181 | + return None; |
| 182 | + } |
| 183 | + }, |
| 184 | + }; |
| 185 | + |
| 186 | + // Service: explicit override wins; otherwise look up signing name from the |
| 187 | + // parsed host's service segment. |
| 188 | + let service = match aws_auth.service.as_deref() { |
| 189 | + Some(s) => s.to_owned(), |
| 190 | + None => { |
| 191 | + let seg = parsed_host |
| 192 | + .as_ref() |
| 193 | + .map(|h| h.normalized_service) |
| 194 | + .unwrap_or(""); |
| 195 | + match parsed_host.as_ref().and_then(|h| h.signing_service()) { |
| 196 | + Some(s) => s.to_owned(), |
| 197 | + None => { |
| 198 | + warn!( |
| 199 | + "AWS route '{}': service segment '{}' from upstream \ |
| 200 | + host '{}' is not in the signing-name table; set \ |
| 201 | + aws_auth.service explicitly — skipping this route.", |
| 202 | + prefix, seg, upstream_host |
| 203 | + ); |
| 204 | + return None; |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + }; |
| 209 | + |
| 210 | + Some((region, service)) |
| 211 | +} |
| 212 | + |
| 213 | +#[cfg(test)] |
| 214 | +#[allow(clippy::unwrap_used)] |
| 215 | +mod tests { |
| 216 | + use super::*; |
| 217 | + |
| 218 | + // ========================================================================= |
| 219 | + // AwsUrlParts::parse — valid inputs |
| 220 | + // ========================================================================= |
| 221 | + |
| 222 | + /// Canonical hostname shapes and region varieties that must parse correctly. |
| 223 | + #[test] |
| 224 | + fn parse_valid_hosts() { |
| 225 | + // (host, expected_normalized_service, expected_region) |
| 226 | + let cases: &[(&str, &str, Option<&str>)] = &[ |
| 227 | + // Regional — standard |
| 228 | + ( |
| 229 | + "bedrock-runtime.us-east-1.amazonaws.com", |
| 230 | + "bedrock-runtime", |
| 231 | + Some("us-east-1"), |
| 232 | + ), |
| 233 | + ("s3.eu-west-2.amazonaws.com", "s3", Some("eu-west-2")), |
| 234 | + ( |
| 235 | + "lambda.ap-southeast-1.amazonaws.com", |
| 236 | + "lambda", |
| 237 | + Some("ap-southeast-1"), |
| 238 | + ), |
| 239 | + // Regional — newer geo prefixes |
| 240 | + ( |
| 241 | + "sts.il-central-1.amazonaws.com", |
| 242 | + "sts", |
| 243 | + Some("il-central-1"), |
| 244 | + ), |
| 245 | + ( |
| 246 | + "sts.ap-southeast-7.amazonaws.com", |
| 247 | + "sts", |
| 248 | + Some("ap-southeast-7"), |
| 249 | + ), |
| 250 | + // Regional — GovCloud (extra word in region code) |
| 251 | + ( |
| 252 | + "s3.us-gov-east-1.amazonaws.com", |
| 253 | + "s3", |
| 254 | + Some("us-gov-east-1"), |
| 255 | + ), |
| 256 | + // Regional — ISO partition |
| 257 | + ( |
| 258 | + "s3.us-iso-east-1.amazonaws.com", |
| 259 | + "s3", |
| 260 | + Some("us-iso-east-1"), |
| 261 | + ), |
| 262 | + // Regional — FIPS suffix stripped |
| 263 | + ( |
| 264 | + "bedrock-runtime-fips.us-east-1.amazonaws.com", |
| 265 | + "bedrock-runtime", |
| 266 | + Some("us-east-1"), |
| 267 | + ), |
| 268 | + // Global (no region) |
| 269 | + ("iam.amazonaws.com", "iam", None), |
| 270 | + ("sts.amazonaws.com", "sts", None), |
| 271 | + ]; |
| 272 | + for &(host, expected_service, expected_region) in cases { |
| 273 | + let h = AwsUrlParts::parse(host).unwrap(); |
| 274 | + assert_eq!(h.normalized_service, expected_service, "host={host:?}"); |
| 275 | + assert_eq!(h.region, expected_region, "host={host:?}"); |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + // ========================================================================= |
| 280 | + // AwsUrlParts::parse — rejected inputs |
| 281 | + // ========================================================================= |
| 282 | + |
| 283 | + /// Inputs that must return None, with the reason documented inline. |
| 284 | + #[test] |
| 285 | + fn parse_rejects_invalid_hosts() { |
| 286 | + let cases: &[(&str, &str)] = &[ |
| 287 | + ("api.openai.com", "non-amazonaws TLD"), |
| 288 | + ( |
| 289 | + "bedrock-runtime.us-east-1.api.aws", |
| 290 | + "api.aws dual-stack not handled", |
| 291 | + ), |
| 292 | + ("localhost", "bare hostname"), |
| 293 | + ("", "empty string"), |
| 294 | + ("amazonaws.com", "too few segments"), |
| 295 | + ( |
| 296 | + "extra.bedrock-runtime.us-east-1.amazonaws.com", |
| 297 | + "too many segments", |
| 298 | + ), |
| 299 | + ("s3.US-EAST-1.amazonaws.com", "uppercase region"), |
| 300 | + ("s3.us-east.amazonaws.com", "region missing trailing digit"), |
| 301 | + ("s3.useast1.amazonaws.com", "region missing hyphens"), |
| 302 | + ( |
| 303 | + "iam.amazonaws.amazonaws.com", |
| 304 | + "\"amazonaws\" is not a region", |
| 305 | + ), |
| 306 | + ]; |
| 307 | + for &(host, reason) in cases { |
| 308 | + assert!( |
| 309 | + AwsUrlParts::parse(host).is_none(), |
| 310 | + "expected None for {host:?} ({reason})" |
| 311 | + ); |
| 312 | + } |
| 313 | + } |
| 314 | + |
| 315 | + // ========================================================================= |
| 316 | + // AwsUrlParts::signing_service |
| 317 | + // ========================================================================= |
| 318 | + |
| 319 | + #[test] |
| 320 | + fn signing_service_maps_known_segments() { |
| 321 | + // Identity: segment equals signing name |
| 322 | + let s3 = AwsUrlParts { |
| 323 | + normalized_service: "s3", |
| 324 | + region: None, |
| 325 | + }; |
| 326 | + assert_eq!(s3.signing_service(), Some("s3")); |
| 327 | + |
| 328 | + // Non-obvious: host prefix differs from signing name |
| 329 | + let email = AwsUrlParts { |
| 330 | + normalized_service: "email", |
| 331 | + region: None, |
| 332 | + }; |
| 333 | + assert_eq!(email.signing_service(), Some("ses")); |
| 334 | + |
| 335 | + // Multi-prefix family: bedrock-runtime signs as bedrock |
| 336 | + let runtime = AwsUrlParts { |
| 337 | + normalized_service: "bedrock-runtime", |
| 338 | + region: None, |
| 339 | + }; |
| 340 | + assert_eq!(runtime.signing_service(), Some("bedrock")); |
| 341 | + } |
| 342 | + |
| 343 | + #[test] |
| 344 | + fn signing_service_returns_none_for_unknown_segment() { |
| 345 | + let unknown = AwsUrlParts { |
| 346 | + normalized_service: "unknownsvc", |
| 347 | + region: None, |
| 348 | + }; |
| 349 | + assert!(unknown.signing_service().is_none()); |
| 350 | + } |
| 351 | +} |
0 commit comments