summaryrefslogtreecommitdiffstats
path: root/library/X509
diff options
context:
space:
mode:
Diffstat (limited to 'library/X509')
-rw-r--r--library/X509/CertificateDetails.php105
-rw-r--r--library/X509/CertificateUtils.php460
-rw-r--r--library/X509/CertificatesTable.php109
-rw-r--r--library/X509/ChainDetails.php116
-rw-r--r--library/X509/ColorScheme.php36
-rw-r--r--library/X509/Command.php35
-rw-r--r--library/X509/Controller.php121
-rw-r--r--library/X509/DataTable.php145
-rw-r--r--library/X509/Donut.php93
-rw-r--r--library/X509/ExpirationWidget.php85
-rw-r--r--library/X509/FilterAdapter.php55
-rw-r--r--library/X509/Hook/SniHook.php53
-rw-r--r--library/X509/Job.php381
-rw-r--r--library/X509/JobsIniRepository.php20
-rw-r--r--library/X509/ProvidedHook/HostsImportSource.php70
-rw-r--r--library/X509/ProvidedHook/ServicesImportSource.php85
-rw-r--r--library/X509/ProvidedHook/x509ImportSource.php49
-rw-r--r--library/X509/React/StreamOptsCaptureConnector.php59
-rw-r--r--library/X509/Scheduler.php59
-rw-r--r--library/X509/SniIniRepository.php20
-rw-r--r--library/X509/SortAdapter.php46
-rw-r--r--library/X509/SqlFilter.php84
-rw-r--r--library/X509/Table.php38
-rw-r--r--library/X509/UsageTable.php83
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('&nbsp;');
+ }
+ } 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('&nbsp;')
+ )
+ )
+ ]);
+ }
+}
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;
+ }
+}