summaryrefslogtreecommitdiffstats
path: root/application/clicommands
diff options
context:
space:
mode:
Diffstat (limited to 'application/clicommands')
-rw-r--r--application/clicommands/CheckCommand.php236
-rw-r--r--application/clicommands/ImportCommand.php57
-rw-r--r--application/clicommands/JobsCommand.php73
-rw-r--r--application/clicommands/ScanCommand.php67
-rw-r--r--application/clicommands/VerifyCommand.php27
5 files changed, 460 insertions, 0 deletions
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php
new file mode 100644
index 0000000..ae7f641
--- /dev/null
+++ b/application/clicommands/CheckCommand.php
@@ -0,0 +1,236 @@
+<?php
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Job;
+use ipl\Sql\Select;
+
+class CheckCommand extends Command
+{
+ const UNIT_PERCENT = 'percent';
+ const UNIT_INTERVAL = 'interval';
+
+ /**
+ * 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 = (new Select())
+ ->from('x509_target t')
+ ->columns([
+ 't.port',
+ 'cc.valid',
+ 'cc.invalid_reason',
+ 'c.subject',
+ 'self_signed' => 'COALESCE(ci.self_signed, c.self_signed)',
+ 'c.valid_from',
+ 'c.valid_to'
+ ])
+ ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id')
+ ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id')
+ ->join('x509_certificate c', 'c.id = ccl.certificate_id')
+ ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash')
+ ->where(['ccl.order = ?' => 0]);
+
+ if ($ip !== null) {
+ $targets->where(['t.ip = ?' => Job::binary($ip)]);
+ }
+ if ($hostname !== null) {
+ $targets->where(['t.hostname = ?' => $hostname]);
+ }
+ if ($this->params->has('port')) {
+ $targets->where(['t.port = ?' => $this->params->get('port')]);
+ }
+
+ $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false);
+ list($warningThreshold, $warningUnit) = $this->splitThreshold($this->params->get('warning', '25%'));
+ list($criticalThreshold, $criticalUnit) = $this->splitThreshold($this->params->get('critical', '10%'));
+
+ $output = [];
+ $perfData = [];
+
+ $state = 3;
+ foreach ($this->getDb()->select($targets) as $target) {
+ if ($target['valid'] === 'no' && ($target['self_signed'] === 'no' || ! $allowSelfSigned)) {
+ $invalidMessage = $target['subject'] . ': ' . $target['invalid_reason'];
+ $output[$invalidMessage] = $invalidMessage;
+ $state = 2;
+ }
+
+ $now = new \DateTime();
+ $validFrom = (new \DateTime())->setTimestamp($target['valid_from']);
+ $validTo = (new \DateTime())->setTimestamp($target['valid_to']);
+ $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold, $criticalUnit);
+ $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold, $warningUnit);
+
+ 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
+ : $target['valid_to'] - time(),
+ $target['valid_to'] - $warningAfter->getTimestamp(),
+ $target['valid_to'] - $criticalAfter->getTimestamp(),
+ $target['valid_to'] - $target['valid_from']
+ );
+ }
+
+ 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 array
+ */
+ protected function splitThreshold($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], self::UNIT_PERCENT];
+ 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), self::UNIT_INTERVAL];
+ }
+
+ /**
+ * Convert the given threshold information to a DateTime object
+ *
+ * @param \DateTime $from
+ * @param \DateTime $to
+ * @param int|\DateInterval $thresholdValue
+ * @param string $thresholdUnit
+ *
+ * @return \DateTime
+ */
+ protected function thresholdToDateTime(\DateTime $from, \DateTime $to, $thresholdValue, $thresholdUnit)
+ {
+ $to = clone $to;
+ if ($thresholdUnit === self::UNIT_INTERVAL) {
+ return $to->sub($thresholdValue);
+ }
+
+ $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100));
+ return $to->sub(new \DateInterval('P' . $coveredDays . 'D'));
+ }
+}
diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php
new file mode 100644
index 0000000..1f9d1ef
--- /dev/null
+++ b/application/clicommands/ImportCommand.php
@@ -0,0 +1,57 @@
+<?php
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use ipl\Sql\Connection;
+
+class ImportCommand extends Command
+{
+ /**
+ * Import all X.509 certificates from the given file and mark them as trusted
+ *
+ * USAGE:
+ *
+ * icingacli x509 import --file <file>
+ *
+ * EXAMPLES:
+ *
+ * icingacli x509 import --file /etc/ssl/certs/ca-bundle.crt
+ */
+ public function indexAction()
+ {
+ $file = $this->params->getRequired('file');
+
+ if (! file_exists($file)) {
+ Logger::warning('The specified certificate file does not exist.');
+ exit(1);
+ }
+
+ $db = $this->getDb();
+
+ $bundle = CertificateUtils::parseBundle($file);
+
+ $count = 0;
+
+ $db->transaction(function (Connection $db) use ($bundle, &$count) {
+ foreach ($bundle as $data) {
+ $cert = openssl_x509_read($data);
+
+ $id = CertificateUtils::findOrInsertCert($db, $cert);
+
+ $db->update(
+ 'x509_certificate',
+ ['trusted' => 'yes'],
+ ['id = ?' => $id]
+ );
+
+ $count++;
+ }
+ });
+
+ printf("Processed %d X.509 certificate%s.\n", $count, $count !== 1 ? 's' : '');
+ }
+}
diff --git a/application/clicommands/JobsCommand.php b/application/clicommands/JobsCommand.php
new file mode 100644
index 0000000..0e1d599
--- /dev/null
+++ b/application/clicommands/JobsCommand.php
@@ -0,0 +1,73 @@
+<?php
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Hook\SniHook;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Scheduler;
+
+class JobsCommand extends Command
+{
+ /**
+ * Run all configured jobs based on their schedule
+ *
+ * USAGE:
+ *
+ * icingacli x509 jobs run
+ */
+ public function runAction()
+ {
+ $parallel = (int) $this->Config()->get('scan', 'parallel', 256);
+
+ if ($parallel <= 0) {
+ $this->fail("The 'parallel' option must be set to at least 1.");
+ }
+
+ $scheduler = new Scheduler();
+
+ $defaultSchedule = $this->Config()->get('jobs', 'default_schedule');
+
+ $db = $this->getDb();
+
+ foreach ($this->Config('jobs') as $name => $jobDescription) {
+ $schedule = $jobDescription->get('schedule', $defaultSchedule);
+
+ if (! $schedule) {
+ Logger::debug("The job '%s' is not scheduled.", $name);
+ continue;
+ }
+
+ $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel);
+
+ $scheduler->add($name, $schedule, function () use ($job, $name, $db) {
+ if (! $db->ping()) {
+ Logger::error('Lost connection to database and failed to re-connect. Skipping this job run.');
+ return;
+ }
+
+ $finishedTargets = $job->run();
+
+ if ($finishedTargets === null) {
+ Logger::warning("The job '%s' does not have any targets.", $name);
+ } else {
+ Logger::info(
+ "Scanned %s target%s in job '%s'.\n",
+ $finishedTargets,
+ $finishedTargets != 1 ? 's' : '',
+ $name
+ );
+
+ $verified = CertificateUtils::verifyCertificates($db);
+
+ Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : '');
+ }
+ });
+ }
+
+ $scheduler->run();
+ }
+}
diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php
new file mode 100644
index 0000000..fd92c7a
--- /dev/null
+++ b/application/clicommands/ScanCommand.php
@@ -0,0 +1,67 @@
+<?php
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Hook\SniHook;
+use Icinga\Module\X509\Job;
+
+class ScanCommand extends Command
+{
+ /**
+ * Scans IP and port ranges to find X.509 certificates.
+ *
+ * This command starts scanning the IP and port ranges which belong to the job that was specified with the
+ * --job parameter.
+ *
+ * USAGE
+ *
+ * icingacli x509 scan --job <name>
+ */
+ public function indexAction()
+ {
+ $name = $this->params->shiftRequired('job');
+
+ $parallel = (int) $this->Config()->get('scan', 'parallel', 256);
+
+ if ($parallel <= 0) {
+ $this->fail("The 'parallel' option must be set to at least 1.");
+ }
+
+ $jobs = $this->Config('jobs');
+
+ if (! $jobs->hasSection($name)) {
+ $this->fail('Job not found.');
+ }
+
+ $jobDescription = $this->Config('jobs')->getSection($name);
+
+ if (! strlen($jobDescription->get('cidrs'))) {
+ $this->fail('The job does not specify any CIDRs.');
+ }
+
+ $db = $this->getDb();
+
+ $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel);
+
+ $finishedTargets = $job->run();
+
+ if ($finishedTargets === null) {
+ Logger::warning("The job '%s' does not have any targets.", $name);
+ } else {
+ Logger::info(
+ "Scanned %s target%s in job '%s'.\n",
+ $finishedTargets,
+ $finishedTargets != 1 ? 's' : '',
+ $name
+ );
+
+ $verified = CertificateUtils::verifyCertificates($db);
+
+ Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : '');
+ }
+ }
+}
diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php
new file mode 100644
index 0000000..a76c100
--- /dev/null
+++ b/application/clicommands/VerifyCommand.php
@@ -0,0 +1,27 @@
+<?php
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+
+class VerifyCommand extends Command
+{
+ /**
+ * Verify all currently collected X.509 certificates
+ *
+ * USAGE:
+ *
+ * icingacli x509 verify
+ */
+ public function indexAction()
+ {
+ $db = $this->getDb();
+
+ $verified = CertificateUtils::verifyCertificates($db);
+
+ Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : '');
+ }
+}