I was given the unfortunate task of securing an ancient Exchange server behind a pair of Sendmail gateways. While this is a great way to use Exchange without the security problems that go with hosting a Windows server in the open, there is no easy way to make Sendmail aware of what email addresses are valid on the Exchange server. This results in a ton of messages to invalid addresses that the Exchange server has to process. Since most spam comes from bogus addresses, each bounce can generate a lot of overhead. On a busy server, this gets out of control very quickly. It's a nightmare.

The solution is to dump a list of the valid adresses from Exchange's LDAP server, and translate them into a format that Sendmail can load. Sendmail can then turf invalid addresses before they are accepted for processing. Exchange Snarf is a  set of scripts to do this. It saved my life.

I found some scripts that worked for other Exchange versions, but none of them worked with the server in question, so I hacked them up to work for my installation.  I later  added  support for Postfix.

A text file called exclude.txt can be used to exclude email addresses from the output files. This is helpful for excluding internal mailing lists and addresses used by calendars. Place one address on each line.

I ran this script every 15 minutes from cron on a linux machine on my internal network and moved the resulting files to the mail servers in the DMZ via SCP by way of a user with low privileges and public keys.

perl exchange_ldap_sendmail.pl
scp mailhost exchange_snarf@192.168.20.4:~exchange_snarf/

Scripts on the mail servers handled moving the files to the proper places and setting permissions.

This setup ran without a hitch for a little over a year, at which point the mail server was upgraded and there was much rejoicing.

#!/usr/bin/perl -w
###################################################################################
#
# Script to export a list of all email addresses from Exchange 5.5
# Active Directory, remove specified internal addresses, and generate
# a mailhost/relay_recipients files destined for sendmail or postfix
# gateway servers.
#
# Rob Ruchte <rob care of roblogic dot net>

# Derived from:
# adexport,v 1.1 by  Brian Landers <brian care of packetslave dot com>
# And
# make_mailhost_script.pl by Kevin Spicer <kevin care of kevinspicer dot co dot uk>
#
###################################################################################

use strict;
$|++;

use 
Net::LDAP;
use 
Net::LDAP::Control::Paged;
use 
Net::LDAP::Constant qwLDAP_CONTROL_PAGED );
 
# ---- Constants ----
our $serverType 'postfix';                                # sendmail||postfix
our $makemaps 'n';                                        # 'y'||'n' build maps from output
our $bind 'cn=administrator,cn=winDomainName';            # Admin account
our $passwd 'password';                                   # Admin password
our $base 'cn=Recipients,ou=CORPORATE,o="MyCorp, Inc."';  # Start from root
our @servers qw172.16.100.34 );                         # LDAP host
our $exchangebox "exchange.mydomain.com";                 # Exchange hostname for mailhost file
our $filter '(objectClass=*)';                            # Object Class filter
our $turf_list 'exclude.txt';                             # List of internal email addresses to exclude
# -------------------

# We use this to keep track of addresses we've seen
my %gSeen;

# Hold all addresses from LDAP
my @allAddrs = ();

# Hold our turf list
my @exclude = @{ read_commented_file"$turf_list" ) };

# Hold our white list
my @validAddrs = ();

# Connect to the server, try each one until we succeed
my $ldap undef;
foreach( @
servers )
{
    
$ldap Net::LDAP->new$_ );
    
last if $ldap;

    
# If we get here, we didn't connect
    
die "Unable to connect to any LDAP servers!n";
}

my $page Net::LDAP::Control::Paged->newsize => 100 );
 
# Try to bind (login) to the server now that we're connected
my $msg $ldap->binddn => $bind
                       
password => $passwd 
                     
);

# If we can't bind, we can't continue
if( $msg->code() )
{
    die( 
"error while binding: "$msg->error_text(), "n" );
}

# Build the args for the search
my @args = ( base     => $base,
             
scope    => "subtree",
             
filter   => $filter,
             
attrs    => [ "mail""otherMailbox" ],
             
callback => &handle_object,
             
control  => [ $page ],
           );

# Now run the search in a loop until we run out of results.  This code
# is taken pretty much directly from the example code in the perldoc
# page for Net::LDAP::Control::Paged

my $cookie;
while(
1)
{
    
# Perform search
    
my $mesg $ldap->search( @args );
  
    
# Only continue on LDAP_SUCCESS
      
$mesg->code and last;

    
# Get cookie from paged control
      
my($resp)  = $mesg->controlLDAP_CONTROL_PAGED ) or last;
      
$cookie    $resp->cookie or last;

      
# Set cookie in paged control
      
$page->cookie($cookie);
}

if( 
$cookie )
{
    
# We had an abnormal exit, so let the server know we do not want any more
    
$page->cookie($cookie);
    
$page->size(0);
    
$ldap->search( @args );
}

# Finally, unbind from the server
$ldap->unbind

# Now process the addresses and output the white list

# Exclude addresses in the turf list
foreach my $address (@allAddrs)
{
    
my $exclude 0;
    foreach 
my $currEx (@exclude)
    {
        if (
$address eq($currEx)){$exclude 1};
    }
    
push (@validAddrs$addressunless ($exclude == 1);
}

# Sendmail mailhost format requires the address of the SMTP server we 
# are forwarding to, postfix requires "OK"
my $appendStr = ($serverType eq("sendmail")) ? $exchangebox:"OK";
my $output_file = ($serverType eq("sendmail")) ? 'mailhost':'relay_recipients';

# Open whitelist file for writing and export the list
open  MAILHOST">$output_file" or die "$output_file: $!";

foreach 
my $address( @validAddrs )
{
    print 
MAILHOST "$addresstt$appendStrn";
}
close MAILHOST;


if (
$makemaps eq 'y')
{
    if ( 
$serverType eq("sendmail"))
    {
        
system('/usr/sbin/makemap hash mailhost.db < '.$output_file);
    }
    else
    {
        
system('/usr/sbin/postmap '.$output_file);
    }
}

# ------------------------------------------------------------------------
# Callback function that gets called for each record we get from the server
# as we get it.  We look at the type of object and call the appropriate
# handler function
#

sub handle_object
{
    
my $msg  shift;       # Net::LDAP::Message object
    
my $data shift;       # May be Net::LDAP::Entry or Net::LDAP::Reference
  
    # Only process if we actually got data
    
return unless $data;
  
    return 
handle_entry$msg$data )     if $data->isa("Net::LDAP::Entry");
    return 
handle_reference$msg$data ) if $data->isa("Net::LDAP::Reference");
  
    
# If we get here, it was something we're not prepared to handle,
    # so just return silently.

    
return;
}

# ------------------------------------------------------------------------
# Handler for a Net::LDAP::Entry object.  This is an actual record.  We
# extract all email addresses from the record and output only the SMTP
# ones we haven't seen before.

sub handle_entry
{
    
my $msg  shift;
    
my $data shift;
  
    
# Extract the email addressess, selecting only the SMTP ones, and
    # filter them so that we only get unique addresses
    
my @entries grep { /^smtp/&& !$gSeen{$_}++ } 
                   
$data->get_value"otherMailbox" );

      
# Get default address
      
push(@entries,$data->get_value"mail" ));

      
# If we found any, strip off the SMTP: identifier and print them out 
      
if( @entries )
      {
            
my @addrs map s/^smtp$(.+)$/L$1/i$_ } @entries;
            
my $output join("n",@addrs);
        
push (@allAddrs, @addrs);
      }
}

# ------------------------------------------------------------------------
# Handler for a Net::LDAP::Reference object.  This is a 'redirect' to 
# another portion of the directory.  We simply extract the references
# from the object and resubmit them to the handle_object function for
# processing.

sub handle_reference
{
      
my $msg  shift;
      
my $data shift;
  
      foreach 
my $obj$data->references() )
    {
            
# Oooh, recursion!  Might be a reference to another reference, after all
            
return handle_object$msg$obj );
      }
}


# ------------------------------------------------------------------------
# Utility routine to read a file and skip comments
# Returns a reference to an array of the file's contents
#
# Note: reads the whole thing into memory, could be bad for huge files
#

sub read_commented_file
{
    
local *FILE;
      
my $file shift;
      die 
"BUG: didn't get a file to read" unless $file;

      
openFILE$file ) || die "$file: $!n";
      
chompmy @data grep { ! /^s*$/ && ! /^s*#/ } <FILE> );
      
close FILE;

      return @
data;
}