1use std::{collections::BTreeMap, path::PathBuf};
4
5#[cfg(feature = "aws-core")]
6use aws_config::meta::region::ProvideRegion;
7#[cfg(feature = "aws-core")]
8use aws_types::region::Region;
9use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::AUTHORIZATION};
10use hyper::Body;
11use vector_lib::codecs::{
12 CharacterDelimitedEncoder,
13 encoding::{Framer, Serializer},
14};
15#[cfg(feature = "aws-core")]
16use vector_lib::config::proxy::ProxyConfig;
17
18use super::{
19 encoder::HttpEncoder, request_builder::HttpRequestBuilder, service::HttpSinkRequestBuilder,
20 sink::HttpSink,
21};
22#[cfg(feature = "aws-core")]
23use crate::aws::AwsAuthentication;
24#[cfg(feature = "aws-core")]
25use crate::sinks::util::http::SigV4Config;
26use crate::{
27 codecs::{EncodingConfigWithFraming, SinkType},
28 http::{Auth, HttpClient, MaybeAuth},
29 sinks::{
30 prelude::*,
31 util::{
32 RealtimeSizeBasedDefaultBatchSettings, UriSerde,
33 http::{
34 HttpService, OrderedHeaderName, RequestConfig, RetryStrategy,
35 http_response_retry_logic,
36 },
37 },
38 },
39 template::ConfinementConfig,
40};
41
42const CONTENT_TYPE_TEXT: &str = "text/plain";
43const CONTENT_TYPE_NDJSON: &str = "application/x-ndjson";
44const CONTENT_TYPE_JSON: &str = "application/json";
45
46#[configurable_component(sink("http", "Deliver observability event data to an HTTP server."))]
48#[derive(Clone, Debug)]
49#[serde(deny_unknown_fields)]
50pub struct HttpSinkConfig {
51 #[configurable(metadata(docs::examples = "https://10.22.212.22:9000/endpoint"))]
55 pub uri: Template,
56
57 #[serde(default)]
59 pub method: HttpMethod,
60
61 #[configurable(derived)]
62 pub auth: Option<Auth>,
63
64 #[configurable(derived)]
65 #[serde(default)]
66 pub compression: Compression,
67
68 #[serde(flatten)]
69 pub encoding: EncodingConfigWithFraming,
70
71 #[configurable(metadata(docs::examples = "{\"data\":"))]
77 #[serde(default)]
78 pub payload_prefix: String,
79
80 #[configurable(metadata(docs::examples = "}"))]
86 #[serde(default)]
87 pub payload_suffix: String,
88
89 #[configurable(derived)]
90 #[serde(default)]
91 pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
92
93 #[configurable(derived)]
94 #[serde(default)]
95 pub request: RequestConfig,
96
97 #[configurable(derived)]
98 pub tls: Option<TlsConfig>,
99
100 #[configurable(derived)]
101 #[serde(
102 default,
103 deserialize_with = "crate::serde::bool_or_struct",
104 skip_serializing_if = "crate::serde::is_default"
105 )]
106 pub acknowledgements: AcknowledgementsConfig,
107
108 #[configurable(derived)]
109 #[serde(default)]
110 pub retry_strategy: RetryStrategy,
111
112 #[serde(flatten)]
113 pub confinement: ConfinementConfig,
114}
115
116#[configurable_component]
122#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
123#[serde(rename_all = "snake_case")]
124pub enum HttpMethod {
125 Get,
127
128 Head,
130
131 #[default]
133 Post,
134
135 Put,
137
138 Delete,
140
141 Options,
143
144 Trace,
146
147 Patch,
149}
150
151impl From<HttpMethod> for Method {
152 fn from(http_method: HttpMethod) -> Self {
153 match http_method {
154 HttpMethod::Head => Self::HEAD,
155 HttpMethod::Get => Self::GET,
156 HttpMethod::Post => Self::POST,
157 HttpMethod::Put => Self::PUT,
158 HttpMethod::Patch => Self::PATCH,
159 HttpMethod::Delete => Self::DELETE,
160 HttpMethod::Options => Self::OPTIONS,
161 HttpMethod::Trace => Self::TRACE,
162 }
163 }
164}
165
166impl HttpSinkConfig {
167 fn build_http_client(&self, cx: &SinkContext) -> crate::Result<HttpClient> {
168 let tls = TlsSettings::from_options(self.tls.as_ref())?;
169 Ok(HttpClient::new(tls, cx.proxy())?)
170 }
171
172 pub(super) fn build_encoder(&self) -> crate::Result<Encoder<Framer>> {
173 let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
174 Ok(Encoder::<Framer>::new(framer, serializer))
175 }
176}
177
178impl GenerateConfig for HttpSinkConfig {
179 fn generate_config() -> toml::Value {
180 toml::from_str(
181 r#"uri = "https://10.22.212.22:9000/endpoint"
182 encoding.codec = "json""#,
183 )
184 .unwrap()
185 }
186}
187
188async fn healthcheck(uri: UriSerde, auth: Option<Auth>, client: HttpClient) -> crate::Result<()> {
189 let auth = auth.choose_one(&uri.auth)?;
190 let uri = uri.with_default_parts();
191 let mut request = Request::head(&uri.uri).body(Body::empty()).unwrap();
192
193 if let Some(auth) = auth {
194 auth.apply(&mut request);
195 }
196
197 let response = client.send(request).await?;
198
199 match response.status() {
200 StatusCode::OK => Ok(()),
201 status => Err(HealthcheckError::UnexpectedStatus { status }.into()),
202 }
203}
204
205pub(super) fn validate_headers(
206 headers: &BTreeMap<String, String>,
207 configures_auth: bool,
208) -> crate::Result<BTreeMap<OrderedHeaderName, HeaderValue>> {
209 let headers = crate::sinks::util::http::validate_headers(headers)?;
210
211 for name in headers.keys() {
212 if configures_auth && name.inner() == AUTHORIZATION {
213 return Err("Authorization header can not be used with defined auth options".into());
214 }
215 }
216
217 Ok(headers)
218}
219
220pub(super) fn validate_payload_wrapper(
221 payload_prefix: &str,
222 payload_suffix: &str,
223 encoder: &Encoder<Framer>,
224) -> crate::Result<(String, String)> {
225 let payload = [payload_prefix, "{}", payload_suffix].join("");
226 match (
227 encoder.serializer(),
228 encoder.framer(),
229 serde_json::from_str::<serde_json::Value>(&payload),
230 ) {
231 (
232 Serializer::Json(_),
233 Framer::CharacterDelimited(CharacterDelimitedEncoder { delimiter: b',' }),
234 Err(_),
235 ) => Err("Payload prefix and suffix wrapper must produce a valid JSON object.".into()),
236 _ => Ok((payload_prefix.to_owned(), payload_suffix.to_owned())),
237 }
238}
239
240#[async_trait]
241#[typetag::serde(name = "http")]
242impl SinkConfig for HttpSinkConfig {
243 async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
244 let result = self.build_without_confinement_gauge(cx, Self::NAME).await?;
245 self.confinement.set_confinement_gauge("sink", Self::NAME);
246 Ok(result)
247 }
248
249 fn input(&self) -> Input {
250 Input::new(self.encoding.config().1.input_type())
251 }
252
253 fn files_to_watch(&self) -> Vec<&PathBuf> {
254 let mut files = Vec::new();
255 if let Some(tls) = &self.tls {
256 if let Some(crt_file) = &tls.crt_file {
257 files.push(crt_file)
258 }
259 if let Some(key_file) = &tls.key_file {
260 files.push(key_file)
261 }
262 };
263 files
264 }
265
266 fn acknowledgements(&self) -> &AcknowledgementsConfig {
267 &self.acknowledgements
268 }
269}
270
271impl HttpSinkConfig {
272 pub(crate) async fn build_without_confinement_gauge(
278 &self,
279 cx: SinkContext,
280 component_name: &'static str,
281 ) -> crate::Result<(VectorSink, Healthcheck)> {
282 let batch_settings = self.batch.validate()?.into_batcher_settings()?;
283
284 let encoder = self.build_encoder()?;
285 let transformer = self.encoding.transformer();
286
287 let request = self.request.clone();
288
289 validate_headers(&request.headers, self.auth.is_some())?;
290 let (static_headers, template_headers) = request.split_headers();
291
292 let (payload_prefix, payload_suffix) =
293 validate_payload_wrapper(&self.payload_prefix, &self.payload_suffix, &encoder)?;
294
295 let client = self.build_http_client(&cx)?;
296
297 let healthcheck = match cx.healthcheck.uri {
298 Some(healthcheck_uri) => {
299 healthcheck(healthcheck_uri, self.auth.clone(), client.clone()).boxed()
300 }
301 None => future::ok(()).boxed(),
302 };
303
304 let content_type = {
305 use Framer::*;
306 use Serializer::*;
307 match (encoder.serializer(), encoder.framer()) {
308 (RawMessage(_) | Text(_), _) => Some(CONTENT_TYPE_TEXT.to_owned()),
309 (Json(_), NewlineDelimited(_)) => Some(CONTENT_TYPE_NDJSON.to_owned()),
310 (Json(_), CharacterDelimited(CharacterDelimitedEncoder { delimiter: b',' })) => {
311 Some(CONTENT_TYPE_JSON.to_owned())
312 }
313 #[cfg(feature = "codecs-opentelemetry")]
314 (Otlp(_), _) => Some("application/x-protobuf".to_owned()),
315 _ => None,
316 }
317 };
318
319 let request_builder = HttpRequestBuilder {
320 encoder: HttpEncoder::new(encoder, transformer, payload_prefix, payload_suffix),
321 compression: self.compression,
322 };
323
324 let content_encoding = self.compression.is_compressed().then(|| {
325 self.compression
326 .content_encoding()
327 .expect("Encoding should be specified for compression.")
328 .to_string()
329 });
330
331 let converted_static_headers = static_headers
332 .into_iter()
333 .map(|(name, value)| -> crate::Result<_> {
334 let header_name =
335 HeaderName::from_bytes(name.as_bytes()).map(OrderedHeaderName::from)?;
336 let header_value = HeaderValue::try_from(value)?;
337 Ok((header_name, header_value))
338 })
339 .collect::<Result<BTreeMap<_, _>, _>>()?;
340
341 let http_sink_request_builder = HttpSinkRequestBuilder::new(
342 self.method,
343 self.auth.clone(),
344 converted_static_headers,
345 content_type,
346 content_encoding,
347 );
348
349 let service = match &self.auth {
350 #[cfg(feature = "aws-core")]
351 Some(Auth::Aws { auth, service }) => {
352 let default_region = crate::aws::region_provider(&ProxyConfig::default(), None)?
353 .region()
354 .await;
355 let region = (match &auth {
356 AwsAuthentication::AccessKey { region, .. } => region.clone(),
357 AwsAuthentication::File { .. } => None,
358 AwsAuthentication::Role { region, .. } => region.clone(),
359 AwsAuthentication::Default { region, .. } => region.clone(),
360 })
361 .map_or(default_region, |r| Some(Region::new(r.to_string())))
362 .expect("Region must be specified");
363
364 HttpService::new_with_sig_v4(
365 client,
366 http_sink_request_builder,
367 SigV4Config {
368 shared_credentials_provider: auth
369 .credentials_provider(region.clone(), &ProxyConfig::default(), None)
370 .await?,
371 region: region.clone(),
372 service: service.clone(),
373 },
374 )
375 }
376 _ => HttpService::new(client, http_sink_request_builder),
377 };
378
379 let request_limits = self.request.tower.into_settings();
380
381 let service = ServiceBuilder::new()
382 .settings(
383 request_limits,
384 http_response_retry_logic(self.retry_strategy.clone()),
385 )
386 .service(service);
387
388 let uri = self
389 .uri
390 .clone()
391 .confine(&self.confinement, component_name, "uri")?;
392
393 let template_headers = template_headers
398 .into_iter()
399 .map(|(name, tpl)| {
400 tpl.confine(&self.confinement, component_name, "request.headers")
401 .map(|tpl| (name, tpl))
402 })
403 .collect::<crate::Result<BTreeMap<_, _>>>()?;
404
405 let sink = HttpSink::new(
406 service,
407 uri,
408 template_headers,
409 batch_settings,
410 request_builder,
411 );
412
413 Ok((VectorSink::from_event_streamsink(sink), healthcheck))
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use vector_lib::codecs::encoding::format::JsonSerializerOptions;
420
421 use super::*;
422 use crate::components::validation::prelude::*;
423 use crate::template::{ConfinementConfig, Template};
424
425 impl ValidatableComponent for HttpSinkConfig {
426 fn validation_configuration() -> ValidationConfiguration {
427 use std::str::FromStr;
428
429 use vector_lib::{
430 codecs::{JsonSerializerConfig, MetricTagValues},
431 config::LogNamespace,
432 };
433
434 let endpoint = "http://127.0.0.1:9000/endpoint";
435 let uri = UriSerde::from_str(endpoint).expect("should never fail to parse");
436
437 let config = HttpSinkConfig {
438 uri: Template::try_from(endpoint).expect("should never fail to parse"),
439 method: HttpMethod::Post,
440 encoding: EncodingConfigWithFraming::new(
441 None,
442 JsonSerializerConfig::new(
443 MetricTagValues::Full,
444 JsonSerializerOptions::default(),
445 )
446 .into(),
447 Transformer::default(),
448 ),
449 auth: None,
450 compression: Compression::default(),
451 batch: BatchConfig::default(),
452 request: RequestConfig::default(),
453 tls: None,
454 acknowledgements: AcknowledgementsConfig::default(),
455 payload_prefix: String::new(),
456 payload_suffix: String::new(),
457 retry_strategy: RetryStrategy::default(),
458 confinement: ConfinementConfig::default(),
459 };
460
461 let external_resource = ExternalResource::new(
462 ResourceDirection::Push,
463 HttpResourceConfig::from_parts(uri.uri, Some(config.method.into())),
464 config.encoding.clone(),
465 );
466
467 ValidationConfiguration::from_sink(
468 Self::NAME,
469 LogNamespace::Legacy,
470 vec![ComponentTestCaseConfig::from_sink(
471 config,
472 None,
473 Some(external_resource),
474 )],
475 )
476 }
477 }
478
479 register_validatable_component!(HttpSinkConfig);
480
481 #[test]
482 fn confinement_rejects_unconfined_uri() {
483 let template: Template = "{{ endpoint }}".try_into().unwrap();
484 let err = template
485 .confine(&ConfinementConfig::default(), "http", "uri")
486 .unwrap_err();
487 assert!(
488 err.to_string().contains("no literal string prefix"),
489 "unexpected error: {err}"
490 );
491 }
492
493 #[test]
494 fn confinement_opt_out_allows_unconfined_uri() {
495 let cfg = ConfinementConfig {
496 dangerously_allow_unconfined_template_resolution: true,
497 };
498 let template: Template = "{{ endpoint }}".try_into().unwrap();
499 assert!(template.confine(&cfg, "http", "uri").is_ok());
500 }
501
502 #[test]
503 fn confinement_blocks_host_redirect_at_render() {
504 use crate::event::Event;
505 use vector_lib::event::LogEvent;
506 use vrl::event_path;
507
508 let template: Template = "https://logs.example.com/ingest/{{ tenant }}"
509 .try_into()
510 .unwrap();
511 let template = template
512 .confine(&ConfinementConfig::default(), "http", "uri")
513 .unwrap();
514
515 let mut event = Event::Log(LogEvent::from("x"));
517 event
518 .as_mut_log()
519 .insert(event_path!("tenant"), "../../evil.com/steal?data=");
520 assert!(template.render_string(&event).is_err());
521 }
522}