From 665f6638462de8eede8926dcff0aff9d84a4a5a7 Mon Sep 17 00:00:00 2001 From: Daniel Berteaud Date: Tue, 16 Jul 2019 18:59:44 +0200 Subject: [PATCH] More advanced ldap_sync script including alias and groups -> dl --- ldap_sync/README.md | 127 ++++++ ldap_sync/ldap_sync.pl | 874 ++++++++++++++++++++++++++++++++++++++++ ldap_sync/ldap_sync.yml | 12 + scripts/sync_ldap.pl | 270 ------------- scripts/sync_ldap.yml | 46 --- 5 files changed, 1013 insertions(+), 316 deletions(-) create mode 100644 ldap_sync/README.md create mode 100644 ldap_sync/ldap_sync.pl create mode 100644 ldap_sync/ldap_sync.yml delete mode 100644 scripts/sync_ldap.pl delete mode 100644 scripts/sync_ldap.yml diff --git a/ldap_sync/README.md b/ldap_sync/README.md new file mode 100644 index 0000000..b177af3 --- /dev/null +++ b/ldap_sync/README.md @@ -0,0 +1,127 @@ +# LDAP synchronisation + +This script brings a complete synchronization of user accounts and groups from an external LDAP server. + +Zimbra (OSE) supports autoprovisioning, but this feature only takes care of user accounts creation. Several other scripts can be found, nut noone of them implemented what I need, so I wrote this one. + +The goals are : + * Do not only provision accounts, but update them if needed (eg : name changed) + * Support AD, OpenLDAP, or any custom LDAP schema + * Synchronize LDAP groups into distribution lists in Zimbra, preserving memberships + * Lock Zimbra accounts when the corresponding LDAP accounts are removed (or not matching the filter anymore) + * Handle email alias defined in LDAP, and translate them into aliases in Zimbra + * Allow objects (aliase, distribution list) to be created directly in Zimbra. Objects coming from LDAP are synchronized, including alias previously defined in LDAP which aren't anymore are removed from Zimbra. But aliases defined directly in Zimbra won't be touched. Same is true for distribution lists. So you can mix LDAP defined and Zimbra defined configuration + +## Configuration + +The configuration is stored in a single file in YAML format. The script will look for a config at /opt/zimbra/conf/ldap_sync.yml or trhe one specified in the --config argument. + +The config has two main section : + + * general : settings which affects all domains, mainly to configure email notification in case of error + * domains : list of domain to sync, and the settings for each of them + +The general section looks like + +``` +general: + notify: + from: zimbra@example.org + to: admin@acme-corp.biz +``` + + +Foreach each domain you defined, you can configure 4 sections : + * ldap : defined where and how to connect to the external LDAP server + * users : define how to search for users in the external LDAP, and which attributes will be mapped to which one in Zimbra + * groups : define how to search for groups in external LDAP, and which attributes will be mapped to which one in Zimbra + * zimbra : define some settings about how the script should behave for this domain (eg, should it create the domain if missing, should it autoconfigure external authentication etc.) + +In most case, the configuration can be minimal, as defaults values are provided. You just have to set the LDAP server, bind DN and password (if applicable), and the schema. The schema can be ad, rfc2307 or rfc2307bis. If one of these schema is specified, adapted defaults values will be used + +Here are some examples of domains definition : + +``` +domains: + # A simple example, against and AD style directory + # Note that this example has no groups definition, and so, + # groups won't be synchronized to distribution lists + acme-corp.biz: + ldap: + servers: + - ldap://dc1.acme-corp.biz:389 + - ldap://dc2.acme-corp.biz:389 + start_tls: True + bind_dn: CN=Zimbra,CN=Users,DC=acme-corp,DC=biz + bind_pass: 'Sup3rS3cret.P@ssPhr4se' + schema: ad + users: + base: OU=CN=Users,DC=acme-corp,DC=biz + + # Another simple example, against OpenLDAP using rfc2307bis schema + corp2.com: + ldap: + servers: + - ldap://ldap.corp2.com + schema: rfc2307bis + users: + base: ou=people,dc=corp2,dc=com + filter: '(memberOf=cn=mail_users,ou=Groups,dc=corp2,dc=com)' + groups: + base: ou=groups,dc=corp2,dc=com + + # A more complete example, which shows all the available settings + corp3.net: + ldap: + servers: + - ldap://ldap1.corp3.net:389 + - ldap://ldap3.corp3.net:389 + start_tls: True + bind_dn: CN=Zimbra,OU=Apps,DC=corp3,DC=net + bind_pass: 'p@ssw0rd' + schema: ad + type: ad + users: + base: OU=People,DC=corp3,DC=net + filter: '(&(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=CN=Role_Mail,OU=Roles,DC=corp3,DC=net)(mail=*))' + key: sAMAccountName + mail_attr: mail + alias_attr: otherMailbox + attr_map: + displayName: displayName + description: description + cn: cn + sn: sn + givenName: givenName + telephoneNumber: telephoneNumber + homePhone: homePhone + mobile: mobile + streetAddress: street + l: l + st: st + co: co + title: title + company: company + groups: + base: OU=Groups,DC=corp3,DC=net + filter: (objectClass=group) + key: cn + members_attr: member + members_as_dn: True + mail_attr: mail + alias_attr: null + attr_map: + displayName: displayName + description: description + zimbra: + create_if_missing: False + setup_ldap_auth: True +``` + +## Command line + +Once a configuration file is ready, the script can be called with the following command line arguments : + + * --config : path to the config file (defaults to /opt/zimbra/conf/ldap_sync.yml) + * --quiet : will not print anything except errors + * --verbose : prints aditional info during the sync diff --git a/ldap_sync/ldap_sync.pl b/ldap_sync/ldap_sync.pl new file mode 100644 index 0000000..8d099f2 --- /dev/null +++ b/ldap_sync/ldap_sync.pl @@ -0,0 +1,874 @@ +#!/usr/bin/perl -w + +use lib '/opt/zimbra/common/lib/perl5'; +use Zimbra::LDAP; +use Zimbra::ZmClient; +use Net::LDAP; +use YAML::Tiny; +use Getopt::Long; +use Data::UUID; +use String::ShellQuote qw(shell_quote); +use Array::Diff; +use Hash::Merge::Simple qw(merge); +use Text::Unidecode; +use Email::MIME; +use Email::Sender::Simple qw(sendmail); +use Email::Sender::Transport::Sendmail; +use utf8; +use Data::Dumper; + +# This is needed for Email::Sender::Simple +# See https://rt.cpan.org/Public/Bug/Display.html?id=76533 +$SIG{CHLD} = sub { wait }; + +# Init an empty conf +my $conf = {}; + +# Defaults for command line flags +my $opt = { + 'config=s' => '/opt/zimbra/conf/ldap_sync.yml', + 'dry' => 0, + 'quiet' => 0, + 'verbose' => 0 +}; + +# Read some options from the command line +GetOptions ( + 'config=s' => \$opt->{config}, + 'dry-run' => \$opt->{dry}, + 'quiet' => \$opt->{quiet}, + 'verbose' => \$opt->{verbose} +); + +if ( $opt->{verbose} and $opt->{quiet} ) { + print "You cannot use quiet and debug at the same time\n"; + usage(); + exit 255; +} + +# Message set in zimbraNotes to identify objects synced from external LDAP +my $sync_from_ldap = "Synced from external LDAP directory. Do not edit this field"; + +# Check if the config file exists, and if so, parse it +# and load it in $conf +if ( -e $opt->{config} ) { + log_verbose( "Reading config file " . $opt->{config} ); + my $yaml = YAML::Tiny->read( $opt->{config} ) + or die "Config file " . $opt->{config} . " is invalid\n"; + + if ( not $yaml->[0] ) { + die "Config file " . $opt->{config} . " is invalid\n"; + } + + $conf = $yaml->[0]; +} else { + # If the config file doesn't exist, just die + die "Config file " . $opt->{config} . " doesn't exist\n"; +} + +my $zim_ldap = Zimbra::LDAP->new(); +my $uuid = Data::UUID->new(); +my $exit = 0; +my $err = ''; + +DOMAIN: foreach my $domain ( keys $conf->{domains} ) { + log_verbose( "Start to process domain $domain" ); + + # Get default config for this domain and merge it with what we have in the config file + $conf->{domains}->{$domain} = get_default_conf( $conf->{domains}->{$domain} ); + + # Search in Zimbra LDAP if the required domain exists + my $zim_domain_search = $zim_ldap->ldap->search( + filter => "(&(objectClass=zimbraDomain)(zimbraDomainName=$domain)(!(zimbraDomainAliasTargetId=*)))", + attrs => [ + 'zimbraDomainName', + 'zimbraDomainType', + 'zimbraId', + 'zimbraAuthMechAdmin', + 'zimbraAuthMech', + 'zimbraAuthLdapSearchBindDn', + 'zimbraAuthLdapSearchBindPassword', + 'zimbraAuthLdapSearchFilter' + ] + ); + if ( $zim_domain_search->code ) { + handle_error( $domain, 'Zimbra domain lookup', $zim_domain_search->error ); + next DOMAIN + } + + # We must have exactly 1 result + if ( scalar $zim_domain_search->entries == 0 ) { + if ( yaml_bool($conf->{domains}->{$domain}->{zimbra}->{create_if_missing}) ) { + log_info( "Creating domain $domain" ); + ZmClient::sendZmprovRequest( "createDomain $domain " . build_domain_attrs($conf->{domains}->{$domain}) ); + } else { + handle_error( $domain, 'Zimbra domain lookup', "Domain $domain doesn't exist in Zimbra"); + next DOMAIN; + } + } elsif ( scalar $zim_domain_search->entries gt 1 ) { + handle_error( $domain, 'Zimbra domain lookup', "Found several matches for domain $domain" ); + next DOMAIN; + } + + # Get LDAP entry representing the domain + my $domain_entry = ldap2hashref( $zim_domain_search, 'zimbraDomainName' )->{$domain}; + + # Check if auth is set to ad or ldap + if ( not defined $domain_entry->{zimbraAuthMech} or $domain_entry->{zimbraAuthMech} !~ m/^ad|ldap$/i) { + if ( yaml_bool( $conf->{domains}->{$domain}->{zimbra}->{setup_ldap_auth} ) ) { + send_zmprov_cmd( "modifyDomain $domain " . build_domain_attrs( $conf->{domains}->{$domain} ) ); + } else { + handle_error( $domain, 'Domain external auth check', "domain $domain must be configured for LDAP or AD authentication first" ); + next DOMAIN; + } + } + + # Now lookup for domain aliases defined in Zimbra + my $zim_domain_alias_search = $zim_ldap->ldap->search( + filter => "(&(objectClass=zimbraDomain)(zimbraDomainAliasTargetId=" . $domain_entry->{zimbraId} . "))" + ); + if ( $zim_domain_alias_search->code ) { + handle_error( $domain, 'Zimbra domain alias lookup', $zim_domain_alias_search->error ); + next DOMAIN; + } + + $domain_entry->{zimbraDomainAliases} = []; + foreach my $alias ( $zim_domain_alias_search->entries ) { + push @{ $domain_entry->{zimbraDomainAliases} }, $alias->get_value('zimbraDomainName'); + } + + log_verbose( "Trying to connect to " . join( ' or ', @{ $conf->{domains}->{$domain}->{ldap}->{servers} } ) ); + + my $ext_ldap = Net::LDAP->new( [ @{ $conf->{domains}->{$domain}->{ldap}->{servers} } ] ); + if ( not $ext_ldap ) { + handle_error( $domain, 'External LDAP connection', $@ ); + next DOMAIN; + } + + log_verbose( "Connection succeeded" ); + + if ( yaml_bool( $conf->{domains}->{$domain}->{ldap}->{start_tls} ) ) { + log_verbose( "Trying to switch to a secured connection using StartTLS" ); + my $tls = $ext_ldap->start_tls( verify => 'require' ); + if ( $tls->code ) { + handle_error( $domain, 'External LDAP StartTLS', $tls->error ); + next DOMAIN; + } + + log_verbose( "StartTLS succeeded" ); + } + + if ( defined $conf->{domains}->{$domain}->{ldap}->{bind_dn} and defined $conf->{domains}->{$domain}->{ldap}->{bind_pass} ) { + log_verbose( "Trying to bind as " . $conf->{domains}->{$domain}->{ldap}->{bind_dn} ); + my $bind = $ext_ldap->bind( + $conf->{domains}->{$domain}->{ldap}->{bind_dn}, + password => $conf->{domains}->{$domain}->{ldap}->{bind_pass} + ); + if ( $bind->code ) { + handle_error( $domain, 'External LDAP bind', $bind->error ); + next DOMAIN; + } + + log_verbose( "Bind succeeded" ); + } + + my $zim_aliases = {}; + + foreach my $domain_alias ( $domain, @{ $domain_entry->{zimbraDomainAliases} }) { + log_verbose( "Searching for aliases in Zimbra for domain alias $domain_alias" ); + my $zim_aliases_search = $zim_ldap->ldap->search ( + base => 'ou=people,' . domain2dn( $domain_alias ), + filter => '(objectClass=zimbraAlias)', + attrs => [ + 'zimbraAliasTargetId', + 'uid' + ] + ); + if ( $zim_aliases_search->code ) { + handle_error( $domain, 'Zimbra user and distribution lists alias lookup', $zim_aliases_search->error ); + next DOMAIN; + } + + $zim_aliases->{$domain_alias} = ldap2hashref( $zim_aliases_search, 'uid' ); + } + + log_verbose( "Searching for potential users in " . $conf->{domains}->{$domain}->{users}->{base} . " matching filter " . $conf->{domains}->{$domain}->{users}->{filter} ); + + # List of attributes to fetch from LDAP + # First, we want all the attributes which are mapped to Zimbra fields + my $fetch_attrs = [ keys $conf->{domains}->{$domain}->{users}->{attr_map} ]; + + # We also want the object key + push $fetch_attrs, $conf->{domains}->{$domain}->{users}->{key}; + + # If defined in the config, we need to get attribute containing email and aliases + foreach ( qw( alias_attr mail_attr ) ) { + next if ( not $conf->{domains}->{$domain}->{users}->{$_} ); + push $fetch_attrs, $conf->{domains}->{$domain}->{users}->{$_}; + } + + my $ext_user_search = $ext_ldap->search( + base => $conf->{domains}->{$domain}->{users}->{base}, + filter => $conf->{domains}->{$domain}->{users}->{filter}, + attrs => $fetch_attrs + ); + + if ( $ext_user_search->code ) { + handle_error( $domain, 'External LDAP user lookup', $ext_user_search->error ); + next DOMAIN; + } + + log_verbose( "Found " . scalar $ext_user_search->entries . " users in external LDAP" ); + + log_verbose( "Searching for users in Zimbra" ); + + # Search for Zimbra users, but exclude known system accounts + my $zim_user_search = $zim_ldap->ldap->search( + base => 'ou=people,' . $domain_entry->{dn}, + filter => '(&(objectClass=zimbraAccount)(!(|' . + '(mail=' . $zim_ldap->global->get_value('zimbraSpamIsSpamAccount') . ')' . + '(mail=' . $zim_ldap->global->get_value('zimbraSpamIsNotSpamAccount') . ')' . + '(mail=' . $zim_ldap->global->get_value('zimbraAmavisQuarantineAccount') . ')' . + '(uid=galsync*)(uid=admin))))', + attrs => [ + ( map { $conf->{domains}->{$domain}->{users}->{attr_map}->{$_} } keys $conf->{domains}->{$domain}->{users}->{attr_map} ), + ( 'uid', 'zimbraAccountStatus', 'zimbraAuthLdapExternalDn', 'zimbraMailAlias', 'mail', 'zimbraNotes' ) + ] + ); + if ( $zim_user_search->code ) { + handle_error( $domain, 'Zimbra users lookup', $zim_user_search->error ); + next DOMAIN; + } + + log_verbose( "Found " . scalar $zim_user_search->entries . " users in Zimbra" ); + + log_verbose( "Comparing the accounts" ); + + my $ext_users = ldap2hashref( + $ext_user_search, + $conf->{domains}->{$domain}->{users}->{key}, + ( $conf->{domains}->{$domain}->{users}->{mail_attr}, $conf->{domains}->{$domain}->{users}->{alias_attr} ) + ); + my $zim_users = ldap2hashref( + $zim_user_search, + 'uid', + ('mail') + ); + + # First loop : Check users which exist in external LDAP but not in Zimbra + # or which exist in both but need to be updated + foreach my $user ( keys $ext_users ) { + my $attrs = ''; + if ( defined $zim_users->{$user} ) { + + # User exists in Zimbra, lets check its attribute are up to date + foreach my $attr ( keys $conf->{domains}->{$domain}->{users}->{attr_map} ) { + + if ( not defined $ext_users->{$user}->{$attr} and not defined $zim_users->{$user}->{$conf->{domains}->{$domain}->{users}->{attr_map}->{$attr}} ) { + # Attr does not exist in external LDAP and in Zimbra, no need to continue comparing them + next; + } + if ( $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} ne 'sn' and not defined $ext_users->{$user}->{$attr} ) { + # If the attribute doesn't exist in external LDAP, we must remove it from Zimbra. + # Except for sn which is mandatory in Zimbra + $attrs .= '-' . $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} . " '" . $zim_users->{$user}->{$conf->{domains}->{$domain}->{users}->{attr_map}->{$attr}} . "' "; + } elsif ( + ( $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} ne 'sn' and + $ext_users->{$user}->{$attr} ne ( $zim_users->{$user}->{$conf->{domains}->{$domain}->{users}->{attr_map}->{$attr}} || '' ) + ) || + $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} eq 'sn' and + defined $ext_users->{$user}->{$attr} and + $ext_users->{$user}->{$attr} ne ( $zim_users->{$user}->{$conf->{domains}->{$domain}->{users}->{attr_map}->{$attr}} || '' ) + ) { + $attrs .= " " . $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} . " " . zim_attr_value( $ext_users->{$user}->{$attr} ); + log_verbose( "Attribute $attr for user $user changed from " . + $zim_users->{$user}->{$conf->{domains}->{$domain}->{users}->{attr_map}->{$attr}} . + " to " . + $ext_users->{$user}->{$attr} + ); + } + } + + if ( + not defined $zim_users->{$user}->{zimbraAuthLdapExternalDn} or + $zim_users->{$user}->{zimbraAuthLdapExternalDn} ne $ext_users->{$user}->{dn} + ) { + $attrs .= " zimbraAuthLdapExternalDn " . zim_attr_value( $ext_users->{$user}->{dn} ); + } + + if ( $attrs ne '' ) { + # Some attribute must change, we need to update Zimbra + log_verbose( "User $user has changed in external LDAP, updating it" ); + send_zmprov_cmd( "modifyAccount $user\@$domain $attrs" ); + } + + } else { + # User exists in external LDAP but not in Zimbra. We must create it + log_verbose( "User $user found in external LDAP but not in Zimbra. Will be created" ); + + foreach my $attr ( keys $conf->{domains}->{$domain}->{users}->{attr_map} ) { + next if (not defined $ext_users->{$user}->{$attr} or $ext_users->{$user}->{$attr} eq ''); + $attrs .= ' ' . $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} . " " . zim_attr_value( $ext_users->{$user}->{$attr} ); + } + # The password won't be used because Zimbra is set to use external LDAP/AD auth + # But better to set it to a random value + my $pass = $uuid->create_str; + send_zmprov_cmd( "createAccount $user\@$domain $pass $attrs" ); + } + + my @ext_aliases = (); + foreach my $mail_attr ( qw( mail_attr alias_attr ) ) { + next if ( + not defined $conf->{domains}->{$domain}->{users}->{$mail_attr} or + not defined $ext_users->{$user}->{$conf->{domains}->{$domain}->{users}->{$mail_attr}} + ); + push @ext_aliases, @{ $ext_users->{$user}->{$conf->{domains}->{$domain}->{users}->{$mail_attr}} }; + } + + @ext_aliases = sort @ext_aliases; + + foreach my $alias ( @ext_aliases ) { + next if ( not alias_matches_domain( $alias, $domain_entry ) ); + next if ( grep { $alias eq $_ } @{ $zim_users->{$user}->{mail} } ); + log_verbose( "Creating alias $alias for user $user" ); + send_zmprov_cmd( "addAccountAlias $user $alias" ); + } + + # On each sync, we register the list of LDAP aliases into Zimbra's LDAP in the zimbraNotes attribute + # We can compare if it has changed, and and/remove the aliases accordingly + # This is not very clean, but at least allows the script to be "stateless" + # and only relies on LDAP content on both sides + my $ext_prev_aliases = parse_zimbra_notes( $zim_users->{$user}->{zimbraNotes} || '' )->{LDAP_Aliases}; + my @ext_prev_aliases = ( defined $ext_prev_aliases ) ? sort @{ $ext_prev_aliases } : (); + my $alias_diff = Array::Diff->diff( \@ext_prev_aliases, \@ext_aliases ); + + foreach my $alias ( @{ $alias_diff->deleted } ) { + my ( $al, $dom ) = split /\@/, $alias; + next if ( not defined $zim_aliases->{$dom} or not defined $zim_aliases->{$dom}->{$al} ); + log_verbose( "Removing LDAP alias $alias from user $user as it doesn't exist in LDAP anymore" ); + send_zmprov_cmd( "removeAccountAlias $user\@$domain $alias" ); + } + + my $note = $sync_from_ldap . "|LDAP_Aliases=" . join(',', @ext_aliases); + if ( $note ne ($zim_users->{$user}->{zimbraNotes} || '') ) { + send_zmprov_cmd( "modifyAccount $user\@$domain zimbraNotes " . zim_attr_value( $note ) ); + } + } + + # Second loop : we loop through the Zimbra users to check if they should be locked (if they don't exist in external LDAP anymore) + foreach my $user ( keys $zim_users ) { + # Make sure we only lock accounts if they don't exist anymore in external LDAP + # has the zimbraNotes attribute set, with the expected value, and the account is active + if ( not defined $ext_users->{$user} and + defined $zim_users->{$user}->{zimbraNotes} and + $zim_users->{$user}->{zimbraNotes} =~ m/^$sync_from_ldap/ and + defined $zim_users->{$user}->{zimbraAccountStatus} and + $zim_users->{$user}->{zimbraAccountStatus} =~ m/^active|lockout$/ ) { + log_verbose( "User $user doesn't exist in external LDAP anymore, locking it in Zimbra" ); + send_zmprov_cmd( "modifyAccount $user\@$domain zimbraAccountStatus locked" ); + } + } + + # Now, we try to sync groups in external LDAP into distribution list in Zimbra + if ( defined $conf->{domains}->{$domain}->{groups} ) { + + log_verbose( "Searching for potential groups in " . + $conf->{domains}->{$domain}->{groups}->{base} . + " matching filter " . + $conf->{domains}->{$domain}->{groups}->{filter} + ); + + $fetch_attrs = [ keys $conf->{domains}->{$domain}->{groups}->{attr_map} ]; + push $fetch_attrs, $conf->{domains}->{$domain}->{groups}->{key}; + push $fetch_attrs, $conf->{domains}->{$domain}->{groups}->{members_attr}; + foreach ( qw( mail_attr alias_attr ) ) { + next if ( not defined $conf->{domains}->{$domain}->{groups}->{$_} ); + push $fetch_attrs, $conf->{domains}->{$domain}->{groups}->{$_}; + } + + my $ext_group_search = $ext_ldap->search( + base => $conf->{domains}->{$domain}->{groups}->{base}, + filter => $conf->{domains}->{$domain}->{groups}->{filter}, + attrs => $fetch_attrs + ); + + if ( $ext_group_search->code ) { + handle_error( $domain, 'External LDAP groups lookup', $ext_group_search->error ); + next DOMAIN; + } + + log_verbose( "Found " . scalar $ext_group_search->entries . " groups in external LDAP" ); + + log_verbose( "Searching for distribution lists in Zimbra" ); + + # Search in Zimbra for distribution lists so we can compare with the groups in external LDAP + my $zim_dl_search = $zim_ldap->ldap->search( + base => 'ou=people,' . $domain_entry->{dn}, + filter => "(objectClass=zimbraDistributionList)", + attrs => [ + ( map { $conf->{domains}->{$domain}->{groups}->{attr_map}->{$_} } keys $conf->{domains}->{$domain}->{groups}->{attr_map} ), + ( 'uid', 'zimbraDistributionListSubscriptionPolicy', 'zimbraDistributionListUnsubscriptionPolicy', + 'zimbraMailForwardingAddress', 'zimbraNotes', 'zimbraMailStatus', 'mail' ) + ] + ); + if ( $zim_dl_search->code ) { + handle_error( $domain, 'Zimbra distribution lists lookup', $zim_dl_search->error ); + next DOMAIN; + } + + log_verbose( "Found " . scalar $zim_dl_search->entries . " distribution list(s) in Zimbra" ); + log_verbose( "Comparing groups with distribution lists" ); + + my $ext_groups = ldap2hashref( + $ext_group_search, + $conf->{domains}->{$domain}->{groups}->{key}, + ( $conf->{domains}->{$domain}->{groups}->{members_attr}, + $conf->{domains}->{$domain}->{groups}->{mail_attr}, + $conf->{domains}->{$domain}->{groups}->{alias_attr} + ) + ); + my $zim_dl = ldap2hashref( + $zim_dl_search, + 'uid', + ('zimbraMailForwardingAddress', 'mail') + ); + + # Build a dn2id hashref to lookup users or groups by their DN + my $dn2id = {}; + $dn2id->{$ext_users->{$_}->{dn}} = $_ foreach ( keys $ext_users ); + $dn2id->{$ext_groups->{$_}->{dn}} = $_ foreach ( keys $ext_groups ); + + # First loop, check if every group in LDAP exist as a DL in Zimbra + foreach my $group ( keys $ext_groups ) { + if ( defined $zim_dl->{$group} ) { + # A group match an existing DL, we must check its attributes + + my $attrs = ''; + foreach my $attr ( keys $conf->{domains}->{$domain}->{groups}->{attr_map} ) { + if ( + not defined $ext_groups->{$group}->{$attr} and + not defined $zim_dl->{$group}->{$conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr}} + ) { + # Attr does not exist in external LDAP and in Zimbra, not need to continue + next; + } elsif ( not defined $ext_groups->{$group}->{$attr} ) { + # Attr doesn't exist in external LDAP, but exists in Zimbra. We must remove it + $attrs = ' -' . $conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr} . " " . zim_attr_value( $zim_dl->{$group}->{$conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr}} ); + } elsif ( $ext_groups->{$group}->{$attr} ne $zim_dl->{$group}->{$conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr}} ) { + # Attr exists in both but doesn't match + $attrs .= " " . $conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr} . " " . zim_attr_value( $ext_groups->{$group}->{$attr} ); + log_verbose( $ext_groups->{$group}->{$attr} . " vs " . $zim_dl->{$group}->{$conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr}} ); + } + } + + # Users cannot subscribe or unsubscribe from LDAP group + if ( + not defined $zim_dl->{$group}->{zimbraDistributionListSubscriptionPolicy} or + $zim_dl->{$group}->{zimbraDistributionListSubscriptionPolicy} ne 'REJECT' + ) { + $attrs .= " zimbraDistributionListSubscriptionPolicy REJECT"; + } + if ( + not defined $zim_dl->{$group}->{zimbraDistributionListUnsubscriptionPolicy} or + $zim_dl->{$group}->{zimbraDistributionListUnsubscriptionPolicy} ne 'REJECT' + ) { + $attrs .= " zimbraDistributionListUnsubscriptionPolicy REJECT"; + } + + # If the group in LDAP has a mail defined, enable mail delivery in Zimbra. Else, disable it + my $mail_status = ( defined $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{mail_attr}} ) ? 'enabled' : 'disabled'; + if ( + not defined $zim_dl->{$group}->{zimbraMailStatus} or + $zim_dl->{$group}->{zimbraMailStatus} ne $mail_status + ) { + $attrs .= " zimbraMailStatus $mail_status"; + } + + if ( $attrs ne '' ) { + # Some attribute must change, lets update Zimbra + log_verbose( "Group $group has changed in external LDAP, updating it" ); + send_zmprov_cmd( "modifyDistributionList $group\@$domain $attrs" ); + } + + } else { + # A new group with no corresponding DL in Zimbra + log_verbose( "Found a new group : $group. Creating it in Zimbra" ); + my $attrs = ''; + foreach my $attr ( keys $conf->{domains}->{$domain}->{groups}->{attr_map} ) { + next if (not defined $ext_groups->{$group}->{$attr} or $ext_groups->{$group}->{$attr} eq ''); + $attrs .= ' ' . $conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr} . " " . zim_attr_value( $ext_groups->{$group}->{$attr} ); + } + $attrs .= " zimbraDistributionListUnsubscriptionPolicy REJECT zimbraDistributionListSubscriptionPolicy REJECT"; + my $mail_status = ( defined $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{mail_attr}} ) ? 'enabled' : 'disabled'; + $attrs .= " zimbraMailStatus $mail_status"; + send_zmprov_cmd( "createDistributionList $group\@$domain $attrs" ); + } + + # Now that all the needed groups exist as distribution list, we need to handle membership + # For that, we must convert the membership list of the external group to the same format as Zimbra + my @ext_members = (); + + if ( not yaml_bool( $conf->{domains}->{$domain}->{groups}->{members_as_dn} ) ) { + foreach my $member ( $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{members_attr}} ) { + next if ( not defined $ext_users->{$member} and not defined $ext_groups->{$member} ); + push @ext_members, $member . '@' . $domain; + } + } else { + foreach my $member ( @{ $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{members_attr}} } ) { + next if not defined $dn2id->{$member}; + push @ext_members, $dn2id->{$member} . '@' . $domain; + } + } + @ext_members = sort @ext_members; + my @zim_members = (defined $zim_dl->{$group}->{zimbraMailForwardingAddress} ) ? sort @{$zim_dl->{$group}->{zimbraMailForwardingAddress}} : (); + my $diff = Array::Diff->diff( \@ext_members, \@zim_members ); + + if ( scalar @{ $diff->deleted } gt 0 ){ + send_zmprov_cmd( "addDistributionListMember $group\@$domain " . join (' ', @{ $diff->deleted } ) ); + } + if ( scalar @{ $diff->added } gt 0 ) { + send_zmprov_cmd( "removeDistributionListMember $group\@$domain $_ ") foreach ( @{ $diff->added } ); + } + my @ext_aliases = (); + foreach my $mail_attr ( qw( mail_attr alias_attr ) ) { + next if (not defined $conf->{domains}->{$domain}->{groups}->{$mail_attr} or not defined $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{$mail_attr}} ); + push @ext_aliases, @{ $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{$mail_attr}} }; + } + foreach my $alias ( @ext_aliases ) { + next if ( not alias_matches_domain( $alias, $domain_entry ) ); + next if ( grep { $alias eq $_ } @{ $zim_dl->{$group}->{mail} } ); + log_verbose( "Creating alias $alias for group $group" ); + send_zmprov_cmd( "addDistributionListAlias $group\@$domain $alias" ); + } + + # Now, check the diff between the list of LDAP alias for this users with the previous run + my $ext_prev_aliases = parse_zimbra_notes( $zim_dl->{$group}->{zimbraNotes} || '' )->{LDAP_Aliases}; + my @ext_prev_aliases = ( defined $ext_prev_aliases ) ? sort @{ $ext_prev_aliases } : (); + my $alias_diff = Array::Diff->diff( \@ext_prev_aliases, \@ext_aliases ); + + foreach my $alias ( @{ $alias_diff->deleted } ) { + my ( $al, $dom ) = split /\@/, $alias; + next if ( not defined $zim_aliases->{$dom} or not defined $zim_aliases->{$dom}->{$al} ); + log_verbose( "Removing LDAP alias $alias from distribution list $group as it doesn't exist in LDAP anymore" ); + send_zmprov_cmd( "removeDistributionListAlias $group\@$domain $alias" ); + } + + my $note = $sync_from_ldap . "|LDAP_Aliases=" . join(',', @ext_aliases); + if ( $note ne ($zim_dl->{$group}->{zimbraNotes} || '') ) { + send_zmprov_cmd( "modifyDistributionList $group\@$domain zimbraNotes " . zim_attr_value( $note ) ); + } + } + + # Now, look at all the distribution list which were created from LDAP but doesn't exist anymore in LDAP + foreach my $dl ( keys $zim_dl ) { + next if ( not defined $zim_dl->{$dl}->{zimbraNotes} or $zim_dl->{$dl}->{zimbraNotes} !~ m/^$sync_from_ldap/ ); + next if ( defined $ext_groups->{$dl} ); + log_verbose( "Group $dl doesn't exist in LDAP anymore, removing the corresponding distribution list" ); + send_zmprov_cmd( "deleteDistributionList $dl\@$domain" ); + } + } +} + +# zmprov breaks terminal (no echo to your input after execution) +# fix it with a tset +system('tset'); + +# Exit with the global exit code (if at least one domain had an error, it'll be != 0) +exit $exit; + + +###### Subroutines ###### + +# Print usage +sub usage { + print <<_EOF; + +Usage: $0 [ --config /path/to/config.yml ] [ --dry-run ] [ --quiet ] [ --verbose ] + +With: + * -c|--config : designate the path of the config file to use. Default is /opt/zimbra/conf/ldap_sync.yml + * -d|--dry-run : do not change anything, only prints what would be done + * -q|--quiet : No output except if there are errors + * -v|--verbose : extra output (not only if something is to be updated in Zimbra) + +_EOF +} + +# Print messages only if the verbose flag was given +sub log_verbose { + my $msg = shift; + print $msg . "\n" if ( $opt->{verbose} ); +} + +# Print info messages unless the quiet flag was given +sub log_info { + my $msg = shift; + print $msg . "\n" if ( not $opt->{quiet} ); +} + +# Print errors +sub log_error { + my $msg = shift; + print $msg . "\n"; +} + +# Just a helper to handle error. Will print the error +# send an email to the admin if nedded, and set an exit code +sub handle_error { + my $domain = shift; + my $step = shift; + my $err = shift; + + log_error( $err ); + if ( defined $conf->{general}->{notify}->{to} ) { + my $mail = Email::MIME->create( + header_str => [ + From => $conf->{general}->{notify}->{from}, + To => $addr, + Subject => "Zimbra LDAP synchronisation error for domain $domain" + ], + attributes => { + charset => 'utf-8', + encoding => 'base64' + }, + body_str => "LDAP synchronisation for domain $domain failed at step '$step'. The error was\n$err\n", + ); + my $transport = Email::Sender::Transport::Sendmail->new({ sendmail => '/opt/zimbra/common/sbin/sendmail' }); + sendmail( $mail, { transport => $transport } ); + } + $exit = 255; +} + +# ldap2hashref takes three args +# * An LDAP search result +# * The attribute used as the key of objects +# * an optional array of attributes we want as an array, even if there's a single value +# It'll return a hashref. The key will be unaccentuated and lower cased. + +sub ldap2hashref { + my ( $search, $key, @want_array ) = @_; + my $return = {}; + + foreach my $entry ( $search->entries ) { + $return->{unidecode( lc $entry->get_value($key) )}->{dn} = $entry->dn; + foreach my $attr ( $entry->attributes ) { + my @values = $entry->get_value($attr); + $return->{unidecode( lc $entry->get_value($key) )}->{$attr} = ( scalar @values == 1 ) ? + ( grep { $attr eq $_ } @want_array ) ? \@values : $values[0] : + \@values; + } + } + return $return; +} + +# Check YAML bool, and return either 1 or 0 +sub yaml_bool { + my $bool = shift; + if ( $bool =~ m/^y|yes|true|1|on$/i ) { + return 1; + } else { + return 0; + } +} + +# Build a string to pass to zmprov to configure a domain +# Takes the domain conf hashref as only arg +sub build_domain_attrs { + my $domain_conf = shift; + my $attrs = "zimbraAuthMech " . zim_attr_value( $domain_conf->{ldap}->{type} ); + $attrs .= " zimbraAuthMechAdmin " . zim_attr_value( $domain_conf->{ldap}->{type} ); + if ( defined $domain_conf->{ldap}->{bind_dn} and defined $domain_conf->{ldap}->{bind_pass} ) { + $attrs .= " zimbraAuthLdapSearchBindDn " . zim_attr_value( $domain_conf->{ldap}->{bind_dn} ); + $attrs .= " zimbraAuthLdapSearchBindPassword " . zim_attr_value( $domain_conf->{ldap}->{bind_pass} ); + } +# if ( defined $domain_conf->{users}->{filter} ) { +# $attrs = " zimbraAuthLdapSearchFilter " . zim_attr_value( "(&(|(" . $domain_conf->{users}->{key} . "=%u)(" . $domain_conf->{users}->{key} . "=%n))(" . $domain_conf->{users}->{filter} . ")" ); +# } + $attrs .= " zimbraAuthLdapURL " . join( ' +zimbraAuthLdapURL', zim_attr_value( $domain_conf->{ldap}->{servers} ) ); + if ( defined $domain_conf->{ldap}->{start_tls} and yaml_bool( $domain_conf->{ldap}->{start_tls} ) ) { + $attrs .= " zimbraAuthLdapStartTlsEnabled TRUE"; + } else { + $attrs .= " zimbraAuthLdapStartTlsEnabled FALSE"; + } + return $attrs; +} + +# Takes a domain name as arg and return its DN in Zimbra LDAP directory +sub domain2dn { + my $domain = shift; + $domain =~ s/\./,dc=/g; + return 'dc=' . $domain; +} + +# Prepare a string to be used as arg to zmprov +sub zim_attr_value { + my $value = shift; + utf8::encode($value); + return shell_quote($value); +} + +# Take an alias and a domain. Return 1 if the alias is member of this domain (or one of the domain aliases) +sub alias_matches_domain { + my $alias = shift; + my $domain = shift; + return 1 if ( $alias =~ m/\@$domain->{zimbraDomainName}$/ ); + foreach my $dom ( @{ $domain->{zimbraDomainAliases} } ) { + return 1 if ( $alias =~ m/\@$dom$/ ); + } + return 0; +} + +# Send a command to zmprov +sub send_zmprov_cmd { + my $cmd = shift; + log_info( "Sending command zmprov " . $cmd ); + if ( not $opt->{dry} ) { + ZmClient::sendZmprovRequest( $cmd ); + } +} + +# Parse the zimbraNotes field and return the content as a hashref +sub parse_zimbra_notes { + my $notes = shift; + my $return = {}; + return $return if ( $notes !~ m/^$sync_from_ldap/ ); + $notes =~ s/^$sync_from_ldap//; + foreach my $rec ( split /\|/, $notes ) { + next if not ( $rec =~ m/^([^=\|]+)=(([^,\|]+,?)+)/ ); + my ( $key, $values ) = ( $1, $2 ); + $return->{$key} = [ split /,/, $values ]; + } + return $return; +} + +# Set default config values if missing +sub get_default_conf { + my $conf = shift; + my $default = {}; + if ( $conf->{ldap}->{schema} eq 'ad' ) { + + $defaults = { + ldap => { + type => 'ad', + start_tls => 1 + }, + users => { + filter => '(&(objectClass=user)(mail=*))', + key => 'sAMAccountName', + mail_attr => 'mail', + alias_attr => 'otherMailbox', + attr_map => { + displayName => 'displayName', + description => 'description', + cn => 'cn', + sn => 'sn', + givenName => 'givenName', + telephoneNumber => 'telephoneNumber', + homePhone => 'homePhone', + mobile => 'mobile', + streetAddress => 'street', + l => 'l', + st => 'st', + co => 'co', + title => 'title', + company => 'company' + } + }, + groups => { + filter => '(objectClass=group)', + key => 'cn', + members_attr => 'member', + members_as_dn => 1, + mail_attr => 'mail', + alias_attr => 0, + attr_map => { + displayName => 'displayName', + description => 'description' + } + } + }; + + } elsif ( $conf->{ldap}->{schema} eq 'rfc2307bis' ) { + + $defaults = { + ldap => { + type => 'ldap', + start_tls => 1 + }, + users => { + filter => '(&(objectClass=inetOrgPerson)(mail=*))', + key => 'uid', + mail_attr => 'mail', + alias_attr => 'mail', + attr_map => { + displayName => 'displayName', + description => 'description', + cn => 'cn', + sn => 'sn', + givenName => 'givenName', + telephoneNumber => 'telephoneNumber', + mobile => 'mobile', + streetAddress => 'street', + l => 'l', + street => 'st', + title => 'title', + o => 'company' + } + }, + groups => { + filter => '(objectClass=groupOfNames)', + key => 'cn', + members_attr => 'member', + members_as_dn => 1, + mail_attr => 0, + alias_attr => 0, + attr_map => { + displayName => 'displayName', + description => 'description' + } + } + }; + + } elsif ( $conf->{ldap}->{schema} eq 'rfc2307' ) { + + $defaults = { + ldap => { + type => 'ldap', + start_tls => 1 + }, + users => { + filter => '(&(objectClass=inetOrgPerson)(mail=*))', + key => 'uid', + mail_attr => 'mail', + alias_attr => 'mail', + attr_map => { + displayName => 'displayName', + description => 'description', + cn => 'cn', + sn => 'sn', + givenName => 'givenName', + telephoneNumber => 'telephoneNumber', + mobile => 'mobile', + streetAddress => 'street', + l => 'l', + street => 'st', + title => 'title', + o => 'company' + } + }, + groups => { + filter => '(objectClass=posixGroup)', + key => 'cn', + members_attr => 'memberUid', + members_as_dn => 0, + mail_attr => 0, + alias_attr => 0, + attr_map => { + displayName => 'displayName', + description => 'description' + } + } + }; + } + return merge $defaults, $conf; +} diff --git a/ldap_sync/ldap_sync.yml b/ldap_sync/ldap_sync.yml new file mode 100644 index 0000000..a93e932 --- /dev/null +++ b/ldap_sync/ldap_sync.yml @@ -0,0 +1,12 @@ +--- + +# General settings, which affect all domain you sync +# general: +# notify: +# from: zimbra@example.org +# to: admin@acme-corp.biz + +# Now, define the list of domain to sync +# and for each of them, the settings. See README.md for examples + +domains: {} diff --git a/scripts/sync_ldap.pl b/scripts/sync_ldap.pl deleted file mode 100644 index 7a90c79..0000000 --- a/scripts/sync_ldap.pl +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/local/bin/perl -w - -use lib '/opt/zimbra/common/lib/perl5'; -use Zimbra::LDAP; -use Zimbra::ZmClient; -use Net::LDAP; -use YAML::Tiny; -use Getopt::Long; -use Data::UUID; -use utf8; -use Data::Dumper; - -my $conf = {}; -my $opt = { - config => '/opt/zimbra/conf/ldap_sync.yml' -}; - -GetOptions ( - 'c|config=s' => \$opt->{config}, -); - -# Check if the config file exists, and if so, parse it -# and load it in $conf -if ( -e $opt->{config} ) { - print "Reading config file " . $opt->{config} . "\n"; - my $yaml = YAML::Tiny->read( $opt->{config} ) - or die "Config file " . $opt->{config} . " is invalid\n"; - - if ( not $yaml->[0] ) { - die "Config file " . $opt->{config} . " is invalid\n"; - } - - $conf = $yaml->[0]; -} else { - # If the config file doesn't exist, just die - die "Config file " . $opt->{config} . " doesn't exist\n"; -} - -my $zim_ldap = Zimbra::LDAP->new(); -my $uuid = Data::UUID->new(); -my $exit = 0; -my $res; - -DOMAIN: foreach my $domain ( keys $conf ) { - print "Checking domain $domain\n"; - # Search in Zimbra LDAP if the required domain exists - $res = $zim_ldap->ldap->search( - filter => "(&(objectClass=zimbraDomain)(zimbraDomainName=$domain)(!(zimbraDomainAliasTargetId=*)))" - ); - if ( $res->code ) { - print "Couldn't lookup zimbra domains : " . $res->error . "\n"; - $exit = 255; - } - - # We must have exactly 1 result - if ( scalar $res->entries == 0 ) { - if ( yaml_bool($conf->{$domain}->{zimbra}->{create_if_missing}) ) { - print "Creating domain $domain"; - ZmClient::sendZmprovRequest( "createDomain $domain " . build_domain_attrs($conf->{$domain}) ); - } else { - print "Domain $domain doesn't exist, you must create it first\n"; - $exit = 255; - } - } elsif ( scalar $res->entries gt 1 ) { - die "Found several domains matching, something is wrong, please check your settings\n"; - } - - # Get LDAP entry representing the domain - my $domain_entry = ($res->entries)[0]; - - # Check if auth is set to ad or ldap - if ( not $domain_entry->exists('zimbraAuthMech') or $domain_entry->get_value('zimbraAuthMech') !~ m/^ad|ldap$/) { - if ( yaml_bool($conf->{$domain}->{zimbra}->{setup_ldap_auth}) ) { - ZmClient::sendZmprovRequest( "modifyDomain $domain " . build_domain_attrs( $conf->{$domain} ) ); - } else { - die "Domain " . $conf->{$domain}->{zimbra}->{domain} . " must be configured for LDAP or AD external authentication first\n"; - } - } - - print "Trying to connect to " . join( ' or ', @{ $conf->{$domain}->{ldap}->{servers} } ) . "\n"; - - my $ext_ldap = Net::LDAP->new( [ @{ $conf->{$domain}->{ldap}->{servers} } ] ); - if ( not $ext_ldap ) { - print "Error while connecting to LDAP : $@\n"; - $exit = 255; - next DOMAIN; - } - - print "Connection succeeded\n"; - - if ( yaml_bool( $conf->{$domain}->{ldap}->{start_tls} ) ) { - print "Trying to switch to a secured connection using StartTLS\n"; - $res = $ext_ldap->start_tls( verify => 'require' ); - if ( $res->code ) { - print "StartTLS failed : " . $res->error . "\n"; - $exit = 255; - next DOMAIN; - } - - print "StartTLS succeeded\n"; - } - - if ( defined $conf->{$domain}->{ldap}->{bind_dn} and defined $conf->{$domain}->{ldap}->{bind_pass} ) { - print "Trying to bind as " . $conf->{$domain}->{ldap}->{bind_dn} . "\n"; - $ext_ldap->bind( - $conf->{$domain}->{ldap}->{bind_dn}, - password => $conf->{$domain}->{ldap}->{bind_pass} - ); - if ( $res->code ) { - print "StartTLS failed : " . $res->error . "\n"; - $exit = 255; - next DOMAIN; - } - - print "Bind succeeded\n"; - } - - print "Searching for potential users in " . $conf->{$domain}->{users}->{base} . " matching filter " . $conf->{$domain}->{users}->{filter} . "\n"; - - my $ext_user_search = $ext_ldap->search( - base => $conf->{$domain}->{users}->{base}, - filter => $conf->{$domain}->{users}->{filter}, - attrs => [ keys $conf->{$domain}->{users}->{attr_map}, ( $conf->{$domain}->{users}->{key} ) ] - ); - if ( $ext_user_search->code ) { - print "Search failed : " . $ext_user_search->error . "\n"; - $exit = 255; - next DOMAIN; - } - - print "Found " . scalar $ext_user_search->entries . " users in external LDAP\n"; - - print "Searching for users in Zimbra\n"; - - my $zim_user_search = $zim_ldap->ldap->search( - base => 'ou=people,' . $domain_entry->dn, - filter => '(&(objectClass=zimbraAccount)(!(|' . - '(mail=' . $zim_ldap->global->get_value('zimbraSpamIsSpamAccount') . ')' . - '(mail=' . $zim_ldap->global->get_value('zimbraSpamIsNotSpamAccount') . ')' . - '(mail=' . $zim_ldap->global->get_value('zimbraAmavisQuarantineAccount') . ')' . - '(uid=galsync*)(uid=admin))))', - attrs => [ ( map { $conf->{$domain}->{users}->{attr_map}->{$_} } keys $conf->{$domain}->{users}->{attr_map} ), ( 'uid', 'zimbraAccountStatus', 'zimbraAuthLdapExternalDn' ) ] - ); - if ( $zim_user_search->code ) { - print "Search failed : " . $zim_user_search->error . "\n"; - $exit = 255; - next DOMAIN; - } - - print "Found " . scalar $zim_user_search->entries . " users in Zimbra\n"; - - print "Now comparing the accounts\n"; - - my $ext_users = ldap2hashref( $ext_user_search, $conf->{$domain}->{users}->{key} ); - my $zim_users = ldap2hashref( $zim_user_search, 'uid' ); - - # First loop : Check users which exist in external LDAP but not in Zimbra - # or which exist in both but need to be updated - foreach my $user ( keys $ext_users ) { - - if ( defined $zim_users->{$user} ) { - # User exists in Zimbra, lets check its attribute are up to date - my $attrs = ''; - foreach my $attr ( keys $conf->{$domain}->{users}->{attr_map} ) { - if ( not defined $ext_users->{$user}->{$attr} and not defined $ext_users->{$user}->{$conf->{$domain}->{users}->{attr_map}->{$attr}} ) { - # Attr does not exist in external LDAP and in Zimbra, not need to continue - next; - } - if ( $conf->{$domain}->{users}->{attr_map}->{$attr} ne 'sn' and not defined $ext_users->{$user}->{$attr} ) { - # If the attribute doesn't exist in external LDAP, we must remove it from Zimbra. - # Except for sn which is mandatory - $attrs .= '-' . $conf->{$domain}->{users}->{attr_map}->{$attr} . " '" . $zim_users->{$user}->{$conf->{$domain}->{users}->{attr_map}->{$attr}} . "' "; - } elsif ( - ( $conf->{$domain}->{users}->{attr_map}->{$attr} ne 'sn' and - $ext_users->{$user}->{$attr} ne ( $zim_users->{$user}->{$conf->{$domain}->{users}->{attr_map}->{$attr}} || '' ) - ) || - $conf->{$domain}->{users}->{attr_map}->{$attr} eq 'sn' and - defined $ext_users->{$user}->{$attr} and - $ext_users->{$user}->{$attr} ne ( $zim_users->{$user}->{$conf->{$domain}->{users}->{attr_map}->{$attr}} || '' ) - ) { - my $value = $ext_users->{$user}->{$attr}; - $value =~ s/'/\\'/g; - utf8::encode($value); - $attrs .= $conf->{$domain}->{users}->{attr_map}->{$attr} . " '" . $value . "' "; - print $ext_users->{$user}->{$attr} . " vs " . $zim_users->{$user}->{$conf->{$domain}->{users}->{attr_map}->{$attr}} . "\n"; - } - } - - if ( not defined $zim_users->{$user}->{zimbraAuthLdapExternalDn} or $zim_users->{$user}->{zimbraAuthLdapExternalDn} ne $ext_users->{$user}->{dn} ) { - my $value = $ext_users->{$user}->{dn}; - utf8::encode($value); - $attrs .= " zimbraAuthLdapExternalDn '$value'"; - } - - if ( $attrs ne '' ) { - # Some attribute must change, lets update Zimbra - print "User $user has changed in external LDAP, updating it\n"; - print "Sending zmprov modifyAccount $user\@$domain $attrs\n"; - ZmClient::sendZmprovRequest( "modifyAccount $user\@$domain $attrs" ); - } - - } else { - # User exists in external LDAP but not in Zimbra. We must create it - print "User $user found in external LDAP but not in Zimbra. Will be created\n"; - my $attrs = ''; - foreach my $attr ( keys $conf->{$domain}->{users}->{attr_map} ) { - next if (not defined $ext_users->{$user}->{$attr} or $ext_users->{$user}->{$attr} eq ''); - $attrs .= ' ' . $conf->{$domain}->{users}->{attr_map}->{$attr} . ' ' . $ext_users->{$user}->{$attr}; - } - my $pass = $uuid->create_str; - print "Sending zmprov createAccount $user\@$domain $pass $attrs\n"; - ZmClient::sendZmprovRequest( "createAccount $user\@$domain $pass $attrs" ); - } - } - - # Now, we loop through the ZImbra user to check if they should be locked (if they don't exist in external LDAP anymore) - foreach my $user ( keys $zim_users ) { - if ( not defined $ext_users->{$user} and defined $zim_users->{$user}->{zimbraAccountStatus} and $zim_users->{$user}->{zimbraAccountStatus} =~ m/^active|lockout$/ ) { - print "User $user doesn't exist in external LDAP anymore, locking it in Zimbra\n"; - print "Sending zmprov modifyAccount $user\@$domain zimbraAccountStatus locked\n"; - ZmClient::sendZmprovRequest( "modifyAccount $user\@$domain zimbraAccountStatus locked" ); - } - } -} - -# zmprov breaks terminal (no echo to your input after execution) -# fix it with a tset -system('tset'); - -sub ldap2hashref { - my $search = shift; - my $key = shift; - my $return = {}; - foreach my $entry ( $search->entries ) { - $return->{lc $entry->get_value($key)}->{dn} = $entry->dn; - foreach my $attr ( $entry->attributes ) { - $return->{lc $entry->get_value($key)}->{$attr} = $entry->get_value($attr) if ($attr ne $key); - } - } - return $return; -} - -# Check YAML bool -sub yaml_bool { - my $bool = shift; - if ( $bool =~ m/^y|yes|true|1|on$/i ) { - return 1; - } else { - return 0; - } -} - -sub build_domain_attrs { - my $domain_conf = shift; - my $attrs = "zimbraAuthMech " . $domain_conf->{ldap}->{type}; - $attrs .= " zimbraAuthMechAdmin " . $domain_conf->{ldap}->{type}; - if ( defined $domain_conf->{ldap}->{bind_dn} and defined $domain_conf->{ldap}->{bind_pass} ) { - my $pass = $domain_conf->{ldap}->{bind_pass}; - $pass =~ s/'/\\'/g; - $attrs .= " zimbraAuthLdapSearchBindDn '" . $domain_conf->{ldap}->{bind_dn} . "' zimbraAuthLdapSearchBindPassword '" . $pass . "'"; - } - if ( defined $domain_conf->{users}->{filter} ) { - $attrs = " zimbraAuthLdapSearchFilter '(&(" . $domain_conf->{users}->{key} . "=%u)(" . $domain_conf->{users}->{filter} . ")'"; - } - $attrs .= " zimbraAuthLdapURL " . join( ' +zimbraAuthLdapURL', $domain_conf->{ldap}->{servers} ); - if ( defined $domain_conf->{ldap}->{start_tls} and yaml_bool($domain_conf->{ldap}->{start_tls}) ) { - $attrs .= " zimbraAuthLdapStartTlsEnabled TRUE"; - } - return $attrs; -} diff --git a/scripts/sync_ldap.yml b/scripts/sync_ldap.yml deleted file mode 100644 index c6fb169..0000000 --- a/scripts/sync_ldap.yml +++ /dev/null @@ -1,46 +0,0 @@ ---- - -#.or:mple -# ldap: -# servers: -# - ldap://ldap1.example.org:389 -# - ldap://ldap2.example.org:389 -# start_tls: True -# bind_dn: CN=Zimbra,OU=Apps,DC=example,DC=org -# bind_pass: 'S3cr3t.P@ssPHr4z' -# type: ad # can be ad or ldap -# -# users: -# base: OU=People,DC=example,DC=org -# filter: '(&(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=CN=Role_Mail,OU=Roles,DC=example,DC=org)(mail=*))' -# key: sAMAccountName -# alias_attr: otherMailbox -# attr_map: -# displayName: displayName -# description: description -# cn: cn -# sn: sn -# givenName: givenName -# telephoneNumber: telephoneNumber -# homePhone: homePhone -# mobile: mobile -# streetAddress: street -# l: l -# st: st -# co: co -# title: title -# company: company -# -# groups: -# base: OU=Groups,DC=example,DC=org -# filter: (&(objectClass=group)(mail=*)) -# key: cn -# members_attr: member -# members_as_dn: True -# attr_map: -# displayName: displayName -# description: description -# -# zimbra: -# create_if_missing: False -# setup_ldap_auth: False