#!/usr/bin/env php
<?php
/*
 *  check-ssl-certs :: Checks to see if SSL certs used by Apache or nginx exist
 *  and have expired, also checks to see if SSL certs in the LetsEncrypt cert
 *  dir have expired.
 *
 *  Version 1.0.0  September 2, 2025
 *  Copyright (c) 2015-2025, Ron Guerin <ron@vnetworx.net>
 *
 *  Requires: PHP_PCRE, PHP-OpenSSL
 *  Suggests: PHP-POSIX, tput, id
 *
 *  Do not edit this script!  Edit /etc/check-ssl-certs.conf.php to change settings.
 *  Script changes are overwritten on upgrades.
 *
 *  check-ssl-certs 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.
 *
 *  check-ssl-certs 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.
 *
 *  If you are not able to view the file COPYING, please write to the
 *  Free Software Foundation, Inc.,
 *  59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *  to get a copy of the GNU General Public License or to report a
 *  possible license violation.
 *
 *  @package check-ssl-certs
 *  @author Ron Guerin <ron@vnetworx.net>
 *  @license http://www.fsf.org/licenses/gpl.html GNU Public License
 *  @copyright Copyright &copy; 2015-2025 Ron Guerin
 *  @filesource
 *  @link http://gothamcode.com/check-ssl-certs check-ssl-certs
 *  @version 1.0.0
*/

error_reporting(E_ALL);
ini_set('display_errors', 1);
define('VERSION', '1.0.0');
define('MEPATH', realpath($argv[0]));
define('MEHOST', hostname(TRUE));
$me = basename(MEPATH);
define('ME', (substr($me, -4) == '.php') ? substr($me, 0, strlen($me) - 4) : $me);
restore_standard_timezone_policy();
define('VERSIONSTAMP', date('F j, Y H:i:s', stat(MEPATH)['mtime']));
openlog(ME, LOG_PID, LOG_USER); // Open syslog
if (function_exists('cli_set_process_title')) cli_set_process_title(ME); // set proctitle

$found = FALSE;

// Parse command-line, early
foreach ($argv as $index => $arg) {
	if (! $index) continue; // skip $argv[0]
	$argbase = ($pos = strpos($arg, '=')) ? substr($arg, 0, $pos) : $arg;
	switch ($argbase) {
		case '-V':
		case '--version':
			echo VERSION."\n";
			exit;
			break;
		case '?':
		case '-?':
		case '-h':
		case '--help':
		case 'help':
			help();
			exit;
			break;
	}
}

if (file_exists('/etc/'.ME.'.conf.php')) require_once '/etc/'.ME.'.conf.php';

define('MAILFROM', (isset($mailfrom)) ? $mailfrom : 'root');
define('MAILFROMNAME', (isset($mailfromname)) ? $mailfromname : 'SSL Expiration Monitor');
define('MAILTO', (isset($mailto)) ? $mailto : 'root');
define('CERT_DIRS', (isset($certdirs)) ? $certdirs : array());
define('APACHE_SITES', (isset($apachesites)) ? $apachesites : '/etc/apache2/sites-enabled');
define('NGINX_SITES', (isset($nginxsites)) ? $nginxsites : '/etc/nginx/sites-enabled');
define('SSL_EXPIRE_WARN_DAYS', (isset($sslexpirewarndays)) ? $sslexpirewarndays : 21);
define('EXPIRE_WINDOW', time() + (86400 * SSL_EXPIRE_WARN_DAYS) + 43500); // roughly half-day tacked on
define('CHECK_WEBS', (isset($checkwebs)) ? $checkwebs : TRUE);
define('CHECK_CERTDIRS', (isset($checkcertdirs)) ? $checkcertdirs : TRUE);
define('CHECK_CTLS', (isset($checkctls)) ? $checkctls : FALSE);
define('CTL_SITES', (isset($ctls)) ? $ctls : array());

if (CHECK_WEBS && ((! is_dir(APACHE_SITES)) && (! is_dir(NGINX_SITES)))) {
	@fwrite(STDERR, 'Error: Check Web sites is on, but cannot find any Web site configs.'."\n");
	exit(1);
}
if (CHECK_CERTDIRS) {
	if (! count(CERT_DIRS)) {
		@fwrite(STDERR, 'Error: Check cert directories is on, but no cert directories are configured.'."\n");
		exit(1);
	}
	else {
		foreach (CERT_DIRS as $certdir) {
			if (! is_dir($certdir)) {
				@fwrite(STDERR, 'Error: Directory "'.$certdir.'" does not exist.'."\n");
				exit(1);
			}
		}
	}
}
if (CHECK_CTLS && (! count(CTL_SITES))) {
	@fwrite(STDERR, 'Error: Check Certificate Transparency Logs is on, but no certificates are configured.'."\n");
	exit(1);
}

// Parse command-line, later
$error = $expires = $check = $skip = FALSE;
foreach ($argv as $index => $arg) {
	if (! $index) continue; // skip $argv[0]
	if ($skip) {
		$skip = FALSE;
		continue;
	}
	$argbase = ($pos = strpos($arg, '=')) ? substr($arg, 0, $pos) : $arg;
	switch ($argbase) {
		case '-c':
		case '--check':
			$check = TRUE;
			break;
		case '-e':
		case '--expires':
			$expires = TRUE;
			break;
		default:
			@fwrite(STDERR, 'Error: Unknown argument "'.$argbase.'"'."\n");
			$error = TRUE;
			break;
	}
}

if ($error) {
	help(TRUE);
	exit(1);
}

if (posix_getuid() != 0) {
	fwrite(STDERR, 'Error: You must be root to run this command'."\n");
	exit(1);
}

# openssl x509 -noout -subject -nameopt multiline -in file.pem | sed -n 's/ *commonName *= //p'
# openssl x509 -enddate -noout -in file.pem
# openssl x509 -checkend $seconds -noout -in file.pem
# $cn = exec('openssl x509 -noout -subject -nameopt multiline -in '.$sslcert.'|sed -n \'s/ *commonName *= //p\'', $return);
# exec('openssl x509 -checkend '.$seconds.' -noout -in '.$sslcert, $return);

$maxlen = 0;
$certs = get_certs(); // Get list of all certs
// Get expiration dates, maxlen and warns returned by reference
$certs = process_certs($certs, $maxlen, $warns);

if ($check) check_certs_and_email($certs);
else {
	terminal_init();
	show_cert_status($certs, $maxlen, $warns, $expires);
}
exit;


####################################################################################################################################
####################################################################################################################################


function check_certs_and_email($certs) {
	// Emails status report
	// The following certificates require your attention:
	// --------------------------------------------------
	// example.com  Starts: 2024-12-15 11:59:44  Expires: 2025-02-13 11:59:43  *WARNING*
	// example.com  Starts: 2024-12-15 11:59:44  Expires: 2025-01-13 11:59:43  *EXPIRED*
	//
	// The warning window starts 21 days before expiration.
	// Certs marked missing are defined in Web configs but do not exist on disk.
	$msg = '';
	foreach ($certs as $certpath => $data) {
		if ((! $data['found']) || ($data['validto'] <= time()) || (($data['validto'] - EXPIRE_WINDOW) <= 0)) {
			$msg .= $data['name'].'  Starts: '
				.((! $data['validfrom']) ? '                   ' : date('Y-m-d H:i:s', $data['validfrom']))
				.'  Expires: '.((! $data['validto']) ? '                   ' : date('Y-m-d H:i:s', $data['validto']));
			if (! $data['found']) $msg .= '  *MISSING*';
			elseif ($data['validto'] <= time()) $msg .= '  *EXPIRED*';
			elseif (($data['validto'] - EXPIRE_WINDOW) <= 0) $msg .= '  *WARNING*';
			$msg .= "\n";
		}
	}
	if ($msg) {
		$msg = 'The following certificates require your attention:'."\n"
		.'--------------------------------------------------'."\n".$msg;
		$msg .= "\n".'The warning window starts '.SSL_EXPIRE_WARN_DAYS.' days before expiration.'."\n"
			.'Certs marked missing are missing from the LetsEncrypt directory, '
			.'the Certificate Transparency Logs, '
			.'dangling symlinks in the LetsEncrypt directory, '
			.'or defined in Web configs but do not exist on disk.'."\n";
		mail_something(MAILFROM, MAILTO, ME.' certificate alert', $msg, MAILFROMNAME);
	}
}

function show_cert_status($certs, $maxlen, $warns, $expires=FALSE) {
	// Prints status report to terminal
	// SSL certificates status: 2025-02-01 08:54:01  Warn window: 21 days
	// ---------------------------------------------------------------------------------
	// example.com  Starts: 2024-12-15 12:01:54  Expires: 2025-03-15 13:01:53
	// example.com  Starts: 2024-12-15 12:01:54  Expires: 2025-02-13 12:01:53  *WARNING*
	// example.com  Starts: 2024-12-15 12:01:54  Expires: 2024-12-15 12:01:53  *EXPIRED*
	// example.com  Starts: 2024-12-15 12:01:54  Expires: 2025-03-15 13:01:53  *MISSING*
	$wlen = ($warns) ? 11 : 0; // If there are warnings, add warning length to line length
	echo 'SSL certificates status: '.date('Y-m-d H:i:s').'  Warn window: '.SSL_EXPIRE_WARN_DAYS.' days'."\n";
	for ($i=0; $i < ($maxlen + 59 + $wlen); $i++) echo '-'; // 59=line length - longest name length - warn length
	echo "\n";
	if (! $expires) ksort($certs);
	else usort($certs, function($a, $b) { return $a['validto'] <=> $b['validto']; });
	foreach ($certs as $certpath => $data) {
		echo $data['name'];
		$spaces = $maxlen - strlen($data['name']);
		for ($i=0; $i < $spaces; $i++) echo ' ';
		echo '  Starts: '.((! $data['validfrom']) ? '                   ' : date('Y-m-d H:i:s', $data['validfrom']))
			.'  Expires: '.((! $data['validto']) ? '                   ' : date('Y-m-d H:i:s', $data['validto']));
		if (! $data['found']) echo '  *MISSING*';
		elseif ($data['validto'] <= time()) echo '  *EXPIRED*';
		elseif (($data['validto'] - EXPIRE_WINDOW) <= 0) echo '  *WARNING*';
		echo "\n";
	}
	echo wordwrap("\n".'Certs marked missing are missing from the LetsEncrypt directory, '
		.'dangling symlinks in the LetsEncrypt directory, '
		.'or defined in Web site configs but do not exist on disk.'."\n", COLUMNS);
}

function process_certs($certs, &$maxlen=0, &$warns=FALSE) {
	// Processes list of certificates, returns $maxlen and $warns by reference.
	// $maxlen is the length of the largest certificate name
	// $warns is true or false depending on whether or not certs have expired or are overdue for renewal
	ksort($certs, SORT_NATURAL);
	$maxlen = 0; $warns = FALSE;
	foreach ($certs as $certpath => $data) {
		if ($data['found'] && ($data['type'] == 0)) {
			$cert = openssl_x509_parse(openssl_x509_read(file_get_contents($certpath)));
			# $cert['issuer']['O'] == 'Let\'s Encrypt';
			$cn = $cert['subject']['CN'];
			$certs[$certpath]['validfrom'] = $cert['validFrom_time_t'];
			$certs[$certpath]['validto'] = $cert['validTo_time_t'];
			if ($certs[$certpath]['validto'] <= time()) $warns = TRUE;
			if (($certs[$certpath]['validto'] - EXPIRE_WINDOW) <= 0) $warns = TRUE;
		}
		elseif ($data['type'] == 1) {
			$gotdata = FALSE; $notgood = TRUE; $ctr = 0;
			while ($notgood) {
				$ctr++;
				if ($ctr >= 16) break;
				$code = 0;
				$json = file_get_contents('https://crt.sh/?output=json&q='.$certpath);
				/*
				issuer_ca_id	295809
				issuer_name	"C=US, O=Let's Encrypt, CN=E8"
				common_name	"example.com"
				name_value	"example.com\nwww.example.com"
				id	999999999999999
				entry_timestamp	"2025-08-29T05:11:14.999"
				not_before	"2025-08-29T13:13:13"
				not_after	"2025-11-27T13:13:12"
				serial_number	"9999xx99xx99999x999999999x0123abcde"
				result_count	3
				*/
				if (preg_match('/HTTP\/\d\.\d\s(\d{3})/', $http_response_header[0], $matches)) $code = $matches[1];
				if ((! $code) || ($code == 502)) {
					sleep(20);
					continue;
				}
				elseif ($code == 200) {
					$ctls = json_decode($json, TRUE);
					$gotdata = TRUE;
					$notgood = FALSE;
				}
				elseif ($code == 404) {
					// 404 seems unreliable at crt.sh, so try twice
					$ctr = 14;
					sleep(30);
					break;
				}
			} // while ($notgood)
			if ($gotdata) {
				$whichkey = FALSE; $newest = 0; $record = array();
				foreach ($ctls as $key => $ctl) {
					if ($ctl['common_name'] != $certpath) continue;
					$time = strtotime($ctl['not_after']);
					if ($time > $newest) {
						$newest = $time;
						$whichkey = $key;
					}
				}
				if ($newest) {
					$certs[$certpath]['found'] = TRUE;
					$certs[$certpath]['name'] = $ctls[$whichkey]['common_name'];
					$certs[$certpath]['validfrom'] = strtotime($ctls[$whichkey]['not_before']);
					$certs[$certpath]['validto'] = strtotime($ctls[$whichkey]['not_after']);
					if (($certs[$certpath]['validto'] <= time()) ||
						(($certs[$certpath]['validto'] - EXPIRE_WINDOW) <= 0)) $warns = TRUE;
				}
			}
		} // elseif ($data['type'] == 1)
		else {
			$warns = TRUE;
			$certs[$certpath]['validfrom'] = 0;
			$certs[$certpath]['validto'] = 0;
		}
		$certs[$certpath]['len'] = strlen($certs[$certpath]['name']);
		if ($certs[$certpath]['len'] > $maxlen) $maxlen = $certs[$certpath]['len'];
	}
	return $certs;
}

function get_certs() {
	// Gets list of all certs we are monitoring
	$certs = array();
	if	(CHECK_WEBS) $certs = get_sites(); // Get all certs referenced in Apache config files
	if (CHECK_CERTDIRS) { // Get all LetsEncrypt certs
		foreach (CERT_DIRS as $certdir) {
			foreach (glob($certdir.'/*') as $namedir) {
				if (! is_dir($namedir)) continue;
				$cert = $namedir.'/fullchain.pem';
				$found = (file_exists($cert)) ? TRUE : FALSE; // this detects dangling symlinks
				if (! array_key_exists($cert, $certs))
					$certs[$cert] = array('name' => basename(dirname($cert)), 'type' => 0, 'found' => $found);
				else $certs[$cert]['found'] = $found;
			}
		}
	}
	if (CHECK_CTLS) {
		foreach (CTL_SITES as $fqdn) {
			if (! array_key_exists($fqdn, $certs))
				$certs[$fqdn] = array('name' => $fqdn, 'type' => 1, 'found' => NULL, 'validfrom' => 0, 'validto' => 0);
		}
	}
	return $certs;
}

function get_sites() {
	// Gets list of certificates that appear in Apache site config files
	$apache = '^(?<!#)\s*SSLCertificateFile\s+([A-Za-z0-9./-]+)';
	$nginx = '^(?<!#)\s*ssl_certificate\s+([A-Za-z0-9./-]+);';
	$certs = array();
	foreach (array_merge(glob(APACHE_SITES.'/*.conf'), glob(NGINX_SITES.'/*')) as $conf) {
		if (file_exists($conf) && (! is_dir($conf)) && (is_link($conf))) $siteconfs[] = $conf;
	}
	foreach ($siteconfs as $siteconf) {
		$sslcert = $cn = $validto = $sitefqdn = $found = FALSE;
		$site = substr($siteconf, 0, strlen($siteconf) - 5);
		if ($fh = fopen($siteconf, 'r')) {
			while (($line = fgets($fh)) !== FALSE) {
				$line = trim($line);
				// Match only lines that don't start with a # comment delimiter ^(?<!#)
				if ((preg_match(chr(7).$apache.chr(7).'i', $line, $matches) == 1)
					||  (preg_match(chr(7).$nginx.chr(7).'i', $line, $matches) == 1)) {
					$sslcertfile = $matches[1]; // SSLCertificateFile /etc/ssl/dehydrated/example.com/fullchain.pem
					$exploded = explode('/', $sslcertfile);
					$sslcert = $exploded[count($exploded) - 2]; // /etc/ssl/dehydrated/example.com/fullchain.pem
					$found = (file_exists($sslcertfile)) ? TRUE : FALSE;
					$certs[$sslcertfile] = array('name' => $sslcert, 'type' => 0, 'found' => $found);
				}
			}
		}
		fclose($fh);
	}
	return $certs;
}

function hostname($fqdn=FALSE) {
	$hostname = php_uname('n');
	return ($fqdn) ? $hostname : substr($hostname, 0, strpos($hostname, '.'));
}

function mail_something($from, $to, $subject, $content, $fromname='', $toname='') {
	$fromname = ($fromname) ? '"'.$fromname.'" ' : '';
	$toname = ($toname) ? '"'.$toname.'" ' : '';
	$sf = ini_get('sendmail_from');
	ini_set('sendmail_from', $from);
	$headers  = 'From: '.$fromname.'<'.$from.'>'."\r\n";
	$headers .= 'X-Mailer: '.ME.' v. '.VERSION."\r\n";
	$message = "\r\n";
	if ($content) $message .= $content."\r\n";
	if (strtolower(substr(PHP_OS, 0, 6)) == 'win') $options = ''; else $options = '-f'.$from;
	mail($to, $subject, $message, $headers, $options);
	ini_set('sendmail_from', $sf);
}

function restore_standard_timezone_policy(&$timezone=FALSE) {
	// Being explicitly told what the timezone is, is not a "guess" to be ignored.
	// Make PHP work correctly by again following decades long conventions.
	// * Use the explicitly provided timezone data *
	// 1. If application chooses a timezone, use that.
	// 2. Else, if the user's TZ if set, this takes priority.
	// 3. Else, if user has not set their TZ, fall back to the system's time zone.
	// 4. Else, if cannot find system timezone, fall back to UTC
	if (! $timezone) {
		$notset = TRUE;
		$timezone = 'UTC';
		$TZ = getenv('TZ');
		if ($TZ !== FALSE) {
			if (in_array($TZ, DateTimeZone::listIdentifiers())) {
				$notset = FALSE;
				$timezone = $TZ;
			}
			else {
				$error = 'Error: Invalid timezone: '.$TZ;
				if (function_exists('error')) error($error);
				else @fwrite(STDERR, $error."\n");
			}
		}
		if (! stristr(PHP_OS_FAMILY, 'windows')) {
			if ($notset && (file_exists('/etc/timezone'))) {
				// Debian / Ubuntu
				$data = file_get_contents('/etc/timezone');
				if ($data) {
					$notset = FALSE;
					$timezone = trim($data);
				}
			}
			if ($notset && file_exists('/etc/sysconfig/clock')) {
				// RHEL / CentOS
				$data = parse_ini_file('/etc/sysconfig/clock');
				if (! empty($data['ZONE'])) {
					$notset = FALSE;
					$timezone = $data['ZONE'];
				}
			}
			if ($notset && is_link('/etc/localtime')) {
				// Mac OSX (and older Linuxes)
				// /etc/localtime is a symlink to the timezone in /usr/share/zoneinfo or /var/db/timezone/zoneinfo
				$filename = readlink('/etc/localtime');
				if (strpos($filename, '/var/db/timezone/zoneinfo/') === 0) $timezone = substr($filename, 26);
				if (strpos($filename, '/usr/share/zoneinfo/') === 0) $timezone = substr($filename, 20);
			}
		}
		else { // Running under Windows
			$tz = exec('tzutil.exe /g', $out, $err);
			if (! $err) $timezone = intltz_get_id_for_windows_id($tz);
		}
	}
	else {
		if (! in_array($timezone, DateTimeZone::listIdentifiers())) {
			$error = 'Error: Invalid timezone: '.$timezone;
			if (function_exists('error')) error($error);
			else @fwrite(STDERR, $error."\n");
			$timezone = 'UTC';
		}
	}
	return (date_default_timezone_set($timezone)) ? $timezone : FALSE;
}

function formatstr($str, $cols=FALSE) {
	if (defined('COLUMNS') && (! $cols)) $cols = COLUMNS;
	return ($cols) ? wordwrap($str, $cols) : $str;
}

function terminal_init(&$rows=FALSE) {
	//	'tput cols'   tput used to, but no longer returns correct value when invoked by PHP exec()
	//	'resize'      works, not commonly installed, needs to parse: COLUMNS=167;\nLINES=48;\nexport COLUMNS LINES;\n
	// 'stty -a'     works, needs to parse: speed 38400 baud; rows 49; columns 167; line = 0;
	if (defined('COLUMNS')) return COLUMNS;
	$rows = FALSE; $cols = FALSE;
	$out = ''; $return = 0;
	exec('stty -a 2>/dev/null', $out, $return);
	if ($return == 0) {
		$out = strtolower(implode("\n", $out));
		if (FALSE !== preg_match_all("/rows.([0-9]+);.columns.([0-9]+);/", $out, $matches)) {
			$rows = $matches[1][0];
			$cols = $matches[2][0];
		}
	}
	if ($cols == FALSE) {
		$cols = exec('tput cols 2>/dev/null', $out, $return);
		if ($rows) $rows = exec('tput lines 2>/dev/null', $out, $return);
	}
	if (! $cols) $cols = 80;
	if (! defined('COLUMNS')) define('COLUMNS', $cols);
	if ($rows && (! defined('ROWS'))) define('ROWS', $rows);
	return $cols;
}

function help($stderr=FALSE) {
	terminal_init();
	$out = ($stderr === FALSE) ? STDOUT : STDERR;
	$str = ME.' v. '.VERSION
		.' Checks SSL certs for expiration dates.  Can examine Apache config files to ensure required '
		.'certificates exist, can also check a directory with a LetsEncrypt-style layout containing certs.  '
		.'You must be root to use this command.'
		."\n\n".'Config file is /etc/'.ME.'.php.conf'."\n"
		."\n".'Certs marked missing are missing from the LetsEncrypt directory, '
		.'dangling symlinks in the LetsEncrypt directory, '
		.'or defined in Apache configs but do not exist on disk.'."\n"
		.'Certs marked with a warning are valid but should have renewed already.'."\n";
	@fwrite($out, formatstr($str."\n", COLUMNS));
	@fwrite($out, formatstr('Usage: '.ME.' [options]'."\n", COLUMNS));
	@fwrite($out, formatstr('By default, shows status of all known certificates sorted by name.'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Options:'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-h|--help] (show this help, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-v|--version] (show version number, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-e|--expires] (sorts status list by expiration date)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-c|--check] (checks certs, sends email if there are issues)'."\n", COLUMNS));
}
