package Fripost::Schema::Local; =head1 NAME Local.pm - Localpart related method for the Fripost Schema =head1 DESCRIPTION This module abstracts the LDAP schema definition and provides methods to add, list or delete virtual users, aliases or lists. =cut use 5.010_000; use strict; use warnings; use utf8; use parent 'Fripost::Schema'; use Fripost::Schema::Util qw/concat split_addr canonical_dn ldap_error dn2mail softdie email_valid ldap_assert_absent/; use Net::IDN::Encode qw/email_to_ascii email_to_unicode/; use Net::LDAP::Util 'escape_filter_value'; =head1 REPRESENTATION Virtual users, aliases and lists are imported and exported as hash references, with the following keys: =over 4 =item B A UTF-8 string representing the (internationalized) e-mail address for the user, alias or list. =item B => user|alias|list The type of the entry. =item B => 0|1 Whether or not the entry is active. =item B An array reference containing UTF-8 strings describing the entry. =item B => 0|1 (List only) Whether or not the entry is pending. New lists are always marked as pending, and it is up to the list manager's side to unlock them. =item B (User only) A string e.g., C<100 MB> representing the current quota on the user's mailboxes. =item B (User and list only) The user or list administrator password. It is never given back by the server (actually noone has read access on that attribute), hence only makes sense upon creation. In users entries, I can be hashed on the client side when prefixed with B<{SHA}>, B<{SSHA}>, B<{MD5}>, B<{SMD5}>, B<{CRYPT}> or B<{CLEARTEXT}>. (Otherwise the password will be automatically salted and SHA-1 hashed.) =item B (Alias and list only) An optional array reference containing the (internationalized) e-mails addresses of the entry owners. =item B (User only) An optional array reference containing a (internationalized) e-mails addresses that will also receive every single message sent to that user. =item B (Alias only) An array reference containing a (internationalized) e-mails addresses that will receive messages sent to that alias. =item B mailman|schleuder (List only) The list manager associated with list entries. =head1 METHODS =over 4 =item B (I, I) Search for I, or list all the known (and visible) users, aliases and lists when I is not defined. If I does not contain a '@' symbol, it is interpreted as a domain name, which limits the scope of that search. In list context, return a list of entries represented as hash references, as explained above. In scalar context, only the first entry found is returned. In void context, no attributes are returned from the LDAP server, but it may nevertheless be useful, to ensure that the result set is not empty for instance. The following options are considered: =over 4 =item B<-no-escape> => 0|1 By default, the local and domain parts of I - when defined - are safely escaped before insertion into the LDAP DN and filter. This flag disables escaping. It is useful if I contains wildcards for instance. Note that in case the domain part contains wildcard, this method will query the LDAP server for every single matching domain. =item B<-filter> => locked|unlocked Limit the search scope to pending / non-pending entries only. =item B<-type> => user|alias|list Limit the search scope to the given entry type. =item B<-keys> An array reference containing the attributes that are to be retrived from the LDAP server. Note that Access Control List may prevent the current user to read or even search anything, though. The default is to retrieve every visible attribute, unless in void context where B<-keys> is set to [] that is, no attribute is sent back to the client. =item B<-count> => 0|1 Return the number of entries in the result set. When set, the B<-keys> option is bypassed not to ask any attribute from the server. =item B<-sort> => 0|1 In list context, sort the results per localpart. =back Errors can be caught with options B<-die> and B<-error>, see B for details. =cut sub search { my $self = shift; my ($localname, $domainname) = split_addr(shift); my %options = @_; # Nothing to do after an error. return if $options{'-error'} && ${$options{'-error'}}; my @filters; if (defined $options{'-type'}) { # Limit the scope to the given type. if ($options{'-type'} eq 'user') { push @filters, 'objectClass=FripostVirtualUser'; } elsif ($options{'-type'} eq 'alias') { push @filters, 'objectClass=FripostVirtualAlias'; } elsif ($options{'-type'} eq 'list') { push @filters, 'objectClass=FripostVirtualList'; } } else { push @filters, '(|(objectClass=FripostVirtualUser)'. '(objectClass=FripostVirtualAlias)'. '(objectClass=FripostVirtualList))'; } my @domainnames; if ($domainname) { if ($options{'-no-escape'} and $domainname =~ /\*/) { # If the domain part contains a wildcard, we have to query # the LDAP server to list the matching domains. my %opts = ( '-no-escape' => 1, -keys => [ 'name' ]) ; $opts{'-filter'} = 'unlocked'; foreach (qw/-filter -error -die/) { $opts{$_} = $options{$_} if $options{$_}; } push @domainnames, map {$_->{name}} $self->domain->search($domainname, %opts); } else { # Otherwise, a single query is enough. $domainname = Net::LDAP::Util::escape_dn_value($domainname) unless $options{'-no-escape'}; push @domainnames, $domainname; } } if ($localname) { $localname = Net::LDAP::Util::escape_filter_value($localname) unless $options{'-no-escape'}; push @filters, 'fvl='.$localname; } if (defined $options{'-filter'}) { push @filters, 'objectClass=FripostPendingEntry' if $options{'-filter'} eq 'locked'; push @filters, '!(objectClass=FripostPendingEntry)' if $options{'-filter'} eq 'unlocked'; } my $attrs = []; if (not (defined wantarray)) { # In void context, we are only interested in whether or not the # result set is empty. $attrs = [ '1.1' ]; } elsif (defined $options{'-keys'}) { $attrs = @{$options{'-keys'}} ? [ &_keys_to_attrs(@{$options{'-keys'}}) ] : [ '1.1' ]; } my $filter = Fripost::Schema::Util::ldap_and_filter(@filters); my $count = 0; my @resultset; foreach my $domainname (@domainnames) { # We query the server for each matching domain. my $locals = $self->ldap->search( base => $self->mail2dn($domainname) , scope => 'one' , deref => 'never' , filter => $filter , attrs => $attrs ); ldap_error($locals, %options) // return; next unless defined wantarray; # We'll drop the result anyway if ($options{'-count'}) { $count += $locals->count; } elsif (wantarray) { push @resultset, &_entries_to_locals( $domainname, $options{'-keys'}, $locals->entries ); } else { # In scalar context, we stop here if we got a match. return &_entries_to_locals( $domainname, $options{'-keys'}, $locals->pop_entry ) if $locals->count; } } return $count if $options{'-count'}; # In list context, we return the whole result set, maybe sorted. $options{'-sort'} ? sort { $a->{name} cmp $b->{name} } @resultset : @resultset; } # Map a list of LDAP::Entry object into our public representation of # users, aliases and lists. sub _entries_to_locals { my $domainname = shift; my $keys = shift // []; my @locals; foreach my $entry (@_) { # Ignore bogus entries. return unless defined $entry; my %local; foreach my $attr ($entry->attributes) { my $val = $entry->get_value($attr, asref => 1); if ($attr eq 'fvl') { $local{name} = email_to_unicode($val->[0].'@'.$domainname) if not @$keys or grep { $_ eq 'name' } @$keys; } elsif ($attr eq 'fripostIsStatusActive') { $local{isActive} = $val->[0] eq 'TRUE' if not @$keys or grep { $_ eq 'isActive' } @$keys; } elsif ($attr eq 'objectClass') { if (grep { lc $_ eq lc 'FripostVirtualUser' } @$val) { $local{type} = 'user'; } elsif (grep { lc $_ eq lc 'FripostVirtualAlias' } @$val) { $local{type} = 'alias'; } elsif (grep { lc $_ eq lc 'FripostVirtualList' } @$val) { $local{type} = 'list'; } $local{isPending} = scalar (grep { lc $_ eq lc 'FripostPendingEntry' } @$val ) if not @$keys or grep { $_ eq 'isPending' } @$keys; } elsif ($attr eq 'description') { $local{description} = [ map { Encode::_utf8_on($_); $_ } @$val ] if not @$keys or grep { $_ eq 'description' } @$keys; } elsif ($attr eq 'fripostUserQuota') { $local{quota} = $val->[0] if not @$keys or grep { $_ eq 'quota' } @$keys; } elsif ($attr eq 'fripostOwner') { $local{owner} = [ map { dn2mail($_) } @$val ] if not @$keys or grep { $_ eq 'owner' } @$keys; } elsif ($attr eq 'fripostOptionalMaildrop') { $local{forward} = [ map { email_to_unicode($_) } @$val ] if not @$keys or grep { $_ eq 'forward' } @$keys; } elsif ($attr eq 'fripostMaildrop') { $local{destination} = [ map { email_to_unicode($_) } @$val ] if not @$keys or grep { $_ eq 'destination' } @$keys; } elsif ($attr eq 'fripostListManager') { $local{transport} = $val->[0] if not @$keys or grep { $_ eq 'transport' } @$keys; } else { die "Missing translation for local attribute ‘".$attr."’"; } } # Stop after the first processed domain in scalar mode. return \%local unless wantarray; push @locals, \%local; } return @locals; } # Map our domain keys into the LDAP attribute(s) that are required to # fetch this information. sub _keys_to_attrs { my %map = ( name => 'fvl' , type => 'objectClass' , isActive => 'fripostIsStatusActive' , description => 'description' , isPending => 'objectClass' , quota => 'fripostUserQuota' , owner => 'fripostOwner' , forward => 'fripostOptionalMaildrop' , destination => 'fripostMaildrop' , transport => 'fripostListManager' ); my %attrs; foreach my $k (@_) { die "Missing translation for key ‘".$k."’" unless exists $map{$k}; if (ref $map{$k} eq 'ARRAY') { $attrs{$_} = 1 for @{$map{$k}}; } else { $attrs{$map{$k}} = 1; } } return keys %attrs; } my %list_commands = ( mailman => [ qw/admin bounces confirm join leave owner request subscribe unsubscribe/ ] , schleuder => [ qw/bounce sendkey/ ] ); sub add { my $self = shift; my $local = shift; my %options = @_; # Nothing to do after an error. return if $options{'-error'} && ${$options{'-error'}}; softdie ("No name specified", %options) // return unless $local->{name} =~ /^.+\@[^\@]+$/; my $name = $local->{name}; my ($localname, $domainname) = split_addr($name); # Check validity. &_assert_valid($local, %options) // return; my $exists; my $t = $local->{type}; if ($options{'-dry-run'} or $options{'-append'}) { # Search for an existing entry with the same name. We can't # use our previously defined method here, since the current user # may not have read access to the entry. There is a race # condition since someone could modify the directory between # this check and the actual insertion, but then the insertion # would fail. $exists = ldap_assert_absent( $self, $name, undef, %options ) // return; if ($t eq 'list') { # Ensure that all commands are available. foreach (@{$list_commands{$local->{transport}}}) { my $name = $localname.'-'.$_.'@'.$domainname; ldap_assert_absent( $self, $name, undef, %options ) // return; } } return 1 if $options{'-dry-run'}; } # Convert the domain into a LDAP entry, and remove keys to empty values. my %attrs = $self->_local_to_entry (%$local); Fripost::Schema::Util::ldap_clean_entry( \%attrs ); my $mesg; my $dn = $self->mail2dn( $local->{name} ); if ($options{'-append'} and $exists) { # Replace single valued attributes; Add other attributes. my %unique; foreach (qw/fripostIsStatusActive userPassword fripostUserQuota/) { $unique{$_} = delete $attrs{$_} if exists $attrs{$_}; } $mesg = $self->ldap->modify( $dn, replace => \%unique, add => \%attrs ); } else { # The default owner is the current user. $attrs{fripostOwner} //= [ $self->whoami ] unless $t eq 'user'; my $die = exists $options{'-die'}; $options{'-die'} = { Net::LDAP::Constant::LDAP_ALREADY_EXISTS => "‘".$name."’ exists" , Net::LDAP::Constant::LDAP_SUCCESS => 0 } unless $die; if ($t eq 'list') { # Lists need special care since we have to create the # commands as well, and we need to communicate with the list # manager. my $pw = delete $attrs{userPassword}; $attrs{objectClass} = [ qw/FripostVirtualList FripostPendingEntry/ ]; $attrs{fripostLocalAlias} = &_mkLocalAlias($name); my @done; my $res = $self->ldap->add( $dn, attrs => [ %attrs ] ); push @done, $dn unless $res->code; foreach (@{$list_commands{$local->{transport}}}) { # Create the commands; Stop if something goes wrong last if $res->code; my $name = $localname.'-'.$_.'@'.$domainname; $options{'-die'} = { Net::LDAP::Constant::LDAP_ALREADY_EXISTS => "‘".$name."’ exists" , Net::LDAP::Constant::LDAP_SUCCESS => 0 } unless $die; my %attrs = ( objectClass => [ qw/FripostVirtualListCommand FripostPendingEntry/ ] , fripostLocalAlias => &_mkLocalAlias($name) ); my $dn = $self->mail2dn( $name ); $res = $self->ldap->add( $dn, attrs => [ %attrs ] ); push @done, $dn unless $res->code; } $mesg = $res; if ($mesg->code) { # Something went wrong. We try to clean up after us, and # delete the bogus entries we created. # It's not too bad if it doesn't work out, because # it'll be cleaned by our service hopefully. $self->ldap->delete($_) for @done; ldap_error($mesg, %options); return; } # TODO: send a signed + encrypted mail } else { $attrs{objectClass} = $t eq 'user' ? 'FripostVirtualUser' : $t eq 'alias'? 'FripostVirtualAlias' : ''; $mesg = $self->ldap->add( $dn, attrs => [ %attrs ] ); # TODO: send a welcome mail? } } ldap_error($mesg, %options) // return; 1; } # Convert our representation of local entries into a hash which keys are # LDAP attributes. sub _local_to_entry { my $self = shift; my %local = @_; my %entry; foreach my $key (keys %local) { if ($key eq 'name') { # Its value is forced by the DN. } elsif ($key eq 'type') { # We fix that one later. } elsif ($key eq 'isActive') { $entry{fripostIsStatusActive} = $local{isActive} ? 'TRUE' : 'FALSE'; } elsif ($key eq 'description') { $entry{description} = $local{description}; } elsif ($key eq 'quota') { $entry{fripostUserQuota} = $local{quota}; } elsif ($key eq 'owner') { $entry{fripostOwner} = [ map { $self->mail2dn($_) } @{$local{owner}} ]; } elsif ($key eq 'forward') { $entry{fripostOptionalMaildrop} = $local{forward}; } elsif ($key eq 'destination') { $entry{fripostMaildrop} = $local{destination}; } elsif ($key eq 'transport') { $entry{fripostListManager} = $local{transport}; } elsif ($key eq 'password') { $entry{userPassword} = $local{password}; } else { die "Missing translation for key ‘".$key."’"; } } return %entry; } sub _assert_valid { my $l = shift; my %options = @_; eval { die "Unspecified type\n" unless defined $l->{type}; die "Unknown type ‘".$l->{type}."’\n" unless grep { $l->{type} eq $_ } qw/user alias list/; my ($u, $d) = split_addr($l->{name}, -encode => 'ascii'); return unless $u && $d; # ^ To avoid unicode issues. die "Recipient delimiter ‘+’ is not allowed in locaparts\n" if $u =~ /\+/; # TODO: should be a config option $l->{name} = email_valid( $u.'@'.$d, -exact => 1 ); unless ($options{'-append'} or $options{'-replace'}) { my @must = qw/name isActive/; push @must, $l->{type} eq 'user' ? 'password' : # TODO: ^ match 'quota' against the Dovecot specifications $l->{type} eq 'alias' ? 'destination' : $l->{type} eq 'list' ? qw/transport password/ : (); Fripost::Schema::Util::must_attrs( $l, @must ); } if ($l->{type} eq 'user') { $l->{forward} = [ map { email_valid($_) } @{$l->{forward}} ] if $l->{forward}; } elsif ($l->{type} eq 'alias') { $a->{destination} = [ map { email_valid($_) } @{$l->{destination}} ] if $l->{destination}; } elsif ($l->{type} eq 'list') { die "Invalid list name: ‘".$l->{name}."’\n" unless $u =~ /^[[:alnum:]_=\+\-\.]+$/; die "Invalid list name: ‘".$l->{name}."’\n" if defined $l->{transport} and grep {$u =~ /-$_$/} @{$list_commands{$l->{transport}}}; die "Invalid transport: ‘".$l->{transport}."’\n" if defined $l->{transport} and not grep { $l->{transport} eq $_ } qw/schleuder mailman/; $l->{transport} //= 'mailman' unless $options{'-append'} or $options{'-replace'}; } }; softdie ($@, %options); } sub _mkLocalAlias { my $name = email_to_ascii(shift); $name =~ /^(.+)@([^\@]+)/ or return; return $1.'#'.$2; } =head1 AUTHOR Guilhem Moulin C<< >> =head1 COPYRIGHT Copyright 2012,2013 Guilhem Moulin. =head1 LICENSE This program is free software; you can redistribute it and/or modify it under the same terms as perl itself. =cut 1; __END__