#!/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 List::MoreUtils qw(uniq); 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; # 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 => '/opt/zimbra/conf/zmldapsync.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 = ''; if (not defined $conf->{domains} or ref $conf->{domains} ne 'HASH'){ print "No domain configured for LDAP sync, nothing to do\n"; exit (0); } 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 = search_zim_domain($domain); if ( not defined $zim_domain_search ) { handle_error( $domain, 'Zimbra domain lookup', 'Search returned an empty object' ); next DOMAIN; } 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" ); send_zmprov_cmd( "createDomain $domain "); send_zmprov_cmd( "modifyDomain $domain " . build_domain_attrs( $conf->{domains}->{$domain} )); # Now that we have created the domain, lets lookup again $zim_domain_search = search_zim_domain($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', [ 'zimbraVirtualHostname' ] )->{$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; } } @{ $domain_entry->{zimbraDomainAliases} } = get_domain_aliases( $domain_entry ); if ( defined $conf->{domains}->{$domain}->{zimbra}->{domain_aliases} ) { log_verbose( "Comparing domain aliases" ); my $aliases_diff = Array::Diff->diff( $domain_entry->{zimbraDomainAliases}, $conf->{domains}->{$domain}->{zimbra}->{domain_aliases} ); foreach my $alias (@{ $aliases_diff->added } ) { log_info( "Creating domain alias $alias for domain $domain" ); send_zmprov_cmd( "createAliasDomain $alias $domain" ); } foreach my $alias (@{ $aliases_diff->deleted } ) { log_info( "Removing domain alias $alias for domain $domain" ); send_zmprov_cmd( "deleteDomain $alias" ); } # Make a new lookup if changes were made if ( $aliases_diff->count > 0 ) { @{ $domain_entry->{zimbraDomainAliases} } = get_domain_aliases( $domain_entry ); } } # Domain configuration, as defined in the conf if (defined $conf->{domains}->{$domain}->{zimbra}->{additional_domain_attrs} ) { my $attr_mod = ''; foreach my $attr (keys %{$conf->{domains}->{$domain}->{zimbra}->{additional_domain_attrs}} ) { if ( ref $conf->{domains}->{$domain}->{zimbra}->{additional_domain_attrs}->{$attr} eq 'ARRAY' ) { my $attr_diff = Array::Diff->diff( $domain_entry->{$attr} || [], $conf->{domains}->{$domain}->{zimbra}->{additional_domain_attrs}->{$attr} ); foreach ( @{ $attr_diff->added } ) { $attr_mod .= " +$attr " . zim_attr_value($_); } foreach ( @{ $attr_diff->deleted } ) { $attr_mod .= " -$attr " . zim_attr_value($_); } } else { if ( ($domain_entry->{$attr} || '') ne $conf->{domains}->{$domain}->{zimbra}->{additional_domain_attrs}->{$attr} ) { $attr_mod .= " $attr " . zim_attr_value( $conf->{domains}->{$domain}->{zimbra}->{additional_domain_attrs}->{$attr} ); } } } if ($attr_mod ne ''){ log_info( "Domain $domain configuration must be updated ($attr_mod)" ); send_zmprov_cmd( "modifyDomain $domain $attr_mod" ); } } 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}->{$_}; } # Now we can run the lookup 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)(!(zimbraIsSystemAccount=TRUE))(!(zimbraIsSystemResource=TRUE)))', 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 @single = keys %{$conf->{domains}->{$domain}->{users}->{attr_map}}; push @single, $conf->{domains}->{$domain}->{users}->{mail_attr}; my $ext_users = ldap2hashref( $ext_user_search, $conf->{domains}->{$domain}->{users}->{key}, [ $conf->{domains}->{$domain}->{users}->{alias_attr} ], \@single ); 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 log_verbose( "Attribute $attr for user $user removed from LDAP, removing it from Zimbra"); $attrs .= '-' . $conf->{domains}->{$domain}->{users}->{attr_map}->{$attr} . " " . zim_attr_value( $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}} || 'an empty value' ). " 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} ); } # Check if user is locked in Zimbra, and if so, unlock it # An account from LDAP should get out of the search filter to get locked if ( $zim_users->{$user}->{zimbraAccountStatus} eq 'locked' ) { log_verbose( "User $user is " . $zim_users->{$user}->{zimbraAccountStatus} . " in Zimbra, will be set to active" ); $attrs .= " zimbraAccountStatus active "; } 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} ); } $attrs .= " zimbraAuthLdapExternalDn " . zim_attr_value( $ext_users->{$user}->{dn} ); # 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, ref $ext_users->{$user}->{$conf->{domains}->{$domain}->{users}->{$mail_attr}} eq 'ARRAY' ? @{ $ext_users->{$user}->{$conf->{domains}->{$domain}->{users}->{$mail_attr}} } : $ext_users->{$user}->{$conf->{domains}->{$domain}->{users}->{$mail_attr}}; } @ext_aliases = uniq( 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\@$domain" ); send_zmprov_cmd( "addAccountAlias $user\@$domain $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 add/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. If only zimbraAlias objectClass allowed zimbraNotes attribute # it'd be easier 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 lists 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 @single = keys %{$conf->{domains}->{$domain}->{groups}->{attr_map}}; push @single, $conf->{domains}->{$domain}->{groups}->{mail_attr}; my $ext_groups = ldap2hashref( $ext_group_search, $conf->{domains}->{$domain}->{groups}->{key}, [ $conf->{domains}->{$domain}->{groups}->{members_attr}, $conf->{domains}->{$domain}->{groups}->{alias_attr} ], \@single ); 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 exists 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} and defined $conf->{domains}->{$domain}->{groups}->{attr_map}->{$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( "Attribute $attr for group $group changed from " . ( $ext_groups->{$group}->{$attr} || 'an empty value' ) . " to " . ( $zim_dl->{$group}->{$conf->{domains}->{$domain}->{groups}->{attr_map}->{$attr}} || 'an empty value' ) ); } } # 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"; $attrs .= " 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} ) ) { # If members are not listed as full DN, but by uid, simply concat it with the domain foreach my $member ( @{ $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{members_attr}} } ) { if ( not defined $ext_users->{$member} ) { log_verbose( "Skiping member $member of group $group as it doesn't match a Zimbra user" ); next; } next if ( not defined $ext_users->{$member} and not defined $ext_groups->{$member} ); push @ext_members, $member . '@' . $domain; } } else { # If members are listed as full DN, we need to lookup in the dn2id we prepared earlier 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}} : (); # Now we can compare members for this group in external LDAP and Zimbra 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, ref $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{$mail_attr}} eq 'ARRAY' ? @{ $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{$mail_attr}} } : $ext_groups->{$group}->{$conf->{domains}->{$domain}->{groups}->{$mail_attr}}; } @ext_aliases = uniq( sort @ext_aliases ); 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" ); } } } # 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 => $conf->{general}->{notify}->{to}, 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 => ( -x '/opt/zimbra/common/sbin/sendmail' ) ? '/opt/zimbra/common/sbin/sendmail' : '/usr/sbin/sendmail' }); sendmail( $mail, { transport => $transport } ); } $exit = 255; } # ldap2hashref takes four 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 # * An optional array of attributes we want single valued. Return the first value if several are provided # It'll return a hashref. The key will be unaccentuated and lower cased. sub ldap2hashref { my $search = shift; my $key = shift; my $want_array = shift; my $want_single = shift; my $return = {}; $want_array ||= []; $want_single ||= []; foreach my $entry ( $search->entries ) { my $val = unidecode( lc $entry->get_value($key) ); # We don't want space here ! $val =~ s/\s+/-/g; $return->{$val}->{dn} = $entry->dn; foreach my $attr ( $entry->attributes ) { my @values = $entry->get_value($attr); if ( grep { $attr eq $_ } @{ $want_array } ) { $return->{$val}->{$attr} = \@values; } elsif ( grep { $attr eq $_ } @{ $want_single } ) { $return->{$val}->{$attr} = $values[0]; } else { $return->{$val}->{$attr} = ( scalar @values == 1 ) ? $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 $type = ( $domain_conf->{ldap}->{schema} =~ m/^ad/i ) ? 'ad' : 'ldap'; my $attrs = "zimbraAuthMech " . zim_attr_value( $type ); $attrs .= " zimbraAuthMechAdmin " . zim_attr_value( $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 ', @{ $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"; } if ( -e '/opt/zimbra/lib/ext/adpassword/ADPassword.jar' ) { $attrs .= " zimbraPasswordChangeListener ADPassword"; } 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; } # Search for a specific domain sub search_zim_domain { my $dom = shift; # Search in Zimbra LDAP if the required domain exists my $zim_domain_search = $zim_ldap->ldap->search( filter => "(&(objectClass=zimbraDomain)(zimbraDomainName=$dom)(!(zimbraDomainAliasTargetId=*)))", ); return $zim_domain_search; } # Get a list of aliases for a domain # Takes a hashref representing a domain entry as argument sub get_domain_aliases { my $dom = shift; my @aliases = (); # Now lookup for domain aliases defined in Zimbra my $zim_domain_alias_search = $zim_ldap->ldap->search( filter => "(&(objectClass=zimbraDomain)(zimbraDomainAliasTargetId=" . $dom->{zimbraId} . "))" ); foreach my $alias ( $zim_domain_alias_search->entries ) { push @aliases, $alias->get_value('zimbraDomainName'); } return @aliases; } # 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=*)(!(useraccountcontrol:1.2.840.113556.1.4.803:=2)))', 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' } } }; } $defaults->{zimbra} = { create_if_missing => 0, setup_ldap_auth => 0, domain_aliases => undef, additional_domain_attrs => {} }; # If some attribute mapping is defined in the provided conf # do not use defaults foreach my $type ( qw( users groups ) ) { $defaults->{$type}->{attr_map} = {} if ( defined $conf->{$type}->{attr_map} ); } return merge $defaults, $conf; }