Scripts and utilities for Zimbra
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1000 lines
36 KiB

#!/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;
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 => '/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 = '';
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}->{$_};
}
# 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
$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}} .
" 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} );
}
$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,
@{ $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" );
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 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} ) {
# 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";
$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,
@{ $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" );
}
}
}
# 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 => $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 => '/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
# * 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 ) {
$return->{unidecode( lc $entry->get_value($key) )}->{dn} = $entry->dn;
foreach my $attr ( $entry->attributes ) {
my @values = $entry->get_value($attr);
if ( grep { $attr eq $_ } @{ $want_array } ) {
$return->{unidecode( lc $entry->get_value($key) )}->{$attr} = \@values;
} elsif ( grep { $attr eq $_ } @{ $want_single } ) {
$return->{unidecode( lc $entry->get_value($key) )}->{$attr} = $values[0];
} else {
$return->{unidecode( lc $entry->get_value($key) )}->{$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";
}
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'
}
}
};
}
$defaults->{zimbra} = {
create_if_missing => 0,
setup_ldap_auth => 0
};
# 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;
}