Skip to main content

vector/aws/
mod.rs

1//! Shared functionality for the AWS components.
2pub mod auth;
3pub mod region;
4pub mod timeout;
5
6use std::{
7    error::Error,
8    pin::Pin,
9    sync::{
10        Arc, OnceLock,
11        atomic::{AtomicUsize, Ordering},
12    },
13    task::{Context, Poll},
14    time::{Duration, Instant, SystemTime},
15};
16
17pub use auth::{AwsAuthentication, ImdsAuthentication};
18use aws_config::{
19    Region, SdkConfig, meta::region::ProvideRegion, retry::RetryConfig, timeout::TimeoutConfig,
20};
21use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
22use aws_sigv4::{
23    http_request::{PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings},
24    sign::v4,
25};
26use aws_smithy_async::rt::sleep::TokioSleep;
27use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder;
28use aws_smithy_runtime_api::client::{
29    http::{
30        HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector,
31    },
32    identity::Identity,
33    orchestrator::{HttpRequest, HttpResponse},
34    result::SdkError,
35    runtime_components::RuntimeComponents,
36};
37use aws_smithy_types::body::SdkBody;
38use aws_types::sdk_config::SharedHttpClient;
39use bytes::Bytes;
40use http::{HeaderMap, HeaderName, header::HeaderValue};
41use http_body::{Body, combinators::BoxBody};
42use pin_project::pin_project;
43use regex::RegexSet;
44pub use region::RegionOrEndpoint;
45use snafu::Snafu;
46pub use timeout::AwsTimeout;
47
48use crate::{
49    config::ProxyConfig,
50    http::{build_proxy_connector, build_tls_connector, status},
51    internal_events::{
52        AwsBytesSent,
53        http_client::{
54            AboutToSendHttpRequest, GotHttpResponse, GotHttpWarning, HttpRequestTelemetry,
55            HttpResponseTelemetry,
56        },
57    },
58    tls::{MaybeTlsSettings, TlsConfig},
59};
60
61static RETRIABLE_CODES: OnceLock<RegexSet> = OnceLock::new();
62
63/// Checks if the request can be retried after the given error was returned.
64pub fn is_retriable_error<T>(error: &SdkError<T, HttpResponse>) -> bool {
65    match error {
66        SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) => true,
67        SdkError::ConstructionFailure(_) => false,
68        SdkError::ResponseError(err) => check_response(err.raw()),
69        SdkError::ServiceError(err) => check_response(err.raw()),
70        _ => {
71            warn!("AWS returned unknown error, retrying request.");
72            true
73        }
74    }
75}
76
77fn check_response(res: &HttpResponse) -> bool {
78    // This header is a direct indication that we should retry the request. Eventually it'd
79    // be nice to actually schedule the retry after the given delay, but for now we just
80    // check that it contains a positive value.
81    let retry_header = res.headers().get("x-amz-retry-after").is_some();
82
83    // Certain 400-level responses will contain an error code indicating that the request
84    // should be retried. Since we don't retry 400-level responses by default, we'll look
85    // for these specifically before falling back to more general heuristics. Because AWS
86    // services use a mix of XML and JSON response bodies and the AWS SDK doesn't give us
87    // a parsed representation, we resort to a simple string match.
88    //
89    // S3: RequestTimeout
90    // SQS: RequestExpired, ThrottlingException
91    // ECS: RequestExpired, ThrottlingException
92    // Kinesis: RequestExpired, ThrottlingException
93    // Cloudwatch: RequestExpired, ThrottlingException
94    //
95    // Now just look for those when it's a client_error
96    let re = RETRIABLE_CODES.get_or_init(|| {
97        RegexSet::new(["RequestTimeout", "RequestExpired", "ThrottlingException"])
98            .expect("invalid regex")
99    });
100
101    let status = res.status();
102    let response_body = String::from_utf8_lossy(res.body().bytes().unwrap_or(&[]));
103
104    retry_header
105        || status.is_server_error()
106        || status.as_u16() == status::TOO_MANY_REQUESTS
107        || (status.is_client_error() && re.is_match(response_body.as_ref()))
108}
109
110/// Creates the http connector that has been configured to use the given proxy and TLS settings.
111/// All AWS requests should use this connector as the aws crates by default use RustTLS which we
112/// have turned off as we want to consistently use openssl.
113fn connector(
114    proxy: &ProxyConfig,
115    tls_options: Option<&TlsConfig>,
116) -> crate::Result<SharedHttpClient> {
117    let tls_settings = MaybeTlsSettings::tls_client(tls_options)?;
118
119    if proxy.enabled {
120        let proxy = build_proxy_connector(tls_settings, proxy)?;
121        Ok(HyperClientBuilder::new().build(proxy))
122    } else {
123        let tls_connector = build_tls_connector(tls_settings)?;
124        Ok(HyperClientBuilder::new().build(tls_connector))
125    }
126}
127
128/// Implement for each AWS service to create the appropriate AWS sdk client.
129pub trait ClientBuilder {
130    /// The type of the client in the SDK.
131    type Client;
132
133    /// Build the client using the given config settings.
134    fn build(&self, config: &SdkConfig) -> Self::Client;
135}
136
137/// Provides the configured AWS region.
138pub fn region_provider(
139    proxy: &ProxyConfig,
140    tls_options: Option<&TlsConfig>,
141) -> crate::Result<impl ProvideRegion + use<>> {
142    // Region is not yet known here, so we cannot wrap with AwsHttpClient for observability.
143    let config = aws_config::provider_config::ProviderConfig::default()
144        .with_http_client(connector(proxy, tls_options)?);
145
146    Ok(aws_config::meta::region::RegionProviderChain::first_try(
147        aws_config::environment::EnvironmentVariableRegionProvider::new(),
148    )
149    .or_else(aws_config::profile::ProfileFileRegionProvider::builder().build())
150    .or_else(
151        aws_config::imds::region::ImdsRegionProvider::builder()
152            .configure(&config)
153            .build(),
154    ))
155}
156
157async fn resolve_region(
158    proxy: &ProxyConfig,
159    tls_options: Option<&TlsConfig>,
160    region: Option<Region>,
161) -> crate::Result<Region> {
162    match region {
163        Some(region) => Ok(region),
164        None => region_provider(proxy, tls_options)?
165            .region()
166            .await
167            .ok_or_else(|| {
168                "Could not determine region from Vector configuration or default providers".into()
169            }),
170    }
171}
172
173/// Create the SDK client using the provided settings.
174pub async fn create_client<T>(
175    builder: &T,
176    auth: &AwsAuthentication,
177    region: Option<Region>,
178    endpoint: Option<String>,
179    proxy: &ProxyConfig,
180    tls_options: Option<&TlsConfig>,
181    timeout: Option<&AwsTimeout>,
182) -> crate::Result<T::Client>
183where
184    T: ClientBuilder,
185{
186    create_client_and_region::<T>(builder, auth, region, endpoint, proxy, tls_options, timeout)
187        .await
188        .map(|(client, _)| client)
189}
190
191/// Create the SDK client and resolve the region using the provided settings.
192pub async fn create_client_and_region<T>(
193    builder: &T,
194    auth: &AwsAuthentication,
195    region: Option<Region>,
196    endpoint: Option<String>,
197    proxy: &ProxyConfig,
198    tls_options: Option<&TlsConfig>,
199    timeout: Option<&AwsTimeout>,
200) -> crate::Result<(T::Client, Region)>
201where
202    T: ClientBuilder,
203{
204    let retry_config = RetryConfig::disabled();
205
206    // The default credentials chains will look for a region if not given but we'd like to
207    // error up front if later SDK calls will fail due to lack of region configuration
208    let region = resolve_region(proxy, tls_options, region).await?;
209
210    let provider_config =
211        aws_config::provider_config::ProviderConfig::empty().with_region(Some(region.clone()));
212
213    let connector = connector(proxy, tls_options)?;
214
215    // Create a custom http connector that will emit the required metrics for us.
216    let connector = AwsHttpClient {
217        http: connector,
218        region: region.clone(),
219        emit_bytes_sent: true,
220    };
221
222    // Build the configuration first.
223    let mut config_builder = SdkConfig::builder()
224        .http_client(connector)
225        .sleep_impl(Arc::new(TokioSleep::new()))
226        .identity_cache(auth.credentials_cache().await?)
227        .credentials_provider(
228            auth.credentials_provider(region.clone(), proxy, tls_options)
229                .await?,
230        )
231        .region(region.clone())
232        .retry_config(retry_config.clone());
233
234    if let Some(endpoint_override) = endpoint {
235        config_builder = config_builder.endpoint_url(endpoint_override);
236    } else if let Some(endpoint_from_config) =
237        aws_config::default_provider::endpoint_url::endpoint_url_provider(&provider_config).await
238    {
239        config_builder = config_builder.endpoint_url(endpoint_from_config);
240    }
241
242    if let Some(use_fips) =
243        aws_config::default_provider::use_fips::use_fips_provider(&provider_config).await
244    {
245        config_builder = config_builder.use_fips(use_fips);
246    }
247
248    if let Some(timeout) = timeout {
249        let mut timeout_config_builder = TimeoutConfig::builder();
250
251        let operation_timeout = timeout.operation_timeout();
252        let connect_timeout = timeout.connect_timeout();
253        let read_timeout = timeout.read_timeout();
254
255        timeout_config_builder
256            .set_operation_timeout(operation_timeout.map(Duration::from_secs))
257            .set_connect_timeout(connect_timeout.map(Duration::from_secs))
258            .set_read_timeout(read_timeout.map(Duration::from_secs));
259
260        config_builder = config_builder.timeout_config(timeout_config_builder.build());
261    }
262
263    let config = config_builder.build();
264
265    Ok((T::build(builder, &config), region))
266}
267
268#[derive(Snafu, Debug)]
269enum SigningError {
270    #[snafu(display("cannot sign the request because the headers are not valid utf-8"))]
271    NotUTF8Header,
272}
273
274/// Sign the request prior to sending to AWS.
275/// The signature is added to the provided `request`.
276pub async fn sign_request(
277    service_name: &str,
278    request: &mut http::Request<Bytes>,
279    credentials_provider: &SharedCredentialsProvider,
280    region: Option<&Region>,
281    payload_checksum_sha256: bool,
282) -> crate::Result<()> {
283    let headers = request
284        .headers()
285        .iter()
286        .map(|(k, v)| {
287            Ok((
288                k.as_str(),
289                std::str::from_utf8(v.as_bytes()).map_err(|_| SigningError::NotUTF8Header)?,
290            ))
291        })
292        .collect::<Result<Vec<_>, SigningError>>()?;
293
294    let signable_request = SignableRequest::new(
295        request.method().as_str(),
296        request.uri().to_string(),
297        headers.into_iter(),
298        SignableBody::Bytes(request.body().as_ref()),
299    )?;
300
301    let credentials = credentials_provider.provide_credentials().await?;
302    let identity = Identity::new(credentials, None);
303
304    let mut signing_settings = SigningSettings::default();
305
306    // Include the x-amz-content-sha256 header when calculating the AWS v4 signature;
307    // this is required by some AWS services, e.g. S3 and OpenSearch Serverless
308    if payload_checksum_sha256 {
309        signing_settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
310    }
311
312    let signing_params_builder = v4::SigningParams::builder()
313        .identity(&identity)
314        .region(region.as_ref().map(|r| r.as_ref()).unwrap_or(""))
315        .name(service_name)
316        .time(SystemTime::now())
317        .settings(signing_settings);
318
319    let signing_params = signing_params_builder
320        .build()
321        .expect("all signing params set");
322
323    let (signing_instructions, _signature) =
324        aws_sigv4::http_request::sign(signable_request, &signing_params.into())?.into_parts();
325    signing_instructions.apply_to_request_http0x(request);
326
327    Ok(())
328}
329
330#[derive(Debug)]
331struct AwsHttpClient<T> {
332    http: T,
333    region: Region,
334    /// When `false`, the connector skips `AwsBytesSent` so that control-plane
335    /// traffic (STS AssumeRole, IMDS, SSO token exchange) does not inflate
336    /// `component_sent_bytes_total`.
337    emit_bytes_sent: bool,
338}
339
340impl<T> HttpClient for AwsHttpClient<T>
341where
342    T: HttpClient,
343{
344    fn http_connector(
345        &self,
346        settings: &HttpConnectorSettings,
347        components: &RuntimeComponents,
348    ) -> SharedHttpConnector {
349        let http_connector = self.http.http_connector(settings, components);
350
351        SharedHttpConnector::new(AwsConnector {
352            region: self.region.clone(),
353            http: http_connector,
354            emit_bytes_sent: self.emit_bytes_sent,
355        })
356    }
357}
358
359#[derive(Clone, Debug)]
360struct AwsConnector<T> {
361    http: T,
362    region: Region,
363    emit_bytes_sent: bool,
364}
365
366// ── Telemetry trait implementations for the AWS SDK HTTP types ────────────────
367//
368// `HttpRequest` and `HttpResponse` are the SDK's own structs (not `http::Request`
369// / `http::Response`), so they cannot implement the hyper-specific body/version
370// accessors.  The required method impls (method/uri and status) are enough for
371// metric labels; the optional rich-logging fields fall back to `None`.
372
373impl HttpRequestTelemetry for HttpRequest {
374    fn method(&self) -> &str {
375        self.method()
376    }
377
378    fn uri(&self) -> String {
379        self.uri().to_string()
380    }
381
382    fn headers(&self) -> HeaderMap<HeaderValue> {
383        smithy_headers_to_map(self.headers())
384    }
385
386    fn body_size_hint(&self) -> (u64, Option<u64>) {
387        let hint = Body::size_hint(self.body());
388        (hint.lower(), hint.upper())
389    }
390
391    fn extra_sensitive_headers(&self) -> &'static [HeaderName] {
392        &AWS_EXTRA_SENSITIVE_HEADERS
393    }
394}
395
396impl HttpResponseTelemetry for HttpResponse {
397    fn status_u16(&self) -> u16 {
398        self.status().as_u16()
399    }
400
401    fn headers(&self) -> HeaderMap<HeaderValue> {
402        smithy_headers_to_map(self.headers())
403    }
404
405    fn body_size_hint(&self) -> (u64, Option<u64>) {
406        let hint = Body::size_hint(self.body());
407        (hint.lower(), hint.upper())
408    }
409
410    fn extra_sensitive_headers(&self) -> &'static [HeaderName] {
411        &AWS_EXTRA_SENSITIVE_HEADERS
412    }
413}
414
415/// AWS-specific headers that carry credential material and must never appear in
416/// logs.
417///
418/// - `x-amz-security-token`    — STS temporary session token
419/// - `x-amz-sso_bearer_token`  — IAM Identity Center access token (exchangeable for role credentials)
420/// - `x-aws-ec2-metadata-token` — IMDSv2 session token (valid for up to 6 h)
421static AWS_EXTRA_SENSITIVE_HEADERS: [HeaderName; 3] = [
422    HeaderName::from_static("x-amz-security-token"),
423    HeaderName::from_static("x-amz-sso_bearer_token"),
424    HeaderName::from_static("x-aws-ec2-metadata-token"),
425];
426
427/// Converts the AWS SDK's string-pair header iterator into an `http::HeaderMap`.
428/// Sanitization (marking sensitive headers) is handled by the trait's default
429/// `sanitized_headers` method.
430fn smithy_headers_to_map(
431    headers: &aws_smithy_runtime_api::http::Headers,
432) -> HeaderMap<HeaderValue> {
433    let mut map = HeaderMap::with_capacity(headers.len());
434    for (name, value) in headers {
435        let Ok(header_name) = http::HeaderName::from_bytes(name.as_bytes()) else {
436            continue;
437        };
438        let Ok(header_value) = HeaderValue::from_str(value) else {
439            continue;
440        };
441        map.insert(header_name, header_value);
442    }
443    map
444}
445
446// ─────────────────────────────────────────────────────────────────────────────
447
448impl<T> AwsConnector<T>
449where
450    T: HttpConnector,
451{
452    /// Calls the inner HTTP connector and emits common telemetry.
453    /// Does not emit `AwsBytesSent` telemetry.
454    fn call_inner(&self, req: HttpRequest) -> HttpConnectorFuture {
455        emit!(AboutToSendHttpRequest { request: &req });
456
457        let fut: HttpConnectorFuture = self.http.call(req);
458
459        HttpConnectorFuture::new(async move {
460            let before = Instant::now();
461            let result = fut.await;
462            let roundtrip = before.elapsed();
463
464            match &result {
465                Ok(response) => {
466                    emit!(GotHttpResponse {
467                        response,
468                        roundtrip
469                    });
470                }
471                Err(error) => {
472                    emit!(GotHttpWarning { error, roundtrip });
473                }
474            }
475
476            result
477        })
478    }
479}
480
481impl<T> HttpConnector for AwsConnector<T>
482where
483    T: HttpConnector,
484{
485    fn call(&self, req: HttpRequest) -> HttpConnectorFuture {
486        if !self.emit_bytes_sent {
487            return HttpConnectorFuture::new(self.call_inner(req));
488        }
489
490        let bytes_sent = Arc::new(AtomicUsize::new(0));
491
492        let req = req.map(|body| {
493            let bytes_sent = Arc::clone(&bytes_sent);
494            body.map_preserve_contents(move |body| {
495                let body = MeasuredBody::new(body, Arc::clone(&bytes_sent));
496                SdkBody::from_body_0_4(BoxBody::new(body))
497            })
498        });
499
500        let fut = self.call_inner(req);
501        let region = self.region.clone();
502
503        HttpConnectorFuture::new(async move {
504            let result = fut.await;
505
506            if let Ok(response) = &result
507                && response.status().is_success()
508            {
509                let byte_size = bytes_sent.load(Ordering::Relaxed);
510
511                emit!(AwsBytesSent {
512                    byte_size,
513                    region: Some(region),
514                });
515            };
516
517            result
518        })
519    }
520}
521
522#[pin_project]
523struct MeasuredBody {
524    #[pin]
525    inner: SdkBody,
526    shared_bytes_sent: Arc<AtomicUsize>,
527}
528
529impl MeasuredBody {
530    const fn new(body: SdkBody, shared_bytes_sent: Arc<AtomicUsize>) -> Self {
531        Self {
532            inner: body,
533            shared_bytes_sent,
534        }
535    }
536}
537
538impl Body for MeasuredBody {
539    type Data = Bytes;
540    type Error = Box<dyn Error + Send + Sync>;
541
542    fn poll_data(
543        self: Pin<&mut Self>,
544        cx: &mut Context<'_>,
545    ) -> Poll<Option<Result<Self::Data, Self::Error>>> {
546        let this = self.project();
547
548        match this.inner.poll_data(cx) {
549            Poll::Ready(Some(Ok(data))) => {
550                this.shared_bytes_sent
551                    .fetch_add(data.len(), Ordering::Release);
552                Poll::Ready(Some(Ok(data)))
553            }
554            Poll::Ready(None) => Poll::Ready(None),
555            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
556            Poll::Pending => Poll::Pending,
557        }
558    }
559
560    fn poll_trailers(
561        self: Pin<&mut Self>,
562        _cx: &mut Context<'_>,
563    ) -> Poll<Result<Option<HeaderMap>, Self::Error>> {
564        Poll::Ready(Ok(None))
565    }
566}