diff options
Diffstat (limited to 'library/X509')
24 files changed, 2407 insertions, 0 deletions
diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php new file mode 100644 index 0000000..3ec2d54 --- /dev/null +++ b/library/X509/CertificateDetails.php @@ -0,0 +1,105 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use DateTime; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +/** + * Widget to display X.509 certificate details + */ +class CertificateDetails extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'cert-details']; + + /** + * @var array + */ + protected $cert; + + public function setCert(array $cert) + { + $this->cert = $cert; + + return $this; + } + + protected function assemble() + { + $pem = CertificateUtils::der2pem($this->cert['certificate']); + $cert = openssl_x509_parse($pem); +// $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem)); + + $subject = Html::tag('dl'); + foreach ($cert['subject'] as $key => $value) { + $subject->add([ + Html::tag('dt', $key), + Html::tag('dd', $value) + ]); + } + + $issuer = Html::tag('dl'); + foreach ($cert['issuer'] as $key => $value) { + $issuer->add([ + Html::tag('dt', $key), + Html::tag('dd', $value) + ]); + } + + $certInfo = Html::tag('dl'); + $certInfo->add([ + Html::tag('dt', mt('x509', 'Serial Number')), + Html::tag('dd', bin2hex($this->cert['serial'])), + Html::tag('dt', mt('x509', 'Version')), + Html::tag('dd', $this->cert['version']), + Html::tag('dt', mt('x509', 'Signature Algorithm')), + Html::tag('dd', $this->cert['signature_algo'] . ' with ' . $this->cert['signature_hash_algo']), + Html::tag('dt', mt('x509', 'Not Valid Before')), + Html::tag('dd', (new DateTime())->setTimestamp($this->cert['valid_from'])->format('l F jS, Y H:i:s e')), + Html::tag('dt', mt('x509', 'Not Valid After')), + Html::tag('dd', (new DateTime())->setTimestamp($this->cert['valid_to'])->format('l F jS, Y H:i:s e')), + ]); + + $pubkeyInfo = Html::tag('dl'); + $pubkeyInfo->add([ + Html::tag('dt', mt('x509', 'Algorithm')), + Html::tag('dd', $this->cert['pubkey_algo']), + Html::tag('dt', mt('x509', 'Key Size')), + Html::tag('dd', $this->cert['pubkey_bits']) + ]); + + $extensions = Html::tag('dl'); + foreach ($cert['extensions'] as $key => $value) { + $extensions->add([ + Html::tag('dt', ucwords(implode(' ', preg_split('/(?=[A-Z])/', $key)))), + Html::tag('dd', $value) + ]); + } + + $fingerprints = Html::tag('dl'); + $fingerprints->add([ + Html::tag('dt', 'SHA-256'), + Html::tag('dd', wordwrap(strtoupper(bin2hex($this->cert['fingerprint'])), 2, ' ', true)) + ]); + + $this->add([ + Html::tag('h2', [Html::tag('i', ['class' => 'x509-icon-cert']), $this->cert['subject']]), + Html::tag('h3', mt('x509', 'Subject Name')), + $subject, + Html::tag('h3', mt('x509', 'Issuer Name')), + $issuer, + Html::tag('h3', mt('x509', 'Certificate Info')), + $certInfo, + Html::tag('h3', mt('x509', 'Public Key Info')), + $pubkeyInfo, + Html::tag('h3', mt('x509', 'Extensions')), + $extensions, + Html::tag('h3', mt('x509', 'Fingerprints')), + $fingerprints + ]); + } +} diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php new file mode 100644 index 0000000..c538444 --- /dev/null +++ b/library/X509/CertificateUtils.php @@ -0,0 +1,460 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Exception; +use Icinga\Application\Logger; +use Icinga\File\Storage\TemporaryLocalFileStorage; +use ipl\Sql\Connection; +use ipl\Sql\Select; + +class CertificateUtils +{ + /** + * Possible public key types + * + * @var string[] + */ + protected static $pubkeyTypes = [ + -1 => 'unknown', + OPENSSL_KEYTYPE_RSA => 'RSA', + OPENSSL_KEYTYPE_DSA => 'DSA', + OPENSSL_KEYTYPE_DH => 'DH', + OPENSSL_KEYTYPE_EC => 'EC' + ]; + + /** + * Convert the given chunk from PEM to DER + * + * @param string $pem + * + * @return string + */ + public static function pem2der($pem) + { + $lines = explode("\n", $pem); + + $der = ''; + + foreach ($lines as $line) { + if (strpos($line, '-----') === 0) { + continue; + } + + $der .= base64_decode($line); + } + + return $der; + } + + /** + * Convert the given chunk from DER to PEM + * + * @param string $der + * + * @return string + */ + public static function der2pem($der) + { + $block = chunk_split(base64_encode($der), 64, "\n"); + + return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----"; + } + + /** + * Format seconds to human-readable duration + * + * @param int $seconds + * + * @return string + */ + public static function duration($seconds) + { + if ($seconds < 60) { + return "$seconds Seconds"; + } + + if ($seconds < 3600) { + $minutes = round($seconds / 60); + + return "$minutes Minutes"; + } + + if ($seconds < 86400) { + $hours = round($seconds / 3600); + + return "$hours Hours"; + } + + if ($seconds < 604800) { + $days = round($seconds / 86400); + + return "$days Days"; + } + + if ($seconds < 2592000) { + $weeks = round($seconds / 604800); + + return "$weeks Weeks"; + } + + if ($seconds < 31536000) { + $months = round($seconds / 2592000); + + return "$months Months"; + } + + $years = round($seconds / 31536000); + + return "$years Years"; + } + + /** + * Get the short name from the given DN + * + * If the given DN contains a CN, the CN is returned. Else, the DN is returned as string. + * + * @param array $dn + * + * @return string The CN if it exists or the full DN as string + */ + private static function shortNameFromDN(array $dn) + { + if (isset($dn['CN'])) { + $cn = (array) $dn['CN']; + return $cn[0]; + } else { + $result = []; + foreach ($dn as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $result[] = "{$key}={$item}"; + } + } else { + $result[] = "{$key}={$value}"; + } + } + + return implode(', ', $result); + } + } + + /** + * Split the given Subject Alternative Names into key-value pairs + * + * @param string $sans + * + * @return \Generator + */ + private static function splitSANs($sans) + { + preg_match_all('/(?:^|, )([^:]+):/', $sans, $keys); + $values = preg_split('/(^|, )[^:]+:\s*/', $sans); + for ($i = 0; $i < count($keys[1]); $i++) { + yield [$keys[1][$i], $values[$i + 1]]; + } + } + + /** + * Yield certificates in the given bundle + * + * @param string $file Path to the bundle + * + * @return \Generator + */ + public static function parseBundle($file) + { + $content = file_get_contents($file); + + $blocks = explode('-----BEGIN CERTIFICATE-----', $content); + + foreach ($blocks as $block) { + $end = strrpos($block, '-----END CERTIFICATE-----'); + + if ($end !== false) { + yield '-----BEGIN CERTIFICATE-----' . substr($block, 0, $end) . '-----END CERTIFICATE-----'; + } + } + } + + /** + * Find or insert the given certificate and return its ID + * + * @param Connection $db + * @param mixed $cert + * + * @return int + */ + public static function findOrInsertCert(Connection $db, $cert) + { + $certInfo = openssl_x509_parse($cert); + + $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true); + + $row = $db->select( + (new Select()) + ->columns(['id']) + ->from('x509_certificate') + ->where(['fingerprint = ?' => $fingerprint]) + )->fetch(); + + if ($row !== false) { + return (int) $row['id']; + } + + Logger::debug("Importing certificate: %s", $certInfo['name']); + + $pem = null; + if (! openssl_x509_export($cert, $pem)) { + die('Failed to encode X.509 certificate.'); + } + $der = CertificateUtils::pem2der($pem); + + $ca = false; + if (isset($certInfo['extensions']['basicConstraints'])) { + if (strpos($certInfo['extensions']['basicConstraints'], 'CA:TRUE') !== false) { + $ca = true; + } + } + + $subjectHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'subject'); + $issuerHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'issuer'); + $pubkey = openssl_pkey_get_details(openssl_pkey_get_public($cert)); + $signature = explode('-', $certInfo['signatureTypeSN']); + + $db->insert( + 'x509_certificate', + [ + 'subject' => CertificateUtils::shortNameFromDN($certInfo['subject']), + 'subject_hash' => $subjectHash, + 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']), + 'issuer_hash' => $issuerHash, + 'version' => $certInfo['version'] + 1, + 'self_signed' => $subjectHash === $issuerHash ? 'yes' : 'no', + 'ca' => $ca ? 'yes' : 'no', + 'pubkey_algo' => CertificateUtils::$pubkeyTypes[$pubkey['type']], + 'pubkey_bits' => $pubkey['bits'], + 'signature_algo' => array_shift($signature), // Support formats like RSA-SHA1 and + 'signature_hash_algo' => array_pop($signature), // ecdsa-with-SHA384 + 'valid_from' => $certInfo['validFrom_time_t'], + 'valid_to' => $certInfo['validTo_time_t'], + 'fingerprint' => $fingerprint, + 'serial' => gmp_export($certInfo['serialNumber']), + 'certificate' => $der + ] + ); + + $certId = (int) $db->lastInsertId(); + + CertificateUtils::insertSANs($db, $certId, $certInfo); + + return $certId; + } + + private static function insertSANs($db, $certId, array $certInfo) + { + if (isset($certInfo['extensions']['subjectAltName'])) { + foreach (CertificateUtils::splitSANs($certInfo['extensions']['subjectAltName']) as $san) { + list($type, $value) = $san; + + $hash = hash('sha256', sprintf('%s=%s', $type, $value), true); + + $row = $db->select( + (new Select()) + ->from('x509_certificate_subject_alt_name') + ->columns('certificate_id') + ->where([ + 'certificate_id = ?' => $certId, + 'hash = ?' => $hash + ]) + )->fetch(); + + // Ignore duplicate SANs + if ($row !== false) { + continue; + } + + $db->insert( + 'x509_certificate_subject_alt_name', + [ + 'certificate_id' => $certId, + 'hash' => $hash, + 'type' => $type, + 'value' => $value + ] + ); + } + } + } + + private static function findOrInsertDn($db, $certInfo, $type) + { + $dn = $certInfo[$type]; + + $data = ''; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $data .= "{$key}=${value}, "; + } + } + $hash = hash('sha256', $data, true); + + $row = $db->select( + (new Select()) + ->from('x509_dn') + ->columns('hash') + ->where([ 'hash = ?' => $hash, 'type = ?' => $type ]) + ->limit(1) + )->fetch(); + + if ($row !== false) { + return $row['hash']; + } + + $index = 0; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $db->insert( + 'x509_dn', + [ + 'hash' => $hash, + '`key`' => $key, + '`value`' => $value, + '`order`' => $index, + 'type' => $type + ] + ); + $index++; + } + } + + return $hash; + } + + /** + * Verify certificates + * + * @param Connection $db Connection to the X.509 database + * + * @return int + */ + public static function verifyCertificates(Connection $db) + { + $files = new TemporaryLocalFileStorage(); + + $caFile = uniqid('ca'); + + $cas = $db->select( + (new Select) + ->from('x509_certificate') + ->columns(['certificate']) + ->where(['ca = ?' => 'yes', 'trusted = ?' => 'yes']) + ); + + $contents = []; + + foreach ($cas as $ca) { + $contents[] = static::der2pem($ca['certificate']); + } + + if (empty($contents)) { + throw new \RuntimeException('Trust store is empty'); + } + + $files->create($caFile, implode("\n", $contents)); + + $count = 0; + + $db->beginTransaction(); + + try { + $chains = $db->select( + (new Select) + ->from('x509_certificate_chain c') + ->join('x509_target t', ['t.latest_certificate_chain_id = c.id', 'c.valid = ?' => 'no']) + ->columns('c.id') + ); + + foreach ($chains as $chain) { + ++$count; + + $certs = $db->select( + (new Select) + ->from('x509_certificate c') + ->columns('c.certificate') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') + ->where(['ccl.certificate_chain_id = ?' => $chain['id']]) + ->orderBy(['ccl.order' => 'DESC']) + ); + + $collection = []; + + foreach ($certs as $cert) { + $collection[] = CertificateUtils::der2pem($cert['certificate']); + } + + $certFile = uniqid('cert'); + + $files->create($certFile, array_pop($collection)); + + $untrusted = ''; + foreach ($collection as $intermediate) { + $intermediateFile = uniqid('intermediate'); + $files->create($intermediateFile, $intermediate); + $untrusted .= ' -untrusted ' . escapeshellarg($files->resolvePath($intermediateFile)); + } + + $command = sprintf( + 'openssl verify -CAfile %s%s %s 2>&1', + escapeshellarg($files->resolvePath($caFile)), + $untrusted, + escapeshellarg($files->resolvePath($certFile)) + ); + + $output = null; + + exec($command, $output, $exitcode); + + $output = implode("\n", $output); + + if ($exitcode !== 0) { + Logger::warning('openssl verify failed for command %s: %s', $command, $output); + } + + preg_match('/^error \d+ at \d+ depth lookup:(.+)$/m', $output, $match); + + if (!empty($match)) { + $set = ['invalid_reason' => trim($match[1])]; + } else { + $set = ['valid' => 'yes', 'invalid_reason' => null]; + } + + $db->update( + 'x509_certificate_chain', + $set, + ['id = ?' => $chain['id']] + ); + } + + $db->commitTransaction(); + } catch (Exception $e) { + Logger::error($e); + $db->rollBackTransaction(); + } + + return $count; + } +} diff --git a/library/X509/CertificatesTable.php b/library/X509/CertificatesTable.php new file mode 100644 index 0000000..3900221 --- /dev/null +++ b/library/X509/CertificatesTable.php @@ -0,0 +1,109 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Web\Url; +use ipl\Html\Html; + +/** + * Table widget to display X.509 certificates + */ +class CertificatesTable extends DataTable +{ + protected $defaultAttributes = [ + 'class' => 'cert-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + protected function createColumns() + { + return [ + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => mt('x509', 'Certificate'), + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if ($ca === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-ca', 'title' => mt('x509', 'Is Certificate Authority')] + ); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if ($selfSigned === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-self-signed', 'title' => mt('x509', 'Is Self-Signed')] + ); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if ($trusted === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'icon icon-thumbs-up', 'title' => mt('x509', 'Is Trusted')] + ); + } + ], + + 'issuer' => mt('x509', 'Issuer'), + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/certificate', ['cert' => $row['id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/ChainDetails.php b/library/X509/ChainDetails.php new file mode 100644 index 0000000..527e06c --- /dev/null +++ b/library/X509/ChainDetails.php @@ -0,0 +1,116 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Web\Url; +use ipl\Html\Html; + +/** + * Table widget to display X.509 chain details + */ +class ChainDetails extends DataTable +{ + protected $defaultAttributes = [ + 'class' => 'cert-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function createColumns() + { + return [ + [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function () { + return Html::tag('i', ['class' => 'x509-icon-cert']); + } + ], + + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => [ + 'label' => mt('x509', 'Subject') + ], + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if ($ca === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-ca', 'title' => mt('x509', 'Is Certificate Authority')] + ); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if ($selfSigned === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-self-signed', 'title' => mt('x509', 'Is Self-Signed')] + ); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if ($trusted === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'icon icon-thumbs-up', 'title' => mt('x509', 'Is Trusted')] + ); + } + ], + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/certificate', ['cert' => $row['certificate_id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/ColorScheme.php b/library/X509/ColorScheme.php new file mode 100644 index 0000000..94a3bf7 --- /dev/null +++ b/library/X509/ColorScheme.php @@ -0,0 +1,36 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ArrayIterator; +use InfiniteIterator; + +class ColorScheme +{ + /** + * The colors of this scheme + * + * @var array + */ + protected $colors; + + public function __construct(array $colors) + { + $this->colors = $colors; + } + + public function scheme() + { + $iter = new InfiniteIterator(new ArrayIterator($this->colors)); + $iter->rewind(); + + return function () use ($iter) { + $color = $iter->current(); + + $iter->next(); + + return $color; + }; + } +} diff --git a/library/X509/Command.php b/library/X509/Command.php new file mode 100644 index 0000000..7737119 --- /dev/null +++ b/library/X509/Command.php @@ -0,0 +1,35 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Application\Icinga; +use Icinga\Data\ResourceFactory; +use ipl\Sql; + +class Command extends \Icinga\Cli\Command +{ + // Fix Web 2 issue where $configs is not properly initialized + protected $configs = []; + + public function init() + { + Icinga::app()->getModuleManager()->loadEnabledModules(); + } + + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + public function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + $this->Config()->get('backend', 'resource') + )); + + $conn = new Sql\Connection($config); + + return $conn; + } +} diff --git a/library/X509/Controller.php b/library/X509/Controller.php new file mode 100644 index 0000000..bb798a0 --- /dev/null +++ b/library/X509/Controller.php @@ -0,0 +1,121 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Data\ResourceFactory; +use Icinga\File\Csv; +use Icinga\Util\Json; +use Icinga\Web\Widget\Tabextension\DashboardAction; +use Icinga\Web\Widget\Tabextension\MenuAction; +use Icinga\Web\Widget\Tabextension\OutputFormat; +use ipl\Sql; +use PDO; + +class Controller extends \Icinga\Web\Controller +{ + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + protected function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + $this->Config()->get('backend', 'resource') + )); + + $config->options = [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION SQL_MODE='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE" + . ",ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'" + ]; + + $conn = new Sql\Connection($config); + + return $conn; + } + + /** + * Set the title tab of this view + * + * @param string $label + * + * @return $this + */ + protected function setTitle($label) + { + $this->getTabs()->add(uniqid(), [ + 'active' => true, + 'label' => (string) $label, + 'url' => $this->getRequest()->getUrl() + ]); + + return $this; + } + + protected function handleFormatRequest(Sql\Connection $db, Sql\Select $select, callable $callback = null) + { + $desiredContentType = $this->getRequest()->getHeader('Accept'); + if ($desiredContentType === 'application/json') { + $desiredFormat = 'json'; + } elseif ($desiredContentType === 'text/csv') { + $desiredFormat = 'csv'; + } else { + $desiredFormat = strtolower($this->params->get('format', 'html')); + } + + if ($desiredFormat !== 'html' && ! $this->params->has('limit')) { + $select->limit(null); // Resets any default limit and offset + } + + switch ($desiredFormat) { + case 'sql': + echo '<pre>' + . var_export((new Sql\QueryBuilder())->assembleSelect($select), true) + . '</pre>'; + exit; + case 'json': + $response = $this->getResponse(); + $response + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'inline; filename=' . $this->getRequest()->getActionName() . '.json' + ) + ->appendBody( + Json::encode( + $callback !== null + ? iterator_to_array($callback($db->select($select))) + : $db->select($select)->fetchAll() + ) + ) + ->sendResponse(); + exit; + case 'csv': + $response = $this->getResponse(); + $response + ->setHeader('Content-Type', 'text/csv') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv' + ) + ->appendBody( + (string) Csv::fromQuery( + $callback !== null ? $callback($db->select($select)) : $db->select($select) + ) + ) + ->sendResponse(); + exit; + } + } + + protected function initTabs() + { + $this->getTabs()->extend(new OutputFormat())->extend(new DashboardAction())->extend(new MenuAction()); + + return $this; + } +} diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php new file mode 100644 index 0000000..edcbf88 --- /dev/null +++ b/library/X509/DataTable.php @@ -0,0 +1,145 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; + +class DataTable extends BaseHtmlElement +{ + protected $tag = 'table'; + + /** + * Columns of the table + * + * @var array + */ + protected $columns; + + /** + * The data to display + * + * @var array|\Traversable + */ + protected $data = []; + + /** + * Get data to display + * + * @return array|\Traversable + */ + public function getData() + { + return $this->data; + } + + /** + * Set the data to display + * + * @param array|\Traversable $data + * + * @return $this + */ + public function setData($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + return $this; + } + + protected function createColumns() + { + } + + public function renderHeader() + { + $cells = []; + + foreach ($this->columns as $column) { + if (is_array($column)) { + if (isset($column['label'])) { + $label = $column['label']; + } else { + $label = new HtmlString(' '); + } + } else { + $label = $column; + } + + $cells[] = Html::tag('th', $label); + } + + return Html::tag('thead', Html::tag('tr', $cells)); + } + + protected function renderRow($row) + { + $cells = []; + + foreach ($this->columns as $key => $column) { + if (! is_int($key) && array_key_exists($key, $row)) { + $data = $row[$key]; + } else { + if (isset($column['column']) && array_key_exists($column['column'], $row)) { + $data = $row[$column['column']]; + } else { + $data = null; + } + } + + if (isset($column['renderer'])) { + $content = call_user_func(($column['renderer']), $data, $row); + } else { + $content = $data; + } + + $cells[] = Html::tag('td', isset($column['attributes']) ? $column['attributes'] : null, $content); + } + + return Html::tag('tr', $cells); + } + + protected function renderBody($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $rows = []; + + foreach ($data as $row) { + $rows[] = $this->renderRow($row); + } + + if (empty($rows)) { + $colspan = count($this->columns); + + $rows = Html::tag( + 'tr', + Html::tag( + 'td', + ['colspan' => $colspan], + mt('x509', 'No results found.') + ) + ); + } + + return Html::tag('tbody', $rows); + } + + protected function assemble() + { + $this->columns = $this->createColumns(); + + $this->add(array_filter([ + $this->renderHeader(), + $this->renderBody($this->getData()) + ])); + } +} diff --git a/library/X509/Donut.php b/library/X509/Donut.php new file mode 100644 index 0000000..f3e199f --- /dev/null +++ b/library/X509/Donut.php @@ -0,0 +1,93 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; +use ipl\Html\Text; + +class Donut extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'cert-donut']; + + /** + * The donut data + * + * @var array|\Traversable + */ + protected $data = []; + + protected $heading; + + protected $headingLevel; + + protected $labelCallback; + + /** + * Get data to display + * + * @return array|\Traversable + */ + public function getData() + { + return $this->data; + } + + /** + * Set the data to display + * + * @param array|\Traversable $data + * + * @return $this + */ + public function setData($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + return $this; + } + + public function setHeading($heading, $level) + { + $this->heading = $heading; + $this->headingLevel = (int) $level; + + return $this; + } + + public function setLabelCallback(callable $callback) + { + $this->labelCallback = $callback; + + return $this; + } + + public function assemble() + { + $colorScheme = (new ColorScheme(['#014573', '#3588A5', '#BBD9B0', '#F5CC0A', '#F04B0D']))->scheme(); + $donut = new \Icinga\Chart\Donut(); + $legend = new Table(); + + foreach ($this->data as $data) { + $color = $colorScheme(); + $donut->addSlice((int) $data['cnt'], ['stroke' => $color]); + $legend->addRow( + [ + Html::tag('span', ['class' => 'badge', 'style' => "background-color: $color; height: 1.75em;"]), + call_user_func($this->labelCallback, $data), + $data['cnt'] + ] + ); + } + + $this->add([Html::tag("h{$this->headingLevel}", $this->heading), new HtmlString($donut->render()), $legend]); + } +} diff --git a/library/X509/ExpirationWidget.php b/library/X509/ExpirationWidget.php new file mode 100644 index 0000000..b3c3081 --- /dev/null +++ b/library/X509/ExpirationWidget.php @@ -0,0 +1,85 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; + +class ExpirationWidget extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $from; + + protected $to; + + public function __construct($from, $to) + { + $this->from = $from; + $this->to = $to; + } + + protected function assemble() + { + $now = time(); + + $from = $this->from; + + if ($from > $now) { + $ratio = 0; + $dateTip = DateFormatter::formatDateTime($from); + $message = sprintf(mt('x509', 'not until after %s'), DateFormatter::timeUntil($from, true)); + } else { + $to = $this->to; + + $secondsRemaining = $to - $now; + $daysRemaining = ($secondsRemaining - $secondsRemaining % 86400) / 86400; + if ($daysRemaining > 0) { + $secondsTotal = $to - $from; + $daysTotal = ($secondsTotal - $secondsTotal % 86400) / 86400; + + $ratio = min(100, 100 - round(($daysRemaining * 100) / $daysTotal, 2)); + $message = sprintf(mt('x509', 'in %d days'), $daysRemaining); + } else { + $ratio = 100; + if ($daysRemaining < 0) { + $message = sprintf(mt('x509', '%d days ago'), $daysRemaining * -1); + } else { + $message = mt('x509', 'today'); + } + } + + $dateTip = DateFormatter::formatDateTime($to); + } + + if ($ratio >= 75) { + if ($ratio >= 90) { + $state = 'state-critical'; + } else { + $state = 'state-warning'; + } + } else { + $state = 'state-ok'; + } + + $this->add([ + Html::tag( + 'span', + ['class' => '', 'style' => 'font-size: 0.9em;', 'title' => $dateTip], + $message + ), + Html::tag( + 'div', + ['class' => 'progress-bar dont-print'], + Html::tag( + 'div', + ['style' => sprintf('width: %.2F%%;', $ratio), 'class' => "bg-stateful {$state}"], + new HtmlString(' ') + ) + ) + ]); + } +} diff --git a/library/X509/FilterAdapter.php b/library/X509/FilterAdapter.php new file mode 100644 index 0000000..1c7dcb0 --- /dev/null +++ b/library/X509/FilterAdapter.php @@ -0,0 +1,55 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filterable; + +/** + * @internal + */ +class FilterAdapter implements Filterable +{ + /** + * @var Filter + */ + protected $filter; + + public function applyFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + + return $this; + } + + public function getFilter() + { + return $this->filter; + } + + public function addFilter(Filter $filter) + { + if (! $filter->isEmpty()) { + if ($this->filter === null) { + $this->filter = $filter; + } else { + $this->filter->andFilter($filter); + } + } + + return $this; + } + + public function where($condition, $value = null) + { + $this->addFilter(Filter::expression($condition, '=', $value)); + + return $this; + } +} diff --git a/library/X509/Hook/SniHook.php b/library/X509/Hook/SniHook.php new file mode 100644 index 0000000..417200f --- /dev/null +++ b/library/X509/Hook/SniHook.php @@ -0,0 +1,53 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Hook; + +use Icinga\Application\Config; +use Icinga\Application\Hook; +use Icinga\Data\Filter\Filter; +use Icinga\Util\StringHelper; + +/** + * Hook for SNI maps + */ +abstract class SniHook +{ + /** + * Return the SNI maps of all hooks + * + * ['192.0.2.1' => ['example.com', 'mail.example.com']] + * + * @return string[][] + */ + public static function getAll() + { + // This is implemented as map of maps to avoid duplicates, + // the caller is expected to handle it as map of sequences though + $sni = []; + + foreach (Hook::all('X509\Sni') as $hook) { + /** @var self $hook */ + foreach ($hook->getHosts() as $ip => $hostname) { + $sni[$ip][$hostname] = $hostname; + } + } + + foreach (Config::module('x509', 'sni') as $ip => $config) { + foreach (array_filter(StringHelper::trimSplit($config->get('hostnames', []))) as $hostname) { + $sni[$ip][$hostname] = $hostname; + } + } + + return $sni; + } + + /** + * Aggregate pairs of ip => hostname + * + * @param Filter $filter + * + * @return \Generator + */ + abstract public function getHosts(Filter $filter = null); +} diff --git a/library/X509/Job.php b/library/X509/Job.php new file mode 100644 index 0000000..4c6cb65 --- /dev/null +++ b/library/X509/Job.php @@ -0,0 +1,381 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Module\X509\React\StreamOptsCaptureConnector; +use Icinga\Util\StringHelper; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use ipl\Sql\Insert; +use ipl\Sql\Select; +use ipl\Sql\Update; +use React\EventLoop\Factory; +use React\Socket\ConnectionInterface; +use React\Socket\Connector; +use React\Socket\ConnectorInterface; +use React\Socket\SecureConnector; +use React\Socket\TimeoutConnector; + +class Job +{ + /** + * @var Connection + */ + private $db; + private $loop; + private $pendingTargets = 0; + private $totalTargets = 0; + private $finishedTargets = 0; + private $targets; + private $jobId; + private $jobDescription; + private $snimap; + private $parallel; + private $name; + + public function __construct($name, Connection $db, ConfigObject $jobDescription, array $snimap, $parallel) + { + $this->db = $db; + $this->jobDescription = $jobDescription; + $this->snimap = $snimap; + $this->parallel = $parallel; + $this->name = $name; + } + + private function getConnector($peerName) + { + $simpleConnector = new Connector($this->loop); + $streamCaptureConnector = new StreamOptsCaptureConnector($simpleConnector); + $secureConnector = new SecureConnector($streamCaptureConnector, $this->loop, array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'capture_peer_cert_chain' => true, + 'SNI_enabled' => true, + 'peer_name' => $peerName + )); + return [new TimeoutConnector($secureConnector, 5.0, $this->loop), $streamCaptureConnector]; + } + + public static function binary($addr) + { + return str_pad(inet_pton($addr), 16, "\0", STR_PAD_LEFT); + } + + private static function addrToNumber($addr) + { + return gmp_import(static::binary($addr)); + } + + private static function numberToAddr($num, $ipv6 = true) + { + if ((bool) $ipv6) { + return inet_ntop(str_pad(gmp_export($num), 16, "\0", STR_PAD_LEFT)); + } else { + return inet_ntop(gmp_export($num)); + } + } + + private static function generateTargets(ConfigObject $jobDescription, array $hostnamesConfig) + { + foreach (StringHelper::trimSplit($jobDescription->get('cidrs')) as $cidr) { + $pieces = explode('/', $cidr); + if (count($pieces) !== 2) { + Logger::warning("CIDR '%s' is in the wrong format.", $cidr); + continue; + } + $start_ip = $pieces[0]; + $prefix = $pieces[1]; +// $subnet = 128; +// if (substr($start_ip, 0, 2) === '::') { +// if (strtoupper(substr($start_ip, 0, 7)) !== '::FFFF:') { +// $subnet = 32; +// } +// } elseif (strpos($start_ip, ':') === false) { +// $subnet = 32; +// } + $ipv6 = filter_var($start_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $subnet = $ipv6 ? 128 : 32; + $ip_count = 1 << ($subnet - $prefix); + $start = static::addrToNumber($start_ip); + for ($i = 0; $i < $ip_count; $i++) { + $ip = static::numberToAddr(gmp_add($start, $i), $ipv6); + foreach (StringHelper::trimSplit($jobDescription->get('ports')) as $portRange) { + $pieces = StringHelper::trimSplit($portRange, '-'); + if (count($pieces) === 2) { + list($start_port, $end_port) = $pieces; + } else { + $start_port = $pieces[0]; + $end_port = $pieces[0]; + } + + foreach (range($start_port, $end_port) as $port) { + $hostnames = isset($hostnamesConfig[$ip]) ? $hostnamesConfig[$ip] : []; + + if (empty($hostnames)) { + $hostnames[] = null; + } + + foreach ($hostnames as $hostname) { + $target = (object)[]; + $target->ip = $ip; + $target->port = $port; + $target->hostname = $hostname; + yield $target; + } + } + } + } + } + } + + private function updateJobStats($finished = false) + { + $fields = ['finished_targets' => $this->finishedTargets]; + + if ($finished) { + $fields['end_time'] = new Expression('NOW()'); + } + + $this->db->update( + 'x509_job_run', + $fields, + ['id = ?' => $this->jobId] + ); + } + + private static function formatTarget($target) + { + $result = "tls://[{$target->ip}]:{$target->port}"; + + if ($target->hostname !== null) { + $result .= " [SNI hostname: {$target->hostname}]"; + } + + return $result; + } + + private function finishTarget() + { + $this->pendingTargets--; + $this->finishedTargets++; + $this->startNextTarget(); + } + + private function startNextTarget() + { + if (!$this->targets->valid()) { + if ($this->pendingTargets == 0) { + $this->updateJobStats(true); + $this->loop->stop(); + } + + return; + } + + $target = $this->targets->current(); + $this->targets->next(); + + $url = "tls://[{$target->ip}]:{$target->port}"; + Logger::debug("Connecting to %s", static::formatTarget($target)); + $this->pendingTargets++; + /** @var ConnectorInterface $connector */ + /** @var StreamOptsCaptureConnector $streamCapture */ + list($connector, $streamCapture) = $this->getConnector($target->hostname); + $connector->connect($url)->then( + function (ConnectionInterface $conn) use ($target, $streamCapture) { + $this->finishTarget(); + + Logger::info("Connected to %s", static::formatTarget($target)); + + // Close connection in order to capture stream context options + $conn->close(); + + $capturedStreamOptions = $streamCapture->getCapturedStreamOptions(); + + $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']); + }, + function (\Exception $exception) use ($target, $streamCapture) { + Logger::debug("Cannot connect to server: %s", $exception->getMessage()); + + $this->finishTarget(); + + $capturedStreamOptions = $streamCapture->getCapturedStreamOptions(); + + if (isset($capturedStreamOptions['ssl']['peer_certificate_chain'])) { + // The scanned target presented its certificate chain despite throwing an error + // This is the case for targets which require client certificates for example + $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']); + } else { + $this->db->update( + 'x509_target', + ['latest_certificate_chain_id' => null], + [ + 'hostname = ?' => $target->hostname, + 'ip = ?' => static::binary($target->ip), + 'port = ?' => $target->port + ] + ); + } + + $step = max($this->totalTargets / 100, 1); + + if ($this->finishedTargets % (int) $step == 0) { + $this->updateJobStats(); + } + //$loop->stop(); + } + )->otherwise(function (\Exception $e) { + echo $e->getMessage() . PHP_EOL; + echo $e->getTraceAsString() . PHP_EOL; + }); + } + + public function getJobId() + { + return $this->jobId; + } + + public function run() + { + $this->loop = Factory::create(); + + $this->totalTargets = iterator_count(static::generateTargets($this->jobDescription, $this->snimap)); + + if ($this->totalTargets == 0) { + return null; + } + + $this->targets = static::generateTargets($this->jobDescription, $this->snimap); + + $this->db->insert( + 'x509_job_run', + [ + 'name' => $this->name, + 'total_targets' => $this->totalTargets, + 'finished_targets' => 0 + ] + ); + + $this->jobId = $this->db->lastInsertId(); + + // Start scanning the first couple of targets... + for ($i = 0; $i < $this->parallel; $i++) { + $this->startNextTarget(); + } + + $this->loop->run(); + + return $this->totalTargets; + } + + protected function processChain($target, $chain) + { + if ($target->hostname === null) { + $hostname = gethostbyaddr($target->ip); + + if ($hostname !== false) { + $target->hostname = $hostname; + } + } + + $this->db->transaction(function () use ($target, $chain) { + $row = $this->db->select( + (new Select()) + ->columns(['id']) + ->from('x509_target') + ->where([ + 'ip = ?' => static::binary($target->ip), + 'port = ?' => $target->port, + 'hostname = ?' => $target->hostname + ]) + )->fetch(); + + if ($row === false) { + $this->db->insert( + 'x509_target', + [ + 'ip' => static::binary($target->ip), + 'port' => $target->port, + 'hostname' => $target->hostname + ] + ); + $targetId = $this->db->lastInsertId(); + } else { + $targetId = $row['id']; + } + + $chainUptodate = false; + + $lastChain = $this->db->select( + (new Select()) + ->columns(['id']) + ->from('x509_certificate_chain') + ->where(['target_id = ?' => $targetId]) + ->orderBy('id', SORT_DESC) + ->limit(1) + )->fetch(); + + if ($lastChain !== false) { + $lastFingerprints = $this->db->select( + (new Select()) + ->columns(['c.fingerprint']) + ->from('x509_certificate_chain_link l') + ->join('x509_certificate c', 'l.certificate_id = c.id') + ->where(['l.certificate_chain_id = ?' => $lastChain[0]]) + ->orderBy('l.`order`') + )->fetchAll(); + + foreach ($lastFingerprints as &$lastFingerprint) { + $lastFingerprint = $lastFingerprint[0]; + } + + $currentFingerprints = []; + + foreach ($chain as $cert) { + $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true); + } + + $chainUptodate = $currentFingerprints === $lastFingerprints; + } + + if ($chainUptodate) { + $chainId = $lastChain[0]; + } else { + $this->db->insert( + 'x509_certificate_chain', + [ + 'target_id' => $targetId, + 'length' => count($chain) + ] + ); + + $chainId = $this->db->lastInsertId(); + + foreach ($chain as $index => $cert) { + $certInfo = openssl_x509_parse($cert); + + $certId = CertificateUtils::findOrInsertCert($this->db, $cert, $certInfo); + + $this->db->insert( + 'x509_certificate_chain_link', + [ + 'certificate_chain_id' => $chainId, + '`order`' => $index, + 'certificate_id' => $certId + ] + ); + } + } + + $this->db->update( + 'x509_target', + ['latest_certificate_chain_id' => $chainId], + ['id = ?' => $targetId] + ); + }); + } +} diff --git a/library/X509/JobsIniRepository.php b/library/X509/JobsIniRepository.php new file mode 100644 index 0000000..9a261ee --- /dev/null +++ b/library/X509/JobsIniRepository.php @@ -0,0 +1,20 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Repository\IniRepository; + +/** + * Collection of jobs stored in the jobs.ini file + */ +class JobsIniRepository extends IniRepository +{ + protected $queryColumns = array('jobs' => array('name', 'cidrs', 'ports', 'schedule')); + + protected $configs = array('jobs' => array( + 'module' => 'x509', + 'name' => 'jobs', + 'keyColumn' => 'name' + )); +} diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php new file mode 100644 index 0000000..6f7cfb3 --- /dev/null +++ b/library/X509/ProvidedHook/HostsImportSource.php @@ -0,0 +1,70 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use ipl\Sql; + +class HostsImportSource extends x509ImportSource +{ + public function fetchData() + { + $targets = (new Sql\Select()) + ->from('x509_target t') + ->columns([ + 'host_ip' => 't.ip', + 'host_name' => 't.hostname', + 'host_ports' => 'GROUP_CONCAT(DISTINCT t.port SEPARATOR ",")' + ]) + ->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') + ->where(['ccl.order = ?' => 0]) + ->groupBy(['t.ip', 't.hostname']); + + $results = []; + $foundDupes = []; + foreach ($this->getDb()->select($targets) as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + $target->host_ip = $ipv4 ?: $ipv6; + $target->host_address = $ipv4; + $target->host_address6 = $ipv6; + + if (isset($foundDupes[$target->host_name])) { + // For load balanced systems the IP address is the better choice + $target->host_name_or_ip = $target->host_ip; + } elseif (! isset($results[$target->host_name])) { + // Hostnames are usually preferred, especially in the case of SNI + $target->host_name_or_ip = $target->host_name; + } else { + $dupe = $results[$target->host_name]; + unset($results[$target->host_name]); + $foundDupes[$dupe->host_name] = true; + $dupe->host_name_or_ip = $dupe->host_ip; + $results[$dupe->host_name_or_ip] = $dupe; + $target->host_name_or_ip = $target->host_ip; + } + + $results[$target->host_name_or_ip] = $target; + } + + return $results; + } + + public function listColumns() + { + return [ + 'host_name_or_ip', + 'host_ip', + 'host_name', + 'host_ports', + 'host_address', + 'host_address6' + ]; + } + + public static function getDefaultKeyColumnName() + { + return 'host_name_or_ip'; + } +} diff --git a/library/X509/ProvidedHook/ServicesImportSource.php b/library/X509/ProvidedHook/ServicesImportSource.php new file mode 100644 index 0000000..19f9de9 --- /dev/null +++ b/library/X509/ProvidedHook/ServicesImportSource.php @@ -0,0 +1,85 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use ipl\Sql; + +class ServicesImportSource extends x509ImportSource +{ + public function fetchData() + { + $targets = (new Sql\Select()) + ->from('x509_target t') + ->columns([ + 'host_ip' => 't.ip', + 'host_name' => 't.hostname', + 'host_port' => 't.port', + 'cert_subject' => 'c.subject', + 'cert_issuer' => 'c.issuer', + 'cert_self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', + 'cert_trusted' => 'c.trusted', + 'cert_valid_from' => 'c.valid_from', + 'cert_valid_to' => 'c.valid_to', + 'cert_fingerprint' => 'HEX(c.fingerprint)', + 'cert_dn' => 'GROUP_CONCAT(CONCAT(dn.key, \'=\', dn.value) SEPARATOR \',\')', + 'cert_subject_alt_name' => (new Sql\Select()) + ->from('x509_certificate_subject_alt_name can') + ->columns('GROUP_CONCAT(CONCAT(can.type, \':\', can.value) SEPARATOR \',\')') + ->where(['can.certificate_id = c.id']) + ->groupBy(['can.certificate_id']) + ]) + ->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') + ->joinLeft('x509_dn dn', 'dn.hash = c.subject_hash') + ->where(['ccl.order = ?' => 0]) + ->groupBy(['t.ip', 't.hostname', 't.port']); + + $results = []; + foreach ($this->getDb()->select($targets) as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + $target->host_ip = $ipv4 ?: $ipv6; + $target->host_address = $ipv4; + $target->host_address6 = $ipv6; + + $target->host_name_ip_and_port = sprintf( + '%s/%s:%d', + $target->host_name, + $target->host_ip, + $target->host_port + ); + + $results[$target->host_name_ip_and_port] = $target; + } + + return $results; + } + + public function listColumns() + { + return [ + 'host_name_ip_and_port', + 'host_ip', + 'host_name', + 'host_port', + 'host_address', + 'host_address6', + 'cert_subject', + 'cert_issuer', + 'cert_self_signed', + 'cert_trusted', + 'cert_valid_from', + 'cert_valid_to', + 'cert_fingerprint', + 'cert_dn', + 'cert_subject_alt_name' + ]; + } + + public static function getDefaultKeyColumnName() + { + return 'host_name_ip_and_port'; + } +} diff --git a/library/X509/ProvidedHook/x509ImportSource.php b/library/X509/ProvidedHook/x509ImportSource.php new file mode 100644 index 0000000..184744b --- /dev/null +++ b/library/X509/ProvidedHook/x509ImportSource.php @@ -0,0 +1,49 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use Icinga\Application\Config; +use Icinga\Data\ResourceFactory; +use Icinga\Module\Director\Hook\ImportSourceHook; +use ipl\Sql; +use PDO; + +abstract class x509ImportSource extends ImportSourceHook +{ + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + protected function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + Config::module('x509')->get('backend', 'resource') + )); + $config->options = [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ + ]; + + $conn = new Sql\Connection($config); + + return $conn; + } + + /** + * Transform the given binary IP address in a human readable format + * + * @param string $ip + * + * @return array The first element is IPv4, the second IPv6 + */ + protected function transformIpAddress($ip) + { + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + return [inet_ntop($ipv4), null]; + } else { + return [null, inet_ntop($ip)]; + } + } +} diff --git a/library/X509/React/StreamOptsCaptureConnector.php b/library/X509/React/StreamOptsCaptureConnector.php new file mode 100644 index 0000000..81cd8aa --- /dev/null +++ b/library/X509/React/StreamOptsCaptureConnector.php @@ -0,0 +1,59 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2020 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\React; + +use React\Socket\ConnectionInterface; +use React\Socket\ConnectorInterface; + +use function React\Promise\resolve; + +/** + * Connector that captures stream context options upon close of the underlying connection + */ +class StreamOptsCaptureConnector implements ConnectorInterface +{ + /** @var array|null */ + protected $capturedStreamOptions; + + /** @var ConnectorInterface */ + protected $connector; + + public function __construct(ConnectorInterface $connector) + { + $this->connector = $connector; + } + + /** + * @return array + */ + public function getCapturedStreamOptions() + { + return (array) $this->capturedStreamOptions; + } + + /** + * @param array $capturedStreamOptions + * + * @return $this + */ + public function setCapturedStreamOptions($capturedStreamOptions) + { + $this->capturedStreamOptions = $capturedStreamOptions; + + return $this; + } + + public function connect($uri) + { + return $this->connector->connect($uri)->then(function (ConnectionInterface $conn) { + $conn->on('close', function () use ($conn) { + if (is_resource($conn->stream)) { + $this->setCapturedStreamOptions(stream_context_get_options($conn->stream)); + } + }); + + return resolve($conn); + }); + } +} diff --git a/library/X509/Scheduler.php b/library/X509/Scheduler.php new file mode 100644 index 0000000..0963016 --- /dev/null +++ b/library/X509/Scheduler.php @@ -0,0 +1,59 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Cron\CronExpression; +use Icinga\Application\Logger; +use React\EventLoop\Factory as Loop; + +class Scheduler +{ + protected $loop; + + public function __construct() + { + $this->loop = Loop::create(); + } + + public function add($name, $cronSchedule, callable $callback) + { + if (! CronExpression::isValidExpression($cronSchedule)) { + throw new \RuntimeException('Invalid cron expression'); + } + + $now = new \DateTime(); + + $expression = new CronExpression($cronSchedule); + + if ($expression->isDue($now)) { + $this->loop->futureTick($callback); + } + + $nextRuns = $expression->getMultipleRunDates(2, $now); + + $interval = $nextRuns[0]->getTimestamp() - $now->getTimestamp(); + + $period = $nextRuns[1]->getTimestamp() - $nextRuns[0]->getTimestamp(); + + Logger::info('Scheduling job %s to run at %s.', $name, $nextRuns[0]->format('Y-m-d H:i:s')); + + $loop = function () use (&$loop, $name, $callback, $period) { + $callback(); + + $nextRun = (new \DateTime()) + ->add(new \DateInterval("PT{$period}S")); + + Logger::info('Scheduling job %s to run at %s.', $name, $nextRun->format('Y-m-d H:i:s')); + + $this->loop->addTimer($period, $loop); + }; + + $this->loop->addTimer($interval, $loop); + } + + public function run() + { + $this->loop->run(); + } +} diff --git a/library/X509/SniIniRepository.php b/library/X509/SniIniRepository.php new file mode 100644 index 0000000..ba4c4ba --- /dev/null +++ b/library/X509/SniIniRepository.php @@ -0,0 +1,20 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Repository\IniRepository; + +/** + * Collection of hostnames stored in the sni.ini file + */ +class SniIniRepository extends IniRepository +{ + protected $queryColumns = array('sni' => array('ip', 'hostnames')); + + protected $configs = array('sni' => array( + 'module' => 'x509', + 'name' => 'sni', + 'keyColumn' => 'ip' + )); +} diff --git a/library/X509/SortAdapter.php b/library/X509/SortAdapter.php new file mode 100644 index 0000000..5adb86b --- /dev/null +++ b/library/X509/SortAdapter.php @@ -0,0 +1,46 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Data\Sortable; +use ipl\Sql; + +/** + * @internal + */ +class SortAdapter implements Sortable +{ + protected $select; + + protected $callback; + + public function __construct(Sql\Select $select, \Closure $callback = null) + { + $this->select = $select; + $this->callback = $callback; + } + + public function order($field, $direction = null) + { + if ($this->callback !== null) { + $field = call_user_func($this->callback, $field) ?: $field; + } + + if ($direction === null) { + $this->select->orderBy($field); + } else { + $this->select->orderBy($field, $direction); + } + } + + public function hasOrder() + { + return $this->select->hasOrderBy(); + } + + public function getOrder() + { + return $this->select->getOrderBy(); + } +} diff --git a/library/X509/SqlFilter.php b/library/X509/SqlFilter.php new file mode 100644 index 0000000..40da0d9 --- /dev/null +++ b/library/X509/SqlFilter.php @@ -0,0 +1,84 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ReflectionClass; +use Icinga\Data\ConfigObject; +use Icinga\Data\Db\DbConnection; +use Icinga\Data\Filter\Filter; +use ipl\Sql\Select; + +/** + * @internal + */ +class Quoter +{ + public function quote($identifier, $quoteCharacter = '"') + { + if (strlen($quoteCharacter) === 1) { + return $quoteCharacter . $identifier . $quoteCharacter; + } else { + return $quoteCharacter[0] . $identifier . $quoteCharacter[1]; + } + } +} + +/** + * @internal + */ +class NoImplicitConnectDbConnection extends DbConnection +{ + protected $renderFilterCallback; + + public function __construct(ConfigObject $config = null) + { + } + + public function setRenderFilterCallback(callable $callback = null) + { + $this->renderFilterCallback = $callback; + + return $this; + } + + protected function renderFilterExpression(Filter $filter) + { + $hit = false; + + if (isset($this->renderFilterCallback)) { + $hit = call_user_func($this->renderFilterCallback, clone $filter); + } + + if ($hit !== false) { + $filter = $hit; + } + + return parent::renderFilterExpression($filter); + } +} + +/** + * @internal + */ +class SqlFilter +{ + public static function apply(Select $select, Filter $filter = null, callable $renderFilterCallback = null) + { + if ($filter === null || $filter->isEmpty()) { + return; + } + + if (! $filter->isEmpty()) { + $conn = (new NoImplicitConnectDbConnection())->setRenderFilterCallback($renderFilterCallback); + + $reflection = new ReflectionClass('\Icinga\Data\Db\DbConnection'); + + $dbAdapter = $reflection->getProperty('dbAdapter'); + $dbAdapter->setAccessible(true); + $dbAdapter->setValue($conn, new Quoter()); + + $select->where($conn->renderFilter($filter)); + } + } +} diff --git a/library/X509/Table.php b/library/X509/Table.php new file mode 100644 index 0000000..1281668 --- /dev/null +++ b/library/X509/Table.php @@ -0,0 +1,38 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class Table extends BaseHtmlElement +{ + protected $tag = 'table'; + + protected $rows = []; + + public function addRow(array $cells, $attributes = null) + { + $row = Html::tag('tr', $attributes); + + foreach ($cells as $cell) { + $row->add(Html::tag('td', $cell)); + } + + $this->rows[] = $row; + } + + public function renderContent() + { + $tbody = Html::tag('tbody'); + + foreach ($this->rows as $row) { + $tbody->add($row); + } + + $this->add($tbody); + + return parent::renderContent(); // TODO: Change the autogenerated stub + } +} diff --git a/library/X509/UsageTable.php b/library/X509/UsageTable.php new file mode 100644 index 0000000..20fd147 --- /dev/null +++ b/library/X509/UsageTable.php @@ -0,0 +1,83 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Web\Url; +use ipl\Html\Html; + +/** + * Table widget to display X.509 certificate usage + */ +class UsageTable extends DataTable +{ + protected $defaultAttributes = [ + 'class' => 'usage-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function createColumns() + { + return [ + 'valid' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($valid) { + $icon = $valid === 'yes' ? 'check -ok' : 'block -critical'; + + return Html::tag('i', ['class' => "icon icon-{$icon}"]); + } + ], + + 'hostname' => mt('x509', 'Hostname'), + + 'ip' => [ + 'label' => mt('x509', 'IP'), + 'renderer' => function ($ip) { + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + + return inet_ntop($ip); + } + ], + + 'port' => mt('x509', 'Port'), + + 'subject' => mt('x509', 'Certificate'), + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/chain', ['id' => $row['certificate_chain_id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} |