feat(consul): add Consul full-stack workbench

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-10 16:25:44 +08:00
1 parent 20ddf2eca5
commit 37f13018d3
148 files changed
+24032 -378

No files matched your search

+10
View File
@@ -103,6 +103,12 @@ pub struct KvKeyMetadata {
pub ephemeral_owner: Option<i64>,
pub data_length: Option<u64>,
pub num_children: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flags: Option<KvInt64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock_index: Option<KvInt64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -138,6 +144,8 @@ pub struct KvListPrefixResponse {
pub keys: Vec<KvKeySummary>,
pub continuation: Option<String>,
pub revision: Option<KvInt64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filtered_by_acls: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -205,6 +213,8 @@ pub struct KvPutOptions {
pub expected_mod_revision: Option<KvInt64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_create_revision: Option<KvInt64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<KvInt64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+35 -2
View File
@@ -118,6 +118,7 @@ pub enum PoolKind {
MessageQueue,
/// Nacos admin connection marker.
Nacos,
Consul(crate::consul::ConsulClient),
/// MQTT broker connection with an active client.
#[cfg(feature = "mq-admin")]
Mqtt(Arc<super::mqtt::client::MqttClient>),
@@ -2039,6 +2040,17 @@ impl AppState {
adapter.test_connection().await?;
PoolKind::Nacos
}
DatabaseType::Consul => {
let mut consul_config = crate::consul::ConsulConfig::from_connection(&db_config)?;
let original_host = consul_config.base_url.host_str().unwrap_or_default();
let original_port = consul_config.base_url.port_or_known_default().unwrap_or(db_config.port);
if host != original_host || port != original_port {
consul_config = consul_config.with_connect_override(&host, port);
}
let client = crate::consul::ConsulClient::new(consul_config).await?;
client.probe().await?;
PoolKind::Consul(client)
}
agent_connection_pool_database_type!() => {
let connect_params = agent_connect_params_with_role(
&db_config,
@@ -3077,7 +3089,8 @@ impl AppState {
| PoolKind::DuckDbWorker(_)
| PoolKind::ExternalDriver { .. }
| PoolKind::MessageQueue
| PoolKind::Nacos => false,
| PoolKind::Nacos
| PoolKind::Consul(_) => false,
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => false,
}
@@ -3969,7 +3982,8 @@ impl AppState {
| PoolKind::DuckDbWorker(_)
| PoolKind::ExternalDriver { .. }
| PoolKind::MessageQueue
| PoolKind::Nacos => true,
| PoolKind::Nacos
| PoolKind::Consul(_) => true,
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => true,
PoolKind::Redis(_) => unreachable!("Redis handled separately"),
@@ -4396,6 +4410,8 @@ fn connection_remote_endpoint(config: &ConnectionConfig) -> (String, u16) {
parse_mqtt_broker_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
} else if config.db_type == DatabaseType::Nacos {
parse_nacos_server_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
} else if config.db_type == DatabaseType::Consul {
parse_consul_server_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
} else {
(config.host.clone(), config.port)
}
@@ -4467,6 +4483,21 @@ fn parse_nacos_server_host_port(config: &ConnectionConfig) -> Option<(String, u1
Some((host, port))
}
fn parse_consul_server_host_port(config: &ConnectionConfig) -> Option<(String, u16)> {
let value = config
.external_config
.as_ref()?
.get("serverAddr")
.or_else(|| config.external_config.as_ref()?.get("server_addr"))?
.as_str()?
.trim();
if value.is_empty() {
return None;
}
let url = reqwest::Url::parse(value).ok()?;
Some((url.host_str()?.to_string(), url.port_or_known_default()?))
}
fn parse_mqtt_broker_host_port(config: &ConnectionConfig) -> Option<(String, u16)> {
let value = config
.external_config
@@ -4618,6 +4649,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
}
PoolKind::MessageQueue => PoolKind::MessageQueue,
PoolKind::Nacos => PoolKind::Nacos,
PoolKind::Consul(client) => PoolKind::Consul(client.clone()),
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(client) => PoolKind::Mqtt(Arc::clone(client)),
PoolKind::Redis(_) => panic!("clone_pool_kind not supported for Redis — handled separately"),
@@ -4679,6 +4711,7 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
}
PoolKind::MessageQueue => {}
PoolKind::Nacos => {}
PoolKind::Consul(_) => {}
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(client) => {
// 发送 DISCONNECT 并等待事件循环任务结束
+587
View File
@@ -0,0 +1,587 @@
use std::fmt;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::client::{client_for_state, ConsulClient};
use super::response::{decode_json_response, ConsulResponseMetadata};
/// Sensitive Consul value. Deliberately does not implement `Debug`.
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ConsulSecret(String);
impl ConsulSecret {
pub fn expose_once(self) -> String {
self.0
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum ConsulAclKind {
Token,
Policy,
Role,
AuthMethod,
BindingRule,
TemplatedPolicy,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclLink {
#[serde(rename = "ID", default)]
pub id: String,
#[serde(default)]
pub name: String,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclToken {
#[serde(rename = "AccessorID", default)]
pub accessor_id: String,
#[serde(rename = "SecretID", default, skip_serializing_if = "Option::is_none")]
pub secret_id: Option<ConsulSecret>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub local: bool,
#[serde(default)]
pub auth_method: String,
#[serde(default)]
pub policies: Vec<ConsulAclLink>,
#[serde(default)]
pub roles: Vec<ConsulAclLink>,
#[serde(default)]
pub service_identities: Vec<serde_json::Value>,
#[serde(default)]
pub node_identities: Vec<serde_json::Value>,
#[serde(default)]
pub templated_policies: Vec<serde_json::Value>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclPolicy {
#[serde(rename = "ID", default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub rules: String,
#[serde(default)]
pub datacenters: Vec<String>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclRole {
#[serde(rename = "ID", default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub policies: Vec<ConsulAclLink>,
#[serde(default)]
pub service_identities: Vec<serde_json::Value>,
#[serde(default)]
pub node_identities: Vec<serde_json::Value>,
#[serde(default)]
pub templated_policies: Vec<serde_json::Value>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclAuthMethod {
#[serde(default)]
pub name: String,
#[serde(rename = "Type", default)]
pub method_type: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(rename = "MaxTokenTTL", default)]
pub max_token_ttl: String,
#[serde(default)]
pub token_locality: String,
#[serde(default)]
pub config: serde_json::Value,
}
impl fmt::Debug for ConsulAclAuthMethod {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ConsulAclAuthMethod")
.field("name", &self.name)
.field("method_type", &self.method_type)
.field("display_name", &self.display_name)
.field("description", &self.description)
.field("max_token_ttl", &self.max_token_ttl)
.field("token_locality", &self.token_locality)
.field("config", &"[REDACTED]")
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclBindingRule {
#[serde(rename = "ID", default)]
pub id: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub auth_method: String,
#[serde(default)]
pub selector: String,
#[serde(default)]
pub bind_type: String,
#[serde(default)]
pub bind_name: String,
#[serde(default)]
pub bind_vars: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclTemplatedPolicy {
#[serde(default)]
pub template_name: String,
#[serde(default)]
pub schema: serde_json::Value,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "items", rename_all = "camelCase")]
pub enum ConsulAclList {
Token(Vec<ConsulAclToken>),
Policy(Vec<ConsulAclPolicy>),
Role(Vec<ConsulAclRole>),
AuthMethod(Vec<ConsulAclAuthMethod>),
BindingRule(Vec<ConsulAclBindingRule>),
TemplatedPolicy(Vec<ConsulAclTemplatedPolicy>),
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
pub enum ConsulAclItem {
Token(ConsulAclToken),
Policy(ConsulAclPolicy),
Role(ConsulAclRole),
AuthMethod(ConsulAclAuthMethod),
BindingRule(ConsulAclBindingRule),
TemplatedPolicy(ConsulAclTemplatedPolicy),
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
pub enum ConsulAclWrite {
Token(ConsulAclToken),
Policy(ConsulAclPolicy),
Role(ConsulAclRole),
AuthMethod(ConsulAclAuthMethod),
BindingRule(ConsulAclBindingRule),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulAclReferences {
pub token_accessor_ids: Vec<String>,
pub role_ids: Vec<String>,
pub binding_rule_ids: Vec<String>,
pub complete: bool,
pub filtered_by_acls: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAclTokenClone {
#[serde(default)]
pub description: String,
}
pub async fn consul_acl_token_self_core(state: &AppState, connection_id: &str) -> Result<ConsulAclToken, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/acl/token/self")?;
client.request_json(Method::GET, url, None::<&()>, true, "read current ACL token").await.map(without_token_secret)
}
pub async fn consul_acl_token_clone_core(
state: &AppState,
connection_id: &str,
accessor_id: &str,
request: ConsulAclTokenClone,
) -> Result<ConsulAclToken, String> {
super::ensure_writable(state, connection_id, "ACL token clone").await?;
let client = client_for_state(state, connection_id).await?;
let accessor_id = percent_encoding::utf8_percent_encode(accessor_id, percent_encoding::NON_ALPHANUMERIC);
let url = client.api_url(&format!("/v1/acl/token/{accessor_id}/clone"))?;
client.request_json(Method::PUT, url, Some(&request), false, "clone ACL token").await
}
pub async fn consul_acl_list_core(
state: &AppState,
connection_id: &str,
kind: ConsulAclKind,
) -> Result<ConsulAclList, String> {
let client = client_for_state(state, connection_id).await?;
match kind {
ConsulAclKind::Token => Ok(ConsulAclList::Token(
list::<ConsulAclToken>(&client, "/v1/acl/tokens", "list ACL tokens")
.await?
.into_iter()
.map(without_token_secret)
.collect(),
)),
ConsulAclKind::Policy => {
Ok(ConsulAclList::Policy(list(&client, "/v1/acl/policies", "list ACL policies").await?))
}
ConsulAclKind::Role => Ok(ConsulAclList::Role(list(&client, "/v1/acl/roles", "list ACL roles").await?)),
ConsulAclKind::AuthMethod => Ok(ConsulAclList::AuthMethod(
list::<ConsulAclAuthMethod>(&client, "/v1/acl/auth-methods", "list ACL auth methods")
.await?
.into_iter()
.map(without_auth_method_secrets)
.collect(),
)),
ConsulAclKind::BindingRule => {
Ok(ConsulAclList::BindingRule(list(&client, "/v1/acl/binding-rules", "list ACL binding rules").await?))
}
ConsulAclKind::TemplatedPolicy => Ok(ConsulAclList::TemplatedPolicy(
list(&client, "/v1/acl/templated-policies", "list ACL templated policies").await?,
)),
}
}
pub async fn consul_acl_get_core(
state: &AppState,
connection_id: &str,
kind: ConsulAclKind,
id: &str,
) -> Result<ConsulAclItem, String> {
let client = client_for_state(state, connection_id).await?;
if kind == ConsulAclKind::TemplatedPolicy {
return list::<ConsulAclTemplatedPolicy>(&client, "/v1/acl/templated-policies", "list ACL templated policies")
.await?
.into_iter()
.find(|policy| policy.template_name == id)
.map(ConsulAclItem::TemplatedPolicy)
.ok_or_else(|| "CONSUL_ACL_NOT_FOUND: Templated policy not found".to_string());
}
let url = client.api_url(&item_path(kind, id)?)?;
match kind {
ConsulAclKind::Token => Ok(ConsulAclItem::Token(without_token_secret(
client.request_json(Method::GET, url, None::<&()>, true, "read ACL token").await?,
))),
ConsulAclKind::Policy => Ok(ConsulAclItem::Policy(
client.request_json(Method::GET, url, None::<&()>, true, "read ACL policy").await?,
)),
ConsulAclKind::Role => {
Ok(ConsulAclItem::Role(client.request_json(Method::GET, url, None::<&()>, true, "read ACL role").await?))
}
ConsulAclKind::AuthMethod => Ok(ConsulAclItem::AuthMethod(without_auth_method_secrets(
client.request_json(Method::GET, url, None::<&()>, true, "read ACL auth method").await?,
))),
ConsulAclKind::BindingRule => Ok(ConsulAclItem::BindingRule(
client.request_json(Method::GET, url, None::<&()>, true, "read ACL binding rule").await?,
)),
ConsulAclKind::TemplatedPolicy => unreachable!("templated policy reads are handled by the collection"),
}
}
pub async fn consul_acl_apply_core(
state: &AppState,
connection_id: &str,
id: Option<&str>,
value: ConsulAclWrite,
) -> Result<ConsulAclItem, String> {
super::ensure_writable(state, connection_id, "ACL write").await?;
let client = client_for_state(state, connection_id).await?;
match value {
ConsulAclWrite::Token(item) => Ok(ConsulAclItem::Token(apply(&client, ConsulAclKind::Token, id, &item).await?)),
ConsulAclWrite::Policy(item) => {
Ok(ConsulAclItem::Policy(apply(&client, ConsulAclKind::Policy, id, &item).await?))
}
ConsulAclWrite::Role(item) => Ok(ConsulAclItem::Role(apply(&client, ConsulAclKind::Role, id, &item).await?)),
ConsulAclWrite::AuthMethod(mut item) => {
if let Some(id) = id {
let url = client.api_url(&item_path(ConsulAclKind::AuthMethod, id)?)?;
let existing: ConsulAclAuthMethod = client
.request_json(Method::GET, url, None::<&()>, true, "read ACL auth method before update")
.await?;
merge_missing_sensitive_config(&mut item.config, &existing.config);
}
Ok(ConsulAclItem::AuthMethod(without_auth_method_secrets(
apply(&client, ConsulAclKind::AuthMethod, id, &item).await?,
)))
}
ConsulAclWrite::BindingRule(item) => {
Ok(ConsulAclItem::BindingRule(apply(&client, ConsulAclKind::BindingRule, id, &item).await?))
}
}
}
pub async fn consul_acl_delete_core(
state: &AppState,
connection_id: &str,
kind: ConsulAclKind,
id: &str,
) -> Result<ConsulAclReferences, String> {
super::ensure_writable(state, connection_id, "ACL delete").await?;
let references = consul_acl_references_core(state, connection_id, kind, id).await?;
if !references.complete {
return Err("CONSUL_IMPACT_INCOMPLETE: ACL references could not be enumerated completely".to_string());
}
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&item_path(kind, id)?)?;
client.send_json(Method::DELETE, url, None::<&()>, false, "delete ACL resource").await?;
Ok(references)
}
pub async fn consul_acl_references_core(
state: &AppState,
connection_id: &str,
kind: ConsulAclKind,
id: &str,
) -> Result<ConsulAclReferences, String> {
let client = client_for_state(state, connection_id).await?;
let tokens = if matches!(kind, ConsulAclKind::Policy | ConsulAclKind::Role | ConsulAclKind::AuthMethod) {
list_with_meta::<ConsulAclToken>(&client, "/v1/acl/tokens", "list ACL token references").await
} else {
Ok((Vec::new(), false))
};
let roles = if kind == ConsulAclKind::Policy {
list_with_meta::<ConsulAclRole>(&client, "/v1/acl/roles", "list ACL role references").await
} else {
Ok((Vec::new(), false))
};
let binding_rules = if kind == ConsulAclKind::AuthMethod {
list_with_meta::<ConsulAclBindingRule>(&client, "/v1/acl/binding-rules", "list ACL binding rule references")
.await
} else {
Ok((Vec::new(), false))
};
let complete = tokens.is_ok() && roles.is_ok() && binding_rules.is_ok();
let (tokens, token_filtered) = tokens.unwrap_or_default();
let (roles, role_filtered) = roles.unwrap_or_default();
let (binding_rules, binding_rule_filtered) = binding_rules.unwrap_or_default();
let mut result = ConsulAclReferences {
complete: complete && !(token_filtered || role_filtered || binding_rule_filtered),
filtered_by_acls: token_filtered || role_filtered || binding_rule_filtered,
..Default::default()
};
match kind {
ConsulAclKind::Policy => {
result.token_accessor_ids = tokens
.iter()
.filter(|token| token.policies.iter().any(|link| link.id == id || link.name == id))
.map(|token| token.accessor_id.clone())
.collect();
result.role_ids = roles
.iter()
.filter(|role| role.policies.iter().any(|link| link.id == id || link.name == id))
.map(|role| role.id.clone())
.collect();
}
ConsulAclKind::Role => {
result.token_accessor_ids = tokens
.iter()
.filter(|token| token.roles.iter().any(|link| link.id == id || link.name == id))
.map(|token| token.accessor_id.clone())
.collect()
}
ConsulAclKind::AuthMethod => {
result.token_accessor_ids =
tokens.iter().filter(|token| token.auth_method == id).map(|token| token.accessor_id.clone()).collect();
result.binding_rule_ids =
binding_rules.iter().filter(|rule| rule.auth_method == id).map(|rule| rule.id.clone()).collect();
}
_ => {}
}
Ok(result)
}
async fn list<T: serde::de::DeserializeOwned>(
client: &ConsulClient,
path: &str,
action: &str,
) -> Result<Vec<T>, String> {
let url = client.api_url(path)?;
client.request_json(Method::GET, url, None::<&()>, true, action).await
}
async fn list_with_meta<T: serde::de::DeserializeOwned>(
client: &ConsulClient,
path: &str,
action: &str,
) -> Result<(Vec<T>, bool), String> {
let url = client.api_url(path)?;
let response = client.send_json(Method::GET, url, None::<&()>, true, action).await?;
let filtered = ConsulResponseMetadata::from_response(&response).filtered_by_acls.unwrap_or(false);
let items = decode_json_response(response, action).await?;
Ok((items, filtered))
}
async fn apply<T: Serialize + serde::de::DeserializeOwned>(
client: &ConsulClient,
kind: ConsulAclKind,
id: Option<&str>,
item: &T,
) -> Result<T, String> {
let (method, path) = if let Some(id) = id {
(Method::PUT, item_path(kind, id)?)
} else {
(Method::PUT, collection_create_path(kind)?.to_string())
};
let url = client.api_url(&path)?;
client.request_json(method, url, Some(item), false, "write ACL resource").await
}
fn without_token_secret(mut token: ConsulAclToken) -> ConsulAclToken {
token.secret_id = None;
token
}
fn without_auth_method_secrets(mut method: ConsulAclAuthMethod) -> ConsulAclAuthMethod {
redact_sensitive_config(&mut method.config);
method
}
fn redact_sensitive_config(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
object.retain(|key, _| !is_sensitive_config_key(key));
for value in object.values_mut() {
redact_sensitive_config(value);
}
}
serde_json::Value::Array(values) => {
for value in values {
redact_sensitive_config(value);
}
}
_ => {}
}
}
fn is_sensitive_config_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("secret")
|| key.contains("password")
|| key.contains("passphrase")
|| key.contains("token")
|| key == "jwt"
|| key.ends_with("jwt")
|| key == "bootstrap"
}
fn merge_missing_sensitive_config(target: &mut serde_json::Value, existing: &serde_json::Value) {
let (serde_json::Value::Object(target), serde_json::Value::Object(existing)) = (target, existing) else {
return;
};
for (key, existing_value) in existing {
if is_sensitive_config_key(key) {
target.entry(key.clone()).or_insert_with(|| existing_value.clone());
} else if let Some(target_value) = target.get_mut(key) {
merge_missing_sensitive_config(target_value, existing_value);
}
}
}
fn collection_create_path(kind: ConsulAclKind) -> Result<&'static str, String> {
match kind {
ConsulAclKind::Token => Ok("/v1/acl/token"),
ConsulAclKind::Policy => Ok("/v1/acl/policy"),
ConsulAclKind::Role => Ok("/v1/acl/role"),
ConsulAclKind::AuthMethod => Ok("/v1/acl/auth-method"),
ConsulAclKind::BindingRule => Ok("/v1/acl/binding-rule"),
ConsulAclKind::TemplatedPolicy => Err("CONSUL_UNSUPPORTED: templated policies are read-only".to_string()),
}
}
fn item_path(kind: ConsulAclKind, id: &str) -> Result<String, String> {
let segment = percent_encoding::utf8_percent_encode(id, percent_encoding::NON_ALPHANUMERIC);
Ok(format!("{}/{}", collection_create_path(kind)?, segment))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acl_paths_are_safe() {
assert_eq!(item_path(ConsulAclKind::Policy, "team/a").unwrap(), "/v1/acl/policy/team%2Fa");
assert!(item_path(ConsulAclKind::TemplatedPolicy, "x").is_err());
}
#[test]
fn ordinary_token_reads_do_not_serialize_secret_id() {
let token = without_token_secret(ConsulAclToken {
accessor_id: "accessor".into(),
secret_id: Some(ConsulSecret("connection-token".into())),
..Default::default()
});
let serialized = serde_json::to_string(&token).unwrap();
assert!(!serialized.contains("connection-token"));
assert!(!serialized.contains("SecretID"));
}
#[test]
fn auth_method_output_and_debug_redact_nested_secrets() {
let method = without_auth_method_secrets(ConsulAclAuthMethod {
name: "oidc".into(),
config: serde_json::json!({
"OIDCClientID": "public-client",
"OIDCClientSecret": "top-secret",
"ServiceAccountJWT": "kubernetes-jwt",
"Nested": { "bearer_token": "nested-secret", "JWKSURL": "https://issuer.example/jwks" }
}),
..Default::default()
});
let serialized = serde_json::to_string(&method).unwrap();
let debug = format!("{method:?}");
assert!(!serialized.contains("top-secret"));
assert!(!serialized.contains("nested-secret"));
assert!(!serialized.contains("kubernetes-jwt"));
assert!(serialized.contains("public-client"));
assert!(!debug.contains("public-client"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn auth_method_update_preserves_omitted_sensitive_config() {
let existing = serde_json::json!({
"Host": "https://kubernetes.example",
"ServiceAccountJWT": "keep-me",
"Nested": { "OIDCClientSecret": "also-keep", "Issuer": "old" }
});
let mut update = serde_json::json!({
"Host": "https://new.example",
"Nested": { "Issuer": "new" }
});
merge_missing_sensitive_config(&mut update, &existing);
assert_eq!(update["ServiceAccountJWT"], "keep-me");
assert_eq!(update["Nested"]["OIDCClientSecret"], "also-keep");
assert_eq!(update["Nested"]["Issuer"], "new");
}
}
File diff suppressed because it is too large. Load diff
+617
View File
@@ -0,0 +1,617 @@
use std::collections::HashSet;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::agent_kv::{KvInt64, KvValue};
use crate::connection::AppState;
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
use super::kv::{ConsulKvRecord, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES};
use super::txn::{cas_operation, ConsulTxnRequest, MAX_TXN_OPERATIONS};
const CONSUL_BUNDLE_FORMAT: &str = "dbx-consul-kv-bundle";
const CONSUL_BUNDLE_VERSION: u32 = 1;
const MAX_VALUE_BYTES: usize = 512 * 1024;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsulExportScopeKind {
Key,
Prefix,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulExportRequest {
pub path: String,
pub kind: ConsulExportScopeKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulBundleScope {
pub datacenter: String,
pub namespace: String,
pub partition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulBundleEntry {
pub key: String,
pub value: KvValue,
pub flags: KvInt64,
pub create_index: Option<KvInt64>,
pub modify_index: Option<KvInt64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulKvBundle {
pub format: String,
pub version: u32,
pub exported_at_unix_ms: u64,
pub prefix: String,
pub scope_kind: ConsulExportScopeKind,
pub source: ConsulBundleScope,
pub entries: Vec<ConsulBundleEntry>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsulImportConflictPolicy {
Abort,
Skip,
Cas,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsulImportOperation {
Create,
Update,
Unchanged,
Skipped,
Conflict,
Locked,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulImportPreviewRow {
pub key: String,
pub operation: ConsulImportOperation,
pub expected_modify_index: Option<KvInt64>,
pub target_session: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulImportPreview {
pub preview_id: String,
pub rows: Vec<ConsulImportPreviewRow>,
pub can_apply: bool,
pub creates: usize,
pub updates: usize,
pub unchanged: usize,
pub skipped: usize,
pub conflicts: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulImportRequest {
pub bundle: ConsulKvBundle,
pub policy: ConsulImportConflictPolicy,
#[serde(default)]
pub preview_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsulImportOutcome {
Succeeded,
Conflicted,
Skipped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulImportResultItem {
pub key: String,
pub outcome: ConsulImportOutcome,
pub message: Option<String>,
pub batch: Option<usize>,
pub op_index: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulImportReport {
pub items: Vec<ConsulImportResultItem>,
pub succeeded: usize,
pub conflicted: usize,
pub skipped: usize,
pub failed: usize,
pub atomic: bool,
}
pub async fn consul_export_bundle_core(
state: &AppState,
connection_id: &str,
request: ConsulExportRequest,
) -> Result<ConsulKvBundle, String> {
let client = client_for_state(state, connection_id).await?;
let entries = match request.kind {
ConsulExportScopeKind::Key => {
let value = client.get_key(&request.path).await?;
if !value.found {
return Err("CONSUL_KEY_NOT_FOUND: The Consul KV key no longer exists".to_string());
}
let metadata = value.metadata.unwrap_or_else(empty_metadata);
vec![ConsulBundleEntry {
key: value.key.unwrap_or(request.path.clone()),
value: value.value.ok_or("Consul returned a KV key without a value")?,
flags: metadata.flags.unwrap_or_else(|| KvInt64("0".to_string())),
create_index: metadata.create_revision,
modify_index: metadata.mod_revision,
}]
}
ConsulExportScopeKind::Prefix => {
let recursive =
client.list_recursive(&request.path, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES).await?;
if recursive.filtered_by_acls == Some(true) {
return Err(
"CONSUL_EXPORT_ACL_FILTERED: Export is blocked because ACL policies hide part of the prefix"
.to_string(),
);
}
recursive.entries.into_iter().map(bundle_entry_from_record).collect()
}
};
Ok(ConsulKvBundle {
format: CONSUL_BUNDLE_FORMAT.to_string(),
version: CONSUL_BUNDLE_VERSION,
exported_at_unix_ms: SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64,
prefix: request.path,
scope_kind: request.kind,
source: ConsulBundleScope {
datacenter: client.datacenter().to_string(),
namespace: client.namespace().to_string(),
partition: client.partition().to_string(),
},
entries,
})
}
pub async fn consul_import_preview_core(
state: &AppState,
connection_id: &str,
request: ConsulImportRequest,
) -> Result<ConsulImportPreview, String> {
validate_bundle(&request.bundle)?;
let client = client_for_state(state, connection_id).await?;
build_preview(&client, connection_id, &request.bundle, request.policy).await
}
pub async fn consul_import_execute_core(
state: &AppState,
connection_id: &str,
request: ConsulImportRequest,
) -> Result<ConsulImportReport, String> {
ensure_writable_core(state, connection_id, "import Consul KV bundle").await?;
validate_bundle(&request.bundle)?;
let client = client_for_state(state, connection_id).await?;
let preview = build_preview(&client, connection_id, &request.bundle, request.policy).await?;
if request.preview_id.as_deref() != Some(preview.preview_id.as_str()) {
return Err(
"CONSUL_IMPORT_PREVIEW_STALE: Target state changed after preview; refresh before importing".to_string()
);
}
if !preview.can_apply {
return Ok(report_from_blocked_preview(preview));
}
let preview_by_key =
preview.rows.into_iter().map(|row| (row.key.clone(), row)).collect::<std::collections::HashMap<_, _>>();
let mut items = Vec::with_capacity(request.bundle.entries.len());
let mut sources = request.bundle.entries;
sources.sort_by(|left, right| left.key.cmp(&right.key));
let mut pending = Vec::new();
for source in sources {
let Some(row) = preview_by_key.get(&source.key) else {
continue;
};
match row.operation {
ConsulImportOperation::Unchanged | ConsulImportOperation::Skipped => {
items.push(result_item(source.key, ConsulImportOutcome::Skipped, row.reason.clone(), None, None));
}
ConsulImportOperation::Conflict | ConsulImportOperation::Locked => {
items.push(result_item(source.key, ConsulImportOutcome::Conflicted, row.reason.clone(), None, None));
}
ConsulImportOperation::Create | ConsulImportOperation::Update => {
let index = row.expected_modify_index.clone().unwrap_or_else(|| KvInt64("0".to_string()));
pending.push((source.key.clone(), cas_operation(source.key, source.value, source.flags, index)));
}
}
}
let atomic = pending.len() <= MAX_TXN_OPERATIONS;
for (batch_index, chunk) in pending.chunks(MAX_TXN_OPERATIONS).enumerate() {
let result = client
.txn(ConsulTxnRequest { operations: chunk.iter().map(|(_, operation)| operation.clone()).collect() })
.await;
match result {
Ok(result) if result.committed => {
items.extend(chunk.iter().enumerate().map(|(op_index, (key, _))| {
result_item(key.clone(), ConsulImportOutcome::Succeeded, None, Some(batch_index), Some(op_index))
}));
}
Ok(result) => {
for (op_index, (key, _)) in chunk.iter().enumerate() {
let error = result.errors.iter().find(|error| error.op_index == op_index);
items.push(result_item(
key.clone(),
if error.is_some() { ConsulImportOutcome::Conflicted } else { ConsulImportOutcome::Failed },
Some(
error
.map(|error| error.message.clone())
.unwrap_or_else(|| "Transaction batch was aborted".to_string()),
),
Some(batch_index),
Some(op_index),
));
}
}
Err(error) => items.extend(chunk.iter().enumerate().map(|(op_index, (key, _))| {
result_item(
key.clone(),
ConsulImportOutcome::Failed,
Some(error.clone()),
Some(batch_index),
Some(op_index),
)
})),
}
}
Ok(summarize_report(items, atomic))
}
async fn build_preview(
client: &ConsulClient,
connection_id: &str,
bundle: &ConsulKvBundle,
policy: ConsulImportConflictPolicy,
) -> Result<ConsulImportPreview, String> {
let mut rows = Vec::with_capacity(bundle.entries.len());
for source in &bundle.entries {
let target = client.get_key(&source.key).await?;
let row = if !target.found {
ConsulImportPreviewRow {
key: source.key.clone(),
operation: ConsulImportOperation::Create,
expected_modify_index: None,
target_session: None,
reason: None,
}
} else {
preview_existing(source, target, policy)
};
rows.push(row);
}
let creates = count_operation(&rows, ConsulImportOperation::Create);
let updates = count_operation(&rows, ConsulImportOperation::Update);
let unchanged = count_operation(&rows, ConsulImportOperation::Unchanged);
let skipped = count_operation(&rows, ConsulImportOperation::Skipped);
let conflicts =
count_operation(&rows, ConsulImportOperation::Conflict) + count_operation(&rows, ConsulImportOperation::Locked);
let can_apply = conflicts == 0 || policy != ConsulImportConflictPolicy::Abort;
let preview_id = preview_id(&rows, bundle, policy, connection_id, client);
Ok(ConsulImportPreview { preview_id, rows, can_apply, creates, updates, unchanged, skipped, conflicts })
}
fn preview_existing(
source: &ConsulBundleEntry,
target: crate::agent_kv::KvGetResponse,
policy: ConsulImportConflictPolicy,
) -> ConsulImportPreviewRow {
let metadata = target.metadata.unwrap_or_else(empty_metadata);
let target_session = metadata.session.filter(|session| !session.is_empty());
if target_session.is_some() {
return ConsulImportPreviewRow {
key: source.key.clone(),
operation: ConsulImportOperation::Locked,
expected_modify_index: metadata.mod_revision,
target_session,
reason: Some("Target key is held by a Consul Session".to_string()),
};
}
let target_flags = metadata.flags.unwrap_or_else(|| KvInt64("0".to_string()));
if target.value.as_ref() == Some(&source.value) && target_flags == source.flags {
return ConsulImportPreviewRow {
key: source.key.clone(),
operation: ConsulImportOperation::Unchanged,
expected_modify_index: metadata.mod_revision,
target_session: None,
reason: None,
};
}
let (operation, reason) = match policy {
ConsulImportConflictPolicy::Abort => {
(ConsulImportOperation::Conflict, Some("Target key exists with a different value or Flags".to_string()))
}
ConsulImportConflictPolicy::Skip => {
(ConsulImportOperation::Skipped, Some("Existing target key is skipped by the selected policy".to_string()))
}
ConsulImportConflictPolicy::Cas => (ConsulImportOperation::Update, None),
};
ConsulImportPreviewRow {
key: source.key.clone(),
operation,
expected_modify_index: metadata.mod_revision,
target_session: None,
reason,
}
}
fn validate_bundle(bundle: &ConsulKvBundle) -> Result<(), String> {
if bundle.format != CONSUL_BUNDLE_FORMAT || bundle.version != CONSUL_BUNDLE_VERSION {
return Err("CONSUL_BUNDLE_UNSUPPORTED: Expected DBX Consul KV bundle version 1".to_string());
}
if bundle.entries.len() > MAX_RECURSIVE_ENTRIES {
return Err(format!("CONSUL_BUNDLE_TOO_LARGE: Bundle contains more than {MAX_RECURSIVE_ENTRIES} keys"));
}
let mut keys = HashSet::with_capacity(bundle.entries.len());
let mut total_bytes = 0usize;
for entry in &bundle.entries {
if entry.key.is_empty() {
return Err("CONSUL_BUNDLE_INVALID: Bundle contains an empty key".to_string());
}
if !keys.insert(&entry.key) {
return Err(format!("CONSUL_BUNDLE_INVALID: Duplicate key in bundle: {}", entry.key));
}
entry
.flags
.as_str()
.parse::<u64>()
.map_err(|_| format!("CONSUL_BUNDLE_INVALID: Invalid Flags for key {}", entry.key))?;
let value_bytes = value_size(&entry.value)?;
if value_bytes > MAX_VALUE_BYTES {
return Err(format!("CONSUL_VALUE_TOO_LARGE: Bundle value exceeds 512 KiB for key {}", entry.key));
}
total_bytes = total_bytes.saturating_add(value_bytes);
if total_bytes > MAX_RECURSIVE_VALUE_BYTES {
return Err("CONSUL_BUNDLE_TOO_LARGE: Bundle values exceed 32 MiB".to_string());
}
}
Ok(())
}
fn value_size(value: &KvValue) -> Result<usize, String> {
match value.encoding {
crate::agent_kv::KvValueEncoding::Utf8 => Ok(value.data.len()),
crate::agent_kv::KvValueEncoding::Base64 => {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(value.data.chars().filter(|char| !char.is_whitespace()).collect::<String>())
.map(|bytes| bytes.len())
.map_err(|error| format!("CONSUL_BUNDLE_INVALID: Invalid Base64 value: {error}"))
}
}
}
fn bundle_entry_from_record(record: ConsulKvRecord) -> ConsulBundleEntry {
ConsulBundleEntry {
key: record.key,
value: record.value,
flags: record.flags,
create_index: Some(record.create_index),
modify_index: Some(record.modify_index),
}
}
fn report_from_blocked_preview(preview: ConsulImportPreview) -> ConsulImportReport {
let items = preview
.rows
.into_iter()
.map(|row| {
let outcome = if matches!(row.operation, ConsulImportOperation::Conflict | ConsulImportOperation::Locked) {
ConsulImportOutcome::Conflicted
} else {
ConsulImportOutcome::Skipped
};
result_item(row.key, outcome, row.reason, None, None)
})
.collect();
summarize_report(items, false)
}
fn summarize_report(items: Vec<ConsulImportResultItem>, atomic: bool) -> ConsulImportReport {
let succeeded = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Succeeded).count();
let conflicted = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Conflicted).count();
let skipped = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Skipped).count();
let failed = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Failed).count();
ConsulImportReport { items, succeeded, conflicted, skipped, failed, atomic }
}
fn result_item(
key: String,
outcome: ConsulImportOutcome,
message: Option<String>,
batch: Option<usize>,
op_index: Option<usize>,
) -> ConsulImportResultItem {
ConsulImportResultItem { key, outcome, message, batch, op_index }
}
fn preview_id(
rows: &[ConsulImportPreviewRow],
bundle: &ConsulKvBundle,
policy: ConsulImportConflictPolicy,
connection_id: &str,
client: &ConsulClient,
) -> String {
preview_id_for_scope(rows, bundle, policy, connection_id, &client.scope())
}
fn preview_id_for_scope(
rows: &[ConsulImportPreviewRow],
bundle: &ConsulKvBundle,
policy: ConsulImportConflictPolicy,
connection_id: &str,
scope: &super::ConsulScope,
) -> String {
let mut hash = 0xcbf29ce484222325u64;
let canonical = serde_json::to_vec(&(connection_id, scope, bundle, policy, rows)).unwrap_or_default();
for byte in canonical {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("consul-preview-{hash:016x}")
}
fn count_operation(rows: &[ConsulImportPreviewRow], operation: ConsulImportOperation) -> usize {
rows.iter().filter(|row| row.operation == operation).count()
}
fn empty_metadata() -> crate::agent_kv::KvKeyMetadata {
crate::agent_kv::KvKeyMetadata {
create_revision: None,
mod_revision: None,
version: None,
lease: None,
ttl: None,
value_size: None,
czxid: None,
mzxid: None,
pzxid: None,
ctime: None,
mtime: None,
cversion: None,
aversion: None,
ephemeral_owner: None,
data_length: None,
num_children: None,
flags: None,
lock_index: None,
session: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_kv::{KvGetResponse, KvKeyMetadata, KvValueEncoding};
#[test]
fn bundle_validation_rejects_wrong_format_and_duplicate_keys() {
let mut bundle = test_bundle();
bundle.format = "wrong".to_string();
assert!(validate_bundle(&bundle).unwrap_err().contains("CONSUL_BUNDLE_UNSUPPORTED"));
let mut bundle = test_bundle();
bundle.entries.push(bundle.entries[0].clone());
assert!(validate_bundle(&bundle).unwrap_err().contains("Duplicate key"));
}
#[test]
fn bundle_round_trips_binary_values_and_u64_flags_without_sensitive_fields() {
let mut bundle = test_bundle();
bundle.entries[0].value = KvValue { encoding: KvValueEncoding::Base64, data: "AP+A".into() };
bundle.entries[0].flags = KvInt64(u64::MAX.to_string());
let encoded = serde_json::to_string(&bundle).unwrap();
for forbidden in ["SecretID", "X-Consul-Token", "session", "lockIndex"] {
assert!(!encoded.contains(forbidden));
}
let decoded: ConsulKvBundle = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.entries[0].value.data, "AP+A");
assert_eq!(decoded.entries[0].flags.as_str(), u64::MAX.to_string());
}
#[test]
fn existing_key_policy_is_explicit() {
let source = test_bundle().entries.remove(0);
let target = || KvGetResponse {
key: Some(source.key.clone()),
key_bytes: None,
found: true,
value: Some(KvValue { encoding: KvValueEncoding::Utf8, data: "different".to_string() }),
metadata: Some(KvKeyMetadata { mod_revision: Some(KvInt64("9".to_string())), ..empty_metadata() }),
};
assert_eq!(
preview_existing(&source, target(), ConsulImportConflictPolicy::Abort).operation,
ConsulImportOperation::Conflict
);
assert_eq!(
preview_existing(&source, target(), ConsulImportConflictPolicy::Skip).operation,
ConsulImportOperation::Skipped
);
assert_eq!(
preview_existing(&source, target(), ConsulImportConflictPolicy::Cas).operation,
ConsulImportOperation::Update
);
}
#[test]
fn preview_id_binds_target_state_bundle_content_and_policy() {
let scope = super::super::ConsulScope {
datacenter: "dc1".into(),
namespace: "team".into(),
partition: "default".into(),
};
let bundle = test_bundle();
let row = |index: &str| ConsulImportPreviewRow {
key: "app/key".into(),
operation: ConsulImportOperation::Update,
expected_modify_index: Some(KvInt64(index.into())),
target_session: None,
reason: None,
};
let initial = preview_id_for_scope(&[row("9")], &bundle, ConsulImportConflictPolicy::Cas, "target-a", &scope);
assert_ne!(
initial,
preview_id_for_scope(&[row("10")], &bundle, ConsulImportConflictPolicy::Cas, "target-a", &scope)
);
assert_ne!(
initial,
preview_id_for_scope(&[row("9")], &bundle, ConsulImportConflictPolicy::Cas, "target-b", &scope)
);
let mut changed_bundle = bundle.clone();
changed_bundle.entries[0].value.data = "changed-after-preview".into();
assert_ne!(
initial,
preview_id_for_scope(&[row("9")], &changed_bundle, ConsulImportConflictPolicy::Cas, "target-a", &scope)
);
assert_ne!(
initial,
preview_id_for_scope(&[row("9")], &bundle, ConsulImportConflictPolicy::Skip, "target-a", &scope)
);
}
fn test_bundle() -> ConsulKvBundle {
ConsulKvBundle {
format: CONSUL_BUNDLE_FORMAT.into(),
version: CONSUL_BUNDLE_VERSION,
exported_at_unix_ms: 1,
prefix: "app/".into(),
scope_kind: ConsulExportScopeKind::Prefix,
source: ConsulBundleScope {
datacenter: "dc1".into(),
namespace: "team".into(),
partition: "default".into(),
},
entries: vec![ConsulBundleEntry {
key: "app/key".into(),
value: KvValue { encoding: KvValueEncoding::Utf8, data: "value".into() },
flags: KvInt64("0".into()),
create_index: Some(KvInt64("1".into())),
modify_index: Some(KvInt64("2".into())),
}],
}
}
}
+540
View File
@@ -0,0 +1,540 @@
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::agent_kv::{KvInt64, KvValue, KvValueEncoding};
use crate::connection::AppState;
use super::catalog::{ConsulCatalogNode, ConsulCatalogServiceNode, ConsulNodeServices};
use super::client::client_for_state;
use super::health::{mark_maintenance, ConsulHealthCheck, ConsulServiceInstance};
use super::kv::ConsulKvRecord;
use super::response::{
decode_json_body, ensure_success, is_consul_kv_not_found, read_bounded_response, ConsulResponseMetadata,
};
use super::types::ConsulKvEntry;
const MIN_QUERY_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulBlockingRequest {
pub operation_id: String,
#[serde(default)]
pub generation: u64,
pub key: String,
pub prefix: bool,
pub index: Option<KvInt64>,
pub wait_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulBlockingResponse {
pub entries: Vec<ConsulKvRecord>,
pub metadata: ConsulResponseMetadata,
pub changed: bool,
pub timed_out: bool,
pub index_reset: bool,
}
/// A non-KV Consul endpoint with blocking-query support.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ConsulDomainWatchTarget {
CatalogNodes,
CatalogServices,
CatalogServiceNodes { service: String },
CatalogNodeServices { node: String },
HealthNode { node: String },
HealthServiceChecks { service: String },
HealthServiceInstances { service: String, passing: Option<bool> },
HealthState { state: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDomainWatchRequest {
pub operation_id: String,
#[serde(default)]
pub generation: u64,
pub target: ConsulDomainWatchTarget,
pub index: Option<KvInt64>,
pub wait_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDomainWatchResponse {
pub items: ConsulDomainWatchItems,
pub metadata: ConsulResponseMetadata,
pub changed: bool,
pub timed_out: bool,
pub index_reset: bool,
}
/// Each allowed watch target has an explicit response shape. The untagged
/// representation preserves Consul's native JSON body for the UI transport.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConsulDomainWatchItems {
CatalogNodes(Vec<ConsulCatalogNode>),
CatalogServices(BTreeMap<String, Vec<String>>),
CatalogServiceNodes(Vec<ConsulCatalogServiceNode>),
CatalogNodeServices(ConsulNodeServices),
HealthNode(Vec<ConsulHealthCheck>),
HealthServiceChecks(Vec<ConsulHealthCheck>),
HealthServiceInstances(Vec<ConsulServiceInstance>),
HealthState(Vec<ConsulHealthCheck>),
}
#[derive(Default)]
struct BlockingControl {
cancelled: AtomicBool,
}
struct BlockingRegistration(String);
impl Drop for BlockingRegistration {
fn drop(&mut self) {
if let Ok(mut registry) = blocking_registry().lock() {
registry.remove(&self.0);
}
}
}
static BLOCKING: OnceLock<Mutex<HashMap<String, Arc<BlockingControl>>>> = OnceLock::new();
pub async fn consul_blocking_query_core(
state: &AppState,
connection_id: &str,
request: ConsulBlockingRequest,
) -> Result<ConsulBlockingResponse, String> {
validate_request(&request)?;
let client = client_for_state(state, connection_id).await?;
let scope = client.scope();
let registry_key = format!(
"{connection_id}\0{}\0{}\0{}\0{}\0{}",
scope.datacenter, scope.partition, scope.namespace, request.generation, request.operation_id
);
let control = Arc::new(BlockingControl::default());
{
let mut registry =
blocking_registry().lock().map_err(|_| "Consul blocking registry is unavailable".to_string())?;
if registry.contains_key(&registry_key) {
return Err("CONSUL_OPERATION_ALREADY_RUNNING: A blocking query with this operation ID is already running"
.to_string());
}
registry.insert(registry_key.clone(), Arc::clone(&control));
}
let _registration = BlockingRegistration(registry_key);
let requested_index = request.index.as_ref().map(parse_index).transpose()?.unwrap_or(1).max(1);
let started = Instant::now();
let future = blocking_request(&client, &request, requested_index);
tokio::pin!(future);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(50));
let mut response = loop {
tokio::select! {
response = &mut future => break response?,
_ = cancel_tick.tick() => {
if control.cancelled.load(Ordering::Acquire) {
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
}
}
}
};
if control.cancelled.load(Ordering::Acquire) {
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
}
let returned_index = response.metadata.index.as_ref().map(parse_index).transpose()?.unwrap_or(0);
response.index_reset = returned_index == 0 || returned_index < requested_index;
response.changed = !response.index_reset && returned_index > requested_index;
response.timed_out =
!response.changed && started.elapsed() >= Duration::from_secs(request.wait_seconds.saturating_sub(1));
if started.elapsed() < MIN_QUERY_INTERVAL {
tokio::time::sleep(MIN_QUERY_INTERVAL - started.elapsed()).await;
}
Ok(response)
}
/// Runs a Catalog or Health blocking query under the same cancellable registry as KV watches.
pub async fn consul_domain_watch_core(
state: &AppState,
connection_id: &str,
request: ConsulDomainWatchRequest,
) -> Result<ConsulDomainWatchResponse, String> {
validate_domain_watch_request(&request)?;
let client = client_for_state(state, connection_id).await?;
let scope = client.scope();
let registry_key = format!(
"{connection_id}\0{}\0{}\0{}\0{}\0{}",
scope.datacenter, scope.partition, scope.namespace, request.generation, request.operation_id
);
let control = Arc::new(BlockingControl::default());
{
let mut registry =
blocking_registry().lock().map_err(|_| "Consul blocking registry is unavailable".to_string())?;
if registry.contains_key(&registry_key) {
return Err("CONSUL_OPERATION_ALREADY_RUNNING: A blocking query with this operation ID is already running"
.to_string());
}
registry.insert(registry_key.clone(), Arc::clone(&control));
}
let _registration = BlockingRegistration(registry_key);
let requested_index = request.index.as_ref().map(parse_index).transpose()?.unwrap_or(1).max(1);
let started = Instant::now();
let future = domain_watch_request(&client, &request, requested_index);
tokio::pin!(future);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(50));
let mut response = loop {
tokio::select! {
response = &mut future => break response?,
_ = cancel_tick.tick() => {
if control.cancelled.load(Ordering::Acquire) {
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
}
}
}
};
if control.cancelled.load(Ordering::Acquire) {
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
}
let returned_index = response.metadata.index.as_ref().map(parse_index).transpose()?.unwrap_or(0);
response.index_reset = returned_index == 0 || returned_index < requested_index;
response.changed = !response.index_reset && returned_index > requested_index;
response.timed_out =
!response.changed && started.elapsed() >= Duration::from_secs(request.wait_seconds.saturating_sub(1));
if started.elapsed() < MIN_QUERY_INTERVAL {
tokio::time::sleep(MIN_QUERY_INTERVAL - started.elapsed()).await;
}
Ok(response)
}
pub fn consul_cancel_blocking_core(
connection_id: &str,
scope: &super::ConsulScope,
generation: u64,
operation_id: &str,
) -> bool {
let key = format!(
"{connection_id}\0{}\0{}\0{}\0{generation}\0{operation_id}",
scope.datacenter, scope.partition, scope.namespace
);
let control = blocking_registry().lock().ok().and_then(|registry| registry.get(&key).cloned());
control.is_some_and(|control| {
control.cancelled.store(true, Ordering::Release);
true
})
}
async fn blocking_request(
client: &super::ConsulClient,
request: &ConsulBlockingRequest,
requested_index: u64,
) -> Result<ConsulBlockingResponse, String> {
let encoded = request
.key
.split('/')
.map(|segment| utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string())
.collect::<Vec<_>>()
.join("/");
let mut url = client.api_url(&format!("/v1/kv/{encoded}"))?;
client.append_scope(&mut url, true);
{
let mut query = url.query_pairs_mut();
query.append_pair("index", &requested_index.to_string());
query.append_pair("wait", &format!("{}s", request.wait_seconds.clamp(1, 600)));
if request.prefix {
query.append_pair("recurse", "");
}
}
let response = client.send(Method::GET, url, None).await?;
let metadata = ConsulResponseMetadata::from_response(&response);
if is_consul_kv_not_found(&response) {
return Ok(ConsulBlockingResponse {
entries: Vec::new(),
metadata,
changed: false,
timed_out: false,
index_reset: false,
});
}
if !response.status().is_success() {
return ensure_success(response, "watch Consul KV", client.token()).await.map(|_| unreachable!());
}
let body = read_bounded_response(response, "watch Consul KV").await?;
let entries = decode_json_body::<Vec<ConsulKvEntry>>(&body, "watch Consul KV")?
.into_iter()
.map(record_from_entry)
.collect::<Result<Vec<_>, _>>()?;
Ok(ConsulBlockingResponse { entries, metadata, changed: false, timed_out: false, index_reset: false })
}
async fn domain_watch_request(
client: &super::ConsulClient,
request: &ConsulDomainWatchRequest,
requested_index: u64,
) -> Result<ConsulDomainWatchResponse, String> {
let (path, action) = match &request.target {
ConsulDomainWatchTarget::CatalogNodes => ("/v1/catalog/nodes".to_string(), "watch Consul catalog nodes"),
ConsulDomainWatchTarget::CatalogServices => {
("/v1/catalog/services".to_string(), "watch Consul catalog services")
}
ConsulDomainWatchTarget::CatalogServiceNodes { service } => {
(format!("/v1/catalog/service/{}", encode_path_segment(service)), "watch Consul catalog service instances")
}
ConsulDomainWatchTarget::CatalogNodeServices { node } => {
(format!("/v1/catalog/node/{}", encode_path_segment(node)), "watch Consul catalog node services")
}
ConsulDomainWatchTarget::HealthNode { node } => {
(format!("/v1/health/node/{}", encode_path_segment(node)), "watch Consul node health")
}
ConsulDomainWatchTarget::HealthServiceChecks { service } => {
(format!("/v1/health/checks/{}", encode_path_segment(service)), "watch Consul service checks")
}
ConsulDomainWatchTarget::HealthServiceInstances { service, passing } => {
let suffix = passing.map(|value| format!("?passing={value}")).unwrap_or_default();
(format!("/v1/health/service/{}{suffix}", encode_path_segment(service)), "watch Consul service instances")
}
ConsulDomainWatchTarget::HealthState { state } => {
(format!("/v1/health/state/{}", state.trim().to_ascii_lowercase()), "watch Consul health state")
}
};
let mut url = client.api_url(&path)?;
client.append_scope(&mut url, true);
{
let mut query = url.query_pairs_mut();
query.append_pair("index", &requested_index.to_string());
query.append_pair("wait", &format!("{}s", request.wait_seconds.clamp(1, 600)));
}
let response = client.send(Method::GET, url, None).await?;
let metadata = ConsulResponseMetadata::from_response(&response);
if !response.status().is_success() {
return ensure_success(response, action, client.token()).await.map(|_| unreachable!());
}
let body = read_bounded_response(response, action).await?;
let items = match &request.target {
ConsulDomainWatchTarget::CatalogNodes => {
ConsulDomainWatchItems::CatalogNodes(decode_json_body::<Vec<ConsulCatalogNode>>(&body, action)?)
}
ConsulDomainWatchTarget::CatalogServices => {
ConsulDomainWatchItems::CatalogServices(decode_json_body::<BTreeMap<String, Vec<String>>>(&body, action)?)
}
ConsulDomainWatchTarget::CatalogServiceNodes { .. } => ConsulDomainWatchItems::CatalogServiceNodes(
decode_json_body::<Vec<ConsulCatalogServiceNode>>(&body, action)?,
),
ConsulDomainWatchTarget::CatalogNodeServices { .. } => {
ConsulDomainWatchItems::CatalogNodeServices(decode_json_body::<ConsulNodeServices>(&body, action)?)
}
ConsulDomainWatchTarget::HealthNode { .. } => {
ConsulDomainWatchItems::HealthNode(decode_health_checks(&body, action)?)
}
ConsulDomainWatchTarget::HealthServiceChecks { .. } => {
ConsulDomainWatchItems::HealthServiceChecks(decode_health_checks(&body, action)?)
}
ConsulDomainWatchTarget::HealthServiceInstances { .. } => {
let mut instances = decode_json_body::<Vec<ConsulServiceInstance>>(&body, action)?;
for instance in &mut instances {
mark_maintenance(&mut instance.checks);
}
ConsulDomainWatchItems::HealthServiceInstances(instances)
}
ConsulDomainWatchTarget::HealthState { .. } => {
ConsulDomainWatchItems::HealthState(decode_health_checks(&body, action)?)
}
};
Ok(ConsulDomainWatchResponse { items, metadata, changed: false, timed_out: false, index_reset: false })
}
fn decode_health_checks(body: &[u8], action: &str) -> Result<Vec<ConsulHealthCheck>, String> {
let mut checks = decode_json_body::<Vec<ConsulHealthCheck>>(body, action)?;
mark_maintenance(&mut checks);
Ok(checks)
}
fn record_from_entry(entry: ConsulKvEntry) -> Result<ConsulKvRecord, String> {
let bytes = entry
.value
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| {
STANDARD.decode(value).map_err(|error| format!("Failed to decode Consul KV Base64 value: {error}"))
})
.transpose()?
.unwrap_or_default();
let value = match String::from_utf8(bytes.clone()) {
Ok(data) => KvValue { encoding: KvValueEncoding::Utf8, data },
Err(_) => KvValue { encoding: KvValueEncoding::Base64, data: STANDARD.encode(bytes) },
};
Ok(ConsulKvRecord {
key: entry.key,
value,
flags: KvInt64(entry.flags.to_string()),
create_index: KvInt64(entry.create_index.to_string()),
modify_index: KvInt64(entry.modify_index.to_string()),
lock_index: KvInt64(entry.lock_index.to_string()),
session: entry.session.filter(|session| !session.is_empty()),
})
}
fn validate_request(request: &ConsulBlockingRequest) -> Result<(), String> {
if request.operation_id.trim().is_empty() || request.operation_id.len() > 128 {
return Err("CONSUL_OPERATION_ID_INVALID: Blocking operation ID is invalid".to_string());
}
if request.wait_seconds == 0 || request.wait_seconds > 600 {
return Err("CONSUL_BLOCKING_WAIT_INVALID: Wait must be between 1 and 600 seconds".to_string());
}
Ok(())
}
fn validate_domain_watch_request(request: &ConsulDomainWatchRequest) -> Result<(), String> {
if request.operation_id.trim().is_empty() || request.operation_id.len() > 128 {
return Err("CONSUL_OPERATION_ID_INVALID: Blocking operation ID is invalid".to_string());
}
if request.wait_seconds == 0 || request.wait_seconds > 600 {
return Err("CONSUL_BLOCKING_WAIT_INVALID: Wait must be between 1 and 600 seconds".to_string());
}
match &request.target {
ConsulDomainWatchTarget::CatalogNodes | ConsulDomainWatchTarget::CatalogServices => Ok(()),
ConsulDomainWatchTarget::CatalogServiceNodes { service } if !service.trim().is_empty() => Ok(()),
ConsulDomainWatchTarget::CatalogNodeServices { node } | ConsulDomainWatchTarget::HealthNode { node }
if !node.trim().is_empty() =>
{
Ok(())
}
ConsulDomainWatchTarget::HealthServiceChecks { service }
| ConsulDomainWatchTarget::HealthServiceInstances { service, .. }
if !service.trim().is_empty() =>
{
Ok(())
}
ConsulDomainWatchTarget::HealthState { state }
if matches!(state.trim().to_ascii_lowercase().as_str(), "any" | "passing" | "warning" | "critical") =>
{
Ok(())
}
ConsulDomainWatchTarget::CatalogServiceNodes { .. }
| ConsulDomainWatchTarget::HealthServiceChecks { .. }
| ConsulDomainWatchTarget::HealthServiceInstances { .. } => {
Err("CONSUL_INVALID_REQUEST: service is required for watch".to_string())
}
ConsulDomainWatchTarget::CatalogNodeServices { .. } | ConsulDomainWatchTarget::HealthNode { .. } => {
Err("CONSUL_INVALID_REQUEST: node is required for watch".to_string())
}
ConsulDomainWatchTarget::HealthState { .. } => {
Err("CONSUL_INVALID_REQUEST: health state must be any, passing, warning, or critical".to_string())
}
}
}
fn encode_path_segment(value: &str) -> String {
utf8_percent_encode(value.trim(), NON_ALPHANUMERIC).to_string()
}
fn parse_index(value: &KvInt64) -> Result<u64, String> {
value
.as_str()
.parse::<u64>()
.map_err(|_| "CONSUL_BLOCKING_INDEX_INVALID: Index must be an unsigned 64-bit integer".to_string())
}
fn blocking_registry() -> &'static Mutex<HashMap<String, Arc<BlockingControl>>> {
BLOCKING.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::consul::test_support::{serve_once, test_client};
#[test]
fn rejects_zero_wait_and_invalid_index() {
let mut request = ConsulBlockingRequest {
operation_id: "watch".to_string(),
generation: 1,
key: "a".to_string(),
prefix: false,
index: Some(KvInt64("-1".to_string())),
wait_seconds: 0,
};
assert!(validate_request(&request).is_err());
request.wait_seconds = 10;
assert!(parse_index(request.index.as_ref().unwrap()).is_err());
}
#[test]
fn validates_catalog_and_health_watch_targets() {
let request = ConsulDomainWatchRequest {
operation_id: "catalog-watch".to_string(),
generation: 4,
target: ConsulDomainWatchTarget::CatalogServiceNodes { service: "web/api".to_string() },
index: Some(KvInt64("0".to_string())),
wait_seconds: 300,
};
assert!(validate_domain_watch_request(&request).is_ok());
assert_eq!(parse_index(request.index.as_ref().unwrap()).unwrap(), 0);
let invalid = ConsulDomainWatchRequest {
target: ConsulDomainWatchTarget::HealthState { state: "unknown".to_string() },
..request
};
assert!(validate_domain_watch_request(&invalid).is_err());
}
#[test]
fn cancellation_is_bound_to_connection_scope_generation_and_operation() {
let scope = super::super::ConsulScope {
datacenter: "dc1".to_string(),
partition: "partition-a".to_string(),
namespace: "team-a".to_string(),
};
let key = format!("connection-a\0{}\0{}\0{}\0{}\0{}", "dc1", "partition-a", "team-a", 7, "watch-a");
let control = Arc::new(BlockingControl::default());
blocking_registry().lock().unwrap().insert(key.clone(), Arc::clone(&control));
assert!(!consul_cancel_blocking_core("connection-a", &scope, 8, "watch-a"));
assert!(!control.cancelled.load(Ordering::Acquire));
assert!(consul_cancel_blocking_core("connection-a", &scope, 7, "watch-a"));
assert!(control.cancelled.load(Ordering::Acquire));
blocking_registry().lock().unwrap().remove(&key);
}
#[tokio::test]
async fn blocking_kv_wire_uses_minimum_index_wait_scope_and_encoded_key() {
let body = r#"[{"Key":"app/a b","Value":"dmFsdWU=","Flags":0,"CreateIndex":1,"ModifyIndex":9,"LockIndex":0}]"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Consul-Index: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (url, request_rx) = serve_once(response).await;
let client = test_client(url).await;
let request = ConsulBlockingRequest {
operation_id: "watch-1".to_string(),
generation: 3,
key: "app/a b".to_string(),
prefix: true,
index: None,
wait_seconds: 10,
};
let result = blocking_request(&client, &request, 1).await.unwrap();
assert_eq!(result.metadata.index.as_ref().unwrap().as_str(), "9");
assert_eq!(result.entries[0].value.data, "value");
let raw = request_rx.await.unwrap();
let headers = raw.split_once("\r\n\r\n").unwrap().0;
assert!(headers.starts_with("GET /proxy/v1/kv/app/a%20b?"));
for query in ["dc=dc1", "ns=team-a", "partition=partition-a", "consistent", "index=1", "wait=10s", "recurse"] {
assert!(headers.contains(query), "missing query component {query}: {headers}");
}
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
}
}
+187
View File
@@ -0,0 +1,187 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::agent_kv::KvInt64;
use crate::connection::AppState;
use super::client::{client_for_state, ensure_writable_core};
use super::kv::{MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES};
use super::txn::{delete_cas_operation, ConsulTxnRequest, MAX_TXN_OPERATIONS};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDeleteCandidate {
pub key: String,
pub modify_index: KvInt64,
pub session: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDeletePrefixPreview {
pub prefix: String,
pub candidates: Vec<ConsulDeleteCandidate>,
pub filtered_by_acls: Option<bool>,
pub complete: bool,
pub can_execute: bool,
pub locked: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDeletePrefixRequest {
pub prefix: String,
pub expected: Vec<ConsulDeleteCandidate>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsulDeleteOutcome {
Succeeded,
Conflicted,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDeleteResultItem {
pub key: String,
pub outcome: ConsulDeleteOutcome,
pub message: Option<String>,
pub batch: Option<usize>,
pub op_index: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulDeletePrefixReport {
pub items: Vec<ConsulDeleteResultItem>,
pub succeeded: usize,
pub conflicted: usize,
pub failed: usize,
pub atomic: bool,
}
pub async fn consul_delete_prefix_preview_core(
state: &AppState,
connection_id: &str,
prefix: &str,
) -> Result<ConsulDeletePrefixPreview, String> {
validate_prefix(prefix)?;
let client = client_for_state(state, connection_id).await?;
let recursive = client.list_recursive(prefix, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES).await?;
let candidates = recursive
.entries
.into_iter()
.map(|entry| ConsulDeleteCandidate { key: entry.key, modify_index: entry.modify_index, session: entry.session })
.collect::<Vec<_>>();
let locked = candidates.iter().filter(|candidate| candidate.session.is_some()).count();
let can_execute =
recursive.complete && recursive.filtered_by_acls != Some(true) && locked == 0 && !candidates.is_empty();
Ok(ConsulDeletePrefixPreview {
prefix: prefix.to_string(),
candidates,
filtered_by_acls: recursive.filtered_by_acls,
complete: recursive.complete,
can_execute,
locked,
})
}
pub async fn consul_delete_prefix_execute_core(
state: &AppState,
connection_id: &str,
request: ConsulDeletePrefixRequest,
) -> Result<ConsulDeletePrefixReport, String> {
ensure_writable_core(state, connection_id, "delete Consul KV prefix").await?;
validate_prefix(&request.prefix)?;
if request.expected.is_empty() || request.expected.len() > MAX_RECURSIVE_ENTRIES {
return Err("CONSUL_DELETE_PREVIEW_REQUIRED: A complete delete preview is required".to_string());
}
let preview = consul_delete_prefix_preview_core(state, connection_id, &request.prefix).await?;
if !preview.can_execute {
return Err(if preview.filtered_by_acls == Some(true) {
"CONSUL_DELETE_ACL_FILTERED: Prefix deletion is blocked because ACL policies hide keys".to_string()
} else if preview.locked > 0 {
format!("CONSUL_DELETE_LOCKED: Prefix contains {} Session-locked keys", preview.locked)
} else {
"CONSUL_DELETE_PREVIEW_INVALID: Prefix cannot be deleted from the current preview".to_string()
});
}
ensure_same_preview(&request.expected, &preview.candidates)?;
let client = client_for_state(state, connection_id).await?;
let mut candidates = preview.candidates;
candidates.sort_by(|left, right| left.key.cmp(&right.key));
let atomic = candidates.len() <= MAX_TXN_OPERATIONS;
let mut items = Vec::with_capacity(candidates.len());
for (batch_index, chunk) in candidates.chunks(MAX_TXN_OPERATIONS).enumerate() {
let operations = chunk
.iter()
.map(|candidate| delete_cas_operation(candidate.key.clone(), candidate.modify_index.clone()))
.collect();
match client.txn(ConsulTxnRequest { operations }).await {
Ok(result) if result.committed => {
items.extend(chunk.iter().enumerate().map(|(op_index, candidate)| ConsulDeleteResultItem {
key: candidate.key.clone(),
outcome: ConsulDeleteOutcome::Succeeded,
message: None,
batch: Some(batch_index),
op_index: Some(op_index),
}))
}
Ok(result) => {
for (op_index, candidate) in chunk.iter().enumerate() {
let error = result.errors.iter().find(|error| error.op_index == op_index);
items.push(ConsulDeleteResultItem {
key: candidate.key.clone(),
outcome: if error.is_some() {
ConsulDeleteOutcome::Conflicted
} else {
ConsulDeleteOutcome::Failed
},
message: Some(
error
.map(|error| error.message.clone())
.unwrap_or_else(|| "Transaction batch was aborted".to_string()),
),
batch: Some(batch_index),
op_index: Some(op_index),
});
}
}
Err(error) => items.extend(chunk.iter().enumerate().map(|(op_index, candidate)| ConsulDeleteResultItem {
key: candidate.key.clone(),
outcome: ConsulDeleteOutcome::Failed,
message: Some(error.clone()),
batch: Some(batch_index),
op_index: Some(op_index),
})),
}
}
let succeeded = items.iter().filter(|item| item.outcome == ConsulDeleteOutcome::Succeeded).count();
let conflicted = items.iter().filter(|item| item.outcome == ConsulDeleteOutcome::Conflicted).count();
let failed = items.iter().filter(|item| item.outcome == ConsulDeleteOutcome::Failed).count();
Ok(ConsulDeletePrefixReport { items, succeeded, conflicted, failed, atomic })
}
fn ensure_same_preview(expected: &[ConsulDeleteCandidate], current: &[ConsulDeleteCandidate]) -> Result<(), String> {
let expected =
expected.iter().map(|candidate| (&candidate.key, candidate.modify_index.as_str())).collect::<BTreeMap<_, _>>();
let current =
current.iter().map(|candidate| (&candidate.key, candidate.modify_index.as_str())).collect::<BTreeMap<_, _>>();
if expected != current {
return Err(
"CONSUL_DELETE_PREVIEW_STALE: Prefix contents changed after preview; refresh before deleting".to_string()
);
}
Ok(())
}
fn validate_prefix(prefix: &str) -> Result<(), String> {
if prefix.trim().is_empty() {
return Err("CONSUL_DELETE_PREFIX_REQUIRED: Deleting the entire Consul KV root is not allowed".to_string());
}
Ok(())
}
+247
View File
@@ -0,0 +1,247 @@
use reqwest::{Method, StatusCode};
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::client::{client_for_state, ConsulClient};
use super::response::decode_json_response;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ConsulCapabilityStatus {
Supported,
Unsupported,
Disabled,
Forbidden,
#[default]
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulCapabilities {
pub version: Option<String>,
pub datacenter: Option<String>,
pub node_name: Option<String>,
pub server: Option<bool>,
pub edition: Option<String>,
pub agent: ConsulCapabilityStatus,
pub catalog: ConsulCapabilityStatus,
pub health: ConsulCapabilityStatus,
pub sessions: ConsulCapabilityStatus,
pub acl: ConsulCapabilityStatus,
pub auth_methods: ConsulCapabilityStatus,
pub binding_rules: ConsulCapabilityStatus,
pub templated_policies: ConsulCapabilityStatus,
pub namespaces: ConsulCapabilityStatus,
pub partitions: ConsulCapabilityStatus,
pub config_entries: ConsulCapabilityStatus,
pub intentions: ConsulCapabilityStatus,
pub peering: ConsulCapabilityStatus,
pub exported_services: ConsulCapabilityStatus,
pub prepared_queries: ConsulCapabilityStatus,
pub events: ConsulCapabilityStatus,
pub coordinates: ConsulCapabilityStatus,
pub operator_autopilot: ConsulCapabilityStatus,
pub operator_raft: ConsulCapabilityStatus,
pub operator_keyring: ConsulCapabilityStatus,
pub operator_usage: ConsulCapabilityStatus,
pub operator_license: ConsulCapabilityStatus,
pub audit: ConsulCapabilityStatus,
}
#[derive(Debug, Deserialize)]
struct AgentSelfResponse {
#[serde(rename = "Config")]
config: AgentSelfConfig,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AgentSelfConfig {
version: Option<String>,
datacenter: Option<String>,
node_name: Option<String>,
server: Option<bool>,
}
pub async fn consul_capabilities_core(state: &AppState, connection_id: &str) -> Result<ConsulCapabilities, String> {
let client = client_for_state(state, connection_id).await?;
let (
mut capabilities,
acl,
auth_methods,
binding_rules,
templated_policies,
namespaces,
partitions,
config_entries,
intentions,
peering,
exported_services,
catalog,
health,
sessions,
prepared_queries,
events,
coordinates,
operator_autopilot,
operator_raft,
operator_keyring,
operator_usage,
operator_license,
audit,
) = tokio::join!(
probe_agent(&client),
probe_acl_endpoint(&client),
probe_endpoint(&client, "/v1/acl/auth-methods"),
probe_endpoint(&client, "/v1/acl/binding-rules"),
probe_endpoint(&client, "/v1/acl/templated-policies"),
probe_endpoint(&client, "/v1/namespaces"),
probe_endpoint(&client, "/v1/partitions"),
probe_endpoint(&client, "/v1/config/service-defaults"),
probe_endpoint(&client, "/v1/connect/intentions"),
probe_endpoint(&client, "/v1/peerings"),
probe_endpoint(&client, "/v1/exported-services"),
probe_endpoint(&client, "/v1/catalog/datacenters"),
probe_endpoint(&client, "/v1/health/state/any"),
probe_endpoint(&client, "/v1/session/list"),
probe_endpoint(&client, "/v1/query"),
probe_endpoint(&client, "/v1/event/list"),
probe_endpoint(&client, "/v1/coordinate/nodes"),
probe_endpoint(&client, "/v1/operator/autopilot/configuration"),
probe_endpoint(&client, "/v1/operator/raft/configuration"),
probe_endpoint(&client, "/v1/operator/keyring"),
probe_endpoint(&client, "/v1/operator/usage"),
probe_endpoint(&client, "/v1/operator/license"),
probe_audit_endpoint(&client),
);
capabilities.acl = acl;
capabilities.auth_methods = auth_methods;
capabilities.binding_rules = binding_rules;
capabilities.templated_policies = templated_policies;
capabilities.namespaces = namespaces;
capabilities.partitions = partitions;
capabilities.config_entries = config_entries;
capabilities.intentions = intentions;
capabilities.peering = peering;
capabilities.exported_services = exported_services;
capabilities.catalog = catalog;
capabilities.health = health;
capabilities.sessions = sessions;
capabilities.prepared_queries = prepared_queries;
capabilities.events = events;
capabilities.coordinates = coordinates;
capabilities.operator_autopilot = operator_autopilot;
capabilities.operator_raft = operator_raft;
capabilities.operator_keyring = operator_keyring;
capabilities.operator_usage = operator_usage;
capabilities.operator_license = operator_license;
capabilities.audit = audit;
capabilities.edition = infer_edition(capabilities.namespaces, capabilities.partitions);
Ok(capabilities)
}
async fn probe_agent(client: &ConsulClient) -> ConsulCapabilities {
let Ok(url) = client.api_url("/v1/agent/self") else {
return ConsulCapabilities::default();
};
let Ok(response) = client.send(Method::GET, url, None).await else {
return ConsulCapabilities::default();
};
let status = capability_status(response.status());
if status != ConsulCapabilityStatus::Supported {
return ConsulCapabilities { agent: status, ..ConsulCapabilities::default() };
}
let Ok(response) = decode_json_response::<AgentSelfResponse>(response, "probe Consul agent").await else {
return ConsulCapabilities::default();
};
ConsulCapabilities {
version: response.config.version,
datacenter: response.config.datacenter,
node_name: response.config.node_name,
server: response.config.server,
agent: ConsulCapabilityStatus::Supported,
..ConsulCapabilities::default()
}
}
async fn probe_endpoint(client: &ConsulClient, path: &str) -> ConsulCapabilityStatus {
let Ok(mut url) = client.api_url(path) else {
return ConsulCapabilityStatus::Unknown;
};
client.append_scope(&mut url, true);
match client.send(Method::GET, url, None).await {
Ok(response) => capability_status(response.status()),
Err(_) => ConsulCapabilityStatus::Unknown,
}
}
async fn probe_acl_endpoint(client: &ConsulClient) -> ConsulCapabilityStatus {
let Ok(mut url) = client.api_url("/v1/acl/token/self") else {
return ConsulCapabilityStatus::Unknown;
};
client.append_scope(&mut url, true);
match client.send(Method::GET, url, None).await {
Ok(response) if response.status() == StatusCode::UNAUTHORIZED => {
let body = response.text().await.unwrap_or_default();
if is_acl_support_disabled(&body) {
ConsulCapabilityStatus::Disabled
} else {
ConsulCapabilityStatus::Forbidden
}
}
Ok(response) => capability_status(response.status()),
Err(_) => ConsulCapabilityStatus::Unknown,
}
}
async fn probe_audit_endpoint(client: &ConsulClient) -> ConsulCapabilityStatus {
let Ok(mut url) = client.api_url("/v1/operator/audit-hash") else {
return ConsulCapabilityStatus::Unknown;
};
client.append_scope(&mut url, true);
match client.send(Method::POST, url, Some(br#"{"Input":"dbx-capability-probe"}"#.to_vec())).await {
Ok(response) => capability_status(response.status()),
Err(_) => ConsulCapabilityStatus::Unknown,
}
}
fn capability_status(status: StatusCode) -> ConsulCapabilityStatus {
match status {
status if status.is_success() => ConsulCapabilityStatus::Supported,
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ConsulCapabilityStatus::Forbidden,
StatusCode::NOT_FOUND => ConsulCapabilityStatus::Unsupported,
_ => ConsulCapabilityStatus::Unknown,
}
}
fn is_acl_support_disabled(body: &str) -> bool {
body.trim().eq_ignore_ascii_case("ACL support disabled")
}
fn infer_edition(namespaces: ConsulCapabilityStatus, partitions: ConsulCapabilityStatus) -> Option<String> {
if matches!(namespaces, ConsulCapabilityStatus::Supported)
|| matches!(partitions, ConsulCapabilityStatus::Supported)
{
Some("enterprise".to_string())
} else if matches!(namespaces, ConsulCapabilityStatus::Unsupported)
&& matches!(partitions, ConsulCapabilityStatus::Unsupported)
{
Some("community".to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_an_explicitly_disabled_acl_subsystem() {
assert!(is_acl_support_disabled("ACL support disabled\n"));
assert!(!is_acl_support_disabled("Permission denied"));
assert_eq!(capability_status(StatusCode::UNAUTHORIZED), ConsulCapabilityStatus::Forbidden);
}
}
+294
View File
@@ -0,0 +1,294 @@
use std::collections::BTreeMap;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::client::{client_for_state, ConsulClient};
use super::response::{decode_json_response, ensure_success, ConsulResponseMetadata};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulReadOptions {
pub filter: Option<String>,
pub near: Option<String>,
pub index: Option<String>,
pub wait: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulListResponse<T> {
pub items: T,
pub metadata: ConsulResponseMetadata,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulCatalogNode {
#[serde(default, rename = "ID", alias = "Id")]
pub id: String,
#[serde(default)]
pub node: String,
#[serde(default)]
pub address: String,
#[serde(default)]
pub datacenter: String,
#[serde(default)]
pub tagged_addresses: BTreeMap<String, String>,
#[serde(default)]
pub node_meta: BTreeMap<String, String>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulCatalogServiceNode {
#[serde(default, rename = "ID", alias = "Id")]
pub id: String,
#[serde(default)]
pub node: String,
#[serde(default)]
pub address: String,
#[serde(default)]
pub datacenter: String,
#[serde(default)]
pub tagged_addresses: BTreeMap<String, String>,
#[serde(default)]
pub node_meta: BTreeMap<String, String>,
#[serde(default)]
pub service_kind: String,
#[serde(default, rename = "ServiceID", alias = "ServiceId")]
pub service_id: String,
#[serde(default)]
pub service_name: String,
#[serde(default)]
pub service_tags: Vec<String>,
#[serde(default)]
pub service_address: String,
#[serde(default)]
pub service_port: u16,
#[serde(default)]
pub service_meta: BTreeMap<String, String>,
#[serde(default)]
pub service_tagged_addresses: BTreeMap<String, ConsulServiceAddress>,
#[serde(default)]
pub service_weights: ConsulServiceWeights,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulServiceAddress {
#[serde(default)]
pub address: String,
#[serde(default)]
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulServiceWeights {
#[serde(default = "default_weight")]
pub passing: u32,
#[serde(default = "default_weight")]
pub warning: u32,
}
impl Default for ConsulServiceWeights {
fn default() -> Self {
Self { passing: 1, warning: 1 }
}
}
fn default_weight() -> u32 {
1
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulCatalogService {
#[serde(default, rename = "ID", alias = "Id")]
pub id: String,
#[serde(default)]
pub service: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub address: String,
#[serde(default)]
pub tagged_addresses: BTreeMap<String, ConsulServiceAddress>,
#[serde(default)]
pub meta: BTreeMap<String, String>,
#[serde(default)]
pub port: u16,
#[serde(default)]
pub weights: ConsulServiceWeights,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulNodeServices {
pub node: ConsulCatalogNode,
#[serde(default)]
pub services: BTreeMap<String, ConsulCatalogService>,
}
impl ConsulClient {
pub async fn catalog_datacenters(&self) -> Result<Vec<String>, String> {
let url = self.api_url("/v1/catalog/datacenters")?;
let response =
ensure_success(self.send(Method::GET, url, None).await?, "list Consul datacenters", self.token()).await?;
decode_json_response(response, "list Consul datacenters").await
}
pub async fn catalog_nodes(
&self,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulCatalogNode>>, String> {
self.read_list("/v1/catalog/nodes", options, "list Consul catalog nodes").await
}
pub async fn catalog_services(
&self,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<BTreeMap<String, Vec<String>>>, String> {
self.read_list("/v1/catalog/services", options, "list Consul catalog services").await
}
pub async fn catalog_service_nodes(
&self,
service: &str,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulCatalogServiceNode>>, String> {
let service = required_segment(service, "service")?;
self.read_list(
&format!("/v1/catalog/service/{}", encode_segment(service)),
options,
"list Consul service nodes",
)
.await
}
pub async fn catalog_node_services(
&self,
node: &str,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<ConsulNodeServices>, String> {
let node = required_segment(node, "node")?;
self.read_list(&format!("/v1/catalog/node/{}", encode_segment(node)), options, "read Consul node services")
.await
}
pub(super) async fn read_list<T: serde::de::DeserializeOwned>(
&self,
path: &str,
options: &ConsulReadOptions,
action: &str,
) -> Result<ConsulListResponse<T>, String> {
let mut url = self.api_url(path)?;
self.append_scope(&mut url, true);
append_read_options(&mut url, options);
let response = ensure_success(self.send(Method::GET, url, None).await?, action, self.token()).await?;
let metadata = ConsulResponseMetadata::from_response(&response);
let items = decode_json_response(response, action).await?;
Ok(ConsulListResponse { items, metadata })
}
}
pub(super) fn append_read_options(url: &mut reqwest::Url, options: &ConsulReadOptions) {
let mut query = url.query_pairs_mut();
for (name, value) in [
("filter", options.filter.as_deref()),
("near", options.near.as_deref()),
("index", options.index.as_deref()),
("wait", options.wait.as_deref()),
] {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
query.append_pair(name, value);
}
}
}
pub(super) fn encode_segment(value: &str) -> String {
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
}
fn required_segment<'a>(value: &'a str, name: &str) -> Result<&'a str, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("CONSUL_INVALID_REQUEST: {name} is required"));
}
Ok(value)
}
pub async fn consul_catalog_datacenters_core(state: &AppState, connection_id: &str) -> Result<Vec<String>, String> {
client_for_state(state, connection_id).await?.catalog_datacenters().await
}
pub async fn consul_catalog_nodes_core(
state: &AppState,
connection_id: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulCatalogNode>>, String> {
client_for_state(state, connection_id).await?.catalog_nodes(&options).await
}
pub async fn consul_catalog_services_core(
state: &AppState,
connection_id: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<BTreeMap<String, Vec<String>>>, String> {
client_for_state(state, connection_id).await?.catalog_services(&options).await
}
pub async fn consul_catalog_service_nodes_core(
state: &AppState,
connection_id: &str,
service: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulCatalogServiceNode>>, String> {
client_for_state(state, connection_id).await?.catalog_service_nodes(service, &options).await
}
pub async fn consul_catalog_node_services_core(
state: &AppState,
connection_id: &str,
node: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<ConsulNodeServices>, String> {
client_for_state(state, connection_id).await?.catalog_node_services(node, &options).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consul_acronym_ids_deserialize_without_becoming_empty() {
let instance: ConsulCatalogServiceNode = serde_json::from_value(serde_json::json!({
"ID": "node-id",
"Node": "node-1",
"ServiceID": "api-1",
"ServiceName": "api"
}))
.unwrap();
assert_eq!(instance.id, "node-id");
assert_eq!(instance.service_id, "api-1");
let service: ConsulCatalogService = serde_json::from_value(serde_json::json!({
"ID": "api-1",
"Service": "api"
}))
.unwrap();
assert_eq!(service.id, "api-1");
}
}
+314
View File
@@ -0,0 +1,314 @@
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,
}
}
}
+204
View File
@@ -0,0 +1,204 @@
use std::fmt;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use crate::models::connection::ConnectionConfig;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
pub struct ConsulScope {
pub datacenter: String,
pub namespace: String,
pub partition: String,
}
impl ConsulScope {
pub fn contains_wildcard(&self) -> bool {
self.datacenter == "*" || self.namespace == "*" || self.partition == "*"
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ConsulConsistency {
#[default]
Default,
Stale,
Consistent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulAgentTarget {
pub node: String,
pub address: String,
}
#[derive(Clone, PartialEq, Eq)]
pub struct ConsulConfig {
pub base_url: Url,
pub token: String,
pub datacenter: String,
pub namespace: String,
pub partition: String,
pub consistency: ConsulConsistency,
pub tls_skip_verify: bool,
pub ca_cert_path: String,
pub client_cert_path: String,
pub client_key_path: String,
pub connect_timeout_secs: u64,
pub request_timeout_secs: u64,
pub connect_override: Option<(String, u16)>,
pub operator_snapshot_restore_enabled: bool,
pub operator_autopilot_write_enabled: bool,
pub operator_raft_write_enabled: bool,
pub operator_keyring_write_enabled: bool,
pub operator_license_write_enabled: bool,
pub agent_target: Option<ConsulAgentTarget>,
}
impl fmt::Debug for ConsulConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let token = if self.token.is_empty() { "none" } else { "redacted" };
formatter
.debug_struct("ConsulConfig")
.field("base_url", &self.base_url)
.field("token", &token)
.field("datacenter", &self.datacenter)
.field("namespace", &self.namespace)
.field("partition", &self.partition)
.field("consistency", &self.consistency)
.field("tls_skip_verify", &self.tls_skip_verify)
.field("ca_cert_path", &self.ca_cert_path)
.field("client_cert_path", &self.client_cert_path)
.field("client_key_path", &self.client_key_path)
.field("connect_timeout_secs", &self.connect_timeout_secs)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("connect_override", &self.connect_override)
.field("operator_snapshot_restore_enabled", &self.operator_snapshot_restore_enabled)
.field("operator_autopilot_write_enabled", &self.operator_autopilot_write_enabled)
.field("operator_raft_write_enabled", &self.operator_raft_write_enabled)
.field("operator_keyring_write_enabled", &self.operator_keyring_write_enabled)
.field("operator_license_write_enabled", &self.operator_license_write_enabled)
.field("agent_target", &self.agent_target)
.finish()
}
}
impl ConsulConfig {
pub fn scope(&self) -> ConsulScope {
ConsulScope {
datacenter: self.datacenter.clone(),
namespace: self.namespace.clone(),
partition: self.partition.clone(),
}
}
pub fn from_connection(connection: &ConnectionConfig) -> Result<Self, String> {
let external = connection.external_config.as_ref();
let server_addr =
external_string(external, &["serverAddr", "server_addr"]).unwrap_or_else(|| connection.connection_url());
let mut base_url =
Url::parse(server_addr.trim()).map_err(|error| format!("Consul server address is invalid: {error}"))?;
if !matches!(base_url.scheme(), "http" | "https") {
return Err("Consul server address must use http or https".to_string());
}
if base_url.host_str().is_none() {
return Err("Consul server address must include a host".to_string());
}
if !base_url.username().is_empty() || base_url.password().is_some() {
return Err("Consul server address must not contain embedded credentials".to_string());
}
if base_url.query().is_some() || base_url.fragment().is_some() {
return Err("Consul server address must not contain a query or fragment".to_string());
}
let normalized_path = base_url.path().trim_end_matches('/').to_string();
base_url.set_path(&normalized_path);
let consistency = match external_string(external, &["consistency", "consulConsistency", "consul_consistency"])
.unwrap_or_else(|| "default".to_string())
.to_ascii_lowercase()
.as_str()
{
"default" | "" => ConsulConsistency::Default,
"stale" => ConsulConsistency::Stale,
"consistent" => ConsulConsistency::Consistent,
value => return Err(format!("Unsupported Consul consistency mode: {value}")),
};
let client_cert_path = connection.client_cert_path.trim().to_string();
let client_key_path = connection.client_key_path.trim().to_string();
if client_cert_path.is_empty() != client_key_path.is_empty() {
return Err("Consul client certificate and key must be configured together".to_string());
}
Ok(Self {
base_url,
token: connection.password.clone(),
datacenter: external_string(external, &["datacenter", "consulDatacenter", "consul_datacenter"])
.unwrap_or_default(),
namespace: external_string(external, &["namespace", "consulNamespace", "consul_namespace"])
.unwrap_or_default(),
partition: external_string(external, &["partition", "consulPartition", "consul_partition"])
.unwrap_or_default(),
consistency,
tls_skip_verify: external_bool(
external,
&["tlsSkipVerify", "tls_skip_verify", "consulTlsSkipVerify", "consul_tls_skip_verify"],
)
.unwrap_or(false),
ca_cert_path: connection.ca_cert_path.trim().to_string(),
client_cert_path,
client_key_path,
connect_timeout_secs: connection.effective_connect_timeout_secs(),
request_timeout_secs: connection.effective_query_timeout_secs(),
connect_override: None,
operator_snapshot_restore_enabled: external_bool(external, &["consulOperatorSnapshotRestoreEnabled"])
.unwrap_or(false),
operator_autopilot_write_enabled: external_bool(external, &["consulOperatorAutopilotWriteEnabled"])
.unwrap_or(false),
operator_raft_write_enabled: external_bool(external, &["consulOperatorRaftWriteEnabled"]).unwrap_or(false),
operator_keyring_write_enabled: external_bool(external, &["consulOperatorKeyringWriteEnabled"])
.unwrap_or(false),
operator_license_write_enabled: external_bool(external, &["consulOperatorLicenseWriteEnabled"])
.unwrap_or(false),
agent_target: parse_agent_target(external)?,
})
}
pub fn with_connect_override(mut self, host: impl Into<String>, port: u16) -> Self {
self.connect_override = Some((host.into(), port));
self
}
}
fn parse_agent_target(value: Option<&serde_json::Value>) -> Result<Option<ConsulAgentTarget>, String> {
let Some(raw) = value
.and_then(serde_json::Value::as_object)
.and_then(|object| object.get("agentTarget").or_else(|| object.get("agent_target")))
else {
return Ok(None);
};
let object = raw.as_object().ok_or("Consul agent_target must contain an explicit node and address")?;
let node = object.get("node").and_then(serde_json::Value::as_str).unwrap_or_default().trim();
let address = object.get("address").and_then(serde_json::Value::as_str).unwrap_or_default().trim();
if node.is_empty() || address.is_empty() {
return Err("Consul agent_target requires both node and address".to_string());
}
Ok(Some(ConsulAgentTarget { node: node.to_string(), address: address.to_string() }))
}
fn external_string(value: Option<&serde_json::Value>, keys: &[&str]) -> Option<String> {
let object = value?.as_object()?;
keys.iter()
.find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn external_bool(value: Option<&serde_json::Value>, keys: &[&str]) -> Option<bool> {
let object = value?.as_object()?;
keys.iter().find_map(|key| object.get(*key).and_then(serde_json::Value::as_bool))
}
+796
View File
@@ -0,0 +1,796 @@
use std::collections::{BTreeMap, BTreeSet};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::client::{client_for_state, ConsulClient};
use super::config::ConsulConsistency;
use super::response::{decode_json_response, ensure_success, ConsulResponseMetadata, MAX_COLLECTION_ITEMS};
// Consul lists config entries by kind and does not expose a single inventory endpoint.
const NAMESPACE_CONFIG_ENTRY_KINDS: &[&str] = &[
"api-gateway",
"bound-api-gateway",
"file-system-certificate",
"http-route",
"ingress-gateway",
"inline-certificate",
"jwt-provider",
"service-defaults",
"service-intentions",
"service-resolver",
"service-router",
"service-splitter",
"tcp-route",
"terminating-gateway",
];
const PARTITION_CONFIG_ENTRY_KINDS: &[&str] = &[
"control-plane-request-limit",
"exported-services",
"mesh",
"product-usage",
"proxy-defaults",
"rate-limit",
"sameness-group",
];
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum ConsulEnterpriseKind {
Namespace,
Partition,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulNamespace {
#[serde(default)]
pub name: String,
#[serde(default)]
pub partition: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub meta: BTreeMap<String, String>,
#[serde(rename = "ACLs", default)]
pub acls: ConsulNamespaceAcls,
#[serde(default)]
pub deleted_at: Option<String>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulNamespaceAcls {
#[serde(default)]
pub policy_defaults: Vec<serde_json::Value>,
#[serde(default)]
pub role_defaults: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPartition {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub meta: BTreeMap<String, String>,
#[serde(default)]
pub deleted_at: Option<String>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "items", rename_all = "camelCase")]
pub enum ConsulEnterpriseList {
Namespace(Vec<ConsulNamespace>),
Partition(Vec<ConsulPartition>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
pub enum ConsulEnterpriseItem {
Namespace(ConsulNamespace),
Partition(ConsulPartition),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
pub enum ConsulEnterpriseWrite {
Namespace(ConsulNamespace),
Partition(ConsulPartition),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulScopeImpact {
pub services: usize,
pub nodes: usize,
pub kv_keys: usize,
pub health_checks: usize,
pub sessions: usize,
pub config_entries: usize,
pub intentions: usize,
pub peerings: usize,
pub namespaces: usize,
pub acl_tokens: usize,
pub acl_policies: usize,
pub acl_roles: usize,
pub acl_auth_methods: usize,
pub acl_binding_rules: usize,
pub complete: bool,
pub filtered_by_acls: bool,
pub unavailable_resources: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
enum ImpactResource {
Services,
Nodes,
KvKeys,
HealthChecks,
Sessions,
ConfigEntries,
Intentions,
Peerings,
Namespaces,
AclTokens,
AclPolicies,
AclRoles,
AclAuthMethods,
AclBindingRules,
}
#[derive(Debug)]
struct ImpactInspection {
count: usize,
filtered_by_acls: bool,
}
impl ConsulScopeImpact {
fn record(&mut self, resource: ImpactResource, label: String, result: Result<ImpactInspection, String>) {
match result {
Ok(inspection) => {
let target = match resource {
ImpactResource::Services => &mut self.services,
ImpactResource::Nodes => &mut self.nodes,
ImpactResource::KvKeys => &mut self.kv_keys,
ImpactResource::HealthChecks => &mut self.health_checks,
ImpactResource::Sessions => &mut self.sessions,
ImpactResource::ConfigEntries => &mut self.config_entries,
ImpactResource::Intentions => &mut self.intentions,
ImpactResource::Peerings => &mut self.peerings,
ImpactResource::Namespaces => &mut self.namespaces,
ImpactResource::AclTokens => &mut self.acl_tokens,
ImpactResource::AclPolicies => &mut self.acl_policies,
ImpactResource::AclRoles => &mut self.acl_roles,
ImpactResource::AclAuthMethods => &mut self.acl_auth_methods,
ImpactResource::AclBindingRules => &mut self.acl_binding_rules,
};
if let Some(total) = target.checked_add(inspection.count) {
*target = total;
} else {
self.unavailable_resources.push(label);
}
self.filtered_by_acls |= inspection.filtered_by_acls;
}
Err(_) => self.unavailable_resources.push(label),
}
}
fn finish(mut self) -> Self {
self.unavailable_resources.sort();
self.unavailable_resources.dedup();
self.complete = self.unavailable_resources.is_empty() && !self.filtered_by_acls;
self
}
}
pub async fn consul_enterprise_list_core(
state: &AppState,
connection_id: &str,
kind: ConsulEnterpriseKind,
) -> Result<ConsulEnterpriseList, String> {
let client = client_for_state(state, connection_id).await?;
match kind {
ConsulEnterpriseKind::Namespace => {
Ok(ConsulEnterpriseList::Namespace(get_json(&client, "/v1/namespaces", "list namespaces").await?))
}
ConsulEnterpriseKind::Partition => {
Ok(ConsulEnterpriseList::Partition(get_json(&client, "/v1/partitions", "list partitions").await?))
}
}
}
pub async fn consul_enterprise_get_core(
state: &AppState,
connection_id: &str,
kind: ConsulEnterpriseKind,
name: &str,
) -> Result<ConsulEnterpriseItem, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&enterprise_path(kind, name))?;
match kind {
ConsulEnterpriseKind::Namespace => Ok(ConsulEnterpriseItem::Namespace(
client.request_json(Method::GET, url, None::<&()>, true, "read namespace").await?,
)),
ConsulEnterpriseKind::Partition => Ok(ConsulEnterpriseItem::Partition(
client.request_json(Method::GET, url, None::<&()>, true, "read partition").await?,
)),
}
}
pub async fn consul_enterprise_apply_core(
state: &AppState,
connection_id: &str,
existing_name: Option<&str>,
item: ConsulEnterpriseWrite,
) -> Result<ConsulEnterpriseItem, String> {
super::ensure_writable(state, connection_id, "Enterprise scope write").await?;
let client = client_for_state(state, connection_id).await?;
match item {
ConsulEnterpriseWrite::Namespace(value) => {
Ok(ConsulEnterpriseItem::Namespace(write_namespace(&client, existing_name, value).await?))
}
ConsulEnterpriseWrite::Partition(value) => {
Ok(ConsulEnterpriseItem::Partition(write_partition(&client, existing_name, value).await?))
}
}
}
async fn write_namespace(
client: &ConsulClient,
existing_name: Option<&str>,
mut value: ConsulNamespace,
) -> Result<ConsulNamespace, String> {
value.partition = target_partition(client.partition()).to_string();
let (method, path) = write_path(ConsulEnterpriseKind::Namespace, existing_name);
let mut url = client.api_url(&path)?;
append_enterprise_target(client, &mut url, ConsulEnterpriseKind::Namespace, false);
client.request_json_unscoped(method, url, Some(&value), "write namespace").await
}
async fn write_partition(
client: &ConsulClient,
existing_name: Option<&str>,
value: ConsulPartition,
) -> Result<ConsulPartition, String> {
let (method, path) = write_path(ConsulEnterpriseKind::Partition, existing_name);
let mut url = client.api_url(&path)?;
append_enterprise_target(client, &mut url, ConsulEnterpriseKind::Partition, false);
client.request_json_unscoped(method, url, Some(&value), "write partition").await
}
fn target_partition(partition: &str) -> &str {
if partition.trim().is_empty() {
"default"
} else {
partition
}
}
pub async fn consul_enterprise_impact_core(
state: &AppState,
connection_id: &str,
kind: ConsulEnterpriseKind,
name: &str,
) -> Result<ConsulScopeImpact, String> {
let client = client_for_state(state, connection_id).await?;
inspect_scope_impact(&client, kind, name).await
}
pub async fn consul_enterprise_delete_core(
state: &AppState,
connection_id: &str,
kind: ConsulEnterpriseKind,
name: &str,
) -> Result<ConsulScopeImpact, String> {
super::ensure_writable(state, connection_id, "Enterprise scope delete").await?;
let impact = consul_enterprise_impact_core(state, connection_id, kind, name).await?;
if !impact.complete {
return Err(
"CONSUL_IMPACT_INCOMPLETE: scoped resources could not be enumerated completely; delete blocked".to_string()
);
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url(&enterprise_path(kind, name))?;
append_enterprise_target(&client, &mut url, kind, false);
ensure_success(client.send(Method::DELETE, url, None).await?, "delete Enterprise scope", client.token()).await?;
Ok(impact)
}
async fn get_json<T: serde::de::DeserializeOwned>(
client: &ConsulClient,
path: &str,
action: &str,
) -> Result<T, String> {
let url = client.api_url(path)?;
client.request_json(Method::GET, url, None::<&()>, true, action).await
}
async fn inspect_scope_impact(
client: &ConsulClient,
kind: ConsulEnterpriseKind,
name: &str,
) -> Result<ConsulScopeImpact, String> {
if name.trim().is_empty() {
return Err("CONSUL_INVALID_REQUEST: Enterprise scope name is required".to_string());
}
let mut impact = ConsulScopeImpact::default();
match kind {
ConsulEnterpriseKind::Namespace => {
inspect_namespace_resources(client, name, client.partition(), &mut impact).await;
}
ConsulEnterpriseKind::Partition => {
match inspect_partition_namespaces(client, name).await {
Ok((namespaces, filtered)) => {
impact.record(
ImpactResource::Namespaces,
"namespaces".to_string(),
Ok(ImpactInspection { count: namespaces.len(), filtered_by_acls: filtered }),
);
// Every partition owns a default namespace. Its absence means the list cannot prove completeness,
// even if an older Consul version omitted the ACL-filtered response header.
if !namespaces.contains("default") {
impact.unavailable_resources.push("namespaces:default".to_string());
}
for namespace in namespaces {
inspect_namespace_resources(client, &namespace, name, &mut impact).await;
}
}
Err(_) => impact.unavailable_resources.push("namespaces".to_string()),
}
record_inspection(
client,
&mut impact,
ImpactResource::Nodes,
"nodes",
"/v1/catalog/nodes",
name,
None,
&[],
false,
)
.await;
record_inspection(
client,
&mut impact,
ImpactResource::Peerings,
"peerings",
"/v1/peerings",
name,
None,
&[],
false,
)
.await;
for config_kind in PARTITION_CONFIG_ENTRY_KINDS {
record_inspection(
client,
&mut impact,
ImpactResource::ConfigEntries,
&format!("configEntries:{config_kind}"),
&format!("/v1/config/{config_kind}"),
name,
Some("default"),
&[],
false,
)
.await;
}
}
}
Ok(impact.finish())
}
async fn inspect_namespace_resources(
client: &ConsulClient,
namespace: &str,
partition: &str,
impact: &mut ConsulScopeImpact,
) {
let resources = [
(ImpactResource::Services, "services", "/v1/catalog/services", false),
(ImpactResource::KvKeys, "kvKeys", "/v1/kv/", true),
(ImpactResource::HealthChecks, "healthChecks", "/v1/health/state/any", false),
(ImpactResource::Sessions, "sessions", "/v1/session/list", false),
(ImpactResource::Intentions, "intentions", "/v1/connect/intentions", false),
(ImpactResource::AclTokens, "aclTokens", "/v1/acl/tokens", false),
(ImpactResource::AclPolicies, "aclPolicies", "/v1/acl/policies", false),
(ImpactResource::AclRoles, "aclRoles", "/v1/acl/roles", false),
(ImpactResource::AclAuthMethods, "aclAuthMethods", "/v1/acl/auth-methods", false),
(ImpactResource::AclBindingRules, "aclBindingRules", "/v1/acl/binding-rules", false),
];
for (resource, label, path, empty_on_not_found) in resources {
let query = if matches!(resource, ImpactResource::KvKeys) { &[("keys", "")][..] } else { &[][..] };
record_inspection(
client,
impact,
resource,
&scoped_label(label, namespace),
path,
partition,
Some(namespace),
query,
empty_on_not_found,
)
.await;
}
for config_kind in NAMESPACE_CONFIG_ENTRY_KINDS {
record_inspection(
client,
impact,
ImpactResource::ConfigEntries,
&scoped_label(&format!("configEntries:{config_kind}"), namespace),
&format!("/v1/config/{config_kind}"),
partition,
Some(namespace),
&[],
false,
)
.await;
}
}
#[allow(clippy::too_many_arguments)]
async fn record_inspection(
client: &ConsulClient,
impact: &mut ConsulScopeImpact,
resource: ImpactResource,
label: &str,
path: &str,
partition: &str,
namespace: Option<&str>,
query: &[(&str, &str)],
empty_on_not_found: bool,
) {
let result = impact_get(client, path, partition, namespace, query, empty_on_not_found).await;
impact.record(resource, label.to_string(), result);
}
async fn impact_get(
client: &ConsulClient,
path: &str,
partition: &str,
namespace: Option<&str>,
extra_query: &[(&str, &str)],
empty_on_not_found: bool,
) -> Result<ImpactInspection, String> {
let mut url = client.api_url(path)?;
append_explicit_scope(client, &mut url, partition, namespace, true);
for (key, value) in extra_query {
url.query_pairs_mut().append_pair(key, value);
}
let response = client.send(Method::GET, url, None).await?;
let filtered = ConsulResponseMetadata::from_response(&response).filtered_by_acls.unwrap_or(false);
if is_empty_not_found(response.status(), empty_on_not_found) {
return Ok(ImpactInspection { count: 0, filtered_by_acls: filtered });
}
let response = ensure_success(response, "inspect scoped resources", client.token()).await?;
let value = decode_json_response::<serde_json::Value>(response, "inspect scoped resources").await?;
let count = count_collection(&value)?;
Ok(ImpactInspection { count, filtered_by_acls: filtered })
}
async fn inspect_partition_namespaces(
client: &ConsulClient,
partition: &str,
) -> Result<(BTreeSet<String>, bool), String> {
let mut url = client.api_url("/v1/namespaces")?;
append_explicit_scope(client, &mut url, partition, None, true);
let response = ensure_success(
client.send(Method::GET, url, None).await?,
"list namespaces for partition impact",
client.token(),
)
.await?;
let filtered = ConsulResponseMetadata::from_response(&response).filtered_by_acls.unwrap_or(false);
let value = decode_json_response::<serde_json::Value>(response, "list namespaces for partition impact").await?;
let items = value
.as_array()
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: namespace impact response must be an array".to_string())?;
if items.len() > MAX_COLLECTION_ITEMS {
return Err(format!("CONSUL_RESPONSE_TOO_LARGE: impact response exceeds {MAX_COLLECTION_ITEMS} items"));
}
let mut namespaces = BTreeSet::new();
for item in items {
let name = item
.get("Name")
.and_then(serde_json::Value::as_str)
.filter(|name| !name.is_empty())
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: namespace impact item is missing Name".to_string())?;
if !namespaces.insert(name.to_string()) {
return Err(format!("CONSUL_INVALID_RESPONSE: duplicate namespace {name:?} in impact response"));
}
}
Ok((namespaces, filtered))
}
fn count_collection(value: &serde_json::Value) -> Result<usize, String> {
let count = match value {
serde_json::Value::Array(items) => items.len(),
serde_json::Value::Object(items) => items.len(),
_ => return Err("CONSUL_INVALID_RESPONSE: impact response must be an array or object".to_string()),
};
if count > MAX_COLLECTION_ITEMS {
return Err(format!("CONSUL_RESPONSE_TOO_LARGE: impact response exceeds {MAX_COLLECTION_ITEMS} items"));
}
Ok(count)
}
fn is_empty_not_found(status: reqwest::StatusCode, empty_on_not_found: bool) -> bool {
empty_on_not_found && status == reqwest::StatusCode::NOT_FOUND
}
fn append_explicit_scope(
client: &ConsulClient,
url: &mut reqwest::Url,
partition: &str,
namespace: Option<&str>,
read: bool,
) {
let mut query = url.query_pairs_mut();
if !client.datacenter().is_empty() {
query.append_pair("dc", client.datacenter());
}
if !partition.is_empty() {
query.append_pair("partition", partition);
}
if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) {
query.append_pair("ns", namespace);
}
if read {
match client.config().consistency {
ConsulConsistency::Default => {}
ConsulConsistency::Stale => {
query.append_pair("stale", "");
}
ConsulConsistency::Consistent => {
query.append_pair("consistent", "");
}
}
}
}
fn append_enterprise_target(client: &ConsulClient, url: &mut reqwest::Url, kind: ConsulEnterpriseKind, read: bool) {
let partition = match kind {
ConsulEnterpriseKind::Namespace => client.partition(),
ConsulEnterpriseKind::Partition => "",
};
append_explicit_scope(client, url, partition, None, read);
}
fn scoped_label(label: &str, namespace: &str) -> String {
format!("{label}@namespace:{namespace}")
}
fn enterprise_path(kind: ConsulEnterpriseKind, name: &str) -> String {
let name = percent_encoding::utf8_percent_encode(name, percent_encoding::NON_ALPHANUMERIC);
match kind {
ConsulEnterpriseKind::Namespace => format!("/v1/namespace/{name}"),
ConsulEnterpriseKind::Partition => format!("/v1/partition/{name}"),
}
}
fn write_path(kind: ConsulEnterpriseKind, existing_name: Option<&str>) -> (Method, String) {
match existing_name {
Some(name) => (Method::PUT, enterprise_path(kind, name)),
None => (
Method::PUT,
match kind {
ConsulEnterpriseKind::Namespace => "/v1/namespace",
ConsulEnterpriseKind::Partition => "/v1/partition",
}
.to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{serve_once, test_client as fixture_client};
use super::*;
use reqwest::Url;
#[test]
fn enterprise_paths_encode_names() {
assert_eq!(enterprise_path(ConsulEnterpriseKind::Namespace, "team/a"), "/v1/namespace/team%2Fa");
}
#[test]
fn namespace_acl_defaults_round_trip_with_the_official_nested_shape() {
let value = serde_json::json!({
"Name": "team-a",
"Partition": "payments",
"ACLs": {
"PolicyDefaults": [{ "Name": "node-read" }],
"RoleDefaults": [{ "ID": "role-1" }]
}
});
let namespace: ConsulNamespace = serde_json::from_value(value.clone()).unwrap();
assert_eq!(namespace.acls.policy_defaults.len(), 1);
assert_eq!(namespace.acls.role_defaults.len(), 1);
assert_eq!(namespace.partition, "payments");
let serialized = serde_json::to_value(namespace).unwrap();
assert_eq!(serialized["ACLs"], value["ACLs"]);
}
#[test]
fn namespace_write_partition_is_explicit_and_defaults_safely() {
assert_eq!(target_partition("partition-a"), "partition-a");
assert_eq!(target_partition(""), "default");
assert_eq!(target_partition(" "), "default");
assert_eq!(
write_path(ConsulEnterpriseKind::Namespace, Some("team/a")),
(Method::PUT, "/v1/namespace/team%2Fa".to_string())
);
}
#[tokio::test]
async fn namespace_create_uses_partition_body_and_excludes_current_namespace() {
let response_body = r#"{"Name":"team-b","Partition":"partition-a","Description":"","Meta":{},"ACLs":{}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
response_body.len()
);
let (url, request_rx) = serve_once(response).await;
let client = fixture_client(url).await;
let written =
write_namespace(&client, None, ConsulNamespace { name: "team-b".to_string(), ..Default::default() })
.await
.unwrap();
assert_eq!(written.partition, "partition-a");
let request = request_rx.await.unwrap();
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
assert!(headers.starts_with("PUT /proxy/v1/namespace?dc=dc1&partition=partition-a HTTP/1.1"));
assert!(!headers.contains("ns="));
assert!(headers.to_ascii_lowercase().contains("content-type: application/json"));
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["Name"], "team-b");
assert_eq!(body["Partition"], "partition-a");
}
#[tokio::test]
async fn explicit_impact_scope_uses_the_target_without_wildcards() {
let client = test_client().await;
let mut url = client.api_url("/v1/catalog/services").unwrap();
append_explicit_scope(&client, &mut url, "target-partition", Some("target-namespace"), true);
let pairs: BTreeMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(pairs.get("dc").map(String::as_str), Some("dc1"));
assert_eq!(pairs.get("partition").map(String::as_str), Some("target-partition"));
assert_eq!(pairs.get("ns").map(String::as_str), Some("target-namespace"));
assert!(pairs.contains_key("consistent"));
assert!(!url.as_str().contains('*'));
assert!(!url.as_str().contains("configured-namespace"));
assert!(!url.as_str().contains("configured-partition"));
}
#[tokio::test]
async fn enterprise_delete_target_does_not_append_the_connection_namespace() {
let client = test_client().await;
let mut namespace_url = client.api_url("/v1/namespace/team-a").unwrap();
append_enterprise_target(&client, &mut namespace_url, ConsulEnterpriseKind::Namespace, false);
assert_eq!(namespace_url.query(), Some("dc=dc1&partition=configured-partition"));
let mut partition_url = client.api_url("/v1/partition/team-a").unwrap();
append_enterprise_target(&client, &mut partition_url, ConsulEnterpriseKind::Partition, false);
assert_eq!(partition_url.query(), Some("dc=dc1"));
}
#[test]
fn impact_is_incomplete_when_any_result_is_filtered_or_unavailable() {
let mut impact = ConsulScopeImpact::default();
impact.record(
ImpactResource::Services,
"services".to_string(),
Ok(ImpactInspection { count: 2, filtered_by_acls: true }),
);
impact.record(ImpactResource::Sessions, "sessions".to_string(), Err("forbidden".to_string()));
let impact = impact.finish();
assert_eq!(impact.services, 2);
assert!(impact.filtered_by_acls);
assert_eq!(impact.unavailable_resources, vec!["sessions"]);
assert!(!impact.complete);
}
#[test]
fn impact_is_complete_only_after_all_recorded_results_are_unfiltered() {
let mut impact = ConsulScopeImpact::default();
impact.record(
ImpactResource::KvKeys,
"kvKeys".to_string(),
Ok(ImpactInspection { count: 3, filtered_by_acls: false }),
);
impact.record(
ImpactResource::ConfigEntries,
"configEntries".to_string(),
Ok(ImpactInspection { count: 4, filtered_by_acls: false }),
);
let impact = impact.finish();
assert_eq!(impact.kv_keys, 3);
assert_eq!(impact.config_entries, 4);
assert!(impact.complete);
}
#[test]
fn impact_collection_count_rejects_non_collections_and_excess_items() {
assert!(count_collection(&serde_json::json!(false)).is_err());
let oversized = serde_json::Value::Array(vec![serde_json::Value::Null; MAX_COLLECTION_ITEMS + 1]);
assert!(count_collection(&oversized).is_err());
}
#[test]
fn config_entry_inventory_covers_namespace_and_partition_scopes_without_duplicates() {
let all = NAMESPACE_CONFIG_ENTRY_KINDS
.iter()
.chain(PARTITION_CONFIG_ENTRY_KINDS.iter())
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(all.len(), NAMESPACE_CONFIG_ENTRY_KINDS.len() + PARTITION_CONFIG_ENTRY_KINDS.len());
for required in [
"service-defaults",
"service-intentions",
"api-gateway",
"bound-api-gateway",
"jwt-provider",
"proxy-defaults",
"mesh",
"exported-services",
"sameness-group",
] {
assert!(all.contains(required), "missing config entry kind {required}");
}
}
#[test]
fn only_explicitly_empty_collections_treat_not_found_as_zero() {
assert!(is_empty_not_found(reqwest::StatusCode::NOT_FOUND, true));
assert!(!is_empty_not_found(reqwest::StatusCode::NOT_FOUND, false));
assert!(!is_empty_not_found(reqwest::StatusCode::FORBIDDEN, true));
}
async fn test_client() -> ConsulClient {
test_client_at(Url::parse("https://consul.example/proxy").unwrap()).await
}
async fn test_client_at(base_url: Url) -> ConsulClient {
ConsulClient::new(super::super::config::ConsulConfig {
base_url,
token: String::new(),
datacenter: "dc1".to_string(),
namespace: "configured-namespace".to_string(),
partition: "configured-partition".to_string(),
consistency: ConsulConsistency::Consistent,
tls_skip_verify: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
connect_timeout_secs: 5,
request_timeout_secs: 30,
connect_override: 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,
agent_target: None,
})
.await
.unwrap()
}
}
+216
View File
@@ -0,0 +1,216 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::catalog::{
encode_segment, ConsulCatalogNode, ConsulListResponse, ConsulReadOptions, ConsulServiceAddress,
ConsulServiceWeights,
};
use super::client::{client_for_state, ConsulClient};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulHealthCheck {
#[serde(default)]
pub node: String,
#[serde(default, rename = "CheckID", alias = "CheckId")]
pub check_id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub output: String,
#[serde(default, rename = "ServiceID", alias = "ServiceId")]
pub service_id: String,
#[serde(default)]
pub service_name: String,
#[serde(default)]
pub service_tags: Vec<String>,
#[serde(default)]
#[serde(rename = "Type")]
pub type_: String,
#[serde(default)]
pub exposed_port: u16,
#[serde(default)]
pub definition: ConsulHealthCheckDefinition,
#[serde(default, rename = "CreateIndex")]
pub create_index: u64,
#[serde(default, rename = "ModifyIndex")]
pub modify_index: u64,
#[serde(default, skip_deserializing)]
pub maintenance: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulHealthCheckDefinition {
#[serde(default)]
pub http: String,
#[serde(default)]
pub tcp: String,
#[serde(default)]
pub grpc: String,
#[serde(default)]
pub interval: String,
#[serde(default)]
pub timeout: String,
#[serde(default)]
pub ttl: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulHealthService {
#[serde(default, rename = "ID", alias = "Id")]
pub id: String,
#[serde(default)]
pub service: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub address: String,
#[serde(default)]
pub tagged_addresses: BTreeMap<String, ConsulServiceAddress>,
#[serde(default)]
pub meta: BTreeMap<String, String>,
#[serde(default)]
pub port: u16,
#[serde(default)]
pub weights: ConsulServiceWeights,
#[serde(default)]
pub kind: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulServiceInstance {
pub node: ConsulCatalogNode,
pub service: ConsulHealthService,
#[serde(default)]
pub checks: Vec<ConsulHealthCheck>,
}
impl ConsulClient {
pub async fn health_node_checks(
&self,
node: &str,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
let mut response: ConsulListResponse<Vec<ConsulHealthCheck>> = self
.read_list(
&format!("/v1/health/node/{}", encode_segment(required(node, "node")?)),
options,
"read Consul node health",
)
.await?;
mark_maintenance(&mut response.items);
Ok(response)
}
pub async fn health_service_checks(
&self,
service: &str,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
let mut response: ConsulListResponse<Vec<ConsulHealthCheck>> = self
.read_list(
&format!("/v1/health/checks/{}", encode_segment(required(service, "service")?)),
options,
"read Consul service checks",
)
.await?;
mark_maintenance(&mut response.items);
Ok(response)
}
pub async fn health_service_instances(
&self,
service: &str,
passing: Option<bool>,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulServiceInstance>>, String> {
let mut path = format!("/v1/health/service/{}", encode_segment(required(service, "service")?));
if let Some(passing) = passing {
path.push_str(if passing { "?passing=true" } else { "?passing=false" });
}
let mut response: ConsulListResponse<Vec<ConsulServiceInstance>> =
self.read_list(&path, options, "read Consul service instances").await?;
for instance in &mut response.items {
mark_maintenance(&mut instance.checks);
}
Ok(response)
}
pub async fn health_state_checks(
&self,
state: &str,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
let state = state.trim().to_ascii_lowercase();
if !matches!(state.as_str(), "any" | "passing" | "warning" | "critical") {
return Err("CONSUL_INVALID_REQUEST: health state must be any, passing, warning, or critical".to_string());
}
let mut response: ConsulListResponse<Vec<ConsulHealthCheck>> =
self.read_list(&format!("/v1/health/state/{state}"), options, "read Consul health state").await?;
mark_maintenance(&mut response.items);
Ok(response)
}
}
fn required<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
let value = value.trim();
if value.is_empty() {
Err(format!("CONSUL_INVALID_REQUEST: {field} is required"))
} else {
Ok(value)
}
}
pub(super) fn mark_maintenance(checks: &mut [ConsulHealthCheck]) {
for check in checks {
check.maintenance =
check.check_id == "_node_maintenance" || check.check_id.starts_with("_service_maintenance:");
}
}
pub async fn consul_health_node_core(
state: &AppState,
connection_id: &str,
node: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
client_for_state(state, connection_id).await?.health_node_checks(node, &options).await
}
pub async fn consul_health_checks_core(
state: &AppState,
connection_id: &str,
service: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
client_for_state(state, connection_id).await?.health_service_checks(service, &options).await
}
pub async fn consul_health_service_core(
state: &AppState,
connection_id: &str,
service: &str,
passing: Option<bool>,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulServiceInstance>>, String> {
client_for_state(state, connection_id).await?.health_service_instances(service, passing, &options).await
}
pub async fn consul_health_state_core(
state: &AppState,
connection_id: &str,
health_state: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
client_for_state(state, connection_id).await?.health_state_checks(health_state, &options).await
}
+573
View File
@@ -0,0 +1,573 @@
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::{Method, Url};
use serde::{Deserialize, Serialize};
use crate::agent_kv::{
KvDeleteOptions, KvDeleteResponse, KvGetResponse, KvInt64, KvKeyMetadata, KvKeySummary, KvListPrefixResponse,
KvPutOptions, KvPutResponse, KvValue, KvValueEncoding, KvWriteMode,
};
use crate::connection::AppState;
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
use super::response::{
decode_json_body, decode_json_response, decode_text_response, ensure_success, is_consul_kv_not_found,
ConsulResponseMetadata, MAX_RESPONSE_BYTES,
};
use super::types::ConsulKvEntry;
const MAX_VALUE_BYTES: usize = 512 * 1024;
pub const MAX_RECURSIVE_ENTRIES: usize = 10_000;
pub const MAX_RECURSIVE_VALUE_BYTES: usize = 32 * 1024 * 1024;
const MAX_RECURSIVE_RESPONSE_BYTES: usize = MAX_RESPONSE_BYTES;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulKvRecord {
pub key: String,
pub value: KvValue,
pub flags: KvInt64,
pub create_index: KvInt64,
pub modify_index: KvInt64,
pub lock_index: KvInt64,
pub session: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulRecursiveListResponse {
pub entries: Vec<ConsulKvRecord>,
pub index: Option<KvInt64>,
pub filtered_by_acls: Option<bool>,
pub total_value_bytes: u64,
pub complete: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulLockRequest {
pub key: String,
pub session: String,
pub value: KvValue,
pub flags: Option<KvInt64>,
pub expected_modify_index: Option<KvInt64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulLockResponse {
pub acquired: bool,
pub key: String,
pub session: String,
}
impl ConsulClient {
pub(super) async fn acquire_lock(&self, request: ConsulLockRequest) -> Result<ConsulLockResponse, String> {
let session = required_lock_field(&request.session, "session")?;
let bytes = value_bytes(&request.value)?;
if bytes.len() > MAX_VALUE_BYTES {
return Err(format!("CONSUL_VALUE_TOO_LARGE: Consul KV values cannot exceed {MAX_VALUE_BYTES} bytes"));
}
let current = match self.get_required_entry(&request.key).await {
Ok(current) => Some(current),
Err(error) if error.starts_with("CONSUL_KEY_NOT_FOUND:") => None,
Err(error) => return Err(error),
};
if let Some(current) = &current {
if let Some(owner) = current.session.as_deref().filter(|owner| !owner.is_empty()) {
if owner != session {
return Err("CONSUL_LOCK_CONFLICT: The key is held by a different Consul Session".to_string());
}
}
let expected = request
.expected_modify_index
.as_ref()
.ok_or("CONSUL_CAS_REQUIRED: Acquiring an existing Consul lock requires its ModifyIndex")?;
if parse_u64(expected)? != current.modify_index {
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before lock acquisition".to_string());
}
} else if request.expected_modify_index.as_ref().is_some_and(|value| value.as_str() != "0") {
return Err("CONSUL_CAS_CONFLICT: The Consul KV key no longer exists".to_string());
}
let cas = current.as_ref().map(|entry| entry.modify_index).unwrap_or(0);
let flags = request
.flags
.as_ref()
.map(parse_u64)
.transpose()?
.unwrap_or_else(|| current.as_ref().map(|entry| entry.flags).unwrap_or(0));
let mut url = self.kv_url(&request.key)?;
self.append_scope(&mut url, false);
url.query_pairs_mut()
.append_pair("acquire", session)
.append_pair("cas", &cas.to_string())
.append_pair("flags", &flags.to_string());
let response =
ensure_success(self.send(Method::PUT, url, Some(bytes)).await?, "acquire Consul KV lock", self.token())
.await?;
let acquired = decode_text_response(response, "acquire Consul KV lock").await?;
if acquired.trim() != "true" {
return Err("CONSUL_LOCK_CONFLICT: Consul refused lock acquisition".to_string());
}
Ok(ConsulLockResponse { acquired: true, key: request.key, session: session.to_string() })
}
pub(super) async fn release_lock(&self, key: &str, session: &str) -> Result<ConsulLockResponse, String> {
let session = required_lock_field(session, "session")?;
let current = self.get_required_entry(key).await?;
if current.session.as_deref() != Some(session) {
return Err("CONSUL_LOCK_SESSION_MISMATCH: Only the Session holding this key can release it".to_string());
}
let bytes = decode_consul_value(current.value.as_deref())?;
let mut url = self.kv_url(key)?;
self.append_scope(&mut url, false);
url.query_pairs_mut()
.append_pair("release", session)
.append_pair("cas", &current.modify_index.to_string())
.append_pair("flags", &current.flags.to_string());
let response =
ensure_success(self.send(Method::PUT, url, Some(bytes)).await?, "release Consul KV lock", self.token())
.await?;
let released = decode_text_response(response, "release Consul KV lock").await?;
if released.trim() != "true" {
return Err("CONSUL_LOCK_CONFLICT: Consul refused lock release".to_string());
}
Ok(ConsulLockResponse { acquired: false, key: key.to_string(), session: session.to_string() })
}
pub async fn list_recursive(
&self,
prefix: &str,
max_entries: usize,
max_value_bytes: usize,
) -> Result<ConsulRecursiveListResponse, String> {
self.list_recursive_with_control(prefix, max_entries, max_value_bytes, None).await
}
pub(super) async fn list_recursive_with_control(
&self,
prefix: &str,
max_entries: usize,
max_value_bytes: usize,
control: Option<&(dyn Fn(usize) -> Result<(), String> + Send + Sync)>,
) -> Result<ConsulRecursiveListResponse, String> {
let max_entries = max_entries.clamp(1, MAX_RECURSIVE_ENTRIES);
let max_value_bytes = max_value_bytes.clamp(MAX_VALUE_BYTES, MAX_RECURSIVE_VALUE_BYTES);
let mut url = self.kv_url(prefix)?;
self.append_scope(&mut url, true);
url.query_pairs_mut().append_pair("recurse", "");
let mut response = self.send(Method::GET, url, None).await?;
let metadata = ConsulResponseMetadata::from_response(&response);
if is_consul_kv_not_found(&response) {
return Ok(ConsulRecursiveListResponse {
entries: Vec::new(),
index: metadata.index,
filtered_by_acls: metadata.filtered_by_acls,
total_value_bytes: 0,
complete: true,
});
}
if !response.status().is_success() {
return ensure_success(response, "list Consul KV prefix recursively", self.token())
.await
.map(|_| unreachable!());
}
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("Failed to read Consul recursive KV response: {}", error.without_url()))?
{
control.map(|check| check(0)).transpose()?;
if body.len().saturating_add(chunk.len()) > MAX_RECURSIVE_RESPONSE_BYTES {
return Err(format!(
"CONSUL_SCAN_LIMIT_EXCEEDED: Consul recursive KV response exceeds {} MiB",
MAX_RECURSIVE_RESPONSE_BYTES / 1024 / 1024
));
}
body.extend_from_slice(&chunk);
}
let raw_entries = decode_json_body::<Vec<ConsulKvEntry>>(&body, "list Consul KV prefix recursively")?;
if raw_entries.len() > max_entries {
return Err(format!("CONSUL_SCAN_LIMIT_EXCEEDED: Consul prefix contains more than {max_entries} keys"));
}
let mut entries = Vec::with_capacity(raw_entries.len());
let mut total_value_bytes = 0usize;
for (index, entry) in raw_entries.into_iter().enumerate() {
control.map(|check| check(index + 1)).transpose()?;
let bytes = decode_consul_value(entry.value.as_deref())?;
total_value_bytes = total_value_bytes.saturating_add(bytes.len());
if total_value_bytes > max_value_bytes {
return Err(format!(
"CONSUL_SCAN_LIMIT_EXCEEDED: Consul prefix values exceed {} MiB",
max_value_bytes / 1024 / 1024
));
}
entries.push(ConsulKvRecord {
key: entry.key,
value: value_from_bytes(&bytes),
flags: KvInt64(entry.flags.to_string()),
create_index: KvInt64(entry.create_index.to_string()),
modify_index: KvInt64(entry.modify_index.to_string()),
lock_index: KvInt64(entry.lock_index.to_string()),
session: entry.session.filter(|session| !session.is_empty()),
});
}
Ok(ConsulRecursiveListResponse {
entries,
index: metadata.index,
filtered_by_acls: metadata.filtered_by_acls,
total_value_bytes: total_value_bytes as u64,
complete: true,
})
}
pub async fn list_prefix(
&self,
prefix: &str,
limit: usize,
continuation: Option<&str>,
) -> Result<KvListPrefixResponse, String> {
let mut url = self.kv_url(prefix)?;
self.append_scope(&mut url, true);
url.query_pairs_mut().append_pair("keys", "").append_pair("separator", "/");
let response = self.send(Method::GET, url, None).await?;
let metadata = ConsulResponseMetadata::from_response(&response);
if is_consul_kv_not_found(&response) {
return Ok(KvListPrefixResponse {
keys: Vec::new(),
continuation: None,
revision: metadata.index,
filtered_by_acls: metadata.filtered_by_acls,
});
}
let response = ensure_success(response, "list KV keys", self.token()).await?;
let mut keys = decode_json_response::<Vec<String>>(response, "list KV keys").await?;
keys.sort();
keys.dedup();
if !prefix.is_empty() {
keys.retain(|key| key != prefix);
}
let after = continuation.map(decode_continuation).transpose()?;
if let Some(after) = after.as_deref() {
keys.retain(|key| key.as_str() > after);
}
let limit = limit.clamp(1, 500);
let has_more = keys.len() > limit;
keys.truncate(limit);
let next = has_more.then(|| keys.last().map(|key| encode_continuation(key))).flatten();
Ok(KvListPrefixResponse {
keys: keys.into_iter().map(summary_for_key).collect(),
continuation: next,
revision: metadata.index,
filtered_by_acls: metadata.filtered_by_acls,
})
}
pub async fn get_key(&self, key: &str) -> Result<KvGetResponse, String> {
let mut url = self.kv_url(key)?;
self.append_scope(&mut url, true);
let response = self.send(Method::GET, url, None).await?;
if is_consul_kv_not_found(&response) {
return Ok(KvGetResponse { found: false, key: None, key_bytes: None, value: None, metadata: None });
}
let response = ensure_success(response, "read KV key", self.token()).await?;
let mut entries = decode_json_response::<Vec<ConsulKvEntry>>(response, "read KV key").await?;
let entry = entries.pop().ok_or("CONSUL_KEY_NOT_FOUND: Consul returned an empty KV response")?;
let bytes = decode_consul_value(entry.value.as_deref())?;
let value = value_from_bytes(&bytes);
Ok(KvGetResponse {
found: true,
key: Some(entry.key.clone()),
key_bytes: None,
value: Some(value),
metadata: Some(metadata_for_entry(&entry, bytes.len() as u64)),
})
}
pub(super) async fn put_key(
&self,
key: &str,
value: KvValue,
options: KvPutOptions,
) -> Result<KvPutResponse, String> {
let bytes = value_bytes(&value)?;
if bytes.len() > MAX_VALUE_BYTES {
return Err(format!(
"CONSUL_VALUE_TOO_LARGE: Consul KV values cannot exceed {MAX_VALUE_BYTES} bytes (received {})",
bytes.len()
));
}
let creating = matches!(options.write_mode, Some(KvWriteMode::Create))
|| options.expected_create_revision.as_ref().is_some_and(|value| value.as_str() == "0");
let (cas, flags) = if creating {
(0, options.flags.as_ref().map(parse_u64).transpose()?.unwrap_or(0))
} else {
let expected = parse_u64(options.expected_mod_revision.as_ref().ok_or_else(|| {
"CONSUL_CAS_REQUIRED: Updating a Consul KV key requires its ModifyIndex".to_string()
})?)?;
let current = self.get_required_entry(key).await?;
ensure_unlocked(&current)?;
if current.modify_index != expected {
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be saved".to_string());
}
(expected, options.flags.as_ref().map(parse_u64).transpose()?.unwrap_or(current.flags))
};
let mut url = self.kv_url(key)?;
self.append_scope(&mut url, false);
url.query_pairs_mut().append_pair("cas", &cas.to_string()).append_pair("flags", &flags.to_string());
let response =
ensure_success(self.send(Method::PUT, url, Some(bytes)).await?, "write KV key", self.token()).await?;
let written = decode_text_response(response, "write KV key").await?;
if written.trim() != "true" {
return Err(if creating {
"CONSUL_KEY_ALREADY_EXISTS: The Consul KV key already exists".to_string()
} else {
"CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be saved".to_string()
});
}
Ok(KvPutResponse { revision: None, key: Some(key.to_string()), created_key: creating.then(|| key.to_string()) })
}
pub(super) async fn delete_key(&self, key: &str, options: KvDeleteOptions) -> Result<KvDeleteResponse, String> {
let expected =
parse_u64(options.expected_mod_revision.as_ref().ok_or_else(|| {
"CONSUL_CAS_REQUIRED: Deleting a Consul KV key requires its ModifyIndex".to_string()
})?)?;
let current = self.get_required_entry(key).await?;
ensure_unlocked(&current)?;
if current.modify_index != expected {
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be deleted".to_string());
}
let mut url = self.kv_url(key)?;
self.append_scope(&mut url, false);
url.query_pairs_mut().append_pair("cas", &expected.to_string());
let response =
ensure_success(self.send(Method::DELETE, url, None).await?, "delete KV key", self.token()).await?;
let deleted = decode_text_response(response, "delete KV key").await?;
if deleted.trim() != "true" {
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be deleted".to_string());
}
Ok(KvDeleteResponse { deleted: 1, revision: None })
}
async fn get_required_entry(&self, key: &str) -> Result<ConsulKvEntry, String> {
let mut url = self.kv_url(key)?;
self.append_scope(&mut url, true);
let response = self.send(Method::GET, url, None).await?;
if is_consul_kv_not_found(&response) {
return Err("CONSUL_KEY_NOT_FOUND: The Consul KV key no longer exists".to_string());
}
let response = ensure_success(response, "read KV metadata", self.token()).await?;
decode_json_response::<Vec<ConsulKvEntry>>(response, "read KV metadata")
.await?
.pop()
.ok_or_else(|| "CONSUL_KEY_NOT_FOUND: Consul returned an empty KV response".to_string())
}
fn kv_url(&self, key: &str) -> Result<Url, String> {
let encoded = key
.split('/')
.map(|segment| utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string())
.collect::<Vec<_>>()
.join("/");
self.api_url(&format!("/v1/kv/{encoded}"))
}
}
pub async fn consul_list_prefix_core(
state: &AppState,
connection_id: &str,
prefix: &str,
limit: usize,
continuation: Option<&str>,
) -> Result<KvListPrefixResponse, String> {
client_for_state(state, connection_id).await?.list_prefix(prefix, limit, continuation).await
}
pub async fn consul_list_recursive_core(
state: &AppState,
connection_id: &str,
prefix: &str,
max_entries: usize,
max_value_bytes: usize,
) -> Result<ConsulRecursiveListResponse, String> {
client_for_state(state, connection_id).await?.list_recursive(prefix, max_entries, max_value_bytes).await
}
pub async fn consul_get_core(state: &AppState, connection_id: &str, key: &str) -> Result<KvGetResponse, String> {
client_for_state(state, connection_id).await?.get_key(key).await
}
pub async fn consul_put_core(
state: &AppState,
connection_id: &str,
key: &str,
value: KvValue,
options: KvPutOptions,
) -> Result<KvPutResponse, String> {
ensure_writable_core(state, connection_id, "write Consul KV key").await?;
client_for_state(state, connection_id).await?.put_key(key, value, options).await
}
pub async fn consul_delete_core(
state: &AppState,
connection_id: &str,
key: &str,
options: KvDeleteOptions,
) -> Result<KvDeleteResponse, String> {
ensure_writable_core(state, connection_id, "delete Consul KV key").await?;
client_for_state(state, connection_id).await?.delete_key(key, options).await
}
pub async fn consul_acquire_lock_core(
state: &AppState,
connection_id: &str,
request: ConsulLockRequest,
) -> Result<ConsulLockResponse, String> {
ensure_writable_core(state, connection_id, "acquire Consul KV lock").await?;
client_for_state(state, connection_id).await?.acquire_lock(request).await
}
pub async fn consul_release_lock_core(
state: &AppState,
connection_id: &str,
key: &str,
session: &str,
) -> Result<ConsulLockResponse, String> {
ensure_writable_core(state, connection_id, "release Consul KV lock").await?;
client_for_state(state, connection_id).await?.release_lock(key, session).await
}
fn required_lock_field<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
let value = value.trim();
if value.is_empty() {
Err(format!("CONSUL_INVALID_REQUEST: {field} is required"))
} else {
Ok(value)
}
}
fn summary_for_key(key: String) -> KvKeySummary {
KvKeySummary { key, key_bytes: None, value: None, metadata: empty_metadata() }
}
fn metadata_for_entry(entry: &ConsulKvEntry, value_size: u64) -> KvKeyMetadata {
KvKeyMetadata {
create_revision: Some(KvInt64(entry.create_index.to_string())),
mod_revision: Some(KvInt64(entry.modify_index.to_string())),
version: None,
lease: None,
ttl: None,
value_size: Some(value_size),
czxid: None,
mzxid: None,
pzxid: None,
ctime: None,
mtime: None,
cversion: None,
aversion: None,
ephemeral_owner: None,
data_length: None,
num_children: None,
flags: Some(KvInt64(entry.flags.to_string())),
lock_index: Some(KvInt64(entry.lock_index.to_string())),
session: entry.session.clone().filter(|session| !session.is_empty()),
}
}
fn empty_metadata() -> KvKeyMetadata {
KvKeyMetadata {
create_revision: None,
mod_revision: None,
version: None,
lease: None,
ttl: None,
value_size: None,
czxid: None,
mzxid: None,
pzxid: None,
ctime: None,
mtime: None,
cversion: None,
aversion: None,
ephemeral_owner: None,
data_length: None,
num_children: None,
flags: None,
lock_index: None,
session: None,
}
}
fn decode_consul_value(value: Option<&str>) -> Result<Vec<u8>, String> {
match value {
Some(value) => {
STANDARD.decode(value).map_err(|error| format!("Failed to decode Consul KV Base64 value: {error}"))
}
None => Ok(Vec::new()),
}
}
fn value_from_bytes(bytes: &[u8]) -> KvValue {
match std::str::from_utf8(bytes) {
Ok(value) => KvValue { encoding: KvValueEncoding::Utf8, data: value.to_string() },
Err(_) => KvValue { encoding: KvValueEncoding::Base64, data: STANDARD.encode(bytes) },
}
}
fn value_bytes(value: &KvValue) -> Result<Vec<u8>, String> {
match value.encoding {
KvValueEncoding::Utf8 => Ok(value.data.as_bytes().to_vec()),
KvValueEncoding::Base64 => STANDARD
.decode(value.data.chars().filter(|char| !char.is_whitespace()).collect::<String>())
.map_err(|error| format!("Invalid Base64 KV value: {error}")),
}
}
fn parse_u64(value: &KvInt64) -> Result<u64, String> {
value.as_str().parse::<u64>().map_err(|_| format!("Invalid Consul index: {}", value.as_str()))
}
fn ensure_unlocked(entry: &ConsulKvEntry) -> Result<(), String> {
if let Some(session) = entry.session.as_deref().filter(|session| !session.is_empty()) {
return Err(format!("CONSUL_KEY_LOCKED: The Consul KV key is held by session {session}"));
}
Ok(())
}
fn encode_continuation(key: &str) -> String {
URL_SAFE_NO_PAD.encode(key.as_bytes())
}
fn decode_continuation(value: &str) -> Result<String, String> {
let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|_| "Invalid Consul continuation token".to_string())?;
String::from_utf8(bytes).map_err(|_| "Invalid Consul continuation token".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn u64_fields_accept_the_full_unsigned_range_only() {
assert_eq!(parse_u64(&KvInt64("0".into())).unwrap(), 0);
assert_eq!(parse_u64(&KvInt64(u64::MAX.to_string())).unwrap(), u64::MAX);
assert!(parse_u64(&KvInt64("-1".into())).is_err());
assert!(parse_u64(&KvInt64("18446744073709551616".into())).is_err());
}
#[test]
fn binary_values_round_trip_without_utf8_coercion() {
let bytes = [0, 0xff, 0x80, b'a'];
let value = value_from_bytes(&bytes);
assert_eq!(value.encoding, KvValueEncoding::Base64);
assert_eq!(value_bytes(&value).unwrap(), bytes);
}
}
@@ -0,0 +1,143 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::super::client::client_for_state;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulConfigEntry {
pub kind: String,
pub name: String,
pub modify_index: u64,
pub raw: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulConfigEntryApply {
pub kind: String,
pub name: String,
pub expected_modify_index: u64,
pub raw: serde_json::Value,
}
pub async fn consul_mesh_config_list_core(
state: &AppState,
connection_id: &str,
kind: &str,
) -> Result<Vec<ConsulConfigEntry>, String> {
validate_identifier(kind, "kind")?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/config/{}", segment(kind)))?;
let values: Vec<serde_json::Value> =
client.request_json(Method::GET, url, None::<&()>, true, "list Service Mesh config entries").await?;
values.into_iter().map(config_entry_from_raw).collect()
}
pub async fn consul_mesh_config_get_core(
state: &AppState,
connection_id: &str,
kind: &str,
name: &str,
) -> Result<ConsulConfigEntry, String> {
validate_identifier(kind, "kind")?;
validate_identifier(name, "name")?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&config_path(kind, name))?;
let value = client.request_json(Method::GET, url, None::<&()>, true, "read Service Mesh config entry").await?;
config_entry_from_raw(value)
}
pub async fn consul_mesh_config_apply_core(
state: &AppState,
connection_id: &str,
request: ConsulConfigEntryApply,
) -> Result<ConsulConfigEntry, String> {
super::super::ensure_writable(state, connection_id, "Service Mesh config entry write").await?;
validate_raw_identity(&request)?;
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/config")?;
url.query_pairs_mut().append_pair("cas", &request.expected_modify_index.to_string());
let applied: bool = client
.request_json(Method::PUT, url, Some(&request.raw), false, "apply Service Mesh config entry with CAS")
.await?;
if !applied {
return Err("CONSUL_CAS_CONFLICT: Service Mesh config entry changed after it was loaded".to_string());
}
consul_mesh_config_get_core(state, connection_id, &request.kind, &request.name).await
}
pub async fn consul_mesh_config_delete_core(
state: &AppState,
connection_id: &str,
kind: &str,
name: &str,
expected_modify_index: u64,
) -> Result<bool, String> {
super::super::ensure_writable(state, connection_id, "Service Mesh config entry delete").await?;
validate_identifier(kind, "kind")?;
validate_identifier(name, "name")?;
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url(&config_path(kind, name))?;
url.query_pairs_mut().append_pair("cas", &expected_modify_index.to_string());
let deleted: bool = client
.request_json(Method::DELETE, url, None::<&()>, false, "delete Service Mesh config entry with CAS")
.await?;
if !deleted {
return Err("CONSUL_CAS_CONFLICT: Service Mesh config entry changed after it was loaded".to_string());
}
Ok(true)
}
fn config_entry_from_raw(raw: serde_json::Value) -> Result<ConsulConfigEntry, String> {
let kind = raw.get("Kind").and_then(serde_json::Value::as_str).unwrap_or_default().to_string();
let name = raw.get("Name").and_then(serde_json::Value::as_str).unwrap_or_default().to_string();
let modify_index = raw.get("ModifyIndex").and_then(serde_json::Value::as_u64).unwrap_or(0);
if kind.is_empty() || name.is_empty() {
return Err("CONSUL_INVALID_RESPONSE: config entry is missing Kind or Name".to_string());
}
Ok(ConsulConfigEntry { kind, name, modify_index, raw })
}
fn validate_raw_identity(request: &ConsulConfigEntryApply) -> Result<(), String> {
validate_identifier(&request.kind, "kind")?;
validate_identifier(&request.name, "name")?;
let object = request.raw.as_object().ok_or("CONSUL_INVALID_REQUEST: config entry raw JSON must be an object")?;
if object.get("Kind").and_then(serde_json::Value::as_str) != Some(request.kind.as_str())
|| object.get("Name").and_then(serde_json::Value::as_str) != Some(request.name.as_str())
{
return Err("CONSUL_INVALID_REQUEST: raw JSON Kind and Name must match the selected resource".to_string());
}
Ok(())
}
fn validate_identifier(value: &str, label: &str) -> Result<(), String> {
if value.trim().is_empty() || value.len() > 256 {
Err(format!("CONSUL_INVALID_REQUEST: config entry {label} is invalid"))
} else {
Ok(())
}
}
fn segment(value: &str) -> String {
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
}
fn config_path(kind: &str, name: &str) -> String {
format!("/v1/config/{}/{}", segment(kind), segment(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_identity_must_match() {
let request = ConsulConfigEntryApply {
kind: "service-defaults".into(),
name: "api".into(),
expected_modify_index: 4,
raw: serde_json::json!({"Kind":"service-defaults","Name":"other"}),
};
assert!(validate_raw_identity(&request).is_err());
}
}
@@ -0,0 +1,40 @@
use super::super::client::client_for_state;
use crate::connection::AppState;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulDiscoveryChain {
#[serde(default)]
pub service_name: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub partition: String,
#[serde(default)]
pub datacenter: String,
#[serde(default)]
pub protocol: String,
#[serde(default)]
pub start_node: String,
#[serde(default)]
pub nodes: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub targets: BTreeMap<String, serde_json::Value>,
}
pub async fn consul_mesh_discovery_chain_core(
state: &AppState,
connection_id: &str,
service: &str,
) -> Result<ConsulDiscoveryChain, String> {
if service.trim().is_empty() {
return Err("CONSUL_INVALID_REQUEST: discovery service is required".into());
}
let client = client_for_state(state, connection_id).await?;
let service = percent_encoding::utf8_percent_encode(service, percent_encoding::NON_ALPHANUMERIC);
let url = client.api_url(&format!("/v1/discovery-chain/{service}"))?;
client.request_json(Method::GET, url, None::<&()>, true, "compile discovery chain").await
}
@@ -0,0 +1,42 @@
use super::super::client::client_for_state;
use super::config_entries::{consul_mesh_config_apply_core, ConsulConfigEntry, ConsulConfigEntryApply};
use crate::connection::AppState;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulExportedService {
#[serde(default)]
pub service: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub partition: String,
#[serde(default)]
pub consumers: Vec<serde_json::Value>,
}
pub async fn consul_mesh_exported_services_list_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<ConsulExportedService>, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/exported-services")?;
client.request_json(Method::GET, url, None::<&()>, true, "list exported services").await
}
pub async fn consul_mesh_exported_services_apply_core(
state: &AppState,
connection_id: &str,
name: &str,
expected_modify_index: u64,
raw: serde_json::Value,
) -> Result<ConsulConfigEntry, String> {
consul_mesh_config_apply_core(
state,
connection_id,
ConsulConfigEntryApply { kind: "exported-services".into(), name: name.into(), expected_modify_index, raw },
)
.await
}
@@ -0,0 +1,219 @@
use reqwest::{Method, Url};
use serde::{Deserialize, Serialize};
use super::super::client::client_for_state;
use crate::connection::AppState;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulIntention {
#[serde(rename = "ID", default)]
pub id: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub source_name: String,
#[serde(default)]
pub destination_name: String,
#[serde(default)]
pub source_namespace: String,
#[serde(default)]
pub destination_namespace: String,
#[serde(default)]
pub source_partition: String,
#[serde(default)]
pub destination_partition: String,
#[serde(default)]
pub action: String,
#[serde(default)]
pub permissions: Vec<serde_json::Value>,
#[serde(default)]
pub precedence: u64,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulIntentionMatchRequest {
pub by: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulIntentionCheckRequest {
pub source: String,
pub destination: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulIntentionExactRequest {
pub source: String,
pub destination: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulIntentionCheckResponse {
pub allowed: bool,
}
pub async fn consul_mesh_intentions_list_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<ConsulIntention>, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/connect/intentions")?;
client.request_json(Method::GET, url, None::<&()>, true, "list intentions").await
}
pub async fn consul_mesh_intention_get_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<ConsulIntention, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/connect/intentions/{}", segment(id)))?;
client.request_json(Method::GET, url, None::<&()>, true, "read intention").await
}
pub async fn consul_mesh_intention_get_exact_core(
state: &AppState,
connection_id: &str,
request: ConsulIntentionExactRequest,
) -> Result<ConsulIntention, String> {
validate_exact_request(&request)?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/connect/intentions/exact")?;
let url = exact_intention_url(url, &request.source, &request.destination);
client.request_json(Method::GET, url, None::<&()>, true, "read exact intention").await
}
pub async fn consul_mesh_intention_upsert_core(
state: &AppState,
connection_id: &str,
item: ConsulIntention,
) -> Result<ConsulIntention, String> {
super::super::ensure_writable(state, connection_id, "intention write").await?;
if item.source_name.trim().is_empty() || item.destination_name.trim().is_empty() {
return Err("CONSUL_INVALID_REQUEST: intention SourceName and DestinationName are required".into());
}
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/connect/intentions/exact")?;
let url = exact_intention_url(url, &item.source_name, &item.destination_name);
let written: bool = client.request_json(Method::PUT, url, Some(&item), false, "upsert intention").await?;
if !written {
return Err("CONSUL_WRITE_REJECTED: Consul rejected the intention upsert".to_string());
}
let mut url = client.api_url("/v1/connect/intentions/exact")?;
url.query_pairs_mut().append_pair("source", &item.source_name).append_pair("destination", &item.destination_name);
client.request_json(Method::GET, url, None::<&()>, true, "read upserted intention").await
}
pub async fn consul_mesh_intention_delete_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<bool, String> {
super::super::ensure_writable(state, connection_id, "intention delete").await?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/connect/intentions/{}", segment(id)))?;
client.send_json(Method::DELETE, url, None::<&()>, false, "delete intention").await?;
Ok(true)
}
pub async fn consul_mesh_intention_delete_exact_core(
state: &AppState,
connection_id: &str,
request: ConsulIntentionExactRequest,
) -> Result<bool, String> {
super::super::ensure_writable(state, connection_id, "exact intention delete").await?;
validate_exact_request(&request)?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/connect/intentions/exact")?;
let url = exact_intention_url(url, &request.source, &request.destination);
client.send_json(Method::DELETE, url, None::<&()>, false, "delete exact intention").await?;
Ok(true)
}
pub async fn consul_mesh_intention_match_core(
state: &AppState,
connection_id: &str,
request: ConsulIntentionMatchRequest,
) -> Result<Vec<ConsulIntention>, String> {
if !matches!(request.by.as_str(), "source" | "destination") {
return Err("CONSUL_INVALID_REQUEST: intention match 'by' must be source or destination".into());
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/connect/intentions/match")?;
url.query_pairs_mut().append_pair("by", &request.by).append_pair("name", &request.name);
client.request_json(Method::GET, url, None::<&()>, true, "match intentions").await
}
pub async fn consul_mesh_intention_check_core(
state: &AppState,
connection_id: &str,
request: ConsulIntentionCheckRequest,
) -> Result<ConsulIntentionCheckResponse, String> {
if request.source.trim().is_empty() || request.destination.trim().is_empty() {
return Err("CONSUL_INVALID_REQUEST: intention check source and destination are required".into());
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/connect/intentions/check")?;
url.query_pairs_mut().append_pair("source", &request.source).append_pair("destination", &request.destination);
client.request_json(Method::GET, url, None::<&()>, true, "check intention authorization").await
}
fn segment(value: &str) -> String {
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
}
fn exact_intention_url(mut url: Url, source: &str, destination: &str) -> Url {
url.query_pairs_mut().append_pair("source", source).append_pair("destination", destination);
url
}
fn validate_exact_request(request: &ConsulIntentionExactRequest) -> Result<(), String> {
if request.source.trim().is_empty() || request.destination.trim().is_empty() {
return Err("CONSUL_INVALID_REQUEST: exact intention source and destination are required".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_upsert_uses_official_method_path_and_query() {
let base = Url::parse("https://consul.example/proxy/v1/connect/intentions/exact").unwrap();
let url = exact_intention_url(base, "api/web", "payments v2");
assert_eq!(url.path(), "/proxy/v1/connect/intentions/exact");
assert_eq!(url.query(), Some("source=api%2Fweb&destination=payments+v2"));
}
#[test]
fn exact_read_and_delete_share_the_official_path_and_validate_names() {
let base = Url::parse("https://consul.example/proxy/v1/connect/intentions/exact").unwrap();
let request = ConsulIntentionExactRequest { source: "api".into(), destination: "db".into() };
assert!(validate_exact_request(&request).is_ok());
let url = exact_intention_url(base, &request.source, &request.destination);
assert_eq!(url.query(), Some("source=api&destination=db"));
assert!(validate_exact_request(&ConsulIntentionExactRequest { source: "".into(), destination: "db".into() })
.is_err());
}
#[test]
fn check_endpoint_uses_official_path() {
let mut url = Url::parse("https://consul.example/proxy/v1/connect/intentions/check").unwrap();
url.query_pairs_mut().append_pair("source", "api").append_pair("destination", "db");
assert_eq!(url.path(), "/proxy/v1/connect/intentions/check");
assert_eq!(url.query(), Some("source=api&destination=db"));
}
}
+11
View File
@@ -0,0 +1,11 @@
mod config_entries;
mod discovery;
mod exported_services;
mod intentions;
mod peering;
pub use config_entries::*;
pub use discovery::*;
pub use exported_services::*;
pub use intentions::*;
pub use peering::*;
+111
View File
@@ -0,0 +1,111 @@
use super::super::client::client_for_state;
use super::super::ConsulSecret;
use crate::connection::AppState;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPeering {
#[serde(rename = "ID", default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub partition: String,
#[serde(default)]
pub state: String,
#[serde(rename = "PeerID", default)]
pub peer_id: String,
#[serde(default)]
pub peer_server_name: String,
#[serde(rename = "PeerCAPems", default)]
pub peer_ca_pems: Vec<String>,
#[serde(default)]
pub peer_server_addresses: Vec<String>,
#[serde(default)]
pub imported_services: Vec<String>,
#[serde(default)]
pub exported_services: Vec<String>,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPeeringGenerateRequest {
pub peer_name: String,
#[serde(default)]
pub partition: String,
#[serde(default)]
pub meta: serde_json::Value,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPeeringToken {
pub peering_token: ConsulSecret,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPeeringEstablishRequest {
pub peer_name: String,
pub peering_token: ConsulSecret,
#[serde(default)]
pub partition: String,
}
pub async fn consul_mesh_peering_list_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<ConsulPeering>, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/peerings")?;
client.request_json(Method::GET, url, None::<&()>, true, "list peerings").await
}
pub async fn consul_mesh_peering_get_core(
state: &AppState,
connection_id: &str,
name: &str,
) -> Result<ConsulPeering, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/peering/{}", segment(name)))?;
client.request_json(Method::GET, url, None::<&()>, true, "read peering").await
}
pub async fn consul_mesh_peering_generate_token_core(
state: &AppState,
connection_id: &str,
request: ConsulPeeringGenerateRequest,
) -> Result<ConsulPeeringToken, String> {
super::super::ensure_writable(state, connection_id, "peering token generation").await?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/peering/token")?;
client.request_json(Method::POST, url, Some(&request), false, "generate peering token").await
}
pub async fn consul_mesh_peering_establish_core(
state: &AppState,
connection_id: &str,
request: ConsulPeeringEstablishRequest,
) -> Result<ConsulPeering, String> {
super::super::ensure_writable(state, connection_id, "peering establish").await?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/peering/establish")?;
client.request_json(Method::POST, url, Some(&request), false, "establish peering").await
}
pub async fn consul_mesh_peering_delete_core(
state: &AppState,
connection_id: &str,
name: &str,
) -> Result<bool, String> {
super::super::ensure_writable(state, connection_id, "peering delete").await?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/peering/{}", segment(name)))?;
client.send_json(Method::DELETE, url, None::<&()>, false, "delete peering").await?;
Ok(true)
}
fn segment(value: &str) -> String {
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
}
+107
View File
@@ -0,0 +1,107 @@
mod acl;
mod agent;
mod archive;
mod blocking;
mod bulk;
mod capabilities;
mod catalog;
mod client;
mod config;
mod enterprise;
mod health;
mod kv;
pub mod mesh;
mod operator;
mod response;
mod search;
mod session;
mod status;
#[cfg(test)]
mod test_support;
mod tools;
mod txn;
mod types;
pub use acl::*;
pub use agent::*;
pub use archive::{
consul_export_bundle_core, consul_import_execute_core, consul_import_preview_core, ConsulBundleEntry,
ConsulBundleScope, ConsulExportRequest, ConsulExportScopeKind, ConsulImportConflictPolicy, ConsulImportOperation,
ConsulImportOutcome, ConsulImportPreview, ConsulImportPreviewRow, ConsulImportReport, ConsulImportRequest,
ConsulImportResultItem, ConsulKvBundle,
};
pub use blocking::{
consul_blocking_query_core, consul_cancel_blocking_core, consul_domain_watch_core, ConsulBlockingRequest,
ConsulBlockingResponse, ConsulDomainWatchItems, ConsulDomainWatchRequest, ConsulDomainWatchResponse,
ConsulDomainWatchTarget,
};
pub use bulk::{
consul_delete_prefix_execute_core, consul_delete_prefix_preview_core, ConsulDeleteCandidate, ConsulDeleteOutcome,
ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulDeletePrefixRequest, ConsulDeleteResultItem,
};
pub use capabilities::{consul_capabilities_core, ConsulCapabilities, ConsulCapabilityStatus};
pub use catalog::*;
pub use client::ConsulClient;
pub use config::{ConsulAgentTarget, ConsulConfig, ConsulConsistency, ConsulScope};
pub use enterprise::*;
pub use health::*;
pub use kv::{
consul_acquire_lock_core, consul_delete_core, consul_get_core, consul_list_prefix_core, consul_list_recursive_core,
consul_put_core, consul_release_lock_core, ConsulKvRecord, ConsulLockRequest, ConsulLockResponse,
ConsulRecursiveListResponse, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES,
};
pub use operator::*;
pub use response::ConsulResponseMetadata;
pub use search::{
consul_cancel_search_core, consul_search_core, consul_search_progress_core, ConsulSearchMatch,
ConsulSearchProgress, ConsulSearchRequest, ConsulSearchResponse,
};
pub use session::*;
pub use status::*;
pub use tools::*;
pub use txn::{
consul_rename_key_core, consul_txn_core, ConsulTxnError, ConsulTxnKvOperation, ConsulTxnKvResult, ConsulTxnRequest,
ConsulTxnResult, ConsulTxnVerb, MAX_TXN_OPERATIONS,
};
async fn ensure_writable(state: &crate::connection::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 contract_tests {
#[test]
fn low_level_mutations_are_not_public_outside_the_consul_module() {
let modules = [
(include_str!("kv.rs"), &["acquire_lock", "release_lock", "put_key", "delete_key"] as &[&str]),
(include_str!("txn.rs"), &["txn"] as &[&str]),
(
include_str!("agent.rs"),
&[
"register_agent_service",
"deregister_agent_service",
"set_agent_service_maintenance",
"register_agent_check",
"deregister_agent_check",
"update_ttl_check",
] as &[&str],
),
(include_str!("session.rs"), &["create_session", "renew_session", "destroy_session"] as &[&str]),
];
for (source, methods) in modules {
for method in methods {
assert!(
source.contains(&format!("pub(super) async fn {method}")),
"{method} must remain module-private so callers cannot bypass Core read-only checks"
);
assert!(!source.contains(&format!("pub async fn {method}")), "{method} must not be publicly callable");
}
}
}
}
+557
View File
@@ -0,0 +1,557 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use flate2::read::GzDecoder;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::Read;
use crate::connection::AppState;
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
use super::response::ensure_success;
const MAX_SNAPSHOT_BYTES: usize = 128 * 1024 * 1024;
const MAX_SNAPSHOT_EXPANDED_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulOperatorField {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulOperatorDocument {
pub kind: String,
pub fields: Vec<ConsulOperatorField>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsulOperatorReadKind {
AutopilotConfiguration,
AutopilotHealth,
AutopilotState,
RaftConfiguration,
Usage,
License,
Audit,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulAutopilotUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub cleanup_dead_servers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_contact_threshold: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_trailing_logs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_quorum: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_stabilization_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redundancy_zone_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_upgrade_migration: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upgrade_version_tag: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSnapshotRestoreRequest {
pub snapshot_base64: String,
pub target_datacenter: String,
pub confirmation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSnapshot {
pub data_base64: String,
pub size_bytes: usize,
pub datacenter: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulRaftWriteRequest {
pub server_id: Option<String>,
pub address: Option<String>,
pub confirmation: String,
}
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsulKeyringOperation {
Install,
Use,
Remove,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulKeyringWriteRequest {
pub operation: ConsulKeyringOperation,
pub key: String,
pub confirmation: String,
}
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct ConsulKeyringWriteBody<'a> {
key: &'a str,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulLicenseWriteRequest {
pub license: String,
pub confirmation: String,
}
pub async fn consul_operator_read_core(
state: &AppState,
connection_id: &str,
kind: ConsulOperatorReadKind,
) -> Result<ConsulOperatorDocument, String> {
let client = client_for_state(state, connection_id).await?;
let path = match kind {
ConsulOperatorReadKind::AutopilotConfiguration => "/v1/operator/autopilot/configuration",
ConsulOperatorReadKind::AutopilotHealth => "/v1/operator/autopilot/health",
ConsulOperatorReadKind::AutopilotState => "/v1/operator/autopilot/state",
ConsulOperatorReadKind::RaftConfiguration => "/v1/operator/raft/configuration",
ConsulOperatorReadKind::Usage => "/v1/operator/usage",
ConsulOperatorReadKind::License => "/v1/operator/license",
ConsulOperatorReadKind::Audit => "/v1/operator/audit-hash",
};
let url = client.api_url(path)?;
let value: serde_json::Value = if matches!(kind, ConsulOperatorReadKind::Audit) {
client
.request_json(
Method::POST,
url,
Some(&serde_json::json!({ "Input": "dbx-operator-audit" })),
true,
"read Consul audit hash",
)
.await?
} else {
client.request_json(Method::GET, url, None::<&()>, true, "read Consul operator state").await?
};
Ok(document(format!("{kind:?}"), value))
}
pub async fn consul_snapshot_generate_core(state: &AppState, connection_id: &str) -> Result<ConsulSnapshot, String> {
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/snapshot")?;
client.append_scope(&mut url, true);
let mut response =
ensure_success(client.send(Method::GET, url, None).await?, "generate Consul snapshot", client.token()).await?;
let mut bytes = Vec::new();
while let Some(chunk) =
response.chunk().await.map_err(|error| format!("Failed to read snapshot: {}", error.without_url()))?
{
if bytes.len().saturating_add(chunk.len()) > MAX_SNAPSHOT_BYTES {
return Err("CONSUL_SNAPSHOT_TOO_LARGE: Snapshot exceeds 128 MiB".to_string());
}
bytes.extend_from_slice(&chunk);
}
Ok(ConsulSnapshot {
data_base64: STANDARD.encode(&bytes),
size_bytes: bytes.len(),
datacenter: client.datacenter().to_string(),
})
}
pub async fn consul_snapshot_restore_core(
state: &AppState,
connection_id: &str,
request: ConsulSnapshotRestoreRequest,
) -> Result<(), String> {
ensure_operator_write(state, connection_id, "snapshot_restore", "restore Consul snapshot").await?;
let client = client_for_state(state, connection_id).await?;
validate_datacenter_and_confirmation(
&client,
&request.target_datacenter,
&request.confirmation,
&format!("RESTORE SNAPSHOT {}", request.target_datacenter),
)?;
let snapshot =
STANDARD.decode(request.snapshot_base64.trim()).map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: {error}"))?;
if snapshot.is_empty() || snapshot.len() > MAX_SNAPSHOT_BYTES {
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot is empty or exceeds 128 MiB".to_string());
}
let snapshot = tokio::task::spawn_blocking(move || {
validate_snapshot_archive(&snapshot)?;
Ok::<_, String>(snapshot)
})
.await
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Snapshot validation task failed: {error}"))??;
let mut url = client.api_url("/v1/snapshot")?;
client.append_scope(&mut url, false);
ensure_success(client.send(Method::PUT, url, Some(snapshot)).await?, "restore Consul snapshot", client.token())
.await
.map(|_| ())
}
fn validate_snapshot_archive(snapshot: &[u8]) -> Result<(), String> {
if snapshot.len() < 10 || !snapshot.starts_with(&[0x1f, 0x8b, 0x08]) {
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot must be a gzip archive generated by Consul".to_string());
}
let decoder = GzDecoder::new(snapshot);
let mut archive = tar::Archive::new(decoder);
let mut digests = HashMap::new();
let mut checksum_manifest = None;
let mut expanded_bytes = 0_u64;
let mut entry_count = 0_u8;
let entries =
archive.entries().map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Invalid snapshot archive: {error}"))?;
for entry in entries {
entry_count = entry_count
.checked_add(1)
.ok_or_else(|| "CONSUL_SNAPSHOT_INVALID: Snapshot contains too many entries".to_string())?;
if entry_count > 64 {
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot contains too many entries".to_string());
}
let mut entry = entry.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Invalid snapshot entry: {error}"))?;
let size = entry.size();
expanded_bytes = expanded_bytes
.checked_add(size)
.ok_or_else(|| "CONSUL_SNAPSHOT_INVALID: Snapshot expanded size overflow".to_string())?;
if expanded_bytes > MAX_SNAPSHOT_EXPANDED_BYTES {
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot expands beyond 512 MiB".to_string());
}
let path = entry
.path()
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Invalid snapshot path: {error}"))?
.to_string_lossy()
.trim_start_matches("./")
.to_string();
match path.as_str() {
"meta.json" | "state.bin" => {
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
let mut bytes_read = 0_u64;
loop {
let count = entry
.read(&mut buffer)
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Failed to read {path}: {error}"))?;
if count == 0 {
break;
}
bytes_read += count as u64;
hasher.update(&buffer[..count]);
}
if bytes_read == 0 {
return Err(format!("CONSUL_SNAPSHOT_INVALID: Snapshot entry {path} is empty"));
}
if digests.insert(path.clone(), hex_digest(hasher.finalize().as_slice())).is_some() {
return Err(format!("CONSUL_SNAPSHOT_INVALID: Snapshot contains duplicate {path}"));
}
}
"SHA256SUMS" => {
if size > 64 * 1024 {
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot checksum manifest is too large".to_string());
}
let mut manifest = Vec::with_capacity(size as usize);
entry
.read_to_end(&mut manifest)
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Failed to read checksum manifest: {error}"))?;
if checksum_manifest.replace(manifest).is_some() {
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot contains duplicate SHA256SUMS".to_string());
}
}
_ => {
std::io::copy(&mut entry, &mut std::io::sink())
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Failed to read snapshot entry: {error}"))?;
}
}
}
let mut decoder = archive.into_inner();
std::io::copy(&mut decoder, &mut std::io::sink())
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Corrupt gzip stream: {error}"))?;
let manifest =
checksum_manifest.ok_or_else(|| "CONSUL_SNAPSHOT_INVALID: Snapshot is missing SHA256SUMS".to_string())?;
let manifest = std::str::from_utf8(&manifest)
.map_err(|_| "CONSUL_SNAPSHOT_INVALID: Snapshot checksum manifest is not UTF-8".to_string())?;
let expected = parse_checksum_manifest(manifest)?;
for required in ["meta.json", "state.bin"] {
let actual =
digests.get(required).ok_or_else(|| format!("CONSUL_SNAPSHOT_INVALID: Snapshot is missing {required}"))?;
let expected = expected
.get(required)
.ok_or_else(|| format!("CONSUL_SNAPSHOT_INVALID: Checksum manifest is missing {required}"))?;
if !actual.eq_ignore_ascii_case(expected) {
return Err(format!("CONSUL_SNAPSHOT_INVALID: Checksum mismatch for {required}"));
}
}
Ok(())
}
fn parse_checksum_manifest(manifest: &str) -> Result<HashMap<String, String>, String> {
let mut checksums = HashMap::new();
for line in manifest.lines().filter(|line| !line.trim().is_empty()) {
let mut fields = line.split_whitespace();
let checksum = fields.next().unwrap_or_default();
let path = fields.next().unwrap_or_default().trim_start_matches('*').trim_start_matches("./");
if checksum.len() != 64 || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) || path.is_empty() {
return Err("CONSUL_SNAPSHOT_INVALID: Malformed SHA256SUMS entry".to_string());
}
if checksums.insert(path.to_string(), checksum.to_string()).is_some() {
return Err(format!("CONSUL_SNAPSHOT_INVALID: Duplicate SHA256SUMS entry for {path}"));
}
}
Ok(checksums)
}
fn hex_digest(digest: &[u8]) -> String {
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub async fn consul_autopilot_update_core(
state: &AppState,
connection_id: &str,
update: ConsulAutopilotUpdate,
confirmation: &str,
) -> Result<(), String> {
ensure_operator_write(state, connection_id, "autopilot", "update Consul Autopilot").await?;
if confirmation != "UPDATE AUTOPILOT" {
return Err("CONSUL_CONFIRMATION_REQUIRED: Enter UPDATE AUTOPILOT".to_string());
}
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/operator/autopilot/configuration")?;
client.send_json(Method::PUT, url, Some(&update), false, "update Consul Autopilot").await.map(|_| ())
}
pub async fn consul_raft_transfer_core(
state: &AppState,
connection_id: &str,
request: ConsulRaftWriteRequest,
) -> Result<(), String> {
ensure_operator_write(state, connection_id, "raft", "transfer Consul Raft leadership").await?;
let id =
request.server_id.filter(|id| !id.is_empty()).ok_or("CONSUL_RAFT_TARGET_REQUIRED: Server ID is required")?;
if request.confirmation != id {
return Err("CONSUL_CONFIRMATION_REQUIRED: Re-enter the target server ID".to_string());
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/operator/raft/transfer-leader")?;
url.query_pairs_mut().append_pair("id", &id);
client.send_json(Method::POST, url, None::<&()>, false, "transfer Raft leadership").await.map(|_| ())
}
pub async fn consul_raft_remove_core(
state: &AppState,
connection_id: &str,
request: ConsulRaftWriteRequest,
) -> Result<(), String> {
ensure_operator_write(state, connection_id, "raft", "remove Consul Raft peer").await?;
let (key, target) = match (
request.server_id.filter(|value| !value.is_empty()),
request.address.filter(|value| !value.is_empty()),
) {
(Some(id), None) => ("id", id),
(None, Some(address)) => ("address", address),
_ => return Err("CONSUL_RAFT_TARGET_INVALID: Specify exactly one server ID or address".to_string()),
};
if request.confirmation != target {
return Err("CONSUL_CONFIRMATION_REQUIRED: Re-enter the peer identifier".to_string());
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/operator/raft/peer")?;
url.query_pairs_mut().append_pair(key, &target);
client.send_json(Method::DELETE, url, None::<&()>, false, "remove Raft peer").await.map(|_| ())
}
pub async fn consul_keyring_write_core(
state: &AppState,
connection_id: &str,
request: ConsulKeyringWriteRequest,
) -> Result<(), String> {
ensure_operator_write(state, connection_id, "keyring", "modify Consul gossip keyring").await?;
if request.confirmation != "CHANGE KEYRING" {
return Err("CONSUL_CONFIRMATION_REQUIRED: Enter CHANGE KEYRING".to_string());
}
if request.key.trim().is_empty() {
return Err("CONSUL_KEYRING_KEY_REQUIRED: Gossip key is required".to_string());
}
let client = client_for_state(state, connection_id).await?;
let method = match request.operation {
ConsulKeyringOperation::Install => Method::POST,
ConsulKeyringOperation::Use => Method::PUT,
ConsulKeyringOperation::Remove => Method::DELETE,
};
let url = client.api_url("/v1/operator/keyring")?;
let body = ConsulKeyringWriteBody { key: request.key.trim() };
client.send_json(method, url, Some(&body), false, "modify Consul gossip keyring").await.map(|_| ())
}
pub async fn consul_license_write_core(
state: &AppState,
connection_id: &str,
request: ConsulLicenseWriteRequest,
) -> Result<(), String> {
ensure_operator_write(state, connection_id, "license", "update Consul license").await?;
if request.confirmation != "UPDATE LICENSE" {
return Err("CONSUL_CONFIRMATION_REQUIRED: Enter UPDATE LICENSE".to_string());
}
if request.license.trim().is_empty() {
return Err("CONSUL_LICENSE_REQUIRED: License is required".to_string());
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/operator/license")?;
client.append_scope(&mut url, false);
ensure_success(
client.send(Method::PUT, url, Some(request.license.into_bytes())).await?,
"update Consul license",
client.token(),
)
.await
.map(|_| ())
}
async fn ensure_operator_write(
state: &AppState,
connection_id: &str,
feature: &str,
action: &str,
) -> Result<(), String> {
ensure_writable_core(state, connection_id, action).await?;
let client = client_for_state(state, connection_id).await?;
if !client.operator_feature_enabled(feature) {
return Err(format!("CONSUL_OPERATOR_FEATURE_DISABLED: {feature} is disabled by default"));
}
Ok(())
}
fn validate_datacenter_and_confirmation(
client: &ConsulClient,
target: &str,
confirmation: &str,
expected: &str,
) -> Result<(), String> {
if client.datacenter().is_empty() || target != client.datacenter() {
return Err("CONSUL_DATACENTER_MISMATCH: Snapshot target must match the configured Datacenter".to_string());
}
if confirmation != expected {
return Err(format!("CONSUL_CONFIRMATION_REQUIRED: Enter {expected}"));
}
Ok(())
}
fn document(kind: String, value: serde_json::Value) -> ConsulOperatorDocument {
let mut fields = Vec::new();
if let Some(object) = value.as_object() {
for (name, value) in object {
let lower = name.to_ascii_lowercase();
if lower.contains("secret") || lower.contains("token") || lower == "license" {
continue;
}
let mut value = value.clone();
redact_nested_sensitive_fields(&mut value);
let mut rendered = match &value {
serde_json::Value::String(value) => value.clone(),
_ => value.to_string(),
};
rendered.truncate(4096);
fields.push(ConsulOperatorField { name: name.clone(), value: rendered });
}
}
ConsulOperatorDocument { kind, fields }
}
fn redact_nested_sensitive_fields(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
object.retain(|name, _| {
let lower = name.to_ascii_lowercase();
!lower.contains("secret") && !lower.contains("token") && lower != "license"
});
for value in object.values_mut() {
redact_nested_sensitive_fields(value);
}
}
serde_json::Value::Array(values) => {
for value in values {
redact_nested_sensitive_fields(value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operator_document_drops_sensitive_fields() {
let result = document(
"license".to_string(),
serde_json::json!({
"SecretID":"top-secret",
"Healthy":true,
"Config": { "PeeringToken": "nested-secret", "Mode": "safe" }
}),
);
assert_eq!(result.fields.len(), 2);
assert!(result.fields.iter().any(|field| field.name == "Healthy"));
let serialized = serde_json::to_string(&result).unwrap();
assert!(!serialized.contains("top-secret"));
assert!(!serialized.contains("nested-secret"));
assert!(serialized.contains("safe"));
}
#[test]
fn keyring_body_uses_the_official_pascal_case_field() {
let body = ConsulKeyringWriteBody { key: "sensitive-gossip-key" };
assert_eq!(serde_json::to_value(body).unwrap(), serde_json::json!({ "Key": "sensitive-gossip-key" }));
}
#[test]
fn snapshot_preflight_requires_a_gzip_archive() {
assert!(validate_snapshot_archive(b"not-a-snapshot").is_err());
let valid = snapshot_fixture(b"raft-state");
assert!(validate_snapshot_archive(&valid).is_ok());
let mut truncated = valid.clone();
truncated.truncate(truncated.len() - 4);
assert!(validate_snapshot_archive(&truncated).is_err());
let invalid_checksum = snapshot_fixture_with_checksum(b"raft-state", "0".repeat(64));
assert!(validate_snapshot_archive(&invalid_checksum).is_err());
}
fn snapshot_fixture(state: &[u8]) -> Vec<u8> {
snapshot_fixture_with_checksum(state, hex_digest(Sha256::digest(state).as_slice()))
}
fn snapshot_fixture_with_checksum(state: &[u8], state_checksum: String) -> Vec<u8> {
let metadata = br#"{"ID":"fixture"}"#;
let manifest =
format!("{} meta.json\n{} state.bin\n", hex_digest(Sha256::digest(metadata).as_slice()), state_checksum);
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut archive = tar::Builder::new(encoder);
for (path, contents) in
[("meta.json", metadata.as_slice()), ("state.bin", state), ("SHA256SUMS", manifest.as_bytes())]
{
let mut header = tar::Header::new_gnu();
header.set_mode(0o600);
header.set_size(contents.len() as u64);
header.set_cksum();
archive.append_data(&mut header, path, contents).unwrap();
}
archive.into_inner().unwrap().finish().unwrap()
}
}
+275
View File
@@ -0,0 +1,275 @@
use reqwest::StatusCode;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::agent_kv::KvInt64;
const MAX_ERROR_BYTES: usize = 4096;
pub(super) const MAX_RESPONSE_BYTES: usize = 48 * 1024 * 1024;
pub(super) const MAX_COLLECTION_ITEMS: usize = 10_000;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulResponseMetadata {
pub index: Option<KvInt64>,
pub filtered_by_acls: Option<bool>,
pub known_leader: Option<bool>,
pub last_contact: Option<KvInt64>,
pub query_backend: Option<String>,
}
impl ConsulResponseMetadata {
pub fn from_response(response: &reqwest::Response) -> Self {
Self {
index: response
.headers()
.get("X-Consul-Index")
.and_then(|value| value.to_str().ok())
.map(|value| KvInt64(value.to_string())),
filtered_by_acls: response
.headers()
.get("X-Consul-Results-Filtered-By-ACLs")
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("true")),
known_leader: header(response, "X-Consul-KnownLeader").map(|value| value.eq_ignore_ascii_case("true")),
last_contact: header(response, "X-Consul-LastContact").map(KvInt64),
query_backend: header(response, "X-Consul-Query-Backend"),
}
}
}
fn header(response: &reqwest::Response, name: &str) -> Option<String> {
response.headers().get(name).and_then(|value| value.to_str().ok()).map(str::to_string)
}
pub(super) async fn ensure_success(
response: reqwest::Response,
action: &str,
token: &str,
) -> Result<reqwest::Response, String> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
let code = match status {
StatusCode::UNAUTHORIZED => "CONSUL_AUTH_REQUIRED",
StatusCode::FORBIDDEN => "CONSUL_PERMISSION_DENIED",
StatusCode::NOT_FOUND if is_consul_kv_not_found(&response) => "CONSUL_KEY_NOT_FOUND",
StatusCode::NOT_FOUND => "CONSUL_REQUEST_FAILED",
StatusCode::TOO_MANY_REQUESTS => "CONSUL_RATE_LIMITED",
_ if status.is_server_error() => "CONSUL_SERVER_ERROR",
_ => "CONSUL_REQUEST_FAILED",
};
let detail = limited_response_text(response).await;
let detail = redact_sensitive(&detail, token);
Err(format!("{code}: Failed to {action} ({status}): {}", detail.trim()))
}
pub(super) async fn decode_json_response<T: DeserializeOwned>(
response: reqwest::Response,
action: &str,
) -> Result<T, String> {
let body = read_bounded_response(response, action).await?;
decode_json_body(&body, action)
}
pub(super) async fn decode_text_response(response: reqwest::Response, action: &str) -> Result<String, String> {
let body = read_bounded_response(response, action).await?;
String::from_utf8(body)
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Failed to decode {action} response as UTF-8: {error}"))
}
pub(super) async fn read_bounded_response(mut response: reqwest::Response, action: &str) -> Result<Vec<u8>, String> {
if response.content_length().is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) {
return Err(response_size_error(action));
}
let capacity = response.content_length().unwrap_or(0).min(MAX_RESPONSE_BYTES as u64) as usize;
let mut body = Vec::with_capacity(capacity);
while let Some(chunk) = response.chunk().await.map_err(|error| {
format!("CONSUL_RESPONSE_READ_FAILED: Failed to read {action} response: {}", error.without_url())
})? {
append_bounded_chunk(&mut body, &chunk, MAX_RESPONSE_BYTES).map_err(|_| response_size_error(action))?;
}
Ok(body)
}
pub(super) fn decode_json_body<T: DeserializeOwned>(body: &[u8], action: &str) -> Result<T, String> {
let value = serde_json::from_slice::<serde_json::Value>(body)
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Failed to decode {action} response: {error}"))?;
validate_collection_limits(&value, action, "$")?;
serde_json::from_value(value)
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Failed to decode {action} response: {error}"))
}
fn response_size_error(action: &str) -> String {
format!(
"CONSUL_RESPONSE_TOO_LARGE: {action} response body exceeds the {} MiB limit",
MAX_RESPONSE_BYTES / 1024 / 1024
)
}
fn append_bounded_chunk(body: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Result<(), ()> {
if body.len().saturating_add(chunk.len()) > limit {
return Err(());
}
body.extend_from_slice(chunk);
Ok(())
}
fn validate_collection_limits(value: &serde_json::Value, action: &str, path: &str) -> Result<(), String> {
match value {
serde_json::Value::Array(items) => {
ensure_collection_limit(items.len(), action, path)?;
for (index, item) in items.iter().enumerate() {
validate_collection_limits(item, action, &format!("{path}[{index}]"))?;
}
}
serde_json::Value::Object(items) => {
ensure_collection_limit(items.len(), action, path)?;
for (key, item) in items {
validate_collection_limits(item, action, &format!("{path}.{}", json_path_key(key)))?;
}
}
_ => {}
}
Ok(())
}
fn ensure_collection_limit(length: usize, action: &str, path: &str) -> Result<(), String> {
if length > MAX_COLLECTION_ITEMS {
return Err(format!(
"CONSUL_RESPONSE_ITEM_LIMIT_EXCEEDED: {action} response collection at {path} contains {length} items; maximum is {MAX_COLLECTION_ITEMS}"
));
}
Ok(())
}
fn json_path_key(key: &str) -> String {
if key.chars().all(|character| character.is_ascii_alphanumeric() || character == '_') {
key.to_string()
} else {
format!("[{key:?}]")
}
}
pub(crate) fn redact_sensitive(detail: &str, token: &str) -> String {
let mut redacted = if token.is_empty() { detail.to_string() } else { detail.replace(token, "[REDACTED]") };
for marker in [
"SecretID",
"Secret_ID",
"Secret-ID",
"Secret",
"JWT",
"BootstrapToken",
"Bootstrap_Token",
"Bootstrap-Token",
"PeeringToken",
"Peering_Token",
"Peering-Token",
"X-Consul-Token",
"X_Consul_Token",
] {
redacted = redact_jsonish_field(&redacted, marker);
}
redacted
}
fn redact_jsonish_field(value: &str, marker: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut rest = value;
let marker = marker.to_ascii_lowercase();
while let Some(index) = rest.to_ascii_lowercase().find(&marker) {
let (before, after_marker) = rest.split_at(index + marker.len());
output.push_str(before);
let Some(separator) = after_marker.find([':', '=']) else {
rest = after_marker;
continue;
};
output.push_str(&after_marker[..=separator]);
let after_separator = &after_marker[separator + 1..];
let leading = after_separator.len() - after_separator.trim_start().len();
output.push_str(&after_separator[..leading]);
output.push_str("[REDACTED]");
let value_start = &after_separator[leading..];
let end = value_start.find([',', '}', '\n', '\r', '&']).unwrap_or(value_start.len());
rest = &value_start[end..];
}
output.push_str(rest);
output
}
pub(super) fn is_consul_kv_not_found(response: &reqwest::Response) -> bool {
response.status() == StatusCode::NOT_FOUND && ConsulResponseMetadata::from_response(response).index.is_some()
}
async fn limited_response_text(mut response: reqwest::Response) -> String {
let mut body = Vec::with_capacity(MAX_ERROR_BYTES);
while body.len() < MAX_ERROR_BYTES {
let Ok(Some(chunk)) = response.chunk().await else {
break;
};
if append_limited_error_chunk(&mut body, &chunk) {
break;
}
}
String::from_utf8_lossy(&body).into_owned()
}
fn append_limited_error_chunk(body: &mut Vec<u8>, chunk: &[u8]) -> bool {
let remaining = MAX_ERROR_BYTES.saturating_sub(body.len());
body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
chunk.len() >= remaining
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_acl_and_peering_secrets() {
let detail = r#"{"SecretID":"acl-secret","peering_token":"peer-secret","bootstrap-token":"bootstrap-secret","nested":{"oidcClientSecret":"oidc-secret"},"message":"bad"}"#;
let redacted = redact_sensitive(detail, "connection-token");
assert!(!redacted.contains("acl-secret"));
assert!(!redacted.contains("peer-secret"));
assert!(!redacted.contains("bootstrap-secret"));
assert!(!redacted.contains("oidc-secret"));
assert!(redacted.contains("[REDACTED]"));
}
#[test]
fn limits_error_body_to_four_kibibytes_across_chunks() {
let mut body = Vec::new();
assert!(!append_limited_error_chunk(&mut body, &[b'a'; 3_000]));
assert!(append_limited_error_chunk(&mut body, &[b'b'; 3_000]));
assert_eq!(body.len(), MAX_ERROR_BYTES);
assert_eq!(&body[..3_000], &[b'a'; 3_000]);
assert!(body[3_000..].iter().all(|byte| *byte == b'b'));
}
#[test]
fn bounded_chunk_accepts_exact_limit_and_rejects_next_byte() {
let mut body = Vec::new();
assert_eq!(append_bounded_chunk(&mut body, b"1234", 4), Ok(()));
assert_eq!(body, b"1234");
assert_eq!(append_bounded_chunk(&mut body, b"5", 4), Err(()));
assert_eq!(body, b"1234");
}
#[test]
fn json_collection_limit_accepts_boundary() {
let body = serde_json::to_vec(&vec![0_u8; MAX_COLLECTION_ITEMS]).unwrap();
let decoded = decode_json_body::<Vec<u8>>(&body, "test list").unwrap();
assert_eq!(decoded.len(), MAX_COLLECTION_ITEMS);
}
#[test]
fn json_collection_limit_reports_path_and_count() {
let value = serde_json::json!({ "items": vec![0_u8; MAX_COLLECTION_ITEMS + 1] });
let body = serde_json::to_vec(&value).unwrap();
let error = decode_json_body::<serde_json::Value>(&body, "test list").unwrap_err();
assert!(error.starts_with("CONSUL_RESPONSE_ITEM_LIMIT_EXCEEDED:"));
assert!(error.contains("$.items"));
assert!(error.contains("10001 items"));
}
}
+201
View File
@@ -0,0 +1,201 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use crate::agent_kv::KvValueEncoding;
use crate::connection::AppState;
use super::client::client_for_state;
use super::kv::{ConsulKvRecord, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSearchRequest {
pub request_id: String,
pub prefix: String,
pub query: String,
pub search_keys: bool,
pub search_values: bool,
pub case_sensitive: bool,
pub limit: usize,
pub max_scan: usize,
#[serde(default)]
pub generation: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSearchMatch {
#[serde(flatten)]
pub record: ConsulKvRecord,
pub matches_key: bool,
pub matches_value: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSearchResponse {
pub matches: Vec<ConsulSearchMatch>,
pub scanned: usize,
pub matched: usize,
pub limited: bool,
pub filtered_by_acls: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSearchProgress {
pub running: bool,
pub scanned: usize,
pub cancelled: bool,
}
#[derive(Default)]
struct SearchControl {
cancelled: AtomicBool,
scanned: AtomicUsize,
}
static SEARCHES: OnceLock<Mutex<HashMap<String, Arc<SearchControl>>>> = OnceLock::new();
struct SearchRegistration {
key: String,
}
impl Drop for SearchRegistration {
fn drop(&mut self) {
if let Ok(mut searches) = searches().lock() {
searches.remove(&self.key);
}
}
}
pub async fn consul_search_core(
state: &AppState,
connection_id: &str,
request: ConsulSearchRequest,
) -> Result<ConsulSearchResponse, String> {
let query = request.query.trim();
if query.is_empty() {
return Err("CONSUL_SEARCH_QUERY_REQUIRED: Search query cannot be empty".to_string());
}
if !request.search_keys && !request.search_values {
return Err("CONSUL_SEARCH_SCOPE_REQUIRED: Enable Key or Value search".to_string());
}
let request_id = request.request_id.trim();
if request_id.is_empty() || request_id.len() > 128 {
return Err("CONSUL_SEARCH_REQUEST_INVALID: Search request ID is invalid".to_string());
}
let client = client_for_state(state, connection_id).await?;
let scope = client.scope();
let key = search_key(connection_id, &scope, request.generation, request_id);
let control = Arc::new(SearchControl::default());
{
let mut registry = searches().lock().map_err(|_| "Consul search registry is unavailable".to_string())?;
if registry.contains_key(&key) {
return Err(
"CONSUL_OPERATION_ALREADY_RUNNING: A search with this request ID is already running".to_string()
);
}
registry.insert(key.clone(), Arc::clone(&control));
}
let _registration = SearchRegistration { key };
let max_scan = request.max_scan.clamp(1, MAX_RECURSIVE_ENTRIES);
let check = |scanned: usize| {
control.scanned.store(scanned, Ordering::Relaxed);
if control.cancelled.load(Ordering::Relaxed) {
Err("CONSUL_SEARCH_CANCELLED: Consul KV search was cancelled".to_string())
} else {
Ok(())
}
};
let recursive =
client.list_recursive_with_control(&request.prefix, max_scan, MAX_RECURSIVE_VALUE_BYTES, Some(&check)).await?;
let normalized_query = normalize(query, request.case_sensitive);
let limit = request.limit.clamp(1, 1000);
let mut matched = 0usize;
let mut matches = Vec::new();
for (index, record) in recursive.entries.into_iter().enumerate() {
check(index + 1)?;
let matches_key =
request.search_keys && normalize(&record.key, request.case_sensitive).contains(&normalized_query);
let matches_value = request.search_values
&& matches!(record.value.encoding, KvValueEncoding::Utf8)
&& normalize(&record.value.data, request.case_sensitive).contains(&normalized_query);
if matches_key || matches_value {
matched += 1;
if matches.len() < limit {
matches.push(ConsulSearchMatch { record, matches_key, matches_value });
}
}
}
Ok(ConsulSearchResponse {
matches,
scanned: control.scanned.load(Ordering::Relaxed),
matched,
limited: matched > limit,
filtered_by_acls: recursive.filtered_by_acls,
})
}
pub fn consul_search_progress_core(
connection_id: &str,
scope: &super::ConsulScope,
generation: u64,
request_id: &str,
) -> ConsulSearchProgress {
let control = searches()
.lock()
.ok()
.and_then(|searches| searches.get(&search_key(connection_id, scope, generation, request_id)).cloned());
match control {
Some(control) => ConsulSearchProgress {
running: true,
scanned: control.scanned.load(Ordering::Relaxed),
cancelled: control.cancelled.load(Ordering::Relaxed),
},
None => ConsulSearchProgress { running: false, scanned: 0, cancelled: false },
}
}
pub fn consul_cancel_search_core(
connection_id: &str,
scope: &super::ConsulScope,
generation: u64,
request_id: &str,
) -> bool {
let control = searches()
.lock()
.ok()
.and_then(|searches| searches.get(&search_key(connection_id, scope, generation, request_id)).cloned());
if let Some(control) = control {
control.cancelled.store(true, Ordering::Relaxed);
true
} else {
false
}
}
fn searches() -> &'static Mutex<HashMap<String, Arc<SearchControl>>> {
SEARCHES.get_or_init(|| Mutex::new(HashMap::new()))
}
fn search_key(connection_id: &str, scope: &super::ConsulScope, generation: u64, request_id: &str) -> String {
format!(
"{connection_id}\0{}\0{}\0{}\0{generation}\0{request_id}",
scope.datacenter, scope.partition, scope.namespace
)
}
fn normalize(value: &str, case_sensitive: bool) -> String {
if case_sensitive {
value.to_string()
} else {
value.to_lowercase()
}
}
+479
View File
@@ -0,0 +1,479 @@
use reqwest::Method;
use serde::{Deserialize, Deserializer, Serialize};
use crate::connection::AppState;
use super::catalog::{encode_segment, ConsulListResponse, ConsulReadOptions};
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
use super::kv::ConsulKvRecord;
use super::response::{decode_json_response, decode_text_response, ensure_success};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulSession {
#[serde(default, rename = "ID", alias = "Id")]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub node: String,
#[serde(default)]
pub lock_delay: u64,
#[serde(default)]
pub behavior: String,
#[serde(default, rename = "TTL", alias = "Ttl")]
pub ttl: String,
#[serde(default, rename = "NodeChecks", alias = "Checks", deserialize_with = "deserialize_null_default")]
pub node_checks: Vec<String>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub service_checks: Vec<ConsulSessionServiceCheck>,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub partition: String,
#[serde(default)]
pub create_index: u64,
#[serde(default)]
pub modify_index: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulSessionServiceCheck {
#[serde(default, rename = "ID", alias = "Id")]
pub id: String,
#[serde(default)]
pub namespace: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSessionCreateRequest {
pub name: String,
pub node: String,
pub lock_delay: Option<String>,
pub behavior: String,
pub ttl: Option<String>,
#[serde(default, alias = "checks")]
pub node_checks: Vec<String>,
#[serde(default)]
pub service_checks: Vec<ConsulSessionServiceCheck>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSessionHeldKey {
pub key: String,
pub modify_index: crate::agent_kv::KvInt64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSessionKeysResponse {
pub items: Vec<ConsulKvRecord>,
pub complete: bool,
pub filtered_by_acls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSessionDestroyImpact {
pub session: ConsulSession,
pub held_keys: Vec<ConsulSessionHeldKey>,
pub complete: bool,
pub filtered_by_acls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulSessionDestroyRequest {
pub id: String,
pub expected_behavior: String,
pub expected_held_keys: Vec<ConsulSessionHeldKey>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct SessionCreateResponse {
#[serde(rename = "ID", alias = "Id")]
id: String,
}
impl ConsulClient {
pub async fn sessions(
&self,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
self.read_list("/v1/session/list", options, "list Consul sessions").await
}
pub async fn node_sessions(
&self,
node: &str,
options: &ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
self.read_list(
&format!("/v1/session/node/{}", encode_segment(required(node, "node")?)),
options,
"list Consul node sessions",
)
.await
}
pub async fn session(&self, id: &str) -> Result<Option<ConsulSession>, String> {
let mut url = self.api_url(&format!("/v1/session/info/{}", encode_segment(required(id, "session id")?)))?;
self.append_scope(&mut url, true);
let response =
ensure_success(self.send(Method::GET, url, None).await?, "read Consul session", self.token()).await?;
let mut items = decode_json_response::<Vec<ConsulSession>>(response, "read Consul session").await?;
Ok(items.pop())
}
pub(super) async fn create_session(&self, request: ConsulSessionCreateRequest) -> Result<ConsulSession, String> {
let behavior = request.behavior.trim().to_ascii_lowercase();
if !matches!(behavior.as_str(), "release" | "delete") {
return Err("CONSUL_INVALID_REQUEST: Session behavior must be release or delete".to_string());
}
let ttl = validate_session_ttl(request.ttl.as_deref())?;
let lock_delay = validate_lock_delay(request.lock_delay.as_deref())?;
let mut url = self.api_url("/v1/session/create")?;
self.append_scope(&mut url, false);
let body = serde_json::to_vec(&serde_json::json!({
"Name": request.name,
"Node": request.node,
"LockDelay": lock_delay,
"Behavior": behavior,
"TTL": ttl,
"NodeChecks": request.node_checks,
"ServiceChecks": request.service_checks,
}))
.map_err(|error| format!("Failed to encode Consul session: {error}"))?;
let response =
ensure_success(self.send(Method::PUT, url, Some(body)).await?, "create Consul session", self.token())
.await?;
let created = decode_json_response::<SessionCreateResponse>(response, "create Consul session").await?;
self.session(&created.id)
.await?
.ok_or_else(|| "CONSUL_SESSION_NOT_FOUND: Created session was not returned by Consul".to_string())
}
pub(super) async fn renew_session(&self, id: &str) -> Result<ConsulSession, String> {
let mut url = self.api_url(&format!("/v1/session/renew/{}", encode_segment(required(id, "session id")?)))?;
self.append_scope(&mut url, false);
let response =
ensure_success(self.send(Method::PUT, url, None).await?, "renew Consul session", self.token()).await?;
let mut items = decode_json_response::<Vec<ConsulSession>>(response, "renew Consul session").await?;
items.pop().ok_or_else(|| "CONSUL_SESSION_NOT_FOUND: Session no longer exists".to_string())
}
pub(super) async fn destroy_session(&self, id: &str) -> Result<bool, String> {
let mut url = self.api_url(&format!("/v1/session/destroy/{}", encode_segment(required(id, "session id")?)))?;
self.append_scope(&mut url, false);
let response =
ensure_success(self.send(Method::PUT, url, None).await?, "destroy Consul session", self.token()).await?;
let result = decode_text_response(response, "destroy Consul session").await?;
if result.trim() != "true" {
return Err("CONSUL_SESSION_CONFLICT: Consul refused to destroy the session".to_string());
}
Ok(true)
}
pub async fn session_keys(&self, id: &str) -> Result<ConsulSessionKeysResponse, String> {
let id = required(id, "session id")?;
let response = self.list_recursive("", 10_000, 32 * 1024 * 1024).await?;
let filtered_by_acls = response.filtered_by_acls == Some(true);
Ok(ConsulSessionKeysResponse {
items: response.entries.into_iter().filter(|entry| entry.session.as_deref() == Some(id)).collect(),
complete: response.complete && !filtered_by_acls,
filtered_by_acls,
})
}
async fn session_destroy_impact(&self, id: &str) -> Result<ConsulSessionDestroyImpact, String> {
let session =
self.session(id).await?.ok_or_else(|| "CONSUL_SESSION_NOT_FOUND: Session no longer exists".to_string())?;
let recursive = self.list_recursive("", 10_000, 32 * 1024 * 1024).await?;
let mut held_keys = recursive
.entries
.into_iter()
.filter(|entry| entry.session.as_deref() == Some(session.id.as_str()))
.map(|entry| ConsulSessionHeldKey { key: entry.key, modify_index: entry.modify_index })
.collect::<Vec<_>>();
held_keys.sort_by(|left, right| left.key.cmp(&right.key));
let filtered_by_acls = recursive.filtered_by_acls == Some(true);
Ok(ConsulSessionDestroyImpact {
session,
held_keys,
complete: recursive.complete && !filtered_by_acls,
filtered_by_acls,
})
}
}
fn required<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
let value = value.trim();
if value.is_empty() {
Err(format!("CONSUL_INVALID_REQUEST: {field} is required"))
} else {
Ok(value)
}
}
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
fn validate_session_ttl(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = normalized_duration(value) else { return Ok(None) };
let milliseconds = parse_consul_duration_ms(&value, "Session TTL")?;
if !(10_000.0..=86_400_000.0).contains(&milliseconds) {
return Err("CONSUL_INVALID_REQUEST: Session TTL must be between 10s and 24h".to_string());
}
Ok(Some(value))
}
fn validate_lock_delay(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = normalized_duration(value) else { return Ok(None) };
let milliseconds = parse_consul_duration_ms(&value, "Session LockDelay")?;
if !(0.0..=60_000.0).contains(&milliseconds) {
return Err("CONSUL_INVALID_REQUEST: Session LockDelay must be between 0s and 60s".to_string());
}
Ok(Some(value))
}
fn normalized_duration(value: Option<&str>) -> Option<String> {
value.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string)
}
fn parse_consul_duration_ms(value: &str, field: &str) -> Result<f64, String> {
let bytes = value.as_bytes();
let mut offset = 0;
let mut total = 0.0;
while offset < bytes.len() {
let start = offset;
let mut dots = 0;
while offset < bytes.len() && (bytes[offset].is_ascii_digit() || bytes[offset] == b'.') {
if bytes[offset] == b'.' {
dots += 1;
}
offset += 1;
}
if offset == start || dots > 1 {
return Err(format!("CONSUL_INVALID_REQUEST: {field} must use duration units ms, s, m, or h"));
}
let amount = value[start..offset]
.parse::<f64>()
.map_err(|_| format!("CONSUL_INVALID_REQUEST: {field} is not a valid duration"))?;
let multiplier = if value[offset..].starts_with("ms") {
offset += 2;
1.0
} else if offset < bytes.len() {
let multiplier = match bytes[offset] {
b's' => 1_000.0,
b'm' => 60_000.0,
b'h' => 3_600_000.0,
_ => return Err(format!("CONSUL_INVALID_REQUEST: {field} must use duration units ms, s, m, or h")),
};
offset += 1;
multiplier
} else {
return Err(format!("CONSUL_INVALID_REQUEST: {field} must include a duration unit"));
};
total += amount * multiplier;
}
if !total.is_finite() {
return Err(format!("CONSUL_INVALID_REQUEST: {field} is not a valid duration"));
}
Ok(total)
}
pub async fn consul_sessions_core(
state: &AppState,
connection_id: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
client_for_state(state, connection_id).await?.sessions(&options).await
}
pub async fn consul_node_sessions_core(
state: &AppState,
connection_id: &str,
node: &str,
options: ConsulReadOptions,
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
client_for_state(state, connection_id).await?.node_sessions(node, &options).await
}
pub async fn consul_session_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<Option<ConsulSession>, String> {
client_for_state(state, connection_id).await?.session(id).await
}
pub async fn consul_session_keys_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<ConsulSessionKeysResponse, String> {
client_for_state(state, connection_id).await?.session_keys(id).await
}
pub async fn consul_session_destroy_impact_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<ConsulSessionDestroyImpact, String> {
client_for_state(state, connection_id).await?.session_destroy_impact(id).await
}
pub async fn consul_create_session_core(
state: &AppState,
connection_id: &str,
request: ConsulSessionCreateRequest,
) -> Result<ConsulSession, String> {
ensure_writable_core(state, connection_id, "create Consul session").await?;
client_for_state(state, connection_id).await?.create_session(request).await
}
pub async fn consul_renew_session_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<ConsulSession, String> {
ensure_writable_core(state, connection_id, "renew Consul session").await?;
client_for_state(state, connection_id).await?.renew_session(id).await
}
pub async fn consul_destroy_session_core(
state: &AppState,
connection_id: &str,
request: ConsulSessionDestroyRequest,
) -> Result<bool, String> {
ensure_writable_core(state, connection_id, "destroy Consul session").await?;
let client = client_for_state(state, connection_id).await?;
let impact = client.session_destroy_impact(&request.id).await?;
if !impact.complete {
return Err("CONSUL_IMPACT_INCOMPLETE: Session-held keys could not be enumerated completely".to_string());
}
validate_destroy_confirmation(&impact, &request)?;
client.destroy_session(&request.id).await
}
fn validate_destroy_confirmation(
impact: &ConsulSessionDestroyImpact,
request: &ConsulSessionDestroyRequest,
) -> Result<(), String> {
let mut expected_keys = request.expected_held_keys.clone();
expected_keys.sort_by(|left, right| left.key.cmp(&right.key));
if impact.session.id != request.id
|| impact.session.behavior != request.expected_behavior
|| impact.held_keys != expected_keys
{
return Err("CONSUL_SESSION_CONFLICT: Session behavior or held keys changed after confirmation".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::consul::test_support::{serve_once, test_client};
#[test]
fn decodes_consul_two_session_checks_and_indexes() {
let session: ConsulSession = serde_json::from_str(
r#"{
"ID":"session-1",
"Name":"demo",
"Node":"node-1",
"LockDelay":15000000000,
"Behavior":"release",
"TTL":"",
"NodeChecks":["serfHealth"],
"ServiceChecks":[{"ID":"service:api:health","Namespace":"team-a"}],
"CreateIndex":12,
"ModifyIndex":18
}"#,
)
.unwrap();
assert_eq!(session.id, "session-1");
assert_eq!(session.node_checks, vec!["serfHealth"]);
assert_eq!(session.service_checks[0].id, "service:api:health");
assert_eq!(session.service_checks[0].namespace, "team-a");
assert_eq!(session.modify_index, 18);
let local_dev: ConsulSession =
serde_json::from_str(r#"{"ID":"session-2","NodeChecks":["serfHealth"],"ServiceChecks":null}"#).unwrap();
assert!(local_dev.service_checks.is_empty());
}
#[test]
fn accepts_legacy_checks_as_node_checks() {
let session: ConsulSession = serde_json::from_str(r#"{"ID":"legacy","Checks":["serfHealth"]}"#).unwrap();
assert_eq!(session.node_checks, vec!["serfHealth"]);
}
#[test]
fn validates_session_duration_boundaries() {
assert_eq!(validate_session_ttl(None).unwrap(), None);
assert_eq!(validate_session_ttl(Some("10s")).unwrap(), Some("10s".into()));
assert_eq!(validate_session_ttl(Some("24h")).unwrap(), Some("24h".into()));
assert!(validate_session_ttl(Some("9.999s")).is_err());
assert!(validate_session_ttl(Some("24h1s")).is_err());
assert_eq!(validate_lock_delay(Some("0s")).unwrap(), Some("0s".into()));
assert_eq!(validate_lock_delay(Some("1m")).unwrap(), Some("1m".into()));
assert!(validate_lock_delay(Some("60s1ms")).is_err());
}
#[tokio::test]
async fn renew_uses_put_encoded_id_scope_and_acl_header() {
let body = r#"[{"ID":"session/a","TTL":"30s","Behavior":"release"}]"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (url, request_rx) = serve_once(response).await;
let client = test_client(url).await;
let renewed = client.renew_session("session/a").await.unwrap();
assert_eq!(renewed.id, "session/a");
assert_eq!(renewed.ttl, "30s");
let request = request_rx.await.unwrap();
let headers = request.split_once("\r\n\r\n").unwrap().0;
assert!(headers
.starts_with("PUT /proxy/v1/session/renew/session%2Fa?dc=dc1&ns=team-a&partition=partition-a HTTP/1.1"));
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
}
#[test]
fn destroy_confirmation_rejects_changed_held_keys() {
let impact = ConsulSessionDestroyImpact {
session: ConsulSession { id: "session-1".into(), behavior: "delete".into(), ..Default::default() },
held_keys: vec![ConsulSessionHeldKey {
key: "locks/a".into(),
modify_index: crate::agent_kv::KvInt64("4".into()),
}],
complete: true,
filtered_by_acls: false,
};
let stale = ConsulSessionDestroyRequest {
id: "session-1".into(),
expected_behavior: "delete".into(),
expected_held_keys: Vec::new(),
};
assert!(validate_destroy_confirmation(&impact, &stale).is_err());
let current = ConsulSessionDestroyRequest {
id: "session-1".into(),
expected_behavior: "delete".into(),
expected_held_keys: impact.held_keys.clone(),
};
assert!(validate_destroy_confirmation(&impact, &current).is_ok());
}
}
+38
View File
@@ -0,0 +1,38 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::connection::AppState;
use super::client::{client_for_state, ConsulClient};
use super::response::{decode_json_response, ensure_success};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulStatus {
pub leader: String,
pub peers: Vec<String>,
}
impl ConsulClient {
pub async fn status_leader(&self) -> Result<String, String> {
let url = self.api_url("/v1/status/leader")?;
let response =
ensure_success(self.send(Method::GET, url, None).await?, "read Consul leader", self.token()).await?;
decode_json_response(response, "read Consul leader").await
}
pub async fn status_peers(&self) -> Result<Vec<String>, String> {
let url = self.api_url("/v1/status/peers")?;
let response =
ensure_success(self.send(Method::GET, url, None).await?, "read Consul peers", self.token()).await?;
decode_json_response(response, "read Consul peers").await
}
}
pub async fn consul_status_leader_core(state: &AppState, connection_id: &str) -> Result<String, String> {
client_for_state(state, connection_id).await?.status_leader().await
}
pub async fn consul_status_peers_core(state: &AppState, connection_id: &str) -> Result<Vec<String>, String> {
client_for_state(state, connection_id).await?.status_peers().await
}
@@ -0,0 +1,72 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use super::{ConsulClient, ConsulConfig, ConsulConsistency};
pub(super) async fn serve_once(response: String) -> (reqwest::Url, oneshot::Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (request_tx, request_rx) = oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let count = socket.read(&mut buffer).await.unwrap();
if count == 0 {
break;
}
request.extend_from_slice(&buffer[..count]);
let Some(header_end) = find_bytes(&request, b"\r\n\r\n") else {
assert!(request.len() <= 1024 * 1024);
continue;
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().ok()).flatten()
})
.unwrap_or(0);
if request.len() >= header_end + 4 + content_length {
break;
}
}
let _ = request_tx.send(String::from_utf8(request).unwrap());
socket.write_all(response.as_bytes()).await.unwrap();
socket.shutdown().await.unwrap();
});
(reqwest::Url::parse(&format!("http://{address}/proxy")).unwrap(), request_rx)
}
pub(super) async fn test_client(base_url: reqwest::Url) -> ConsulClient {
ConsulClient::new(ConsulConfig {
base_url,
token: "fixture-token".to_string(),
datacenter: "dc1".to_string(),
namespace: "team-a".to_string(),
partition: "partition-a".to_string(),
consistency: ConsulConsistency::Consistent,
tls_skip_verify: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
connect_timeout_secs: 5,
request_timeout_secs: 5,
connect_override: 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,
agent_target: None,
})
.await
.unwrap()
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|window| window == needle)
}
+560
View File
@@ -0,0 +1,560 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::agent_kv::KvInt64;
use crate::connection::AppState;
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
const MAX_EVENT_PAYLOAD_BYTES: usize = 100;
const MAX_EVENT_NAME_BYTES: usize = 256;
const MAX_EVENT_FILTER_BYTES: usize = 1_024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedQueryService {
pub service: String,
#[serde(default)]
pub near: String,
#[serde(default)]
pub only_passing: bool,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedQuery {
#[serde(rename = "ID", alias = "Id", default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub session: String,
pub service: ConsulPreparedQueryService,
#[serde(default = "zero_index")]
pub create_index: KvInt64,
#[serde(default = "zero_index")]
pub modify_index: KvInt64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulPreparedQueryInput {
pub name: String,
pub session: String,
pub service: ConsulPreparedQueryService,
}
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct ConsulPreparedQueryWrite<'a> {
name: &'a str,
session: &'a str,
service: &'a ConsulPreparedQueryService,
}
impl ConsulPreparedQueryInput {
fn wire(&self) -> ConsulPreparedQueryWrite<'_> {
ConsulPreparedQueryWrite { name: &self.name, session: &self.session, service: &self.service }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulPreparedQueryExecuteRequest {
pub query: String,
pub limit: usize,
pub connect: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedQueryNode {
pub node: ConsulPreparedNodeIdentity,
pub service: ConsulPreparedServiceIdentity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedNodeIdentity {
pub node: String,
pub address: String,
#[serde(default)]
pub datacenter: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedServiceIdentity {
#[serde(rename = "ID", alias = "Id")]
pub id: String,
pub service: String,
#[serde(default)]
pub address: String,
pub port: u16,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedQueryExecuteResponse {
#[serde(default)]
pub service: String,
#[serde(default)]
pub nodes: Vec<ConsulPreparedQueryNode>,
#[serde(rename = "DNS", alias = "Dns", default)]
pub dns: ConsulPreparedQueryDns,
#[serde(default)]
pub datacenter: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulPreparedQueryDns {
#[serde(rename = "TTL", alias = "Ttl", default)]
pub ttl: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ConsulPreparedQueryExplainResponse {
query: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulEventFireRequest {
pub name: String,
pub payload_base64: String,
pub node_filter: String,
pub service_filter: String,
pub tag_filter: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulEvent {
#[serde(rename = "ID", alias = "Id")]
pub id: String,
pub name: String,
#[serde(default)]
pub payload: Option<String>,
#[serde(default)]
pub node_filter: String,
#[serde(default)]
pub service_filter: String,
#[serde(default)]
pub tag_filter: String,
#[serde(default = "zero_index")]
pub l_time: KvInt64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulCoordinate {
pub node: String,
#[serde(default)]
pub segment: String,
pub coord: ConsulCoordinateValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ConsulCoordinateValue {
#[serde(default)]
pub vec: Vec<f64>,
pub error: f64,
pub adjustment: f64,
pub height: f64,
}
impl ConsulClient {
async fn prepared_queries(&self) -> Result<Vec<ConsulPreparedQuery>, String> {
let url = self.api_url("/v1/query")?;
self.request_json(Method::GET, url, None::<&()>, true, "list prepared queries").await
}
}
pub async fn consul_prepared_query_list_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<ConsulPreparedQuery>, String> {
client_for_state(state, connection_id).await?.prepared_queries().await
}
pub async fn consul_prepared_query_read_core(
state: &AppState,
connection_id: &str,
id: &str,
) -> Result<ConsulPreparedQuery, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
client
.request_json::<Vec<ConsulPreparedQuery>, _>(Method::GET, url, None::<&()>, true, "read prepared query")
.await?
.into_iter()
.next()
.ok_or_else(|| "CONSUL_QUERY_NOT_FOUND: Prepared query not found".to_string())
}
pub async fn consul_prepared_query_create_core(
state: &AppState,
connection_id: &str,
input: ConsulPreparedQueryInput,
) -> Result<String, String> {
ensure_writable_core(state, connection_id, "create Consul prepared query").await?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/query")?;
#[derive(Deserialize)]
struct Created {
#[serde(rename = "ID")]
id: String,
}
Ok(client
.request_json::<Created, _>(Method::POST, url, Some(&input.wire()), false, "create prepared query")
.await?
.id)
}
pub async fn consul_prepared_query_update_core(
state: &AppState,
connection_id: &str,
id: &str,
input: ConsulPreparedQueryInput,
) -> Result<(), String> {
ensure_writable_core(state, connection_id, "update Consul prepared query").await?;
let client = client_for_state(state, connection_id).await?;
let read_url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
let mut existing = client
.request_json::<Vec<serde_json::Value>, _>(
Method::GET,
read_url,
None::<&()>,
true,
"read prepared query before update",
)
.await?
.into_iter()
.next()
.ok_or_else(|| "CONSUL_QUERY_NOT_FOUND: Prepared query not found".to_string())?;
apply_prepared_query_patch(&mut existing, &input)?;
let url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
client.send_json(Method::PUT, url, Some(&existing), false, "update prepared query").await.map(|_| ())
}
fn apply_prepared_query_patch(
existing: &mut serde_json::Value,
input: &ConsulPreparedQueryInput,
) -> Result<(), String> {
if input.name.trim().is_empty() || input.service.service.trim().is_empty() {
return Err("CONSUL_INVALID_REQUEST: Prepared query name and service are required".to_string());
}
let object = existing
.as_object_mut()
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: Prepared query is not an object".to_string())?;
object.remove("ID");
object.remove("CreateIndex");
object.remove("ModifyIndex");
object.insert("Name".to_string(), serde_json::Value::String(input.name.trim().to_string()));
let service = object
.entry("Service")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: Prepared query Service is not an object".to_string())?;
service.insert("Service".to_string(), serde_json::Value::String(input.service.service.trim().to_string()));
Ok(())
}
pub async fn consul_prepared_query_delete_core(state: &AppState, connection_id: &str, id: &str) -> Result<(), String> {
ensure_writable_core(state, connection_id, "delete Consul prepared query").await?;
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
client.send_json(Method::DELETE, url, None::<&()>, false, "delete prepared query").await.map(|_| ())
}
pub async fn consul_prepared_query_execute_core(
state: &AppState,
connection_id: &str,
request: ConsulPreparedQueryExecuteRequest,
) -> Result<ConsulPreparedQueryExecuteResponse, String> {
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url(&format!("/v1/query/{}/execute", encode(&request.query)))?;
url.query_pairs_mut()
.append_pair("limit", &request.limit.clamp(1, 1000).to_string())
.append_pair("connect", if request.connect { "true" } else { "false" });
client.request_json(Method::GET, url, None::<&()>, true, "execute prepared query").await
}
pub async fn consul_prepared_query_explain_core(
state: &AppState,
connection_id: &str,
query: &str,
) -> Result<serde_json::Value, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url(&format!("/v1/query/{}/explain", encode(query)))?;
let response: ConsulPreparedQueryExplainResponse =
client.request_json(Method::GET, url, None::<&()>, true, "explain prepared query").await?;
let mut query = response.query;
redact_prepared_query_secrets(&mut query);
Ok(query)
}
fn redact_prepared_query_secrets(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
if key.eq_ignore_ascii_case("token") {
*value = serde_json::Value::String("****************".to_string());
} else {
redact_prepared_query_secrets(value);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
redact_prepared_query_secrets(value);
}
}
_ => {}
}
}
pub async fn consul_event_list_core(
state: &AppState,
connection_id: &str,
name: Option<&str>,
) -> Result<Vec<ConsulEvent>, String> {
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url("/v1/event/list")?;
if let Some(name) = name.filter(|name| !name.is_empty()) {
url.query_pairs_mut().append_pair("name", name);
}
client.request_json(Method::GET, url, None::<&()>, true, "list Consul events").await
}
pub async fn consul_event_fire_core(
state: &AppState,
connection_id: &str,
request: ConsulEventFireRequest,
) -> Result<ConsulEvent, String> {
ensure_writable_core(state, connection_id, "fire Consul event").await?;
validate_event_text(&request.name, "name", MAX_EVENT_NAME_BYTES)?;
if request.name.starts_with('_') {
return Err("CONSUL_EVENT_NAME_RESERVED: Event names beginning with '_' are reserved by Consul".to_string());
}
for (name, value) in [
("node filter", request.node_filter.as_str()),
("service filter", request.service_filter.as_str()),
("tag filter", request.tag_filter.as_str()),
] {
if value.len() > MAX_EVENT_FILTER_BYTES {
return Err(format!("CONSUL_EVENT_FILTER_TOO_LARGE: {name} exceeds {MAX_EVENT_FILTER_BYTES} bytes"));
}
}
let encoded_payload = request.payload_base64.trim();
let max_encoded_len = MAX_EVENT_PAYLOAD_BYTES.div_ceil(3) * 4;
if encoded_payload.len() > max_encoded_len {
return Err(format!("CONSUL_EVENT_PAYLOAD_TOO_LARGE: Event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} bytes"));
}
let payload = STANDARD.decode(encoded_payload).map_err(|error| format!("CONSUL_EVENT_PAYLOAD_INVALID: {error}"))?;
if payload.len() > MAX_EVENT_PAYLOAD_BYTES {
return Err(format!("CONSUL_EVENT_PAYLOAD_TOO_LARGE: Event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} bytes"));
}
let client = client_for_state(state, connection_id).await?;
let mut url = client.api_url(&format!("/v1/event/fire/{}", encode(&request.name)))?;
{
let mut query = url.query_pairs_mut();
if !request.node_filter.is_empty() {
query.append_pair("node", &request.node_filter);
}
if !request.service_filter.is_empty() {
query.append_pair("service", &request.service_filter);
}
if !request.tag_filter.is_empty() {
query.append_pair("tag", &request.tag_filter);
}
}
client.append_scope(&mut url, false);
let response = client.send(Method::PUT, url, Some(payload)).await?;
let response = super::response::ensure_success(response, "fire Consul event", client.token()).await?;
super::response::decode_json_response(response, "fire Consul event").await
}
fn validate_event_text(value: &str, field: &str, max_bytes: usize) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("CONSUL_INVALID_REQUEST: Event {field} is required"));
}
if value.len() > max_bytes {
return Err(format!("CONSUL_EVENT_INPUT_TOO_LARGE: Event {field} exceeds {max_bytes} bytes"));
}
Ok(())
}
pub async fn consul_coordinate_nodes_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<ConsulCoordinate>, String> {
let client = client_for_state(state, connection_id).await?;
let url = client.api_url("/v1/coordinate/nodes")?;
client.request_json(Method::GET, url, None::<&()>, true, "list network coordinates").await
}
fn encode(value: &str) -> String {
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
}
fn zero_index() -> KvInt64 {
KvInt64("0".to_string())
}
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepared_query_transport_input_maps_to_pascal_case_wire_body() {
let input = ConsulPreparedQueryInput {
name: "payments".into(),
session: "session-1".into(),
service: ConsulPreparedQueryService { service: "api".into(), ..Default::default() },
};
let value = serde_json::to_value(input.wire()).unwrap();
assert_eq!(value["Name"], "payments");
assert_eq!(value["Session"], "session-1");
assert_eq!(value["Service"]["Service"], "api");
assert!(value.get("name").is_none());
}
#[test]
fn prepared_query_update_preserves_advanced_and_sensitive_fields() {
let mut existing = serde_json::json!({
"ID": "query-1",
"Name": "old",
"Token": "keep-secret",
"Service": { "Service": "old-api", "Near": "_agent", "Tags": ["v1"], "Failover": { "Datacenters": ["dc2"] } },
"DNS": { "TTL": "5s" },
"CreateIndex": 10,
"ModifyIndex": 11
});
let input = ConsulPreparedQueryInput {
name: "new".into(),
session: String::new(),
service: ConsulPreparedQueryService { service: "new-api".into(), ..Default::default() },
};
apply_prepared_query_patch(&mut existing, &input).unwrap();
assert_eq!(existing["Name"], "new");
assert_eq!(existing["Service"]["Service"], "new-api");
assert_eq!(existing["Service"]["Near"], "_agent");
assert_eq!(existing["Token"], "keep-secret");
assert_eq!(existing["DNS"]["TTL"], "5s");
assert!(existing.get("ID").is_none());
assert!(existing.get("ModifyIndex").is_none());
}
#[test]
fn event_validation_rejects_reserved_names_and_oversized_encoded_payloads() {
assert!(validate_event_text("", "name", MAX_EVENT_NAME_BYTES).is_err());
assert!("_internal".starts_with('_'));
let oversized = vec![0_u8; MAX_EVENT_PAYLOAD_BYTES + 3];
assert!(STANDARD.encode(oversized).len() > MAX_EVENT_PAYLOAD_BYTES.div_ceil(3) * 4);
}
#[test]
fn official_event_response_uses_id_acronym() {
let event: ConsulEvent = serde_json::from_value(serde_json::json!({
"ID": "7f12d2f4-2cb8-4795-9e5f-f8e35e9912aa",
"Name": "deploy",
"Payload": "cmVsZWFzZS0x",
"NodeFilter": "",
"ServiceFilter": "api",
"TagFilter": "v1",
"Version": 1,
"LTime": 42
}))
.unwrap();
assert_eq!(event.id, "7f12d2f4-2cb8-4795-9e5f-f8e35e9912aa");
assert_eq!(event.l_time, KvInt64("42".into()));
let encoded = serde_json::to_value(event).unwrap();
assert_eq!(encoded["ID"], "7f12d2f4-2cb8-4795-9e5f-f8e35e9912aa");
assert!(encoded.get("Id").is_none());
}
#[test]
fn official_prepared_query_response_preserves_protocol_acronyms() {
let query: ConsulPreparedQuery = serde_json::from_value(serde_json::json!({
"ID": "query-1",
"Name": "payments",
"Session": "",
"Service": { "Service": "api", "Near": "", "OnlyPassing": true, "Tags": ["v1"] },
"CreateIndex": 10,
"ModifyIndex": 11
}))
.unwrap();
assert_eq!(query.id, "query-1");
let encoded = serde_json::to_value(query).unwrap();
assert_eq!(encoded["ID"], "query-1");
assert!(encoded.get("Id").is_none());
}
#[test]
fn official_prepared_query_execute_response_preserves_dns_ttl_and_service_id() {
let response: ConsulPreparedQueryExecuteResponse = serde_json::from_value(serde_json::json!({
"Service": "api",
"Nodes": [{
"Node": { "Node": "node-1", "Address": "10.0.0.1", "Datacenter": "dc1" },
"Service": { "ID": "api-1", "Service": "api", "Port": 8080, "Tags": null }
}],
"DNS": { "TTL": "5s" },
"Datacenter": "dc1"
}))
.unwrap();
assert_eq!(response.nodes[0].service.id, "api-1");
assert_eq!(response.nodes[0].service.address, "");
assert!(response.nodes[0].service.tags.is_empty());
assert_eq!(response.dns.ttl, "5s");
let encoded = serde_json::to_value(response).unwrap();
assert_eq!(encoded["Nodes"][0]["Service"]["ID"], "api-1");
assert_eq!(encoded["DNS"]["TTL"], "5s");
assert!(encoded.get("Dns").is_none());
}
#[test]
fn official_prepared_query_explain_response_unwraps_query_document() {
let response: ConsulPreparedQueryExplainResponse = serde_json::from_value(serde_json::json!({
"Query": {
"ID": "query-1",
"Name": "payments",
"Session": "",
"Token": "secret-management-token",
"Template": { "Type": "name_prefix_match", "Regexp": "^payments-(.*)$" },
"Service": { "Service": "api", "Near": "", "OnlyPassing": true, "Tags": [] },
"DNS": { "TTL": "5s" },
"CreateIndex": 10,
"ModifyIndex": 11
},
"Index": 11,
"KnownLeader": true
}))
.unwrap();
let mut query = response.query;
redact_prepared_query_secrets(&mut query);
assert_eq!(query["ID"], "query-1");
assert_eq!(query["Service"]["Service"], "api");
assert_eq!(query["Template"]["Type"], "name_prefix_match");
assert_eq!(query["DNS"]["TTL"], "5s");
assert_eq!(query["Token"], "****************");
}
}
+527
View File
@@ -0,0 +1,527 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use reqwest::{Method, StatusCode};
use serde::{Deserialize, Serialize};
use crate::agent_kv::{KvInt64, KvValue, KvValueEncoding};
use crate::connection::AppState;
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
use super::response::{decode_json_response, ensure_success};
pub const MAX_TXN_OPERATIONS: usize = 64;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ConsulTxnVerb {
Get,
GetTree,
Set,
Cas,
Delete,
DeleteCas,
DeleteTree,
Lock,
Unlock,
CheckIndex,
CheckNotExists,
CheckSession,
}
impl ConsulTxnVerb {
fn as_str(self) -> &'static str {
match self {
Self::Get => "get",
Self::GetTree => "get-tree",
Self::Set => "set",
Self::Cas => "cas",
Self::Delete => "delete",
Self::DeleteCas => "delete-cas",
Self::DeleteTree => "delete-tree",
Self::Lock => "lock",
Self::Unlock => "unlock",
Self::CheckIndex => "check-index",
Self::CheckNotExists => "check-not-exists",
Self::CheckSession => "check-session",
}
}
fn writes(self) -> bool {
matches!(
self,
Self::Set | Self::Cas | Self::Delete | Self::DeleteCas | Self::DeleteTree | Self::Lock | Self::Unlock
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulTxnKvOperation {
pub verb: ConsulTxnVerb,
pub key: String,
pub value: Option<KvValue>,
pub flags: Option<KvInt64>,
pub index: Option<KvInt64>,
pub session: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulTxnRequest {
pub operations: Vec<ConsulTxnKvOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulTxnError {
pub op_index: usize,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsulTxnResult {
pub committed: bool,
pub errors: Vec<ConsulTxnError>,
pub results: Vec<ConsulTxnKvResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConsulTxnKvResult {
pub key: String,
pub value: Option<KvValue>,
pub flags: KvInt64,
pub create_index: KvInt64,
pub modify_index: KvInt64,
pub lock_index: KvInt64,
pub session: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RawTxnResponse {
#[serde(default)]
errors: Option<Vec<RawTxnError>>,
#[serde(default)]
results: Option<Vec<RawTxnResult>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RawTxnResult {
#[serde(rename = "KV", alias = "Kv")]
kv: Option<RawTxnKvResult>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RawTxnKvResult {
#[serde(default)]
key: String,
value: Option<String>,
#[serde(default)]
flags: u64,
#[serde(default)]
create_index: u64,
#[serde(default)]
modify_index: u64,
#[serde(default)]
lock_index: u64,
session: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RawTxnError {
op_index: usize,
what: String,
}
impl ConsulClient {
pub(super) async fn txn(&self, request: ConsulTxnRequest) -> Result<ConsulTxnResult, String> {
validate_operations(&request.operations)?;
let body = serde_json::to_vec(&request.operations.iter().map(operation_json).collect::<Result<Vec<_>, _>>()?)
.map_err(|error| format!("Failed to encode Consul transaction: {error}"))?;
let mut url = self.api_url("/v1/txn")?;
self.append_scope(&mut url, false);
let response = self.send(Method::PUT, url, Some(body)).await?;
let status = response.status();
let response = if is_txn_result_status(response.status()) {
response
} else {
ensure_success(response, "execute transaction", self.token()).await?
};
let response = decode_json_response::<RawTxnResponse>(response, "execute transaction").await?;
map_txn_response(status, response)
}
}
fn map_txn_response(status: StatusCode, response: RawTxnResponse) -> Result<ConsulTxnResult, String> {
let errors = response
.errors
.unwrap_or_default()
.into_iter()
.map(|error| ConsulTxnError { op_index: error.op_index, message: error.what })
.collect::<Vec<_>>();
let results = response
.results
.unwrap_or_default()
.into_iter()
.filter_map(|result| result.kv)
.map(txn_result)
.collect::<Result<_, _>>()?;
Ok(ConsulTxnResult { committed: status.is_success() && errors.is_empty(), errors, results })
}
fn is_txn_result_status(status: StatusCode) -> bool {
status.is_success() || status == StatusCode::CONFLICT
}
pub async fn consul_txn_core(
state: &AppState,
connection_id: &str,
request: ConsulTxnRequest,
) -> Result<ConsulTxnResult, String> {
validate_public_operations(&request.operations)?;
if request.operations.iter().any(|operation| operation.verb.writes()) {
ensure_writable_core(state, connection_id, "execute Consul transaction").await?;
}
let client = client_for_state(state, connection_id).await?;
for operation in request
.operations
.iter()
.filter(|operation| matches!(operation.verb, ConsulTxnVerb::Cas | ConsulTxnVerb::DeleteCas))
{
let current = client.get_key(&operation.key).await?;
if current
.metadata
.as_ref()
.and_then(|metadata| metadata.session.as_deref())
.is_some_and(|session| !session.is_empty())
{
return Err(format!("CONSUL_KEY_LOCKED: The Consul KV key '{}' is held by a Session", operation.key));
}
}
client.txn(request).await
}
pub async fn consul_rename_key_core(
state: &AppState,
connection_id: &str,
source: &str,
target: &str,
expected_modify_index: KvInt64,
copy: bool,
) -> Result<ConsulTxnResult, String> {
ensure_writable_core(state, connection_id, if copy { "copy Consul KV key" } else { "rename Consul KV key" })
.await?;
if source.is_empty() || target.is_empty() || source == target {
return Err("CONSUL_RENAME_INVALID: Source and target keys must be distinct and non-empty".to_string());
}
let expected = parse_index(&expected_modify_index)?;
let client = client_for_state(state, connection_id).await?;
let current = client.get_key(source).await?;
if !current.found {
return Err("CONSUL_KEY_NOT_FOUND: Source key no longer exists".to_string());
}
let current_index = current
.metadata
.as_ref()
.and_then(|metadata| metadata.mod_revision.as_ref())
.ok_or("CONSUL_CAS_REQUIRED: Source key has no ModifyIndex")?;
if parse_index(current_index)? != expected {
return Err("CONSUL_CAS_CONFLICT: Source key changed before the transaction".to_string());
}
if current.metadata.as_ref().and_then(|metadata| metadata.session.as_deref()).is_some_and(|value| !value.is_empty())
{
return Err("CONSUL_KEY_LOCKED: Source key is held by a Consul Session".to_string());
}
let mut operations = vec![
ConsulTxnKvOperation {
verb: ConsulTxnVerb::CheckIndex,
key: source.to_string(),
value: None,
flags: None,
index: Some(expected_modify_index.clone()),
session: None,
},
operation(ConsulTxnVerb::CheckNotExists, target),
ConsulTxnKvOperation {
verb: ConsulTxnVerb::Cas,
key: target.to_string(),
value: current.value,
flags: current.metadata.as_ref().and_then(|metadata| metadata.flags.clone()),
index: Some(KvInt64("0".to_string())),
session: None,
},
];
if !copy {
operations.push(ConsulTxnKvOperation {
verb: ConsulTxnVerb::DeleteCas,
key: source.to_string(),
value: None,
flags: None,
index: Some(expected_modify_index),
session: None,
});
}
client.txn(ConsulTxnRequest { operations }).await
}
pub(crate) fn cas_operation(key: String, value: KvValue, flags: KvInt64, index: KvInt64) -> ConsulTxnKvOperation {
ConsulTxnKvOperation {
verb: ConsulTxnVerb::Cas,
key,
value: Some(value),
flags: Some(flags),
index: Some(index),
session: None,
}
}
pub(crate) fn delete_cas_operation(key: String, index: KvInt64) -> ConsulTxnKvOperation {
ConsulTxnKvOperation {
verb: ConsulTxnVerb::DeleteCas,
key,
value: None,
flags: None,
index: Some(index),
session: None,
}
}
fn operation(verb: ConsulTxnVerb, key: &str) -> ConsulTxnKvOperation {
ConsulTxnKvOperation { verb, key: key.to_string(), value: None, flags: None, index: None, session: None }
}
fn validate_operations(operations: &[ConsulTxnKvOperation]) -> Result<(), String> {
if operations.is_empty() {
return Err("CONSUL_TXN_EMPTY: A transaction requires at least one operation".to_string());
}
if operations.len() > MAX_TXN_OPERATIONS {
return Err(format!(
"CONSUL_TXN_LIMIT_EXCEEDED: Consul transactions support at most {MAX_TXN_OPERATIONS} operations"
));
}
for operation in operations {
if operation.key.is_empty() {
return Err("CONSUL_TXN_KEY_REQUIRED: Transaction keys cannot be empty".to_string());
}
if operation.index.as_ref().is_some_and(|index| parse_index(index).is_err()) {
return Err(format!("CONSUL_TXN_INDEX_INVALID: Invalid Index for key {}", operation.key));
}
if operation.flags.as_ref().is_some_and(|flags| parse_index(flags).is_err()) {
return Err(format!("CONSUL_TXN_FLAGS_INVALID: Invalid Flags for key {}", operation.key));
}
if matches!(operation.verb, ConsulTxnVerb::Lock | ConsulTxnVerb::Unlock | ConsulTxnVerb::CheckSession)
&& operation.session.as_deref().map(str::trim).is_none_or(str::is_empty)
{
return Err(format!(
"CONSUL_TXN_SESSION_REQUIRED: {:?} requires a Session for key {}",
operation.verb, operation.key
));
}
if matches!(operation.verb, ConsulTxnVerb::Lock) && operation.value.is_none() {
return Err(format!("CONSUL_TXN_VALUE_REQUIRED: lock requires a value for key {}", operation.key));
}
}
Ok(())
}
fn validate_public_operations(operations: &[ConsulTxnKvOperation]) -> Result<(), String> {
validate_operations(operations)?;
for operation in operations {
match operation.verb {
ConsulTxnVerb::Set | ConsulTxnVerb::Delete | ConsulTxnVerb::DeleteTree => {
return Err(format!(
"CONSUL_TXN_UNSAFE_VERB: Public transactions cannot use unconditional {:?}; use CAS or a dedicated prefix workflow",
operation.verb
));
}
ConsulTxnVerb::Cas | ConsulTxnVerb::DeleteCas if operation.index.is_none() => {
return Err(format!(
"CONSUL_CAS_REQUIRED: {:?} requires an Index for key {}",
operation.verb, operation.key
));
}
ConsulTxnVerb::Cas if operation.value.is_none() => {
return Err(format!("CONSUL_TXN_VALUE_REQUIRED: cas requires a value for key {}", operation.key));
}
_ => {}
}
}
Ok(())
}
fn txn_result(raw: RawTxnKvResult) -> Result<ConsulTxnKvResult, String> {
let value = raw
.value
.as_deref()
.map(|encoded| {
let bytes = STANDARD
.decode(encoded)
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Invalid transaction Value: {error}"))?;
Ok::<_, String>(match String::from_utf8(bytes.clone()) {
Ok(value) => KvValue { encoding: KvValueEncoding::Utf8, data: value },
Err(_) => KvValue { encoding: KvValueEncoding::Base64, data: STANDARD.encode(bytes) },
})
})
.transpose()?;
Ok(ConsulTxnKvResult {
key: raw.key,
value,
flags: KvInt64(raw.flags.to_string()),
create_index: KvInt64(raw.create_index.to_string()),
modify_index: KvInt64(raw.modify_index.to_string()),
lock_index: KvInt64(raw.lock_index.to_string()),
session: raw.session.filter(|session| !session.is_empty()),
})
}
fn operation_json(operation: &ConsulTxnKvOperation) -> Result<serde_json::Value, String> {
let mut kv = serde_json::Map::new();
kv.insert("Verb".to_string(), operation.verb.as_str().into());
kv.insert("Key".to_string(), operation.key.clone().into());
if let Some(value) = &operation.value {
let bytes = match value.encoding {
KvValueEncoding::Utf8 => value.data.as_bytes().to_vec(),
KvValueEncoding::Base64 => STANDARD
.decode(value.data.chars().filter(|character| !character.is_whitespace()).collect::<String>())
.map_err(|error| format!("CONSUL_VALUE_ENCODING_INVALID: {error}"))?,
};
kv.insert("Value".to_string(), STANDARD.encode(bytes).into());
}
if let Some(flags) = &operation.flags {
kv.insert("Flags".to_string(), parse_index(flags)?.into());
}
if let Some(index) = &operation.index {
kv.insert("Index".to_string(), parse_index(index)?.into());
}
if let Some(session) = &operation.session {
kv.insert("Session".to_string(), session.clone().into());
}
Ok(serde_json::json!({ "KV": kv }))
}
fn parse_index(value: &KvInt64) -> Result<u64, String> {
value.as_str().parse::<u64>().map_err(|_| format!("Invalid unsigned 64-bit integer: {}", value.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::consul::test_support::{serve_once, test_client};
#[test]
fn enforces_transaction_limit() {
let operations =
(0..65).map(|index| operation(ConsulTxnVerb::Get, &format!("key-{index}"))).collect::<Vec<_>>();
assert!(validate_operations(&operations).unwrap_err().contains("CONSUL_TXN_LIMIT_EXCEEDED"));
}
#[test]
fn serializes_u64_as_json_number_and_value_as_base64() {
let value = KvValue { encoding: KvValueEncoding::Utf8, data: "hello".to_string() };
let encoded = operation_json(&cas_operation(
"a b".to_string(),
value,
KvInt64(u64::MAX.to_string()),
KvInt64("0".to_string()),
))
.unwrap();
assert_eq!(encoded["KV"]["Value"], "aGVsbG8=");
assert_eq!(encoded["KV"]["Flags"], u64::MAX);
}
#[test]
fn public_transactions_reject_unconditional_writes_and_require_cas_fields() {
for verb in [ConsulTxnVerb::Set, ConsulTxnVerb::Delete, ConsulTxnVerb::DeleteTree] {
assert!(validate_public_operations(&[operation(verb, "key")]).unwrap_err().contains("UNSAFE_VERB"));
}
assert!(validate_public_operations(&[operation(ConsulTxnVerb::Cas, "key")])
.unwrap_err()
.contains("CAS_REQUIRED"));
let mut cas = cas_operation(
"key".into(),
KvValue { encoding: KvValueEncoding::Utf8, data: "value".into() },
KvInt64("0".into()),
KvInt64("0".into()),
);
assert!(validate_public_operations(&[cas.clone()]).is_ok());
cas.value = None;
assert!(validate_public_operations(&[cas]).unwrap_err().contains("VALUE_REQUIRED"));
}
#[test]
fn transaction_results_preserve_kv_data_and_indexes() {
let result = txn_result(RawTxnKvResult {
key: "binary".into(),
value: Some("AP+A".into()),
flags: u64::MAX,
create_index: 2,
modify_index: 3,
lock_index: 4,
session: Some("session-1".into()),
})
.unwrap();
assert_eq!(result.value.unwrap().encoding, KvValueEncoding::Base64);
assert_eq!(result.flags.as_str(), u64::MAX.to_string());
assert_eq!(result.modify_index.as_str(), "3");
assert_eq!(result.session.as_deref(), Some("session-1"));
}
#[test]
fn transaction_conflicts_are_decoded_as_results() {
assert!(is_txn_result_status(StatusCode::OK));
assert!(is_txn_result_status(StatusCode::CONFLICT));
assert!(!is_txn_result_status(StatusCode::BAD_REQUEST));
assert!(!is_txn_result_status(StatusCode::INTERNAL_SERVER_ERROR));
}
#[test]
fn empty_conflict_response_is_never_committed() {
let result =
map_txn_response(StatusCode::CONFLICT, RawTxnResponse { errors: Some(vec![]), results: Some(vec![]) })
.unwrap();
assert!(!result.committed);
assert!(result.errors.is_empty());
assert!(result.results.is_empty());
}
#[test]
fn committed_delete_response_accepts_consul_null_collections() {
// Consul returns this exact shape for a successful delete-cas transaction.
let raw: RawTxnResponse = serde_json::from_str(r#"{"Results":[],"Errors":null}"#).unwrap();
let result = map_txn_response(StatusCode::OK, raw).unwrap();
assert!(result.committed);
assert!(result.errors.is_empty());
assert!(result.results.is_empty());
}
#[tokio::test]
async fn conflict_response_preserves_results_errors_and_wire_contract() {
let body = r#"{"Results":[{"KV":{"Key":"source","Value":"aGVsbG8=","Flags":7,"CreateIndex":2,"ModifyIndex":3,"LockIndex":0}}],"Errors":[{"OpIndex":1,"What":"target exists"}]}"#;
let response = format!(
"HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (url, request_rx) = serve_once(response).await;
let client = test_client(url).await;
let result =
client.txn(ConsulTxnRequest { operations: vec![operation(ConsulTxnVerb::Get, "source")] }).await.unwrap();
assert!(!result.committed);
assert_eq!(result.errors[0].op_index, 1);
assert_eq!(result.results[0].key, "source");
assert_eq!(result.results[0].value.as_ref().unwrap().data, "hello");
let request = request_rx.await.unwrap();
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
assert!(headers.starts_with("PUT /proxy/v1/txn?dc=dc1&ns=team-a&partition=partition-a HTTP/1.1"));
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
let wire: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(wire[0]["KV"]["Verb"], "get");
assert_eq!(wire[0]["KV"]["Key"], "source");
}
}
+13
View File
@@ -0,0 +1,13 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct ConsulKvEntry {
pub(super) create_index: u64,
pub(super) modify_index: u64,
pub(super) lock_index: u64,
pub(super) key: String,
pub(super) flags: u64,
pub(super) value: Option<String>,
pub(super) session: Option<String>,
}
+1
View File
@@ -25,6 +25,7 @@ pub mod cloud_sync;
pub mod config;
pub mod connection;
pub mod connection_secrets;
pub mod consul;
pub mod correction;
pub mod csv_export;
pub mod data_compare;
+73 -1
View File
@@ -2,6 +2,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@@ -68,7 +69,7 @@ pub fn database_info_from_protocol_value(value: &Value) -> Option<DatabaseConnec
serde_json::from_value::<DatabaseInfoEnvelope>(value.clone()).ok()?.database_info
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[derive(Clone, Serialize, PartialEq)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
@@ -182,6 +183,42 @@ pub struct ConnectionConfig {
pub database_info: Option<DatabaseConnectionInfo>,
}
impl fmt::Debug for ConnectionConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut value = serde_json::to_value(self).map_err(|_| fmt::Error)?;
redact_connection_debug_value(&mut value);
formatter.debug_tuple("ConnectionConfig").field(&value).finish()
}
}
fn redact_connection_debug_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
let key = key.to_ascii_lowercase().replace(['_', '-'], "");
if key.contains("password")
|| key.contains("passphrase")
|| key.contains("token")
|| key.contains("secret")
|| key.contains("apikey")
|| key == "connectionstring"
|| key == "initscript"
{
*value = serde_json::Value::String("[REDACTED]".to_string());
} else {
redact_connection_debug_value(value);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
redact_connection_debug_value(value);
}
}
_ => {}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum TransportLayerConfig {
@@ -536,6 +573,7 @@ pub enum DatabaseType {
#[serde(rename = "zookeeper")]
ZooKeeper,
Nacos,
Consul,
#[serde(rename = "iris")]
Iris,
#[serde(rename = "turso")]
@@ -1123,6 +1161,7 @@ impl ConnectionConfig {
DatabaseType::MessageQueue => self.message_queue_admin_url(),
DatabaseType::Mqtt => self.mqtt_broker_url(),
DatabaseType::Nacos => self.nacos_admin_url(),
DatabaseType::Consul => self.consul_api_url(),
}
}
@@ -1365,6 +1404,7 @@ impl ConnectionConfig {
DatabaseType::MessageQueue => self.message_queue_admin_url(),
DatabaseType::Mqtt => self.mqtt_broker_url(),
DatabaseType::Nacos => self.nacos_admin_url(),
DatabaseType::Consul => self.consul_api_url(),
}
}
@@ -1401,6 +1441,20 @@ impl ConnectionConfig {
.to_string()
}
fn consul_api_url(&self) -> String {
self.external_config
.as_ref()
.and_then(|value| value.get("serverAddr").or_else(|| value.get("server_addr")))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{}:{}", bracket_ipv6(&self.host), self.port)
})
}
fn mqtt_broker_url(&self) -> String {
#[cfg(feature = "mq-admin")]
{
@@ -2368,6 +2422,24 @@ mod tests {
}
}
#[test]
fn connection_debug_redacts_passwords_tokens_and_nested_secrets() {
let mut config = mysql_config("root", "database-password", Some("app"));
config.connection_string = Some("server=db;password=inline-secret".into());
config.init_script = Some("CREATE SECRET leaked".into());
config.external_config = Some(serde_json::json!({
"consulToken": "consul-secret",
"nested": { "OIDCClientSecret": "oidc-secret", "visible": "safe" }
}));
let output = format!("{config:?}");
for secret in ["database-password", "inline-secret", "CREATE SECRET leaked", "consul-secret", "oidc-secret"] {
assert!(!output.contains(secret));
}
assert!(output.contains("[REDACTED]"));
assert!(output.contains("safe"));
}
#[test]
fn legacy_mcp_access_is_ignored_and_not_serialized() {
let legacy: ConnectionConfig = serde_json::from_value(serde_json::json!({
+3 -1
View File
@@ -1669,6 +1669,7 @@ async fn do_execute_typed(
.map(|result| truncate_result_with_max_rows(result, max_rows))
}
PoolKind::HBase(_) => Err("SQL execution is not supported for HBase connections".to_string()),
PoolKind::Consul(_) => Err("SQL execution is not supported for Consul connections".to_string()),
};
result
.map(normalize_query_result_for_js)
@@ -2852,6 +2853,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
| PoolKind::Agent(_) => true,
PoolKind::MessageQueue
| PoolKind::Nacos
| PoolKind::Consul(_)
| PoolKind::HBase(_)
| PoolKind::DuckDbWorker(_)
| PoolKind::Redis(_)
@@ -3095,7 +3097,7 @@ pub async fn execute_statements_in_transaction_on_pool_typed(
TxPath::Explicit
}
PoolKind::Agent(client) => TxPath::Agent(client.clone()),
PoolKind::MessageQueue | PoolKind::Nacos | PoolKind::HBase(_) => TxPath::None,
PoolKind::MessageQueue | PoolKind::Nacos | PoolKind::Consul(_) | PoolKind::HBase(_) => TxPath::None,
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => TxPath::None,
PoolKind::DuckDbWorker(_)
@@ -674,7 +674,7 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | Elasticsearch | Easysearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra
| Etcd | ZooKeeper | Nacos | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
| Etcd | ZooKeeper | Nacos | Consul | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
conservative_ansi(db_type)
}
}
+2 -2
View File
@@ -2627,7 +2627,7 @@ mod tests {
)
.await;
let export = run_external_driver_export(&fixture);
let export = Box::pin(run_external_driver_export(&fixture));
let cancel = async {
wait_for_external_driver_call(&fixture.calls, "executeQueryPage").await;
set_export_cancelled(&fixture.request.export_id).await;
@@ -2666,7 +2666,7 @@ mod tests {
)
.await;
let export = run_external_driver_export(&fixture);
let export = Box::pin(run_external_driver_export(&fixture));
let cancel = async {
wait_for_external_driver_call(&fixture.calls, "fetchQueryPage").await;
set_export_cancelled(&fixture.request.export_id).await;
+103
View File
@@ -557,6 +557,109 @@ async fn main() {
.route("/zookeeper/get", post(routes::zookeeper::get))
.route("/zookeeper/put", post(routes::zookeeper::put))
.route("/zookeeper/delete", post(routes::zookeeper::delete))
// Consul
.route("/consul/capabilities", post(routes::consul::capabilities))
.route("/consul/txn", post(routes::consul::txn))
.route("/consul/rename-key", post(routes::consul::rename_key))
.route("/consul/blocking-query", post(routes::consul::blocking_query))
.route("/consul/domain-watch", post(routes::consul::domain_watch))
.route("/consul/cancel-blocking", post(routes::consul::cancel_blocking))
.route("/consul/list-prefix", post(routes::consul::list_prefix))
.route("/consul/list-recursive", post(routes::consul::list_recursive))
.route("/consul/search", post(routes::consul::search))
.route("/consul/search-progress", post(routes::consul::search_progress))
.route("/consul/cancel-search", post(routes::consul::cancel_search))
.route("/consul/export-bundle", post(routes::consul::export_bundle))
.route("/consul/import-preview", post(routes::consul::import_preview))
.route("/consul/import-execute", post(routes::consul::import_execute))
.route("/consul/delete-prefix-preview", post(routes::consul::delete_prefix_preview))
.route("/consul/delete-prefix-execute", post(routes::consul::delete_prefix_execute))
.route("/consul/get", post(routes::consul::get))
.route("/consul/put", post(routes::consul::put))
.route("/consul/delete", post(routes::consul::delete))
.route("/consul/prepared-query/list", post(routes::consul::prepared_query_list))
.route("/consul/prepared-query/read", post(routes::consul::prepared_query_read))
.route("/consul/prepared-query/create", post(routes::consul::prepared_query_create))
.route("/consul/prepared-query/update", post(routes::consul::prepared_query_update))
.route("/consul/prepared-query/delete", post(routes::consul::prepared_query_delete))
.route("/consul/prepared-query/execute", post(routes::consul::prepared_query_execute))
.route("/consul/prepared-query/explain", post(routes::consul::prepared_query_explain))
.route("/consul/event/list", post(routes::consul::event_list))
.route("/consul/event/fire", post(routes::consul::event_fire).layer(DefaultBodyLimit::max(16 * 1024)))
.route("/consul/coordinate/nodes", post(routes::consul::coordinate_nodes))
.route("/consul/operator/read", post(routes::consul::operator_read))
.route("/consul/operator/snapshot/generate", post(routes::consul::snapshot_generate))
.route("/consul/operator/snapshot/restore", post(routes::consul::snapshot_restore))
.route("/consul/operator/autopilot/update", post(routes::consul::autopilot_update))
.route("/consul/operator/raft/transfer", post(routes::consul::raft_transfer))
.route("/consul/operator/raft/remove", post(routes::consul::raft_remove))
.route("/consul/operator/keyring/write", post(routes::consul::keyring_write))
.route("/consul/operator/license/write", post(routes::consul::license_write))
.route("/consul/status/leader", post(routes::consul::status_leader))
.route("/consul/status/peers", post(routes::consul::status_peers))
.route("/consul/agent/self", post(routes::consul::agent_self))
.route("/consul/agent/members", post(routes::consul::agent_members))
.route("/consul/agent/metrics", post(routes::consul::agent_metrics))
.route("/consul/catalog/datacenters", post(routes::consul::catalog_datacenters))
.route("/consul/catalog/nodes", post(routes::consul::catalog_nodes))
.route("/consul/catalog/services", post(routes::consul::catalog_services))
.route("/consul/catalog/service-nodes", post(routes::consul::catalog_service_nodes))
.route("/consul/catalog/node-services", post(routes::consul::catalog_node_services))
.route("/consul/health/node", post(routes::consul::health_node))
.route("/consul/health/checks", post(routes::consul::health_checks))
.route("/consul/health/service", post(routes::consul::health_service))
.route("/consul/health/state", post(routes::consul::health_state))
.route("/consul/agent/services", post(routes::consul::agent_services))
.route("/consul/agent/service", post(routes::consul::agent_service))
.route("/consul/agent/checks", post(routes::consul::agent_checks))
.route("/consul/agent/service/register", post(routes::consul::agent_register_service))
.route("/consul/agent/service/deregister", post(routes::consul::agent_deregister_service))
.route("/consul/agent/service/maintenance", post(routes::consul::agent_service_maintenance))
.route("/consul/agent/check/register", post(routes::consul::agent_register_check))
.route("/consul/agent/check/deregister", post(routes::consul::agent_deregister_check))
.route("/consul/agent/check/ttl", post(routes::consul::agent_update_ttl))
.route("/consul/sessions", post(routes::consul::sessions))
.route("/consul/sessions/node", post(routes::consul::node_sessions))
.route("/consul/session", post(routes::consul::session))
.route("/consul/session/keys", post(routes::consul::session_keys))
.route("/consul/session/destroy-impact", post(routes::consul::session_destroy_impact))
.route("/consul/session/create", post(routes::consul::create_session))
.route("/consul/session/renew", post(routes::consul::renew_session))
.route("/consul/session/destroy", post(routes::consul::destroy_session))
.route("/consul/lock/acquire", post(routes::consul::acquire_lock))
.route("/consul/lock/release", post(routes::consul::release_lock))
.route("/consul/acl/list", post(routes::consul::acl_list))
.route("/consul/acl/token/self", post(routes::consul::acl_token_self))
.route("/consul/acl/token/clone", post(routes::consul::acl_token_clone))
.route("/consul/acl/get", post(routes::consul::acl_get))
.route("/consul/acl/apply", post(routes::consul::acl_apply))
.route("/consul/acl/references", post(routes::consul::acl_references))
.route("/consul/acl/delete", post(routes::consul::acl_delete))
.route("/consul/enterprise/list", post(routes::consul::enterprise_list))
.route("/consul/enterprise/get", post(routes::consul::enterprise_get))
.route("/consul/enterprise/apply", post(routes::consul::enterprise_apply))
.route("/consul/enterprise/impact", post(routes::consul::enterprise_impact))
.route("/consul/enterprise/delete", post(routes::consul::enterprise_delete))
.route("/consul/mesh/config/list", post(routes::consul::mesh_config_list))
.route("/consul/mesh/config/get", post(routes::consul::mesh_config_get))
.route("/consul/mesh/config/apply", post(routes::consul::mesh_config_apply))
.route("/consul/mesh/config/delete", post(routes::consul::mesh_config_delete))
.route("/consul/mesh/intentions/list", post(routes::consul::mesh_intentions_list))
.route("/consul/mesh/intentions/get", post(routes::consul::mesh_intention_get))
.route("/consul/mesh/intentions/get-exact", post(routes::consul::mesh_intention_get_exact))
.route("/consul/mesh/intentions/upsert", post(routes::consul::mesh_intention_upsert))
.route("/consul/mesh/intentions/delete", post(routes::consul::mesh_intention_delete))
.route("/consul/mesh/intentions/delete-exact", post(routes::consul::mesh_intention_delete_exact))
.route("/consul/mesh/intentions/match", post(routes::consul::mesh_intention_match))
.route("/consul/mesh/intentions/check", post(routes::consul::mesh_intention_check))
.route("/consul/mesh/discovery-chain", post(routes::consul::mesh_discovery_chain))
.route("/consul/mesh/peerings/list", post(routes::consul::mesh_peering_list))
.route("/consul/mesh/peerings/get", post(routes::consul::mesh_peering_get))
.route("/consul/mesh/peerings/generate-token", post(routes::consul::mesh_peering_generate_token))
.route("/consul/mesh/peerings/establish", post(routes::consul::mesh_peering_establish))
.route("/consul/mesh/peerings/delete", post(routes::consul::mesh_peering_delete))
.route("/consul/mesh/exported-services/list", post(routes::consul::mesh_exported_services_list))
.route("/consul/mesh/exported-services/apply", post(routes::consul::mesh_exported_services_apply))
// HBase REST
.route("/hbase/table-schema", post(routes::hbase::get_table_schema))
.route("/hbase/scan-rows", post(routes::hbase::scan_rows))
+102 -6
View File
@@ -180,6 +180,41 @@ async fn run_temporary_connection_test(
None
};
// Keep all fallible post-connect checks inside this block so cleanup below
// runs before either a successful result or an error is returned.
let result: Result<ConnectionTestResult, String> = async {
let success_message = if pool_result.is_ok() && config.db_type == DatabaseType::Consul {
let client = {
let connections = app.connections.read().await;
match connections.get(&temp_id) {
Some(PoolKind::Consul(client)) => Some(client.clone()),
_ => None,
}
};
if let Some(client) = client {
let configured_target =
dbx_core::consul::ConsulConfig::from_connection(&config)?.agent_target.is_some();
let identity = if configured_target {
Some(client.validate_configured_agent_target().await?)
} else {
client.agent_self().await.ok()
};
identity
.map(|identity| format!("Connection successful (Agent: {} at {})", identity.node, identity.address))
.unwrap_or_else(|| {
"Connection successful (Agent identity unavailable; Agent writes disabled)".to_string()
})
} else {
"Connection successful (Agent identity unavailable; Agent writes disabled)".to_string()
}
} else {
"Connection successful".to_string()
};
pool_result.map(|_| ConnectionTestResult::success(success_message).with_database_info(database_info))
}
.await;
app.remove_connection_pools(&temp_id).await;
// Pool drain intentionally keeps durable MQ adapters for reconnect reuse; temporary
// probes must still release any registry entry if a cached path was used.
@@ -188,7 +223,7 @@ async fn run_temporary_connection_test(
app.reset_connection_transport_for_config(&temp_id, &config).await;
app.configs.write().await.remove(&temp_id);
pool_result.map(|_| ConnectionTestResult::success("Connection successful").with_database_info(database_info))
result
}
pub async fn test_connection(
@@ -484,9 +519,10 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
mod tests {
use super::{
apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections,
mark_mongo_legacy_driver, mcp_add_connection, mcp_remove_connection, save_connection_database_info,
save_connections, test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest,
McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
mark_mongo_legacy_driver, mcp_add_connection, mcp_remove_connection, run_temporary_connection_test,
save_connection_database_info, save_connections, test_connection, test_connection_with_info, ConnectRequest,
DisconnectRequest, McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest,
SaveConnectionsRequest,
};
use crate::state::WebState;
use axum::extract::State;
@@ -498,9 +534,7 @@ mod tests {
};
use dbx_core::storage::{McpGlobalPolicy, Storage};
use std::sync::Arc;
#[cfg(feature = "mq-admin")]
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[cfg(feature = "mq-admin")]
use tokio::net::TcpListener;
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
@@ -575,6 +609,23 @@ mod tests {
config
}
fn consul_config(id: &str, server_addr: &str) -> ConnectionConfig {
let parsed = reqwest::Url::parse(server_addr).unwrap();
let mut config = sqlite_config(id, "");
config.name = "Consul".to_string();
config.db_type = DatabaseType::Consul;
config.host = parsed.host_str().unwrap().to_string();
config.port = parsed.port().unwrap();
config.external_config = Some(serde_json::json!({
"serverAddr": server_addr,
"agentTarget": {
"node": "expected-agent",
"address": "127.0.0.1"
}
}));
config
}
#[test]
fn mongo_legacy_marker_updates_only_mongodb_profiles() {
let mut mongo = sqlite_config("mongo", "");
@@ -675,6 +726,51 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn failed_consul_agent_target_validation_cleans_up_temporary_state() {
let (state, dir) = test_web_state().await;
let server_addr = spawn_consul_agent_server().await;
let config = consul_config("consul-test", &server_addr);
let error = run_temporary_connection_test(&state.app, config, false).await.unwrap_err();
assert!(error.contains("CONSUL_AGENT_TARGET_MISMATCH"), "unexpected error: {error}");
assert!(state.app.configs.read().await.keys().all(|key| !key.starts_with("__test_")));
assert!(state.app.connections.read().await.keys().all(|key| !key.starts_with("__test_")));
let _ = std::fs::remove_dir_all(dir);
}
async fn spawn_consul_agent_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let Ok(n) = stream.read(&mut buf).await else {
return;
};
let request = String::from_utf8_lossy(&buf[..n]);
let body = if request.starts_with("GET /v1/agent/self ") {
r#"{"Config":{"NodeName":"actual-agent","Datacenter":"dc1"},"Member":{"Addr":"127.0.0.1","Tags":{}}}"#
} else {
"[]"
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn mq_connection_test_does_not_retain_temporary_adapter() {
File diff suppressed because it is too large. Load diff
+1
View File
@@ -3,6 +3,7 @@ pub mod ai;
pub mod app_settings;
pub mod cloud_sync;
pub mod connection;
pub mod consul;
pub mod data_compare;
pub mod database_export;
pub mod dialect;