#!/usr/bin/perl -w
# #
# Software subject to following license(s):
#   Apache 2 License (http://www.opensource.org/licenses/apache2.0)
#   Copyright (c) Responsible Organization
#

# #
# Current developer(s):
#   Luis Fernando Muñoz Mejías <Luis.Munoz@UGent.be>
#

# #
# Author(s): Michel Jouvin, Ben Jones, Gabor Gombas, Nick Williams
#

# #
# server, 15.4.0-rc11, rc11_1, 2015-06-02T10:13:30Z
#
################################################################################
#
# aii-dhcp      Automatic DHCP Server Configuration
#
################################################################################

=pod

=head1 NAME

aii-dhcp - add/remove host entries to an ISC DHCP server.

=head1 SYNOPSIS

 aii-dhcp [options] <--configure <hostname> --mac <mac> |
                     --configurelist <filename>   |
                     --remove <hostname>          |
                     --removelist <filename>

=head1 DESCRIPTION

aii-dhcp is a command line tool to add/remove nodes specific entries to
an ISC DHCP server. Already existing entries are preserved.
The administrator has to prepare the DHCP server configuration file with
all common network definitions and subnets declarations. The tool
add/remove/update entries to the corresponding subnet and restart the
DHCP server. A backup copy of the configuration file is created
before updating it and restarting the DHCP server.

Command line options override default values in /etc/aii/aii-dhcp.conf.

=head1 COMMANDS

=over 4

=item --configure <hostname> --mac <mac>

Configure <hostname> in the DHCP server with the physical <mac> address
(syntax: XX:XX:XX:XX:XX:XX). If the node is present its
configuration is removed and replaced by the new one.

=item --tftpserver <hostname>

TFTP server (optional). Can be specified only with --configure.

=item --addoptions <text>

Additional DHCP options for the node that will be specified
inside the entry host. Can be specified only with --configure;
they should be specified between quotes, e.g.:

 aii-dhcp --configure node002 --addoptions 'filename loader.bin;'

=item --configurelist <filename>

Configure hosts listed on <filename>. Hosts have to be listed one per line
with the syntax <hostname> <mac> [tftpserver] [addoptions], where <hostname>
and <mac> are mandatory. Lines with # are comments. If a different TFTP server
should not be specified but there are additional options, use a ';'.
Additional options are written exactly as they have to written in DHCP
configuration file. An example:

 # You can use both : and - in the MAC address
 node1         00:80:45:6F:19:1A
 node2.qwer.fi 00-80-45:6F-19-1B  bootserver
 node3.qwer.fi 00:80:45:6F:19:1C  bootserver.qwer.fi filename "down.bin";
 node3.qwer.fi 00-80-45-6F-19-1D

Note that in this example, host node3 has to NICs.
 
=item --remove <hostname>

Remove <hostname> from the DHCP server configuration.

=item --removelist <filename>

Remove hosts listed on <filename> from the DHCP server. Hosts have to
be listed one per line. Lines with # are comments.

=back

=head1 OPTIONS

=over 4

=item --dhcpconf <path>

Configuration file for DHCP server (default: /etc/dhcpd.conf)

=item --restartcmd <command>

Command to be used to restart the server (default: /sbin/service
dhcpd restart). Should be provided between quotes, e.g.

 aii-dhcp --configurelist list --restartcmd '/sbin/mydhcpd --restart'.

=item --norestart

Update the configuration file but do not restart the server.

=back

=head2 Other Options

=over

=item --help

Displays a help message with all options and default settings.

=item --version

Displays program version information.

=item --verbose

Print verbose details on operations.

=item --debug <1..5>

Set the debugging level to <1..5>.

=item --cfgfile <path>

Use the as configuration file <path> instead of default
/etc/aii/aii-dhcp.conf

=item --logfile <file>

Store and append log messages in <file>.

=back

=head1 CONFIGURATION FILE

=over 4

Default values of command lines options can be specified in the file
/etc/aii/aii-dhcp.conf using syntax:

 <option> = <value>

e.g.:

 dhcpconf = /etc/my_dhcpd.conf

=back

=head1 AUTHORS

Enrico Ferro <enrico.ferro@pd.infn.it>
Rafael A. Garcia Leiva <angel.leiva@uam.es>

=cut

#
# Standard Common Application Framework beginning sequence
#

#
# Beginning sequence (necessary only for EDG stuff)
#
BEGIN {

    # use perl libs in /usr/lib/perl
    push(@INC, '/usr/lib/perl');
    push(@INC,'/opt/edg/lib/perl');

}

#------------------------------------------------------------
# Application
#------------------------------------------------------------

package aii_dhcp;

use CAF::Application;
use CAF::Reporter;
use LC::Exception qw (SUCCESS throw_error);

use Socket;

use strict;
use vars qw(@ISA);

@ISA = qw(CAF::Application CAF::Reporter);

#
# Public Methods/Functions for CAF
#

sub app_options {

    # these options complement the ones defined in CAF::Application
    push(my @array,

        # aii-dhcp specific options

        { NAME    => 'configure=s',
          HELP    => 'Node to be configured (needs MAC address)',
          DEFAULT => undef },

        { NAME    => 'configurelist=s', 
          HELP    => 'File with the list of nodes to be configured',
          DEFAULT => undef },

        { NAME    => 'mac=s',
          HELP    => 'MAC address of the node (mandatory with --configure)',
          DEFAULT => undef },

        { NAME    => 'tftpserver=s',
          HELP    => 'TFTP server (optional with --configure)',
          DEFAULT => undef },

        { NAME    => 'addoptions=s',
          HELP    => 'Additional parameters (optional with --configure)',
          DEFAULT => undef },

        { NAME    => 'remove=s',
          HELP    => 'Node to be removed',
          DEFAULT => undef },

        { NAME    => 'removelist=s',
          HELP    => 'File with the list of nodes to be removed',
          DEFAULT => undef },

        # options for DHCP configuration

        { NAME    => 'dhcpconf=s',
          HELP    => 'DHCP server configuration file',
          DEFAULT => '/etc/dhcpd.conf' },

        { NAME    => 'restartcmd=s',
          HELP    => 'Command to restart the DHCP server',
          DEFAULT => '/sbin/service dhcpd restart' },

        { NAME    => 'norestart',
          HELP    => 'Do not restart the DHCP server',
          DEFAULT => undef },

        # other common options

        { NAME    => 'logfile=s',
          HELP    => 'path/filename to use for prograprogramm logs',
          DEFAULT => "/var/log/aii-dhcp.log" },

        { NAME    => 'cfgfile=s',
          HELP    => 'Configuration file',
          DEFAULT => '/etc/aii/aii-dhcp.conf' }

        # options inherited from CAF
        #   --help
        #   --version
        #   --verbose
        #   --debug
        #   --quiet

        );

    return(\@array);

}

sub _initialize {

    my $self = shift;

    #
    # define application specific data.
    #

    # external version number
    $self->{'VERSION'} = '1.0';

    # show setup text
    $self->{'USAGE'} = "Usage: aii-dhcp [options]\n";

    #
    # log file policies
    #

    # append to logfile, do not truncate
    $self->{'LOG_APPEND'} = 1;

    # add time stamp before every entry in log
    $self->{'LOG_TSTAMP'} = 1;

    #
    # start initialization of CAF::Application
    #
    unless ($self->SUPER::_initialize(@_)) {
        return(undef);
    }

    # start using log file (could be done later on instead)
    $self->set_report_logfile($self->{'LOG'});

    return(SUCCESS);

}

#############################################################
# aii-dhcp main program
#############################################################

package main;

use File::Compare;
use LC::Exception qw (SUCCESS throw_error);
use CAF::Lock qw (FORCE_IF_STALE);

use strict;
use vars qw($this_app %SIG);

# locking configuration
use constant TIMEOUT => 60;
use constant RETRIES => 6;

#
# Global Attributes
#

my (@NTC, @NTR);

@NTC = (); # NodesToCofigure
@NTR = (); # NodesToRemove


#
# restart_daemon()
#
# restart dhcp daemon
#
sub restart_daemon {

    my ($cmd, $output);

    $this_app->debug(3, "aii-dhcp: restarting daemon dhcpd");

    $cmd = $this_app->option('restartcmd');

    $output = `$cmd 2>&1`;
    if ($? != 0) {
        $this_app->error("aii-dhcp: error restarting dhcp daemon: $output");
        return(1);
    } else {
        $this_app->verbose("aii-dhcp: daemon restarted: $output");
    }

    return(0);

}

#
# update_dhcp_config_file($text)
#
# Remove and add host declarations
#
sub update_dhcp_config_file($) {

    my $text = shift;
    my ($nodes_regexp, $node);

    #
    # Remove existing nodes (both NodesToConfigure and NodesToRemove)
    #
    $nodes_regexp = '';
    foreach $node (@NTC, @NTR) {
        $nodes_regexp .= $node->{NAME} . '|' . $node->{FQDN} . '|';
    }
    $nodes_regexp =~ s/\|$//; # remove last '|'
    ##
    ## primitive support for if {} else {} parameters host group
    ## only starting from perl 5.10 can you use recursive regexp
    ## fro recursive examples, see http://www.perlmonks.org/?node_id=547596 or
    ## http://stackoverflow.com/questions/133601/can-regular-expressions-be-used-to-match-nested-patterns
    ##
    $nodes_regexp = '\\n\s*host\s+(' . $nodes_regexp . ')\s*\{[^{}]*(?:\{[^{}]*\}(?:[^{}]*\{[^{}]*\})?|[^{}]*)[^{}]*\}';
    $text =~ s/$nodes_regexp//gm;

    #
    # Collect the subnets + netmask definitions
    #
	# Fix for bug #10455: regexp should not match comment lines
	#
    my @netandmasks = ($text =~
                       /\n\s*subnet\s+([\d\.]+)\s+netmask\s+([\d\.]+)/g);
    if ($#netandmasks % 2 == 0) {
        $this_app->error("aii-dhcp: syntax error on dhcpd.conf: " . 
                    "netmask/network missing in subnet declaration");
        return(1, '');
    }
    my (@subnets, $i);
    for ($i=0 ; $i<$#netandmasks ; $i=$i+2) {
        push (@subnets,
                 { NET     => unpack('N',Socket::inet_aton($netandmasks[$i])),
                   MASK    => unpack('N',Socket::inet_aton($netandmasks[$i+1])),
                   ST_NET  => $netandmasks[$i],
                   ST_MASK => $netandmasks[$i+1]});
        $this_app->verbose("aii-dhcp: found subnet $netandmasks[$i] " . 
                      "mask $netandmasks[$i+1]");
    }
    # If subnets are not defined in the DHCP configuration file managed by AII,
    # just add an empty subnet. This will have the effect of disabling all the checks
    # related to subnets.
    my $subnet_defined = 1;
    if ( @subnets == 0 ) {
      push (@subnets,{});
      $subnet_defined = 0;
    }

    #
    # for each subnet, write entries that belong to it
    #
    my ($net, $mac, $newnodes, $netfound);
    my $indent = "  ";
    unless ( $subnet_defined ) {
      $indent = "";
    }
    
    foreach $net (@subnets) {
        $newnodes = '';
 
        foreach $node (@NTC) {

            # Does the node belong to this subnet?
            # Always true if no subnet defined.
            if ( !$subnet_defined || (($node->{IP} & $net->{MASK}) == $net->{NET}) ) {

                $node->{OK} = 1;

                # basic host declaration
                $newnodes .= "\n".$indent."host $node->{NAME} {  # added by aii-dhcp\n";
		
                foreach $mac (split(' ', $node->{MAC})) {
                    $newnodes .= "$indent  hardware ethernet $mac;\n";
                }
                
                $newnodes .= "$indent  fixed-address $node->{ST_IP};\n";

                # TFTP server
                if ($node->{ST_IP_TFTP}) {
                    $newnodes.="$indent  next-server $node->{ST_IP_TFTP};\n";
                }
   
                # additional options
                if ($node->{MORE_OPT}) {
                    $newnodes .= "$indent  $node->{MORE_OPT}\n";
                }
 
                $newnodes.="$indent}\n";
                if ( $subnet_defined ) {   
                    $this_app->verbose("aii-dhcp: added node $node->{NAME} ".
                                       "to subnet $net->{ST_NET}");
                } else {
                    $this_app->verbose("aii-dhcp: added node $node->{NAME} (no subnet specified)");                  
                }
            }

        }

        # Insert the nodes to the current subnet
        if ($newnodes ne '') {
            $this_app->debug(1,"aii-dhcp: newnodes=|$newnodes|\n");
            if ( $subnet_defined ) {
                $text =~ s/( \s*  subnet  \s+ \Q$net->{ST_NET}\E  \s+
                         netmask \s+ \Q$net->{ST_MASK}\E \s+ \{
                         ([^{}]+\{[^{}]+\})*)
                         ([^}]+)(\})/$1$3$newnodes$4/x;
            } else {
                $text .= $newnodes;
            };
        }

    }

    #
    # Just a stupid check for nodes not inserted
    #
    foreach $node (@NTC) {
        ($node->{OK}) || $this_app->warn("dhcp: No valid subnet found " .
                                    "for $node->{FQDN}");
    }

    return (0, $text);

}

#
# update_dhcp_config()
#
# Update DHCP configuration file
#
sub update_dhcp_config {

    my (@lines);
    my ($filename, $text, $error, $lockfile);

    #
    # Lock and load the current dhcp configuration file
    #
    $filename = $this_app->option('dhcpconf');
    $lockfile = $filename . ".lock";
    my $lock = CAF::Lock->new ($lockfile);
    unless ($lock && $lock->set_lock (RETRIES, TIMEOUT, FORCE_IF_STALE)) {
	$this_app->error("dhcp: couldn't acquire lock on $lockfile");
	return(1);
    }
    $this_app->debug(3, "Locked dhcp configuration");
    $this_app->debug(3,"DHCP configuration file : $filename");
    if (!open(FILE, "< $filename")) {
        $this_app->error("dhcp: update configuration: ". 
                         "file access error $filename");
        return(1);
    }
    @lines = <FILE>;
    close (FILE);
    $text = join ('', @lines);

    #
    # Add/removal of nodes
    #
    ($error, $text) = update_dhcp_config_file($text);
    if ($error != 0) {
        return(1);
    }

    #
    # Backup the old one
    #
    if (!rename ($filename, $filename . '.pre_aii')) {
        $this_app->error("dhcp: error creating backup copy $filename.pre_aii");
        return(1);
    }

    #
    # Write the new dhcp configuration file
    #
    if (!open(FILE, "> $filename") ) {
        $this_app->error("dhcp: error creating output file $filename");
        return(1);
    } else {
        print(FILE $text);
        close(FILE);
    }
 
    return(0);

}

#
# new_remove_entry($host)
#
# Check and add a node to the array NTR (nodes to remove)
#
sub new_remove_entry($) {

    my $host = shift;
    my ($fqdn, @all_address);

    #
    # Check host
    #
    if (!defined($host) || $host eq '') {
        $this_app->warn('aii-dhcp: missing hostname');
        return(1);
    }

    ($fqdn, @all_address) = (gethostbyname($host))[0,4];
    if ($#all_address < 0) {
        # The array is empty => invalid name
        $this_app->warn("aii-dhcp: invalid hostname to remove ($host)");
        return(1);
    } else {
        $host = $fqdn;
# why remove the domain name?
#        $host =~ s/([^.]+)(.*)/$1/;
    }

    #
    # add entry to NodesToRemove array
    #
    push(@NTR, { FQDN => $fqdn,
                 NAME => $host,
                 IP   => Socket::inet_ntoa($all_address[0]) } );

    $this_app->debug(2, "aii-dhcp: mark $host (fqdn: $fqdn, ip: "
                        . Socket::inet_ntoa($all_address[0]) . ") to remove");

    return(0);

}

#
# new_configure_entry($host, $mac, [$tftpserver, @params])
#
# Check and add a node to the array NTC (nodes to configure)
#
sub new_configure_entry {

    my $host       = shift;
    my $mac        = shift;
    my $tftpserver = shift;
    my $additional = join(' ', @_);

    my ($fqdn, @all_address, @all_tftp_address);
    my ($item);

    #
    # Check hostname
    #

    if (!defined($host) || $host eq '') {
        $this_app->warn('aii-dhcp: missing hostname');
        return(1);
    }

    ($fqdn, @all_address) = (gethostbyname($host))[0,4];
    if ($#all_address < 0) {		# The array is empty => invalid name
        $this_app->warn("aii-dhcp: invalid hostname to add ($host)");
        return(1);
    }

    #
    # Check MAC address
    #

    if (!defined($mac)) {
        $this_app->warn("aii-dhcp: missing MAC address for host $host");
        return(1);
    }

    if ($mac !~ /^([[:xdigit:]]{2}[\:\-]){5}[[:xdigit:]]{2}$/) {
        $this_app->warn("aii-dhcp: MAC address $mac not valid for host $host");
        return(1);
    }

    #
    # Check TFTP server
    # 
    # special case: input from file, there are additional options defined
    # but no tftpserver => in the text file tftpserver should be ';'
    #

    if ($tftpserver && $tftpserver ne ';') {
        @all_tftp_address=(gethostbyname($tftpserver))[4];
        if ($#all_tftp_address < 0) { # The array is empty => invalid name
            $this_app->warn("aii-dhcp: invalid TFTP server ($tftpserver) ".
                            "for $host");
            return(1);
        } else {
            # Get the IP address
            $tftpserver = Socket::inet_ntoa($all_tftp_address[0]);
        }
    }

    # IP in dotted form
    my $ip = Socket::inet_ntoa($all_address[0]);

    $host = $fqdn;
# again, why remove the domain name?
#    $host =~ s/([^.]+)(.*)/$1/;

    # check if the host entry already exists
    foreach $item (@NTC) {
        if($$item{FQDN} eq $fqdn) {
            $this_app->debug(2,
	        	"aii-dhcp: new MAC entry for existing host = $host mac = $mac");
	        $$item{MAC} .= " $mac";
	        $mac = ""; # flag meaning object found
	        last;
        }
    }
    
    if ($mac ne "") {	# it was a new host entry
        push(@NTC, { FQDN       => $fqdn,
                     NAME       => $host,
                     ST_IP      => Socket::inet_ntoa($all_address[0]),
                     IP         => unpack('N', $all_address[0]),
                     MAC        => $mac,
                     ST_IP_TFTP => $tftpserver,
                     MORE_OPT   => $additional} );
        $this_app->debug(2, "aii-dhcp: add new entry: host = $host mac = $mac");
        $this_app->debug(3, "aii-dhcp: add new entry: additional opts -->$additional<--");
    }

    return(0);

}

#
# read_input()
#
# Read from command line and/or file lists the hostnames involved
# and save them in @NTC (NodesToConfigure) or in @NTR (NodesToRemove)
#
# Return true in case of error
#
sub read_input {

    my ($host);
    my ($item, $filename, $error);
    my (@nodelist, @data);

    #
    # add one entry (from command line)
    #
    if ($this_app->option('configure')) {
        $error = new_configure_entry($this_app->option('configure'),
                                     $this_app->option('mac'),
                                     $this_app->option('tftpserver'),
                                     $this_app->option('addoptions') );
        return($error);
    }

    #
    # remove one entry (from command line)
    #
    if ($this_app->option('remove')) {
        $error = new_remove_entry($this_app->option('remove'));
        return($error);
    }

    #
    # add more than one entries (from a text file)
    #
    if ($this_app->option('configurelist')) {

        # get input data
        $filename = $this_app->option('configurelist');
        if (!open(FILE, "< $filename") ) {
            $this_app->error("aii-dhcp: configurelist error: " .
                             "file access error $filename");
            return(1);
        }

        $this_app->debug(2, "aii-dhcp: reading nodes from file: $filename");
        @nodelist = <FILE>;
        close(FILE);

        foreach $item (@nodelist) {
            $item =~ s/^\s*(\w*)\s*$/$1/;
            if ($item !~ /^#/ && $item ne "") {
                 (@data) = split("[ \t\n]+", $item);
                 new_configure_entry(@data);
            }
        }

    }

    #
    # Remove some entries (from a text file)
    #
    if ($this_app->option('removelist')) {

        # get input data
        $filename = $this_app->option('removelist');
        if (!open(FILE, "< $filename") ) {
            $this_app->error("aii-dhcp: removelist error: " .
                             "file access error $filename");
            return(1);
        }

        $this_app->debug(2, "aii-dhcp: reading nodes from file: $filename");
        @nodelist=<FILE>;
        close (FILE);

        foreach $item (@nodelist) {
            $item =~ s/[\s\n]//g;		# remove spaces and \n
            if ($item ne ""  && ($item !~ /^\s*\#/)) {
                new_remove_entry($item);
            }
        }

    }

    return(0);

}

#------------------------------------------------------------
# main loop
#------------------------------------------------------------

# fix umask
umask (022);

# unbuffer STDOUT & STDERR
autoflush STDOUT 1;
autoflush STDERR 1;

# initialize the main class.
unless ($this_app = aii_dhcp->new($0, @ARGV)) {
    throw_error("aii-dhcp: cannot start application");
}

# process command line options
$this_app->debug(1, "aii-dhcp: reading cmd line or input files");
if (read_input()) {
    $this_app->error("aii-dhcp: failed to process cmd line or input files");
    exit(1);
}

# update dhcpd configuration file
if( scalar(@NTC) > 0 || scalar(@NTR) > 0 ) {
    $this_app->debug(1, "aii-dhcp: updating dhcpd configuration");
    if (update_dhcp_config()) {
        $this_app->error("aii-dhcp: failed to update dhcpd configuration");
        exit(1);
    }
} else {
    $this_app->debug(1, "aii-nbp: there are no changes to dhcpd configuration");
}

# restart dhcpd daemon
if (!$this_app->option('norestart')) {

    if (compare($this_app->option('dhcpconf'),
                $this_app->option('dhcpconf').'.pre_aii') == 0) {
        $this_app->verbose('aii-dhcp: no changes to '
                           . $this_app->option('dhcpconf')
                           . ': daemon not restarted');
    } else {
        $this_app->debug(1, "aii-dhcp: restarting dhcpd daemon");
        restart_daemon();
    }
} else {
    $this_app->verbose("aii-dhcp: dhcpd daemon do not restarted");
}

exit (0);

