#![doc = include_str!("../../README.md")]
use ldap_types::basic::LDAPEntry;
use ldap_types::filter::search_filter_parser;
use ldap_utils::apply_ldap_operations;
use ldap_utils::diff_entries;
use ldap_utils::search_entries;
use ldap_utils::{
connect_with_parameters, parse_scope, query_ldap_schema, query_root_dse,
toml_connect_parameters,
};
use tracing::instrument;
use tracing::Level;
use tracing_subscriber::{EnvFilter, FmtSubscriber};
use std::collections::HashMap;
use chumsky::Parser as _;
#[derive(clap::Parser, Debug)]
#[clap(name = clap::crate_name!(),
about = clap::crate_description!(),
author = clap::crate_authors!(),
version = clap::crate_version!(),
)]
#[expect(
clippy::struct_excessive_bools,
reason = "This models the command line interface for clap, we can not just refactor this into a different structure"
)]
struct Options {
#[clap(
long,
help = "do not perform any changes, just show (in logs) what would be done"
)]
dry_run: bool,
#[clap(long, help = "Add entries missing at the destination")]
add: bool,
#[clap(
long,
help = "Update entries with differing attributes and/or objectclasses at the destination"
)]
update: bool,
#[clap(
long,
help = "Delete entries at the destination not present at the source"
)]
delete: bool,
#[clap(
long,
help = "Name of TOML file describing the CA certificate, client certificate, client key and LDAP URI for the source LDAP server"
)]
source_ldap_server: std::path::PathBuf,
#[clap(
long,
help = "base DN for the search to perform on the source LDAP server"
)]
source_search_base: std::string::String,
#[clap(
long,
help = "Name of TOML file describing the CA certificate, client certificate, client key and LDAP URI for the destination LDAP server"
)]
destination_ldap_server: std::path::PathBuf,
#[clap(
long,
help = "base DN for the search to perform on the destination LDAP server"
)]
destination_search_base: std::string::String,
#[clap(long, value_parser = parse_scope)]
search_scope: ldap3::Scope,
#[clap(
long,
help = "LDAP search filter for the search on the source LDAP server, for the destination LDAP server the DN-valued attributes are automatically transformed to the destination base DN"
)]
search_filter: std::string::String,
#[clap(long = "attribute", number_of_values = 1)]
attributes: Vec<std::string::String>,
#[clap(
long = "ignore-object-class",
help = "if specified ignore these object classes when transferring data (useful e.g. to avoid transferring passwords or similar local information)"
)]
ignore_object_classes: Vec<std::string::String>,
#[clap(
long = "ignore-attribute",
help = "if specified ignore these attributes when transferring data (useful e.g. to avoid transferring passwords or similar local information)"
)]
ignore_attributes: Vec<std::string::String>,
#[clap(
long,
help = "if this is specified all children of matched entries are also transferred, this is necessary because those children themselves might not have any objectclasses or attributes to use in the search filter"
)]
include_children: bool,
}
use thiserror::Error;
#[derive(Debug, Error)]
enum SyncLdapSubtreesError {
#[error(transparent)]
Clap(#[from] clap::Error),
#[error(transparent)]
TomlConfig(#[from] ldap_utils::TomlConfigError),
#[error(transparent)]
Connect(#[from] ldap_utils::ConnectError),
#[error(transparent)]
LdapSchema(#[from] ldap_utils::LdapSchemaError),
#[error(transparent)]
LdapOperations(#[from] ldap_utils::LdapOperationError),
#[error("failed to parse search filter: {0}")]
SearchFilterParsing(String),
#[error("missing LDAP schema")]
MissingLdapSchema,
}
#[tokio::main]
async fn main() {
FmtSubscriber::builder()
.with_max_level(Level::WARN)
.with_env_filter(EnvFilter::from_default_env())
.init();
match do_sync().await {
Ok(()) => (),
Err(e) => {
tracing::error!("{}", e);
std::process::exit(1);
}
}
}
#[instrument]
async fn do_sync() -> Result<(), SyncLdapSubtreesError> {
let options = <Options as clap::Parser>::try_parse()?;
tracing::debug!("{:#?}", options);
let source_connect_parameters = toml_connect_parameters(options.source_ldap_server.to_owned())?;
tracing::debug!(
"Source connect parameters\n{:#?}",
source_connect_parameters
);
let (mut source_ldap, source_base_dn) =
connect_with_parameters(source_connect_parameters).await?;
tracing::debug!("Source base DN {:#?}", source_base_dn);
let source_root_dse = query_root_dse(&mut source_ldap).await?;
tracing::debug!("Source root DSE {:#?}", source_root_dse);
let source_ldap_schema = query_ldap_schema(&mut source_ldap)
.await?
.ok_or(SyncLdapSubtreesError::MissingLdapSchema)?;
tracing::debug!("Source LDAP schema {:#?}", source_ldap_schema);
let destination_connect_parameters =
toml_connect_parameters(options.destination_ldap_server.to_owned())?;
tracing::debug!(
"Destination connect parameters\n{:#?}",
destination_connect_parameters
);
let (mut destination_ldap, destination_base_dn) =
connect_with_parameters(destination_connect_parameters).await?;
tracing::debug!("Destination base DN {:#?}", destination_base_dn);
let destination_root_dse = query_root_dse(&mut destination_ldap).await?;
tracing::debug!("Destination root DSE {:#?}", destination_root_dse);
let destination_ldap_schema = query_ldap_schema(&mut destination_ldap)
.await?
.ok_or(SyncLdapSubtreesError::MissingLdapSchema)?;
tracing::debug!("Destination LDAP schema {:#?}", destination_ldap_schema);
let source_search_filter = search_filter_parser()
.parse(&options.search_filter)
.into_result()
.map_err(|e| SyncLdapSubtreesError::SearchFilterParsing(format!("{e:#?}")))?;
tracing::debug!("Source search filter: {:#?}", source_search_filter);
let destination_search_filter = source_search_filter.transform_base_dns(
&source_base_dn,
&destination_base_dn,
&source_ldap_schema,
);
tracing::debug!(
"Destination search filter: {:#?}",
destination_search_filter
);
tracing::debug!(
"Search filter as passed in: {}",
&options.search_filter
);
let source_search_filter = source_search_filter.to_string();
let destination_search_filter = destination_search_filter.to_string();
tracing::debug!(
"Source search filter as string: {}",
source_search_filter
);
tracing::debug!(
"Destination search filter as string: {}",
destination_search_filter
);
let mut source_entries: HashMap<String, LDAPEntry> = HashMap::new();
tracing::debug!("Performing base query for source entries");
search_entries(
&mut source_ldap,
&source_base_dn,
&options.source_search_base,
options.search_scope,
&source_search_filter,
options.attributes.as_slice(),
&mut source_entries,
)
.await?;
if options.include_children {
let source_base_query_dns = source_entries.keys().cloned().collect::<Vec<String>>();
for dn in &source_base_query_dns {
tracing::debug!("Performing subtree query for source entry {}", &dn);
search_entries(
&mut source_ldap,
&source_base_dn,
dn,
ldap3::Scope::Subtree,
"(objectClass=*)",
options.attributes.as_slice(),
&mut source_entries,
)
.await?;
}
}
let mut destination_entries: HashMap<String, LDAPEntry> = HashMap::new();
tracing::debug!("Performing base query for destination entries");
search_entries(
&mut destination_ldap,
&destination_base_dn,
&options.destination_search_base,
options.search_scope,
&destination_search_filter,
options.attributes.as_slice(),
&mut destination_entries,
)
.await?;
if options.include_children {
let destination_base_query_dns =
destination_entries.keys().cloned().collect::<Vec<String>>();
for dn in &destination_base_query_dns {
tracing::debug!("Performing subtree query for destination entry {}", &dn);
search_entries(
&mut destination_ldap,
&destination_base_dn,
dn,
ldap3::Scope::Subtree,
"(objectClass=*)",
options.attributes.as_slice(),
&mut destination_entries,
)
.await?;
}
}
tracing::debug!("Source entries:\n{:#?}", source_entries);
tracing::debug!("Destination entries:\n{:#?}", destination_entries);
let ldap_operations = diff_entries(
&source_entries,
&destination_entries,
&source_base_dn,
&destination_base_dn,
&options.ignore_object_classes,
&options.ignore_attributes,
&source_ldap_schema,
options.add,
options.update,
options.delete,
)?;
tracing::debug!("Operations to apply: {:#?}", ldap_operations);
if !options.dry_run {
apply_ldap_operations(
&mut destination_ldap,
&destination_base_dn,
&ldap_operations,
vec![],
)
.await?;
}
Ok(())
}