feat(elasticsearch): allow disabling connectivity checks

This commit is contained in:
JcEvoX authored and GitHub committed 2026-08-13 20:11:52 +08:00
1 parent be3336c1ec
commit cce36ae058
12 files changed
+202 -10

No files matched your search

+112 -2
View File
@@ -5,6 +5,7 @@ use serde::Deserialize;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use super::{http_client_builder, with_connection_timeout};
@@ -51,6 +52,9 @@ pub struct EsClient {
transport_mode: ElasticsearchTransportMode,
/// GET path used for connect / health / test (default "/").
connectivity_check_path: String,
/// 为 true 时完全跳过连通性检查(test_connection 直接返回 Ok)。
/// 用于账号连 `/` 或任何检查路径都无权限、且集群间权限不统一的场景。
connectivity_check_disabled: bool,
/// 正则:把易变的时间/滚动后缀折叠成 `*`,将同一前缀的滚动索引聚合成一个
/// pattern 节点。`None` 表示关闭聚合,展示原始索引名。
index_grouping: Option<Regex>,
@@ -72,6 +76,7 @@ impl EsClient {
timeout,
ElasticsearchTransportMode::Direct,
"/".to_string(),
false,
None,
)
}
@@ -84,6 +89,7 @@ impl EsClient {
timeout: Duration,
transport_mode: ElasticsearchTransportMode,
connectivity_check_path: String,
connectivity_check_disabled: bool,
index_grouping: Option<Regex>,
) -> Self {
let base_url = url.trim_end_matches('/').to_string();
@@ -91,10 +97,22 @@ impl EsClient {
(Some(u), Some(p)) if !u.is_empty() => Some((u.to_string(), p.to_string())),
_ => None,
};
let builder = http_client_builder(timeout).danger_accept_invalid_certs(accept_invalid_certs);
let mut builder = http_client_builder(timeout).danger_accept_invalid_certs(accept_invalid_certs);
if let Some(addrs) = elasticsearch_localhost_resolve_addrs(&base_url, connectivity_check_disabled) {
builder = builder.resolve_to_addrs("localhost", &addrs);
}
let http = builder.build().unwrap_or_else(|_| HttpClient::new());
let fallback_base_urls = elasticsearch_base_url_fallbacks(&base_url);
Self { http, base_url, fallback_base_urls, auth, transport_mode, connectivity_check_path, index_grouping }
Self {
http,
base_url,
fallback_base_urls,
auth,
transport_mode,
connectivity_check_path,
connectivity_check_disabled,
index_grouping,
}
}
pub fn from_config(
@@ -114,6 +132,7 @@ impl EsClient {
};
let base_url = format!("{}{}", url.trim_end_matches('/'), kibana_base_path.as_deref().unwrap_or(""));
let connectivity_check_path = elasticsearch_connectivity_check_path(external_config);
let connectivity_check_disabled = elasticsearch_connectivity_check_disabled(external_config);
let index_grouping = elasticsearch_index_grouping(external_config);
Self::new_with_mode(
&base_url,
@@ -123,6 +142,7 @@ impl EsClient {
timeout,
transport_mode,
connectivity_check_path,
connectivity_check_disabled,
index_grouping,
)
}
@@ -188,6 +208,7 @@ impl Clone for EsClient {
auth: self.auth.clone(),
transport_mode: self.transport_mode,
connectivity_check_path: self.connectivity_check_path.clone(),
connectivity_check_disabled: self.connectivity_check_disabled,
index_grouping: self.index_grouping.clone(),
}
}
@@ -242,6 +263,23 @@ pub fn elasticsearch_connectivity_check_path(external_config: Option<&Value>) ->
///
/// 语义:用 `${1}*` 模板替换匹配区间——正则带捕获组 1 时保留其内容做前缀,
/// 不带捕获组时即把匹配到的“易变尾巴”替换成 `*`。
/// 是否完全跳过连通性检查。读取配置 `connectivityCheckDisabled`(布尔)。
/// 也兼容字符串 "true"/"1"/"yes"/"on"。用于账号无任何集群/索引探活权限的场景。
pub fn elasticsearch_connectivity_check_disabled(external_config: Option<&Value>) -> bool {
let Some(value) = external_config.and_then(Value::as_object).and_then(|c| c.get("connectivityCheckDisabled"))
else {
return false;
};
match value {
Value::Bool(b) => *b,
Value::String(s) => {
let s = s.trim();
s.eq_ignore_ascii_case("true") || s == "1" || s.eq_ignore_ascii_case("yes") || s.eq_ignore_ascii_case("on")
}
_ => false,
}
}
pub fn elasticsearch_index_grouping(external_config: Option<&Value>) -> Option<Regex> {
let raw = external_config
.and_then(Value::as_object)
@@ -277,6 +315,10 @@ fn group_index_names(names: Vec<String>, pattern: Option<&Regex>) -> Vec<String>
}
pub async fn test_connection(client: &mut EsClient, timeout: Duration) -> Result<(), String> {
// 用户显式关闭连通性检查:不发探活请求,直接视为可连。
if client.connectivity_check_disabled {
return Ok(());
}
let mut errors = Vec::new();
let urls = std::iter::once(client.base_url.clone()).chain(client.fallback_base_urls.clone());
let check_path = client.connectivity_check_path.clone();
@@ -356,6 +398,17 @@ fn elasticsearch_base_url_fallbacks(base_url: &str) -> Vec<String> {
}
}
fn elasticsearch_localhost_resolve_addrs(base_url: &str, connectivity_check_disabled: bool) -> Option<[SocketAddr; 2]> {
if !connectivity_check_disabled {
return None;
}
let parsed = reqwest::Url::parse(base_url).ok()?;
if !parsed.host_str()?.eq_ignore_ascii_case("localhost") {
return None;
}
Some([SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)])
}
fn elasticsearch_index_path(index: &str, endpoint: &str) -> String {
format!("/{}/{}", elasticsearch_path_segment(index), endpoint.trim_start_matches('/'))
}
@@ -2273,6 +2326,18 @@ mod tests {
assert_eq!(names, vec!["ngx-log-1".to_string(), "ngx-log-2".to_string()]);
}
#[test]
fn connectivity_check_disabled_parses_bool_and_string() {
use super::elasticsearch_connectivity_check_disabled as disabled;
assert!(!disabled(None));
assert!(!disabled(Some(&serde_json::json!({}))));
assert!(disabled(Some(&serde_json::json!({ "connectivityCheckDisabled": true }))));
assert!(disabled(Some(&serde_json::json!({ "connectivityCheckDisabled": "on" }))));
assert!(disabled(Some(&serde_json::json!({ "connectivityCheckDisabled": "TRUE" }))));
assert!(!disabled(Some(&serde_json::json!({ "connectivityCheckDisabled": false }))));
assert!(!disabled(Some(&serde_json::json!({ "connectivityCheckDisabled": "off" }))));
}
#[test]
fn group_index_names_off_by_default() {
// 缺省配置 → 关闭聚合,原样返回。
@@ -2406,6 +2471,51 @@ mod tests {
assert_eq!(elasticsearch_base_url_fallbacks("https://search.example.com:9200"), Vec::<String>::new());
}
#[test]
fn disabled_connectivity_check_keeps_localhost_request_fallback() {
assert_eq!(
super::elasticsearch_localhost_resolve_addrs("https://localhost:9200", true),
Some(["[::1]:0".parse().unwrap(), "127.0.0.1:0".parse().unwrap()])
);
assert_eq!(super::elasticsearch_localhost_resolve_addrs("https://localhost:9200", false), None);
assert_eq!(super::elasticsearch_localhost_resolve_addrs("https://search.example.com:9200", true), None);
}
#[tokio::test]
async fn disabled_connectivity_check_can_request_ipv4_localhost() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.starts_with("GET /_cluster/health "), "unexpected request: {request}");
let body = r#"{"status":"green"}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let mut client = EsClient::from_config(
&format!("http://localhost:{}", addr.port()),
None,
None,
false,
None,
Some(&json!({ "connectivityCheckDisabled": "yes" })),
Duration::from_secs(2),
);
super::test_connection(&mut client, Duration::from_secs(2)).await.unwrap();
let response = client.get("/_cluster/health").send().await.unwrap();
assert!(response.status().is_success());
server.await.unwrap();
}
#[test]
fn elasticsearch_client_from_config_uses_url_params_for_tls_verification() {
let client = EsClient::from_config(