diff options
Diffstat (limited to 'application/clicommands/CheckCommand.php')
-rw-r--r-- | application/clicommands/CheckCommand.php | 268 |
1 files changed, 268 insertions, 0 deletions
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php new file mode 100644 index 0000000..0c369d9 --- /dev/null +++ b/application/clicommands/CheckCommand.php @@ -0,0 +1,268 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use DateInterval; +use DateTime; +use DateTimeInterface; +use Icinga\Application\Logger; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509Target; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; + +class CheckCommand extends Command +{ + /** + * Check a host's certificate + * + * This command utilizes this module's database to check if the given host serves valid certificates. + * + * USAGE + * + * icingacli x509 check host [options] + * + * OPTIONS + * + * You can either pass --ip or --host or both at the same time but at least one is mandatory. + * + * --ip A hosts IP address + * --host A hosts name + * --port The port to check in particular + * --warning Less remaining time results in state WARNING + * Default: 25% + * --critical Less remaining time results in state CRITICAL + * Default: 10% + * --allow-self-signed Ignore if a certificate or its issuer has been + * self-signed + * + * EXAMPLES + * + * icingacli x509 check host --ip 10.0.10.78 + * icingacli x509 check host --host mail.example.org + * icingacli x509 check host --ip 10.0.10.78 --host mail.example.org --port 993 + * + * THRESHOLD DEFINITION + * + * Thresholds can either be defined relative (in percent) or absolute + * (time interval). Time intervals consist of a digit and an accompanying + * unit (e.g. "3M" are three months). Supported units are: + * + * Year: y, Y + * Month: M + * Day: d, D + * Hour: h, H + * Minute: m + * Second: s, S + */ + public function hostAction() + { + $ip = $this->params->get('ip'); + $hostname = $this->params->get('host'); + if ($ip === null && $hostname === null) { + $this->showUsage('host'); + exit(3); + } + + $targets = X509Target::on(Database::get())->with([ + 'chain', + 'chain.certificate', + 'chain.certificate.issuer_certificate' + ]); + + $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT'); + + $targets->columns([ + 'port', + 'chain.valid', + 'chain.invalid_reason', + 'subject' => 'chain.certificate.subject', + 'self_signed' => new Expression('COALESCE(%s, %s)', [ + 'chain.certificate.issuer_certificate.self_signed', + 'chain.certificate.self_signed' + ]) + ]); + + // Sub query for `valid_from` column + $validFrom = $targets->createSubQuery(new X509Certificate(), 'chain.certificate'); + $validFrom + ->columns([new Expression('MAX(GREATEST(%s, %s))', ['valid_from', 'issuer_certificate.valid_from'])]) + ->getSelectBase() + ->resetWhere() + ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id')); + + // Sub query for `valid_to` column + $validTo = $targets->createSubQuery(new X509Certificate(), 'chain.certificate'); + $validTo + ->columns([new Expression('MIN(LEAST(%s, %s))', ['valid_to', 'issuer_certificate.valid_to'])]) + ->getSelectBase() + // Reset the where clause generated within the createSubQuery() method. + ->resetWhere() + ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id')); + + list($validFromSelect, $_) = $validFrom->dump(); + list($validToSelect, $_) = $validTo->dump(); + $targets + ->withColumns([ + 'valid_from' => new Expression($validFromSelect), + 'valid_to' => new Expression($validToSelect) + ]) + ->getSelectBase() + ->where(new Expression('target_chain_link.order = 0')); + + if ($ip !== null) { + $targets->filter(Filter::equal('ip', $ip)); + } + if ($hostname !== null) { + $targets->filter(Filter::equal('hostname', $hostname)); + } + if ($this->params->has('port')) { + $targets->filter(Filter::equal('port', $this->params->get('port'))); + } + + $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); + $warningThreshold = $this->splitThreshold($this->params->get('warning', '25%')); + $criticalThreshold = $this->splitThreshold($this->params->get('critical', '10%')); + + $output = []; + $perfData = []; + + $state = 3; + foreach ($targets as $target) { + if (! $target->chain->valid && (! $target['self_signed'] || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target->chain->invalid_reason; + $output[$invalidMessage] = $invalidMessage; + $state = 2; + } + + $now = new DateTime(); + $validFrom = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_from / 1000.0)); + $validTo = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_to / 1000.0)); + $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold); + $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold); + + if ($now > $criticalAfter) { + $state = 2; + } elseif ($state !== 2 && $now > $warningAfter) { + $state = 1; + } elseif ($state === 3) { + $state = 0; + } + + $remainingTime = $now->diff($validTo); + if (! $remainingTime->invert) { + // The certificate has not expired yet + $output[$target->subject] = sprintf( + '%s expires in %d days', + $target->subject, + $remainingTime->days + ); + } else { + $output[$target->subject] = sprintf( + '%s has expired since %d days', + $target->subject, + $remainingTime->days + ); + } + + $perfData[$target->subject] = sprintf( + "'%s'=%ds;%d:;%d:;0;%d", + $target->subject, + $remainingTime->invert + ? 0 + : $validTo->getTimestamp() - time(), + $validTo->getTimestamp() - $warningAfter->getTimestamp(), + $validTo->getTimestamp() - $criticalAfter->getTimestamp(), + $validTo->getTimestamp() - $validFrom->getTimestamp() + ); + } + + echo ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN'][$state]; + echo ' - '; + + if (! empty($output)) { + echo join('; ', $output); + } elseif ($state === 3) { + echo 'Host not found'; + } + + if (! empty($perfData)) { + echo '|' . join(' ', $perfData); + } + + echo PHP_EOL; + exit($state); + } + + /** + * Parse the given threshold definition + * + * @param string $threshold + * + * @return int|DateInterval + */ + protected function splitThreshold(string $threshold) + { + $match = preg_match('/(\d+)([%\w]{1})/', $threshold, $matches); + if (! $match) { + Logger::error('Invalid threshold definition: %s', $threshold); + exit(3); + } + + switch ($matches[2]) { + case '%': + return (int) $matches[1]; + case 'y': + case 'Y': + $intervalSpec = 'P' . $matches[1] . 'Y'; + break; + case 'M': + $intervalSpec = 'P' . $matches[1] . 'M'; + break; + case 'd': + case 'D': + $intervalSpec = 'P' . $matches[1] . 'D'; + break; + case 'h': + case 'H': + $intervalSpec = 'PT' . $matches[1] . 'H'; + break; + case 'm': + $intervalSpec = 'PT' . $matches[1] . 'M'; + break; + case 's': + case 'S': + $intervalSpec = 'PT' . $matches[1] . 'S'; + break; + default: + Logger::error('Unknown threshold unit given: %s', $threshold); + exit(3); + } + + return new DateInterval($intervalSpec); + } + + /** + * Convert the given threshold information to a DateTime object + * + * @param DateTime $from + * @param DateTime $to + * @param int|DateInterval $thresholdValue + * + * @return DateTimeInterface + */ + protected function thresholdToDateTime(DateTime $from, DateTime $to, $thresholdValue): DateTimeInterface + { + $to = clone $to; + if ($thresholdValue instanceof DateInterval) { + return $to->sub($thresholdValue); + } + + $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100)); + return $to->sub(new DateInterval('P' . $coveredDays . 'D')); + } +} |