#!/usr/bin/perl -w
#
# racadm-update - scripted ssh access to Dell iDRAC interface
#
# Copyright 2015 Board of Trustees, Leland Stanford Jr. University All
# rights reserved.
    
use 5.006;
use strict;
use warnings;

use AppConfig qw(:argcount :expand);
use Carp;
use Getopt::Long;
use File::Temp qw ( tempfile );
use Net::OpenSSH;
use Pod::Usage;
use Socket;
use Sys::Hostname;
use Term::ReadKey;

my $opt_config;
my $opt_debug;
my $opt_example;
my $opt_global = '/etc/racadm-update/racadm.conf';
my $opt_help;
my $opt_manual;

my $CONF;
my $GLOBAL;
my $DEBUG_TIME = time;

##############################################################################
# Subroutines
##############################################################################

# ------------------------------------------------------------------------
# Generate debugging messages.

sub dbg {
    my ($msg) = @_;

    my $now     = time;
    my $elapsed = $now - $DEBUG_TIME;
    prt("$now ($elapsed) $msg\n");
    $DEBUG_TIME = $now;
    return;
}

# ------------------------------------------------------------------------
# Write messages to stdout.

sub prt {
    my ($msg) = @_;
    print STDOUT $msg or croak('Problem writing to STDOUT');
    return;
}

# ------------------------------------------------------------------------
# Read the global configuration file

sub read_global {
    my ($conf_file) = @_;

    $GLOBAL = AppConfig->new({});
    $GLOBAL->define('password', { ARGCOUNT => ARGCOUNT_ONE});
    $GLOBAL->define(
        'timeout',
        {
            DEFAULT  => 60,
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $GLOBAL->define(
        'user',
        {
            DEFAULT  => 'root',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );

    if (-r $conf_file) {
        if ($opt_debug) {
            dbg("Reading $conf_file");
        }
        $GLOBAL->file($conf_file);
    }

    return;
}

# ------------------------------------------------------------------------
# Read the iDRAC version specific configuration file

sub read_config {
    my ($conf_file) = @_;

    if (!-r $conf_file) {
        croak("ERROR: Configuration file not found $conf_file");
    }

    $CONF = AppConfig->new({});
    $CONF->define('show_cmd',   { ARGCOUNT => ARGCOUNT_LIST });
    $CONF->define('update_cmd', { ARGCOUNT => ARGCOUNT_LIST });
    $CONF->define(
        'syslog_host',
        {
            DEFAULT  => 'logsink1.stanford.edu',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $CONF->define(
        'timeout',
        {
            DEFAULT  => 60,
            ARGCOUNT => ARGCOUNT_ONE
        }
    );

    $CONF->file($conf_file);

    return;
}

# ------------------------------------------------------------------------
# Display an example configuration files

sub example_config {

    my $example = <<'END_EXAMPLE';
# ====================================================================
# Example global configuration /etc/racadam-update/racadm.conf
#
# It is not recommended that the password be store in the
# configuration file.
#password = somesecret
#
# The username to use when connecting to the iDRAC
#user = root
#
# The timeout in seconds for the initial connection.
#timeout = 60

# ====================================================================
# Example configuration /etc/racadam-update/racadm-1.46.conf
#
#syslog_host = logsink1.stanford.edu
#
# The timeout value in seconds used for command sets.
#timeout = 60
#
# Define the commands sets.
# The strings ==syslog_ip==, ==hostname==, and ==syslog_host==
# are will have the values of the ip address of the syslog
# server, the hostname of the iDRAC, and the hostname of the 
# syslog used respectively.  Substitions are preformed on both 
# update and show command sets.
#
# The update set of commands to execute.  The strings 
[update]
cmd = racadm config -g cfgRemotehosts -o cfgRhostsSyslogEnable 1
cmd = racadm set iDRAC.Syslog.SyslogEnable 1
cmd = racadm config -g cfgRemoteHosts -o cfgRhostsSyslogServer1 ==syslog_ip==
cmd = racadm iDRAC.Syslog.Server1 ==syslog_ip==
cmd = racadm config -g cfgLanNetworking -o cfgDNSDomainName ==hostname==
cmd = racadm set iDRAC.SysLog.PowerLogEnable 1
cmd = racadm config -g cfgIpmiLan -o cfgIpmiLanAlertEnable 1
cmd = racadm set iDRAC.IPMILan.AlertEnable 1
cmd = racadm eventfilters set -c idrac.alert.all -a none -n all

[show]
cmd = racadm getconfig -g cfgRemoteHosts
cmd = racadm getconfig -g cfgLanNetworking
cmd = racadm eventfilters get -c idrac.alert.all

END_EXAMPLE

    prt($example);

    return;
}

# ------------------------------------------------------------------------
# Get the ip address of a host

sub get_ip {
    my ($hostname) = @_;
    my $address = inet_ntoa(inet_aton($hostname));
    return $address;
}

# ------------------------------------------------------------------------
# Get the password

sub get_pass {
    my $pw;
    if ($GLOBAL->password) {
        $pw = $GLOBAL->password;
    } else {
        ReadMode ('noecho');
        print "Enter IPMI Password: ";
        chomp($pw = <STDIN>);
        ReadMode ('restore');
        print "\n";
    }
    return $pw;
}

# ------------------------------------------------------------------------
# Connect to the iDRAC, get summary information, return the ssh
# connection and a hash with the summary information.

sub idrac_connect {
    my ($host, $passwd) = @_;

    # Connect to iDRAC
    my $this_user = getpwent();
    my %opts = (
        user        => $GLOBAL->user,
        passwd      => $passwd,
        timeout     => $GLOBAL->timeout,
        strict_mode => 0,
        ctl_dir     => "/tmp/${this_user}-libnet-openssh-perl"
    );
    my $ssh  = Net::OpenSSH->new ($host, %opts);
    if ($ssh->error) {
        die "ERROR: connecting to $host " . $ssh->error;
    }

    # Figure out which iDRAC we have
    my $this_cmd = 'racadm getsysinfo';
    if ($opt_debug) {
        dbg("Executing $this_cmd");
    }
    my @out = $ssh->capture({ timeout => $GLOBAL->timeout }, $this_cmd );
    if ($ssh->error) {
        prt("WARN: problem executing ($this_cmd)\n");
        for my $m (@out) {
            prt("INFO: $m");
        }
        die 'ERROR: command failure with ' . $ssh->error;
    }
    my %rac_info = ();
    for my $line (@out) {
        my ($a, $v) = split /=/, $line, 2;
        if ($v) {
            $a =~ s/\s+//xmsg;
            $v =~ s/^\s+//xms;
            $v =~ s/\s+$//xms;
            $rac_info{$a} = $v;
        }
    }
    return $ssh, \%rac_info;
}

# ------------------------------------------------------------------------
# Run a set of racadm commands

sub run_cmds {
    my ($ssh, $host, $cmd_list_ref) = @_;

    my %subs = ();
    $subs{'==hostname=='} = $host;

    if (eval{ $CONF->syslog_host }) {
        my $syslog_ip = get_ip($CONF->syslog_host);
        if (!$syslog_ip) {
            croak(
              'ERROR: invalid syslog hostname (' . $CONF->syslog_host . ')'
            );
        }
        $subs{'==syslog_ip=='}   = $syslog_ip;
        $subs{'==syslog_host=='} = $CONF->syslog_host;
    }

    my @cmd_list = @{$cmd_list_ref};
    for my $cmd (@cmd_list) {
        for my $regex (keys %subs) {
            my $val = $subs{$regex};
            $cmd =~ s/$regex/$val/xmsg;
        }
        if ($opt_debug) {
            dbg("Executing: $cmd");
        }
        my $out = $ssh->capture({ timeout => $CONF->timeout }, $cmd );
        if ($ssh->error) {
            prt("WARN: problem executing ($cmd)\n");
            prt("INFO: $out\n");
            croak 'ERROR: command failure with ' . $ssh->error;
        }
        if ($out) {
            prt($out);
        }
    }
    return;
}

##############################################################################
# Main Routine
##############################################################################

GetOptions(
    'config=s' => \$opt_config,
    'debug'    => \$opt_debug,
    'example'  => \$opt_example,
    'global'   => \$opt_global,
    'help'     => \$opt_help,
    'manual'   => \$opt_manual
);

# Flush output immediately.
$| = 1;

if ($opt_example) {
    example_config();
    exit;
}

# First arguement is the action
my $action = $ARGV[0];
if (($action && $action eq 'manual') || $opt_manual) {
    pod2usage(-verbose => 2);
}
if (!$action || ($action && $action eq 'help') || $opt_help) {
    pod2usage(-verbose => 0);
}
if ($action !~ /^(update|show)$/xms) {
    prt("ERROR: unrecognized action ($action)\n");
    pod2usage(-verbose => 0);
}
shift;

# All of the rest of the arguements are taken as hostnames
my @host_list;
if (scalar(@ARGV)>0) {
    @host_list = @ARGV;
} else {
    my $this_idrac = hostname;
    $this_idrac =~ s/[.].*//xms;
    $this_idrac .= '-ipmi.console.sunet';
    push @host_list, $this_idrac;
}

# Process each host in the list
for my $host (@host_list) {
    # Allow short form names
    if ($host !~ /[.]/) {
        $host .= '-ipmi.console.sunet';
    }
    # Make sure it is in the DNS
    my $ip = get_ip($host);
    if (!$ip) {
        die "ERROR: host $host not found";
    }
    # Read global configuration file
    read_global($opt_global);
    # All access requires a password
    my $pw = get_pass();
    if (!$pw) {
        die "ERROR: password is required";
    }
    # Connect to the iDRAC
    my ($ssh, $idrac_info_ref) = idrac_connect($host, $pw);
    my %idrac_info = %{$idrac_info_ref};
    if ($opt_debug) {
        for my $a (sort keys %idrac_info) {
            dbg("idrac_info $a = $idrac_info{$a}");
        }
    }
    # Read the configuration file
    if (!$opt_config) {
        $opt_config
          = '/etc/racadm-update/racadm-' . $idrac_info{'FirmwareVersion'}
        . '.conf';
    }
    read_config($opt_config);

    # Perform the set of commands
    if ($action eq 'update') {
        run_cmds($ssh, $host, $CONF->update_cmd);
    } else {
        run_cmds($ssh, $host, $CONF->show_cmd);
    }

    # Hang up the phone
    $ssh->capture({ timeout => $GLOBAL->timeout }, 'exit' );
}

exit;

__END__

##############################################################################
# Documentation
##############################################################################

=head1 NAME

racadm-update

=head1 SYNOPSIS

racadm-update show|update|help|manual [host host ...] [--config=file]
[--global=file] [--example] [--connect_timeout=i] [--debug] [--help]
[--manual]

=head1 DESCRIPTION

This script uses ssh to connection to Dell's iDRAC and perform a set
of commands.  The script divides comamnds into an update set and a
show set.  The default is to execute the show set of commands.  The
command set name of show and update are fairly arbitrary since the
either set can contain any valid iDRAC commands.

The show and update command sets are defined in the configuration
file.  For syntax of the configuration file see the --example switch
output.

The script attempts to read two configuration files.  The first 
configuration file, referred to as the global configuration, is 
used in the initial connection to the iDRAC.  This configuration 
file include the minimum option set that is required to make the 
the connection and is options.  The default file name for the 
global configuration is /etc/racadm-update/racadm.conf.

The second configuration file is iDRAC version specific and uses
information from the initial connection to choose which is used to
form the configuration file name.  If the configuration file is
specified on the command line then that configuration file is used
otherwise the script forms a file name using the iDRAC firmware
version and attempts to use that.  The configuration file name is of
the form:

=over 4

/etc/racadm-update/racadm-I<firmware version>.conf

=back 

If a password is not supplied in the global configuration file then
the script prompts for the iDRAC password.

=head1 OPTIONS

=over 4

=item --global=file

Over ride the default global configuration file.  The default
configuration file is /etc/racadm-update/racadm.conf.  For the format
of the configuration file see --example.

=item --config=file

Over ride the default configuration file.  The default configuration 
file is /etc/racadm-update/racadm-I<firmware version>.conf.  For 
the format of the configuration file see --example.

=item --connection_timeout=i

Over ride the default connection time out of 60 seconds.  This cannot
be read from the configuration because the initial connection also
extracts the firmware version used to form the default configuration
file name.

=item --example

Display an example configuration file.

=item --debug

Display debugging output.

=item --help

A short help message.

=item --manual

The complete documentation.

=back

=head1 AUTHOR

Bill MacAllister <whm@stanford.edu>

=head1 COPYRIGHT

Copyright 2015 Board of Trustees, Leland Stanford Jr. University.
All rights reserved.

=cut
