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.
977 lines
36 KiB
977 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 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}->{$_};
|
|
}
|
|
|
|
# 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)(!(|' .
|
|
'(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_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} );
|
|
}
|
|
# 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 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 $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 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}} ) {
|
|
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}} };
|
|
}
|
|
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 $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', 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'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
# 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;
|
|
}
|
|
|