#!/usr/bin/perl
#
#  x509watch 0.6.1
#
#  (c) 2009-2017 by Robert Scheck <x509watch@robert-scheck.de>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License along
#  with this program; if not, write to the Free Software Foundation, Inc.,
#  51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

use strict;
use Getopt::Long qw(:config no_ignore_case);
use File::Find;
use Fcntl;
use IPC::Open3;
use POSIX qw(mktime);
use Config;

# Default configuration values
my %config = (
  period  => 30,
  deflt_d => ["/etc/pki", "/etc/ssl", "/usr/share/ssl"],
  deflt_f => [],
  openssl => "/usr/bin/openssl",
  x509ext => ["\.(pem|crt)\$"],
  exclude => ["\/(cert\.pem|((email|objsign|tls)-)?ca-bundle\.(crt|(legacy|trust)\.crt|pem)|ca-certificates\.crt)\$", "\.((bak|old)\$|drbdlinks\/)", "\/(demo|entitlement|expired|private)\/"],
  warning => 1,
  help    => 0
);

# Handle given parameters and options
GetOptions(
  "period|p=i"    => \$config{'period'},
  "directory|d=s" => \@{$config{'directories'}},
  "file|f=s"      => \@{$config{'files'}},
  "ignore|i=s{,}" => \@{$config{'ignore'}},
  "no-warning|n!" => sub { $config{'warning'} = 0 },
  "openssl|o=s"   => \$config{'openssl'},
  "help|?!"       => \$config{'help'},
  "version|V!"    => \$config{'help'}
) || do { $config{'help'} = 2; };

# If wished, print help and version information
if($#ARGV != -1 || $config{'help'})
{
  print "\n" if($config{'help'} == 2);
  print "x509watch 0.6.1, (c) 2009-2017 by Robert Scheck\n";
  print "This program is free software with ABSOLUTELY NO WARRANTY;\n";
  print "you may redistribute it under the terms of the GNU General\n";
  print "Public License version 2 or later.\n\n";
  print "Usage: x509watch [OPTIONS]\n";
  print "  -p, --period=days     Number of days to be warned before;\n";
  print "                        default: " . $config{'period'} . "\n";
  print "  -d, --directory=path  Recursive searched filesystem path;\n";
  print "                        default: " . join(", ", grep(-d $_, @{$config{'deflt_d'}})) . "\n";
  print "  -f, --file=path       Filesystem path to certificate file\n";
  print "  -i, --ignore=glob     Path with globbing of ignored files\n";
  print "  -n, --no-warning      Suppress all warnings during access\n";
  print "  -o, --openssl=path    Alternative path to OpenSSL binary;\n";
  print "                        default: " . $config{'openssl'} . "\n";
  print "  -?, --help            Display this help and exit\n";
  print "  -V, --version         Output version information and exit\n\n";
  print "Locates soon expiring or already expired SSL certificates.\n";
  exit(0);
}

# Check for valid number of days
if($config{'period'} <= 0)
{
  print "Error: Day period must be always greater than zero!\n";
  exit(1);
}

# Check whether given alternative OpenSSL exists
if(!-x $config{'openssl'})
{
  print "Error: OpenSSL \"" . $config{'openssl'} . "\" does not exist or is not executable!\n";
  exit(1);
}

# Check whether directories or files were given
if(scalar @{$config{'directories'}} > 0 || scalar @{$config{'files'}} > 0)
{
  foreach my $directory (@{$config{'directories'}})
  {
    # Check whether directory exists
    if(!-d $directory && $config{'warning'} == 1)
    {
      print "Directory \"$directory\" does not exist or is not readable!\n";
    }
  }
}
else
{
  foreach my $directory (@{$config{'deflt_d'}})
  {
    # Check whether directory exists
    if(-d $directory)
    {
      push(@{$config{'directories'}}, $directory);
    }
  }

  foreach my $file (@{$config{'deflt_f'}})
  {
    # Check whether file exists
    if(-f $file)
    {
      push(@{$config{'files'}}, $file);
    }
  }

  # Check whether any directory or file is left
  if(scalar @{$config{'directories'}} == 0 && scalar @{$config{'files'}} == 0)
  {
    print "Error: No directory or file given, use --directory=path or --file=path to specify one!\n";
    exit(1);
  }
}

# Walk through all given directories
if(scalar @{$config{'directories'}} > 0)
{
  find({ wanted => \&wanted, follow => 1, follow_skip => 2 }, @{$config{'directories'}});
}

# Walk through all given files
foreach my $file (@{$config{'files'}})
{
  check($file);
}

sub wanted
{
  # Try to find all certificates
  if(grep($File::Find::name =~ $_, @{$config{'x509ext'}}))
  {
    # Check whether an exclusion applies
    if(!grep($File::Find::name =~ $_, @{$config{'exclude'}}) && !grep($File::Find::name eq $_, @{$config{'ignore'}}))
    {
      check($File::Find::name);
    }
  }
}

sub check
{
  my $file = shift;

  # Check whether file is readable
  if(-r $file)
  {
    my $cert;

    # Open certificate file for later handling
    sysopen(CERT, $file, O_RDONLY) || do { print "File \"$file\" is not readable!\n"; };
    while(<CERT>)
    {
      $cert .= $_;

      # Handle multiple certificates in one file
      if($_ =~ /^\-+END(\s\w+)?\sCERTIFICATE\-+$/)
      {
        my ($month, $day, $year, $valid, $cn);
        local (*WRITER, *READER, *ERROR);

        # Open the OpenSSL process for writing and reading
        my $pid = open3(*WRITER, *READER, *ERROR, $config{'openssl'}, "x509", "-noout", "-text") || do { print "Error: IPC::Open3 failed!\n"; exit(1); };
        print WRITER $cert;
        close(WRITER);
        waitpid($pid, 0);
        undef($cert);

        # Check whether result is successful or unsuccessful
        if($? == 0)
        {
          # Walk through each line of the output
          foreach my $line (<READER>)
          {
            if($line =~ /Not After : (\S+)\s{1,2}(\d+) \d+:\d+:\d+ (\d+)/)
            {
              my %months = ("Jan" => 0, "Feb" => 1, "Mar" => 2, "Apr" => 3,
                            "May" => 4, "Jun" => 5, "Jul" => 6, "Aug" => 7,
                            "Sep" => 8, "Oct" => 9, "Nov" => 10, "Dec" => 11);

              ($month, $day, $year) = ($months{$1} + 1, $2, $3);
              $valid = mktime(0, 0, 0, $day, $month - 1, $year - 1900, 0, 0);
            }
            elsif($line =~ /Subject:.*CN=([^\/\n]+)/)
            {
              $cn = $1;
            }
            elsif($line =~ /Subject:.*OU=([^\/\n,]+)/ && !defined($cn))
            {
              $cn = $1;
            }
          }
        }
        else
        {
          print "Error: OpenSSL handling of file \"$file\" failed:\n" . join("", <ERROR>);
          exit(1);
        }
        close(READER);
        close(ERROR);

        # 32 bit perl versions: Work around certificates valid > 2037
        if($valid == undef)
        {
          if($year > 2037 && $Config{'ptrsize'} == 4)
          {
            if(((localtime(time))[5] + 1900) > 2037)
            {
              print "Error: Running x509watch on Y2K38 affected system!\n";
              exit(1);
            }
            else
            {
              $valid = 2**31-1;
            }
          }
        }

        # Check whether certificate is expiring or expired already
        if(int(time()) > int($valid))
        {
          printf("$file ($cn) is not valid since %04d-%02d-%02d\n", $year, $month, $day);
        }
        elsif(int(time()) > int($valid) - $config{'period'} * 60 * 60 * 24)
        {
          printf("$file ($cn) is not valid after %04d-%02d-%02d\n", $year, $month, $day);
        }
      }
    }
    close(CERT);
  }
  elsif($config{'warning'} == 1)
  {
    # Check why file isn't readable
    if(-l $file)
    {
      print "File \"$file\" is a dangling symlink!\n";
    }
    else
    {
      print "File \"$file\" does not exist or is not readable!\n";
    }
  }
}
