#!/usr/bin/perl -w
#
# usage:
#  cidrexpand < /etc/mail/access | makemap -r hash /etc/mail/access
#
# v 1.1
#
# 17 July 2000 Derek J. Balling (dredd@megacity.org)
#
# Acts as a preparser on /etc/mail/access_db to allow you to use address/bit
# notation.
#
# If you have two overlapping CIDR blocks with conflicting actions
# e.g.   10.2.3.128/25 REJECT and 10.2.3.143 ACCEPT
# make sure that the exceptions to the more general block are specified
# later in the access_db.
#
# the -r flag to makemap will make it "do the right thing"
#
# Modifications
# -------------
# 26 Jul 2001 Derek Balling (dredd@megacity.org)
#     Now uses Net::CIDR because it makes life a lot easier.
#
#  5 Nov 2002 Richard Rognlie (richard@sendmail.com)
#     Added code to deal with the prefix tags that may now be included in
#     the access_db
#
#     Added clarification in the notes for what to do if you have
#     exceptions to a larger CIDR block.
#
#  26 Jul 2006 Richard Rognlie (richard@sendmail.com)
#     Added code to strip "comments" (anything after a non-escaped #)
#     # characters after a \ or within quotes (single and double) are
#     left intact.
#
#     e.g.
#	From:1.2.3.4	550 Die spammer # spammed us 2006.07.26
#     becomes
#	From:1.2.3.4	550 Die spammer
#
#  3 August 2006
#     Corrected a bug to have it handle the special case of "0.0.0.0/0"
#     since Net::CIDR doesn't handle it properly.
#
#  27 April 2016
#     Corrected IPv6 handling.  Note that UseCompressedIPv6Addresses must
#     be turned off for this to work; there are three reasons for this:
#       1) if the MTA uses compressed IPv6 addresses then CIDR 'cuts'
#          in the compressed range *cannot* be matched, as the MTA simply
#          won't look for them.  E.g., there's no way to accurately
#          match "IPv6:fe80::/64" when for the address "IPv6:fe80::54ad"
#          the MTA doesn't lookup up "IPv6:fe80:0:0:0"
#       2) cidrexpand only generates uncompressed addresses, so CIDR
#          'cuts' to the right of the compressed range won't be matched
#          either.  Why doesn't it generate compressed address output?
#          Oh, because:
#       3) compressed addresses are ambiguous when colon-groups are
#          chopped off!  You want an access map entry for
#               IPv6:fe80::0:5420
#          but not for
#               IPv6:fe80::5420:1234
#          ?  Sorry, the former is really
#               IPv6:fe80::5420
#          which will also match the latter!
#
#  25 July 2016
#     Since cidrexpand already requires UseCompressedIPv6Addresses to be
#     turned off, it can also canonicalize non-CIDR IPv6 addresses to the
#     format that sendmail looks up, expanding compressed addresses and
#     trimming superfluous leading zeros.
#
# Report bugs to: <dredd@megacity.org>
#

our $VERSION = '1.1';

use strict;
use Net::CIDR qw(cidr2octets cidrvalidate);
use Getopt::Std;
$Getopt::Std::STANDARD_HELP_VERSION = 1;

sub VERSION_MESSAGE;
sub HELP_MESSAGE;
sub print_expanded_v4network;
sub print_expanded_v6network;

our %opts;
getopts('cfhOSt:', \%opts);

if ($opts{h}) {
    HELP_MESSAGE(\*STDOUT);
    exit 0;
}

# Delimiter between the key and value
my $space_re = exists $opts{t} ? $opts{t} : '\s+';

# Regexp that matches IPv4 address literals
my $ipv4_re = qr"(?:\d+\.){3}\d+";

# Regexp that matches IPv6 address literals, plus a lot more.
# Further checks are required for verifying that it's really one
my $ipv6_re = qr"[0-9A-Fa-f:]{2,39}(?:\.\d+\.\d+\.\d+)?";

my %pending;
while (<>)
{
    chomp;
    my ($prefix, $network, $len, $right);

    next if /^#/ && $opts{S};
    if ( (/\#/) && $opts{c} )
    {
	# print "checking...\n";
	my $i;
	my $qtype='';
	for ($i=0 ; $i<length($_) ; $i++)
	{
	    my $ch = substr($_,$i,1);
	    if ($ch eq '\\')
	    {
		$i++;
		next;
	    }
	    elsif ($qtype eq '' && $ch eq '#')
	    {
		substr($_,$i) = '';
		last;
	    }
	    elsif ($qtype ne '' && $ch eq $qtype)
	    {
		$qtype = '';
	    }
	    elsif ($qtype eq '' && $ch =~ /[\'\"]/)
	    {
		$qtype = $ch;
	    }
	}
    }

    if (($prefix, $network, $len, $right) =
	    m!^(|[^\s:]+:)(${ipv4_re})/(\d+)(${space_re}.*)$!)
    {
	print_expanded_v4network($network, $len, $prefix, $right);
    }
    elsif ((($prefix, $network, $len, $right) =
	    m!^((?:[^\s:]+:)?[Ii][Pp][Vv]6:)(${ipv6_re})(?:/(\d+))?(${space_re}.*)$!) &&
	    (!defined($len) || $len <= 128) &&
	    defined(cidrvalidate($network)))
    {
	print_expanded_v6network($network, $len // 128, $prefix, $right);
    }
    else
    {
	if (%pending && m!^(.+?)${space_re}!)
	{
	    delete $pending{$opts{f} ? $1 : lc($1)};
	}
	print "$_\n";
    }
}
print foreach values %pending;

sub print_expanded_v4network
{
    my ($network, $len, $prefix, $suffix) = @_;
    my $fp = $opts{f} ? $prefix : lc($prefix);

    # cidr2octets() doesn't handle a prefix-length of zero, so do
    # that ourselves
    foreach my $nl ($len == 0 ? (0..255) : cidr2octets("$network/$len"))
    {
	my $val = "$prefix$nl$suffix\n";
	if ($opts{O})
	{
	    $pending{"$fp$nl"} = $val;
	    next;
	}
	print $val;
    }
}

sub print_expanded_v6network
{
    my ($network, $len, $prefix, $suffix) = @_;

    # cidr2octets() doesn't handle a prefix-length of zero, so do
    # that ourselves.  Easiest is to just recurse on bottom and top
    # halves with a length of 1
    if ($len == 0) {
	print_expanded_v6network("::", 1, $prefix, $suffix);
	print_expanded_v6network("8000::", 1, $prefix, $suffix);
    }
    else
    {
	my $fp = $opts{f} ? $prefix : lc($prefix);
	foreach my $nl (cidr2octets("$network/$len"))
	{
	    # trim leading zeros from each group
	    $nl =~ s/(^|:)0+(?=[^:])/$1/g;
	    my $val = "$prefix$nl$suffix\n";
	    if ($opts{O})
	    {
		$pending{"$fp$nl"} = $val;
		next;
	    }
	    print $val;
	}
    }
}

sub VERSION_MESSAGE
{
    my ($fh) = @_;
    print $fh "cidrexpand - Version $VERSION\n";
}

sub HELP_MESSAGE
{
    my ($fh) = @_;
    print $fh <<'EOF';
Usage: cidrexpand [-cfhOS] [-t regexp] files...

Expand CIDR format inside the keys of map entries for makemap.

  -c	Truncate lines at the first unquoted '#'

  -f	Treat keys as case-sensitive when doing override detection
	for the -O option.  By default overlap detection is
	case-insensitive.

  -h	Print this usage

  -O	When a CIDR expansion would generate a partial conflict
	with a later entry, suppress the overlap from the earlier
	expansion

  -S	Skip lines that start with '#'

  -t regexp
	Use 'regexp' to match the delimiter between key and value,
	defaulting to \s+

EOF
}
