Files
dbx/crates/dbx-core/src/consul/client.rs
T

315 lines
12 KiB
Rust

use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use reqwest::{Method, Url};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::connection::{AppState, PoolKind};
use crate::path_utils::expand_tilde;
use super::config::ConsulScope;
use super::config::{ConsulConfig, ConsulConsistency};
#[derive(Debug, Clone)]
pub struct ConsulClient {
config: ConsulConfig,
http: reqwest::Client,
}
impl ConsulClient {
pub async fn new(config: ConsulConfig) -> Result<Self, String> {
let mut builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(config.connect_timeout_secs.max(1)))
.redirect(reqwest::redirect::Policy::none());
if config.request_timeout_secs > 0 {
builder = builder.timeout(Duration::from_secs(config.request_timeout_secs));
}
if config.tls_skip_verify {
builder = builder.danger_accept_invalid_certs(true);
}
if !config.ca_cert_path.is_empty() {
let path = expand_tilde(&config.ca_cert_path);
let bytes = tokio::fs::read(&path)
.await
.map_err(|error| format!("Failed to read Consul CA certificate at {path}: {error}"))?;
let certificates = reqwest::Certificate::from_pem_bundle(&bytes)
.or_else(|_| reqwest::Certificate::from_der(&bytes).map(|certificate| vec![certificate]))
.map_err(|error| format!("Failed to parse Consul CA certificate at {path}: {error}"))?;
for certificate in certificates {
builder = builder.add_root_certificate(certificate);
}
}
if !config.client_cert_path.is_empty() {
let cert_path = expand_tilde(&config.client_cert_path);
let key_path = expand_tilde(&config.client_key_path);
let mut pem = tokio::fs::read(&cert_path)
.await
.map_err(|error| format!("Failed to read Consul client certificate at {cert_path}: {error}"))?;
if !pem.ends_with(b"\n") {
pem.push(b'\n');
}
pem.extend(
tokio::fs::read(&key_path)
.await
.map_err(|error| format!("Failed to read Consul client key at {key_path}: {error}"))?,
);
let identity = reqwest::Identity::from_pem(&pem)
.map_err(|error| format!("Failed to parse Consul client identity: {error}"))?;
builder = builder.identity(identity);
}
if let Some((override_host, override_port)) = &config.connect_override {
let original_host = config.base_url.host_str().ok_or("Consul server address has no host")?;
let ip = override_host
.parse::<IpAddr>()
.map_err(|_| format!("Consul transport target must resolve to an IP address: {override_host}"))?;
builder = builder.resolve(original_host, SocketAddr::new(ip, *override_port));
}
let http = builder.build().map_err(|error| format!("Failed to initialize Consul HTTP client: {error}"))?;
Ok(Self { config, http })
}
pub async fn probe(&self) -> Result<(), String> {
self.list_prefix("", 1, None).await.map(|_| ())
}
pub(super) fn api_url(&self, path: &str) -> Result<Url, String> {
let base = self.config.base_url.as_str().trim_end_matches('/');
Url::parse(&format!("{base}/{}", path.trim_start_matches('/')))
.map_err(|error| format!("Failed to build Consul API URL: {error}"))
}
pub(super) fn append_scope(&self, url: &mut Url, read: bool) {
let mut query = url.query_pairs_mut();
if !self.config.datacenter.is_empty() {
query.append_pair("dc", &self.config.datacenter);
}
if !self.config.namespace.is_empty() {
query.append_pair("ns", &self.config.namespace);
}
if !self.config.partition.is_empty() {
query.append_pair("partition", &self.config.partition);
}
if read {
match self.config.consistency {
ConsulConsistency::Default => {}
ConsulConsistency::Stale => {
query.append_pair("stale", "");
}
ConsulConsistency::Consistent => {
query.append_pair("consistent", "");
}
}
}
}
pub(super) async fn send(
&self,
method: Method,
url: Url,
body: Option<Vec<u8>>,
) -> Result<reqwest::Response, String> {
let mut request = self.request(method, url);
if let Some(body) = body {
request = request.body(body);
}
request.send().await.map_err(|error| format!("Consul request failed: {}", error.without_url()))
}
fn request(&self, method: Method, url: Url) -> reqwest::RequestBuilder {
let mut request = self.http.request(method, url);
if !self.config.token.is_empty() {
request = request.header("X-Consul-Token", &self.config.token);
}
request
}
pub(super) async fn send_json<T: Serialize + ?Sized>(
&self,
method: Method,
mut url: Url,
body: Option<&T>,
read: bool,
action: &str,
) -> Result<reqwest::Response, String> {
self.append_scope(&mut url, read);
let body = body
.map(serde_json::to_vec)
.transpose()
.map_err(|error| format!("Failed to serialize Consul request: {error}"))?;
let mut request = self.http.request(method, url);
if !self.config.token.is_empty() {
request = request.header("X-Consul-Token", &self.config.token);
}
if let Some(body) = body {
request = request.header(reqwest::header::CONTENT_TYPE, "application/json").body(body);
}
let response =
request.send().await.map_err(|error| format!("Consul request failed: {}", error.without_url()))?;
super::response::ensure_success(response, action, self.token()).await
}
pub(super) async fn request_json<R: DeserializeOwned, T: Serialize + ?Sized>(
&self,
method: Method,
url: Url,
body: Option<&T>,
read: bool,
action: &str,
) -> Result<R, String> {
let response = self.send_json(method, url, body, read, action).await?;
super::response::decode_json_response(response, action).await
}
pub(super) async fn request_json_unscoped<R: DeserializeOwned, T: Serialize + ?Sized>(
&self,
method: Method,
url: Url,
body: Option<&T>,
action: &str,
) -> Result<R, String> {
let body = body
.map(serde_json::to_vec)
.transpose()
.map_err(|error| format!("Failed to serialize Consul request: {error}"))?;
let mut request = self.request(method, url);
if let Some(body) = body {
request = request.header(reqwest::header::CONTENT_TYPE, "application/json").body(body);
}
let response =
request.send().await.map_err(|error| format!("Consul request failed: {}", error.without_url()))?;
let response = super::response::ensure_success(response, action, self.token()).await?;
super::response::decode_json_response(response, action).await
}
pub(super) fn token(&self) -> &str {
&self.config.token
}
pub(super) fn datacenter(&self) -> &str {
&self.config.datacenter
}
pub(super) fn namespace(&self) -> &str {
&self.config.namespace
}
pub(super) fn partition(&self) -> &str {
&self.config.partition
}
pub fn scope(&self) -> ConsulScope {
self.config.scope()
}
pub(super) fn config(&self) -> &ConsulConfig {
&self.config
}
pub(super) fn operator_feature_enabled(&self, feature: &str) -> bool {
match feature {
"snapshot_restore" => self.config.operator_snapshot_restore_enabled,
"autopilot" => self.config.operator_autopilot_write_enabled,
"raft" => self.config.operator_raft_write_enabled,
"keyring" => self.config.operator_keyring_write_enabled,
"license" => self.config.operator_license_write_enabled,
_ => false,
}
}
}
pub(super) async fn client_for_state(state: &AppState, connection_id: &str) -> Result<ConsulClient, String> {
state.get_or_create_pool(connection_id, None).await?;
let connections = state.connections.read().await;
match connections.get(connection_id) {
Some(PoolKind::Consul(client)) => Ok(client.clone()),
Some(_) => Err("Connection is not a Consul connection".to_string()),
None => Err("Connection not found".to_string()),
}
}
pub(crate) async fn ensure_writable_core(state: &AppState, connection_id: &str, action: &str) -> Result<(), String> {
if let Some(name) = crate::query::connection_readonly_name(state, connection_id).await {
return Err(format!(
"CONSUL_READ_ONLY: connection '{name}' has read-only protection enabled; {action} blocked"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preserves_proxy_path_scope_and_token_header() {
let client = test_client("fixture-secret");
let mut url = client.api_url("/v1/catalog/nodes").unwrap();
client.append_scope(&mut url, true);
let request = client.request(Method::GET, url).build().unwrap();
assert_eq!(request.method(), Method::GET);
assert_eq!(request.url().path(), "/proxy/v1/catalog/nodes");
assert_eq!(request.url().query(), Some("dc=dc1&ns=ns1&partition=part1"));
assert_eq!(request.headers().get("X-Consul-Token").unwrap(), "fixture-secret");
}
#[test]
fn redacts_sensitive_error_fields() {
let body = format!("SecretID=fixture-secret {}", "x".repeat(8_000));
let redacted = super::super::response::redact_sensitive(&body[..4096], "fixture-secret");
assert!(!redacted.contains("fixture-secret"));
assert!(redacted.contains("[REDACTED]"));
assert!(redacted.len() < 4_100);
}
#[test]
fn operator_write_flags_are_independent_and_unknown_features_stay_disabled() {
let mut client = test_client("");
for feature in ["snapshot_restore", "autopilot", "raft", "keyring", "license", "unknown"] {
assert!(!client.operator_feature_enabled(feature));
}
let cases = [("snapshot_restore", 0usize), ("autopilot", 1), ("raft", 2), ("keyring", 3), ("license", 4)];
for (enabled_feature, enabled_index) in cases {
client.config.operator_snapshot_restore_enabled = enabled_index == 0;
client.config.operator_autopilot_write_enabled = enabled_index == 1;
client.config.operator_raft_write_enabled = enabled_index == 2;
client.config.operator_keyring_write_enabled = enabled_index == 3;
client.config.operator_license_write_enabled = enabled_index == 4;
for (feature, index) in
["snapshot_restore", "autopilot", "raft", "keyring", "license"].into_iter().zip(0usize..)
{
assert_eq!(client.operator_feature_enabled(feature), index == enabled_index, "{enabled_feature}");
}
}
}
fn test_client(token: &str) -> ConsulClient {
ConsulClient { config: test_config(token), http: reqwest::Client::new() }
}
fn test_config(token: &str) -> ConsulConfig {
ConsulConfig {
base_url: Url::parse("http://127.0.0.1:8500/proxy").unwrap(),
token: token.to_string(),
datacenter: "dc1".to_string(),
namespace: "ns1".to_string(),
partition: "part1".to_string(),
consistency: ConsulConsistency::Default,
tls_skip_verify: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
connect_timeout_secs: 2,
request_timeout_secs: 2,
connect_override: None,
agent_target: None,
operator_snapshot_restore_enabled: false,
operator_autopilot_write_enabled: false,
operator_raft_write_enabled: false,
operator_keyring_write_enabled: false,
operator_license_write_enabled: false,
}
}
}