Skip to content

Commit 63ce10e

Browse files
enhancement(loki sink): validate endpoint via HttpEndpoint (#26195)
Co-authored-by: Pavlos Rontidis <pavlos.rontidis@gmail.com>
1 parent 48bebf8 commit 63ce10e

8 files changed

Lines changed: 258 additions & 60 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
Sink `endpoint` options now require an absolute URL that includes a host. Endpoints without a scheme are defaulted to `https://` (for example `endpoint: "localhost:8080"` becomes `https://localhost:8080`). Previously, partial or empty endpoints (for example `endpoint: ""` or `endpoint: "localhost:8080"` without a scheme) were accepted at configuration load and only failed when the sink attempted to send data, or were silently completed with a default scheme and host. Empty, host-less, or non-`http(s)` endpoints (for example `endpoint: ""`, `endpoint: "/path"`, or `endpoint: "ftp://example.com"`) are now rejected at configuration load with a clear error, including with `vector validate --no-environment`. This affects the `appsignal`, `azure_logs_ingestion`, `datadog_events`, `datadog_logs`, `datadog_metrics`, `datadog_traces`, `elasticsearch`, `gcp_cloud_storage`, `gcp_pubsub`, `gcp_stackdriver_logs`, `gcp_stackdriver_metrics`, `honeycomb`, `humio`, `influxdb`, `prometheus_remote_write`, `sematext`, `splunk_hec`, and `webhdfs` sinks.
1+
Sink `endpoint` options now require an absolute URL that includes a host. Endpoints without a scheme are defaulted to `https://` (for example `endpoint: "localhost:8080"` becomes `https://localhost:8080`). Previously, partial or empty endpoints (for example `endpoint: ""` or `endpoint: "localhost:8080"` without a scheme) were accepted at configuration load and only failed when the sink attempted to send data, or were silently completed with a default scheme and host. Empty, host-less, or non-`http(s)` endpoints (for example `endpoint: ""`, `endpoint: "/path"`, or `endpoint: "ftp://example.com"`) are now rejected at configuration load with a clear error, including with `vector validate --no-environment`. This affects the `appsignal`, `azure_logs_ingestion`, `datadog_events`, `datadog_logs`, `datadog_metrics`, `datadog_traces`, `elasticsearch`, `gcp_cloud_storage`, `gcp_pubsub`, `gcp_stackdriver_logs`, `gcp_stackdriver_metrics`, `honeycomb`, `humio`, `influxdb`, `loki`, `prometheus_remote_write`, `sematext`, `splunk_hec`, and `webhdfs` sinks.
22

33
authors: thomasqueirozb

‎src/sinks/loki/config.rs‎

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::collections::HashMap;
33
use vrl::value::Kind;
44

55
use super::{
6+
append_loki_path,
67
healthcheck::healthcheck,
78
sink::{LokiSink, confine_template_keys},
89
};
@@ -12,7 +13,7 @@ use crate::{
1213
schema,
1314
sinks::{
1415
prelude::*,
15-
util::{UriSerde, service::TowerRequestSettings},
16+
util::{HttpEndpoint, service::TowerRequestSettings},
1617
},
1718
template::{ConfinementConfig, Template, UnconfinedTemplate},
1819
};
@@ -34,7 +35,7 @@ pub struct LokiConfig {
3435
///
3536
/// The `path` value is appended to this.
3637
#[configurable(metadata(docs::examples = "http://localhost:3100"))]
37-
pub endpoint: UriSerde,
38+
pub endpoint: HttpEndpoint,
3839

3940
/// The path to use in the URL of the Loki instance.
4041
#[serde(default = "default_loki_path")]
@@ -246,6 +247,11 @@ impl SinkConfig for LokiConfig {
246247

247248
#[derive(Clone, Debug)]
248249
pub struct ValidatedLokiSink {
250+
/// The push URL: `endpoint` with `path` appended. Used by `LokiService`.
251+
pub(super) endpoint: HttpEndpoint,
252+
/// The credential-free configured endpoint, including any user-supplied base path.
253+
/// The healthcheck appends `ready` or `/` to this URL.
254+
pub(super) base_endpoint: HttpEndpoint,
249255
pub(super) auth: Option<Auth>,
250256
pub(super) request_limits: TowerRequestSettings,
251257
pub(super) transformer: Transformer,
@@ -270,7 +276,11 @@ impl ValidatedSink for LokiConfig {
270276
}
271277
}
272278

273-
let auth = self.auth.choose_one(&self.endpoint.auth)?;
279+
// Extract basic-auth credentials embedded in the endpoint URL and strip
280+
// the userinfo, so credentials are sent as an `Authorization` header
281+
// rather than in the request URL.
282+
let (base_endpoint, endpoint_auth) = self.endpoint.clone().extract_basic_auth()?;
283+
let auth = self.auth.choose_one(&endpoint_auth)?;
274284

275285
let request_limits = match self.out_of_order_action {
276286
OutOfOrderAction::Accept => self.request.into_settings(),
@@ -302,17 +312,15 @@ impl ValidatedSink for LokiConfig {
302312
"structured_metadata[key]",
303313
)?;
304314

305-
if !matches!(self.endpoint.uri.scheme_str(), Some("http" | "https"))
306-
|| self.endpoint.uri.authority().is_none()
307-
{
308-
return Err("Loki endpoint must be an absolute http(s) URL".into());
309-
}
310-
311-
self.endpoint.append_path(&self.path)?;
315+
// The push URL appends `path`; the healthcheck appends `ready`/`` to the
316+
// configured base endpoint, so both are retained.
317+
let endpoint = append_loki_path(&base_endpoint, &self.path)?;
312318

313319
let batch_settings = self.batch.into_batcher_settings()?;
314320

315321
Ok(ValidatedLokiSink {
322+
endpoint,
323+
base_endpoint,
316324
auth,
317325
request_limits,
318326
transformer,
@@ -330,14 +338,14 @@ impl ValidatedSink for LokiConfig {
330338
) -> crate::Result<(VectorSink, crate::sinks::Healthcheck)> {
331339
let client = self.build_client(cx)?;
332340

333-
let config = LokiConfig {
334-
auth: validated.auth.clone(),
335-
..self.clone()
336-
};
341+
let sink = LokiSink::from_validated(self, validated.clone(), client.clone())?;
337342

338-
let sink = LokiSink::from_validated(config.clone(), validated.clone(), client.clone())?;
339-
340-
let healthcheck = healthcheck(config, client).boxed();
343+
let healthcheck = healthcheck(
344+
validated.base_endpoint.clone(),
345+
validated.auth.clone(),
346+
client,
347+
)
348+
.boxed();
341349
Ok((VectorSink::from_event_streamsink(sink), healthcheck))
342350
}
343351
}
@@ -434,6 +442,26 @@ mod tests {
434442
);
435443
}
436444

445+
#[test]
446+
fn deserialize_rejects_non_http_endpoint() {
447+
let result = serde_yaml::from_str::<LokiConfig>(
448+
r#"
449+
endpoint: "ftp://localhost:3100"
450+
labels:
451+
test_name: "placeholder"
452+
encoding:
453+
codec: json
454+
"#,
455+
);
456+
457+
assert!(
458+
result.is_err(),
459+
"non-http endpoints must fail at config load"
460+
);
461+
let message = result.unwrap_err().to_string();
462+
assert!(message.contains("ftp://localhost:3100"), "{message}");
463+
}
464+
437465
#[test]
438466
fn validate_returns_usable_values() {
439467
let config: LokiConfig = serde_yaml::from_str(
@@ -454,10 +482,38 @@ mod tests {
454482
}
455483

456484
#[test]
457-
fn validate_rejects_non_http_endpoint() {
485+
fn validate_extracts_endpoint_basic_auth() {
486+
let config: LokiConfig = serde_yaml::from_str(
487+
r#"
488+
endpoint: "http://user:pass@localhost:3100"
489+
labels:
490+
test_name: "placeholder"
491+
encoding:
492+
codec: json
493+
"#,
494+
)
495+
.unwrap();
496+
497+
let validated = config.validate().expect("validation should succeed");
498+
assert!(
499+
validated.auth.is_some(),
500+
"credentials embedded in the endpoint must be extracted as auth"
501+
);
502+
assert!(
503+
!validated.endpoint.to_string().contains('@'),
504+
"userinfo must be stripped from the endpoint retained for build"
505+
);
506+
}
507+
508+
#[test]
509+
fn validate_rejects_conflicting_auth() {
458510
let config: LokiConfig = serde_yaml::from_str(
459511
r#"
460-
endpoint: "ftp://example.com"
512+
endpoint: "http://user:pass@localhost:3100"
513+
auth:
514+
strategy: "basic"
515+
user: "other"
516+
password: "other"
461517
labels:
462518
test_name: "placeholder"
463519
encoding:
@@ -468,7 +524,7 @@ mod tests {
468524

469525
assert!(
470526
config.validate().is_err(),
471-
"a non-http(s) endpoint must be rejected before it reaches the HTTP client"
527+
"explicit auth and endpoint-embedded auth must not both be configured"
472528
);
473529
}
474530

‎src/sinks/loki/healthcheck.rs‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,43 @@
1-
use super::config::LokiConfig;
2-
use crate::http::HttpClient;
1+
use super::append_loki_path;
2+
3+
use crate::{
4+
http::{Auth, HttpClient},
5+
sinks::util::HttpEndpoint,
6+
};
37

48
async fn fetch_status(
5-
endpoint: &str,
6-
config: &LokiConfig,
9+
base_endpoint: &HttpEndpoint,
10+
path: &str,
11+
auth: &Option<Auth>,
712
client: &HttpClient,
813
) -> crate::Result<http::StatusCode> {
9-
let endpoint = config.endpoint.append_path(endpoint)?;
14+
let endpoint = append_loki_path(base_endpoint, path)?;
1015

11-
let mut req = http::Request::get(endpoint.uri)
16+
let mut req = http::Request::get(endpoint.as_uri())
1217
.body(hyper::Body::empty())
1318
.expect("Building request never fails.");
1419

15-
if let Some(auth) = &config.auth {
20+
if let Some(auth) = auth {
1621
auth.apply(&mut req);
1722
}
1823

1924
Ok(client.send(req).await?.status())
2025
}
2126

22-
pub async fn healthcheck(config: LokiConfig, client: HttpClient) -> crate::Result<()> {
23-
let status = match fetch_status("ready", &config, &client).await? {
27+
pub async fn healthcheck(
28+
base_endpoint: HttpEndpoint,
29+
auth: Option<Auth>,
30+
client: HttpClient,
31+
) -> crate::Result<()> {
32+
let status = match fetch_status(&base_endpoint, "ready", &auth, &client).await? {
2433
// Issue https://github.com/vectordotdev/vector/issues/6463
2534
http::StatusCode::NOT_FOUND => {
2635
debug!("Endpoint `/ready` not found. Retrying healthcheck with top level query.");
27-
fetch_status("", &config, &client).await?
36+
// Probe the normalized base path with one trailing slash (`/loki/`,
37+
// not `/loki`), matching the pre-`HttpEndpoint` behavior. Reverse
38+
// proxies commonly redirect `/loki` to `/loki/`, and the healthcheck
39+
// rejects non-200 responses rather than following them.
40+
fetch_status(&base_endpoint, "/", &auth, &client).await?
2841
}
2942
status => status,
3043
};

‎src/sinks/loki/mod.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,29 @@
1010
//!
1111
//! If an event produces no labels, this can happen if the template
1212
//! does not match, we will add a default label `{agent="vector"}`.
13+
14+
use crate::sinks::util::HttpEndpoint;
15+
16+
/// Appends a Loki path while retaining the boundary normalization used by
17+
/// `UriSerde::append_path`: all trailing slashes on the endpoint and all
18+
/// leading slashes on the path collapse to one separator.
19+
fn append_loki_path(endpoint: &HttpEndpoint, path: &str) -> crate::Result<HttpEndpoint> {
20+
let mut parts = endpoint.as_uri().clone().into_parts();
21+
let base_path = parts
22+
.path_and_query
23+
.as_ref()
24+
.map(http::uri::PathAndQuery::path)
25+
.unwrap_or_default();
26+
let joined = format!(
27+
"{}/{}",
28+
base_path.trim_end_matches('/'),
29+
path.trim_start_matches('/')
30+
);
31+
parts.path_and_query = Some(joined.parse()?);
32+
33+
Ok(HttpEndpoint::new(http::Uri::from_parts(parts)?)?)
34+
}
35+
1336
mod config;
1437
mod event;
1538
mod healthcheck;

‎src/sinks/loki/service.rs‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use tracing::Instrument;
77

88
use crate::{
99
http::{Auth, HttpClient},
10-
sinks::{prelude::*, util::UriSerde},
10+
sinks::{prelude::*, util::HttpEndpoint},
1111
};
1212

1313
#[derive(Clone)]
@@ -85,20 +85,18 @@ impl MetaDescriptive for LokiRequest {
8585

8686
#[derive(Debug, Clone)]
8787
pub struct LokiService {
88-
endpoint: UriSerde,
88+
endpoint: HttpEndpoint,
8989
client: HttpClient,
90+
auth: Option<Auth>,
9091
}
9192

9293
impl LokiService {
93-
pub fn new(
94-
client: HttpClient,
95-
endpoint: UriSerde,
96-
path: String,
97-
auth: Option<Auth>,
98-
) -> crate::Result<Self> {
99-
let endpoint = endpoint.append_path(&path)?.with_auth(auth);
100-
101-
Ok(Self { client, endpoint })
94+
pub const fn new(client: HttpClient, endpoint: HttpEndpoint, auth: Option<Auth>) -> Self {
95+
Self {
96+
client,
97+
endpoint,
98+
auth,
99+
}
102100
}
103101
}
104102

@@ -116,7 +114,8 @@ impl Service<LokiRequest> for LokiService {
116114
Compression::Snappy => "application/x-protobuf",
117115
_ => "application/json",
118116
};
119-
let mut req = http::Request::post(&self.endpoint.uri).header("Content-Type", content_type);
117+
let mut req =
118+
http::Request::post(self.endpoint.as_uri()).header("Content-Type", content_type);
120119

121120
let metadata = request.get_metadata().clone();
122121

@@ -131,7 +130,7 @@ impl Service<LokiRequest> for LokiService {
131130
let body = hyper::Body::from(request.payload);
132131
let mut req = req.body(body).unwrap();
133132

134-
if let Some(auth) = &self.endpoint.auth {
133+
if let Some(auth) = &self.auth {
135134
auth.apply(&mut req);
136135
}
137136

‎src/sinks/loki/sink.rs‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -493,29 +493,28 @@ impl LokiSink {
493493
#[allow(clippy::missing_const_for_fn)] // const cannot run destructor
494494
pub fn new(config: LokiConfig, client: HttpClient) -> crate::Result<Self> {
495495
let validated = config.validate()?;
496-
Self::from_validated(config, validated, client)
496+
Self::from_validated(&config, validated, client)
497497
}
498498

499499
/// Builds the sink from already-validated state. No pure validation is
500500
/// repeated here: auth, request limits, encoder, confined templates and
501501
/// batch settings all come from `validated`.
502502
#[allow(clippy::missing_const_for_fn)] // const cannot run destructor
503503
pub fn from_validated(
504-
config: LokiConfig,
504+
config: &LokiConfig,
505505
validated: ValidatedLokiSink,
506506
client: HttpClient,
507507
) -> crate::Result<Self> {
508508
let compression = config.compression;
509509

510-
let protocol = get_http_scheme_from_uri(&config.endpoint.uri);
510+
let protocol = get_http_scheme_from_uri(validated.endpoint.as_uri());
511511
let service = tower::ServiceBuilder::new()
512512
.settings(validated.request_limits.clone(), LokiRetryLogic)
513513
.service(LokiService::new(
514514
client,
515-
config.endpoint,
516-
config.path,
517-
config.auth,
518-
)?);
515+
validated.endpoint.clone(),
516+
validated.auth.clone(),
517+
));
519518

520519
let batch_encoder = match config.compression {
521520
Compression::Snappy => LokiBatchEncoder(LokiBatchEncoding::Protobuf),

0 commit comments

Comments
 (0)