summaryrefslogtreecommitdiffstats
path: root/library
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-14 13:31:28 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-14 13:31:28 +0000
commit067008c5f094ba9606daacbe540f6b929dc124ea (patch)
tree3092ce2cd8bf1ac6db6c97f4c98c7f71a51c6ac8 /library
parentInitial commit. (diff)
downloadicingaweb2-module-x509-067008c5f094ba9606daacbe540f6b929dc124ea.tar.xz
icingaweb2-module-x509-067008c5f094ba9606daacbe540f6b929dc124ea.zip
Adding upstream version 1:1.3.2.upstream/1%1.3.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--library/X509/CertificateDetails.php120
-rw-r--r--library/X509/CertificateUtils.php538
-rw-r--r--library/X509/CertificatesTable.php104
-rw-r--r--library/X509/ChainDetails.php111
-rw-r--r--library/X509/ColorScheme.php37
-rw-r--r--library/X509/Command.php18
-rw-r--r--library/X509/Common/Database.php56
-rw-r--r--library/X509/Common/JobOptions.php162
-rw-r--r--library/X509/Common/JobUtils.php77
-rw-r--r--library/X509/Common/Links.php37
-rw-r--r--library/X509/Controller.php87
-rw-r--r--library/X509/DataTable.php150
-rw-r--r--library/X509/DbTool.php45
-rw-r--r--library/X509/Donut.php92
-rw-r--r--library/X509/ExpirationWidget.php80
-rw-r--r--library/X509/FilterAdapter.php56
-rw-r--r--library/X509/Hook/SniHook.php54
-rw-r--r--library/X509/Job.php755
-rw-r--r--library/X509/Model/Behavior/DERBase64.php44
-rw-r--r--library/X509/Model/Behavior/ExpressionInjector.php62
-rw-r--r--library/X509/Model/Behavior/Ip.php39
-rw-r--r--library/X509/Model/Schema.php49
-rw-r--r--library/X509/Model/X509Certificate.php159
-rw-r--r--library/X509/Model/X509CertificateChain.php58
-rw-r--r--library/X509/Model/X509CertificateChainLink.php46
-rw-r--r--library/X509/Model/X509CertificateSubjectAltName.php50
-rw-r--r--library/X509/Model/X509Dn.php51
-rw-r--r--library/X509/Model/X509Job.php73
-rw-r--r--library/X509/Model/X509JobRun.php77
-rw-r--r--library/X509/Model/X509Schedule.php70
-rw-r--r--library/X509/Model/X509Target.php74
-rw-r--r--library/X509/ProvidedHook/DbMigration.php95
-rw-r--r--library/X509/ProvidedHook/HostsImportSource.php91
-rw-r--r--library/X509/ProvidedHook/ServicesImportSource.php143
-rw-r--r--library/X509/ProvidedHook/X509ImportSource.php11
-rw-r--r--library/X509/React/StreamOptsCaptureConnector.php60
-rw-r--r--library/X509/Schedule.php125
-rw-r--r--library/X509/SniIniRepository.php21
-rw-r--r--library/X509/Table.php39
-rw-r--r--library/X509/UsageTable.php91
-rw-r--r--library/X509/Web/Control/SearchBar/ObjectSuggestions.php203
-rw-r--r--library/X509/Widget/JobDetails.php61
-rw-r--r--library/X509/Widget/Jobs.php64
-rw-r--r--library/X509/Widget/Schedules.php61
44 files changed, 4496 insertions, 0 deletions
diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php
new file mode 100644
index 0000000..f28e423
--- /dev/null
+++ b/library/X509/CertificateDetails.php
@@ -0,0 +1,120 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use DateTime;
+use Icinga\Module\X509\Model\X509Certificate;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\IcingaIcon;
+
+/**
+ * Widget to display X.509 certificate details
+ */
+class CertificateDetails extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'cert-details'];
+
+ /**
+ * @var X509Certificate
+ */
+ protected $cert;
+
+ public function setCert(X509Certificate $cert)
+ {
+ $this->cert = $cert;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $pem = $this->cert->certificate;
+ $cert = openssl_x509_parse($pem);
+// $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem));
+
+ $subject = Html::tag('dl');
+ $sans = CertificateUtils::splitSANs($cert['extensions']['subjectAltName'] ?? null);
+ if (! isset($cert['subject']['CN']) && ! empty($sans)) {
+ foreach ($sans as $type => $values) {
+ foreach ($values as $value) {
+ $subject->addHtml(Html::tag('dt', $type), Html::tag('dd', $value));
+ }
+ }
+ } else {
+ 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', $this->cert->valid_from->format('l F jS, Y H:i:s e')),
+ Html::tag('dt', mt('x509', 'Not Valid After')),
+ Html::tag('dd', $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', [new IcingaIcon('certificate'), $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..e524024
--- /dev/null
+++ b/library/X509/CertificateUtils.php
@@ -0,0 +1,538 @@
+<?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 Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateSubjectAltName;
+use Icinga\Module\X509\Model\X509Dn;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Orm\Model;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Str;
+
+use function ipl\Stdlib\yield_groups;
+
+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): string
+ {
+ if (isset($dn['CN'])) {
+ return ((array) $dn['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 $sanStr
+ *
+ * @return array
+ */
+ public static function splitSANs(?string $sanStr): array
+ {
+ $sans = [];
+ foreach (Str::trimSplit($sanStr) as $altName) {
+ if (strpos($altName, ':') === false) {
+ [$k, $v] = Str::trimSplit($altName, '=', 2);
+ } else {
+ [$k, $v] = Str::trimSplit($altName, ':', 2);
+ }
+
+ $sans[$k][] = $v;
+ }
+
+ $order = array_flip(['DNS', 'URI', 'IP Address', 'email', 'DirName']);
+ uksort($sans, function ($a, $b) use ($order) {
+ return ($order[$a] ?? PHP_INT_MAX) <=> ($order[$b] ?? PHP_INT_MAX);
+ });
+
+ return $sans;
+ }
+
+ /**
+ * 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 array
+ */
+ public static function findOrInsertCert(Connection $db, $cert)
+ {
+ $dbTool = new DbTool($db);
+
+ $certInfo = openssl_x509_parse($cert);
+
+ $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true);
+
+ $row = X509Certificate::on($db);
+ $row
+ ->columns(['id', 'issuer_hash'])
+ ->filter(Filter::equal('fingerprint', $fingerprint));
+
+ $row = $row->first();
+ if ($row) {
+ return [$row->id, $row->issuer_hash];
+ }
+
+ 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']);
+
+ $sans = static::splitSANs($certInfo['extensions']['subjectAltName'] ?? null);
+ if (! isset($certInfo['subject']['CN']) && ! empty($sans)) {
+ $subject = current($sans)[0];
+ } else {
+ $subject = self::shortNameFromDN($certInfo['subject']);
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_certificate',
+ [
+ 'subject' => $subject,
+ 'subject_hash' => $dbTool->marshalBinary($subjectHash),
+ 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']),
+ 'issuer_hash' => $dbTool->marshalBinary($issuerHash),
+ 'version' => $certInfo['version'] + 1,
+ 'self_signed' => $subjectHash === $issuerHash ? 'y' : 'n',
+ 'ca' => $ca ? 'y' : 'n',
+ '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'] * 1000.0,
+ 'valid_to' => $certInfo['validTo_time_t'] * 1000.0,
+ 'fingerprint' => $dbTool->marshalBinary($fingerprint),
+ 'serial' => $dbTool->marshalBinary(gmp_export($certInfo['serialNumber'])),
+ 'certificate' => $dbTool->marshalBinary($der),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $certId = $db->lastInsertId();
+
+ CertificateUtils::insertSANs($db, $certId, $sans);
+
+ return [$certId, $issuerHash];
+ }
+
+ private static function insertSANs($db, $certId, iterable $sans): void
+ {
+ $dbTool = new DbTool($db);
+ foreach ($sans as $type => $values) {
+ foreach ($values as $value) {
+ $hash = hash('sha256', sprintf('%s=%s', $type, $value), true);
+
+ $row = X509CertificateSubjectAltName::on($db);
+ $row->columns([new Expression('1')]);
+
+ $filter = Filter::all(
+ Filter::equal('certificate_id', $certId),
+ Filter::equal('hash', $hash)
+ );
+
+ $row->filter($filter);
+
+ // Ignore duplicate SANs
+ if ($row->execute()->hasResult()) {
+ continue;
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_certificate_subject_alt_name',
+ [
+ 'certificate_id' => $certId,
+ 'hash' => $dbTool->marshalBinary($hash),
+ 'type' => $type,
+ 'value' => $value,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ }
+ }
+ }
+
+ private static function findOrInsertDn($db, $certInfo, $type)
+ {
+ $dbTool = new DbTool($db);
+
+ $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 = X509Dn::on($db);
+ $row
+ ->columns(['hash'])
+ ->filter(Filter::all(
+ Filter::equal('hash', $hash),
+ Filter::equal('type', $type)
+ ))
+ ->limit(1);
+
+ $row = $row->first();
+ if ($row) {
+ return $row->hash;
+ }
+
+ $index = 0;
+ foreach ($dn as $key => $value) {
+ if (!is_array($value)) {
+ $values = [$value];
+ } else {
+ $values = $value;
+ }
+
+ foreach ($values as $value) {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_dn',
+ [
+ 'hash' => $dbTool->marshalBinary($hash),
+ $db->quoteIdentifier('key') => $key,
+ $db->quoteIdentifier('value') => $value,
+ $db->quoteIdentifier('order') => $index,
+ 'type' => $type,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ $index++;
+ }
+ }
+
+ return $hash;
+ }
+
+ /**
+ * Remove certificates that are no longer in use
+ *
+ * Remove chains that aren't used by any target, certificates that aren't part of any chain, and DNs
+ * that aren't used anywhere.
+ *
+ * @param Connection $conn
+ */
+ public static function cleanupNoLongerUsedCertificates(Connection $conn)
+ {
+ $chainQuery = $conn->delete(
+ 'x509_certificate_chain',
+ ['id NOT IN ?' => X509Target::on($conn)->columns('latest_certificate_chain_id')->assembleSelect()]
+ );
+
+ $rows = $chainQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d certificate chains that are not used by any targets', $rows);
+ }
+
+ $certsQuery = $conn->delete('x509_certificate', [
+ 'id NOT IN ?' => (new Select())
+ ->from('x509_certificate_chain_link ccl')
+ ->columns(['ccl.certificate_id'])
+ ->distinct(),
+ 'trusted = ?' => 'n',
+ ]);
+
+ $rows = $certsQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d certificates that are not part of any chains', $rows);
+ }
+
+ $dnQuery = $conn->delete('x509_dn', [
+ 'hash NOT IN ?' => X509Certificate::on($conn)->columns('subject_hash')->assembleSelect()
+ ]);
+
+ $rows = $dnQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d DNs that are not used anywhere', $rows);
+ }
+ }
+
+ /**
+ * 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 = X509Certificate::on($db);
+ $cas
+ ->columns(['certificate'])
+ ->filter(Filter::all(
+ Filter::equal('ca', true),
+ Filter::equal('trusted', true)
+ ));
+
+ $contents = [];
+ /** @var Model $ca */
+ foreach ($cas as $ca) {
+ $contents[] = $ca->certificate;
+ }
+
+ if (empty($contents)) {
+ throw new \RuntimeException('Trust store is empty');
+ }
+
+ $files->create($caFile, implode("\n", $contents));
+
+ $count = 0;
+ $certs = X509Certificate::on($db)
+ ->with(['chain'])
+ ->utilize('chain.target')
+ ->columns(['chain.id', 'certificate'])
+ ->filter(Filter::equal('chain.valid', false))
+ ->orderBy('chain.id')
+ ->orderBy(new Expression('certificate_link.order'), SORT_DESC);
+
+ $db->beginTransaction();
+
+ try {
+ $caFile = escapeshellarg($files->resolvePath($caFile));
+ $verifyCertsFunc = function (int $chainId, array $collection) use ($db, $caFile) {
+ $certFiles = new TemporaryLocalFileStorage();
+ $certFile = uniqid('cert');
+ $certFiles->create($certFile, array_pop($collection));
+
+ $untrusted = '';
+ if (! empty($collection)) {
+ $intermediateFile = uniqid('intermediate');
+ $certFiles->create($intermediateFile, implode("\n", $collection));
+
+ $untrusted = sprintf(
+ ' -untrusted %s',
+ escapeshellarg($certFiles->resolvePath($intermediateFile))
+ );
+ }
+
+ $command = sprintf(
+ 'openssl verify -CAfile %s%s %s 2>&1',
+ $caFile,
+ $untrusted,
+ escapeshellarg($certFiles->resolvePath($certFile))
+ );
+
+ $output = null;
+
+ exec($command, $output, $exitcode);
+
+ $output = implode("\n", $output);
+
+ if ($exitcode !== 0) {
+ Logger::debug('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' => 'y', 'invalid_reason' => null];
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->update('x509_certificate_chain', $set, ['id = ?' => $chainId]);
+ };
+
+ $groupBy = function (X509Certificate $cert): array {
+ // Group all the certificates by their chain id.
+ return [$cert->chain->id, $cert->certificate];
+ };
+
+ foreach (yield_groups($certs, $groupBy) as $chainId => $collection) {
+ ++$count;
+ $verifyCertsFunc($chainId, $collection);
+ }
+
+ $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..1c1970e
--- /dev/null
+++ b/library/X509/CertificatesTable.php
@@ -0,0 +1,104 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+use ipl\Web\Widget\IcingaIcon;
+use ipl\Web\Widget\Icon;
+
+/**
+ * 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) {
+ return null;
+ }
+
+ return new IcingaIcon('ca-check-circle', ['title' => mt('x509', 'Is Certificate Authority')]);
+ }
+ ],
+
+ 'self_signed' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($selfSigned) {
+ if (! $selfSigned) {
+ return null;
+ }
+
+ return new IcingaIcon('refresh-cert', ['title' => mt('x509', 'Is Self-Signed')]);
+ }
+ ],
+
+ 'trusted' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($trusted) {
+ if (! $trusted) {
+ return null;
+ }
+
+ return new 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(X509Certificate $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..722b7b3
--- /dev/null
+++ b/library/X509/ChainDetails.php
@@ -0,0 +1,111 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+use ipl\Web\Widget\IcingaIcon;
+use ipl\Web\Widget\Icon;
+
+/**
+ * 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 new IcingaIcon('certificate', ['title' => mt('x509', 'Is a x509 certificate')]);
+ }
+ ],
+
+ 'version' => [
+ 'attributes' => ['class' => 'version-col'],
+ 'renderer' => function ($version) {
+ return Html::tag('div', ['class' => 'badge'], $version);
+ }
+ ],
+
+ 'subject' => [
+ 'label' => mt('x509', 'Subject', 'x509.certificate')
+ ],
+
+ 'ca' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($ca) {
+ if (! $ca) {
+ return null;
+ }
+
+ return new IcingaIcon('ca-check-circle', ['title' => mt('x509', 'Is Certificate Authority')]);
+ }
+ ],
+
+ 'self_signed' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($selfSigned) {
+ if (! $selfSigned) {
+ return null;
+ }
+
+ return new IcingaIcon('refresh-cert', ['title' => mt('x509', 'Is Self-Signed')]);
+ }
+ ],
+
+ 'trusted' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($trusted) {
+ if (! $trusted) {
+ return null;
+ }
+
+ return new 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(X509Certificate $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/ColorScheme.php b/library/X509/ColorScheme.php
new file mode 100644
index 0000000..14a436e
--- /dev/null
+++ b/library/X509/ColorScheme.php
@@ -0,0 +1,37 @@
+<?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..9f18727
--- /dev/null
+++ b/library/X509/Command.php
@@ -0,0 +1,18 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Application\Icinga;
+
+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();
+ }
+}
diff --git a/library/X509/Common/Database.php b/library/X509/Common/Database.php
new file mode 100644
index 0000000..d6eb3e1
--- /dev/null
+++ b/library/X509/Common/Database.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Common;
+
+use Icinga\Application\Config;
+use Icinga\Data\ResourceFactory;
+use ipl\Sql;
+use PDO;
+
+final class Database
+{
+ /** @var Sql\Connection Database connection */
+ private static $instance;
+
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the database connection
+ *
+ * @return Sql\Connection
+ */
+ public static function get(): Sql\Connection
+ {
+ if (self::$instance === null) {
+ self::$instance = self::getDb();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get the connection to the X.509 database
+ *
+ * @return Sql\Connection
+ */
+ private static function getDb(): Sql\Connection
+ {
+ $config = new Sql\Config(ResourceFactory::getResourceConfig(
+ Config::module('x509')->get('backend', 'resource', 'x509')
+ ));
+
+ $options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ];
+ if ($config->db === 'mysql') {
+ $options[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'";
+ }
+
+ $config->options = $options;
+
+ return new Sql\Connection($config);
+ }
+}
diff --git a/library/X509/Common/JobOptions.php b/library/X509/Common/JobOptions.php
new file mode 100644
index 0000000..5112272
--- /dev/null
+++ b/library/X509/Common/JobOptions.php
@@ -0,0 +1,162 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Common;
+
+use DateTime;
+use Exception;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Schedule;
+use InvalidArgumentException;
+use LogicException;
+use stdClass;
+
+trait JobOptions
+{
+ /** @var bool Whether this job should only perform a rescan */
+ protected $rescan;
+
+ /** @var bool Whether this job should perform a full scan */
+ protected $fullScan;
+
+ /** @var ?string Since last scan threshold used to filter out scan targets */
+ protected $sinceLastScan;
+
+ /** @var int Used to control how many targets can be scanned in parallel */
+ protected $parallel = Job::DEFAULT_PARALLEL;
+
+ /** @var Schedule The job schedule config */
+ protected $schedule;
+
+ /**
+ * Get whether this job is performing only a rescan
+ *
+ * @return bool
+ */
+ public function isRescan(): bool
+ {
+ return $this->rescan;
+ }
+
+ /**
+ * Set whether this job should do only a rescan or full scan
+ *
+ * @param bool $rescan
+ *
+ * @return $this
+ */
+ public function setRescan(bool $rescan): self
+ {
+ $this->rescan = $rescan;
+
+ return $this;
+ }
+
+ public function getParallel(): int
+ {
+ return $this->parallel;
+ }
+
+ public function setParallel(int $parallel): self
+ {
+ $this->parallel = $parallel;
+
+ return $this;
+ }
+
+ /**
+ * Set whether this job should scan all known and unknown targets
+ *
+ * @param bool $fullScan
+ *
+ * @return $this
+ */
+ public function setFullScan(bool $fullScan): self
+ {
+ $this->fullScan = $fullScan;
+
+ return $this;
+ }
+
+ /**
+ * Set since last scan threshold for the targets to rescan
+ *
+ * @param ?string $time
+ *
+ * @return $this
+ */
+ public function setLastScan(?string $time): self
+ {
+ if ($time && $time !== 'null') {
+ $sinceLastScan = $time;
+ if ($sinceLastScan[0] !== '-') {
+ // When the user specified "2 days" as a threshold strtotime() will compute the
+ // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days)
+ $sinceLastScan = "-$sinceLastScan";
+ }
+
+ try {
+ // Ensure it's a valid date time string representation.
+ new DateTime($sinceLastScan);
+
+ $this->sinceLastScan = $sinceLastScan;
+ } catch (Exception $_) {
+ throw new InvalidArgumentException(sprintf(
+ 'The specified last scan time is in an unknown format: %s',
+ $time
+ ));
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the targets since last scan threshold
+ *
+ * @return ?DateTime
+ */
+ public function getSinceLastScan(): ?DateTime
+ {
+ if (! $this->sinceLastScan) {
+ return null;
+ }
+
+ return new DateTime($this->sinceLastScan);
+ }
+
+ /**
+ * Get the schedule config of this job
+ *
+ * @return Schedule
+ */
+ public function getSchedule(): Schedule
+ {
+ if (! $this->schedule) {
+ throw new LogicException('You are accessing an unset property. Please make sure to set it beforehand.');
+ }
+
+ return $this->schedule;
+ }
+
+ /**
+ * Set the schedule config of this job
+ *
+ * @param Schedule $schedule
+ *
+ * @return $this
+ */
+ public function setSchedule(Schedule $schedule): self
+ {
+ $this->schedule = $schedule;
+
+ /** @var stdClass $config */
+ $config = $schedule->getConfig();
+ $this->setFullScan($config->full_scan ?? false);
+ $this->setRescan($config->rescan ?? false);
+ $this->setLastScan($config->since_last_scan ?? Job::DEFAULT_SINCE_LAST_SCAN);
+
+ return $this;
+ }
+}
diff --git a/library/X509/Common/JobUtils.php b/library/X509/Common/JobUtils.php
new file mode 100644
index 0000000..54398fe
--- /dev/null
+++ b/library/X509/Common/JobUtils.php
@@ -0,0 +1,77 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Common;
+
+use GMP;
+use Icinga\Application\Logger;
+use ipl\Stdlib\Str;
+
+trait JobUtils
+{
+ /**
+ * Parse the given comma separated CIDRs
+ *
+ * @param string $cidrs
+ *
+ * @return array<string, array<int, int|string>>
+ */
+ public function parseCIDRs(string $cidrs): array
+ {
+ $result = [];
+ foreach (Str::trimSplit($cidrs) as $cidr) {
+ $pieces = Str::trimSplit($cidr, '/');
+ if (count($pieces) !== 2) {
+ Logger::warning('CIDR %s is in the wrong format', $cidr);
+ continue;
+ }
+
+ $result[$cidr] = $pieces;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Parse the given comma separated ports
+ *
+ * @param string $ports
+ *
+ * @return array<int, array<string>>
+ */
+ public function parsePorts(string $ports): array
+ {
+ $result = [];
+ foreach (Str::trimSplit($ports) as $portRange) {
+ $pieces = Str::trimSplit($portRange, '-');
+ if (count($pieces) === 2) {
+ list($start, $end) = $pieces;
+ } else {
+ $start = $pieces[0];
+ $end = $pieces[0];
+ }
+
+ $result[] = [$start, $end];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Parse the given comma separated excluded targets
+ *
+ * @param ?string $excludes
+ *
+ * @return array<string>
+ */
+ public function parseExcludes(?string $excludes): array
+ {
+ $result = [];
+ if (! empty($excludes)) {
+ $result = array_flip(Str::trimSplit($excludes));
+ }
+
+ return $result;
+ }
+}
diff --git a/library/X509/Common/Links.php b/library/X509/Common/Links.php
new file mode 100644
index 0000000..c1570dc
--- /dev/null
+++ b/library/X509/Common/Links.php
@@ -0,0 +1,37 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Common;
+
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Model\X509Schedule;
+use ipl\Web\Url;
+
+class Links
+{
+ public static function job(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job', ['id' => $job->id]);
+ }
+
+ public static function updateJob(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job/update', ['id' => $job->id]);
+ }
+
+ public static function schedules(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job/schedules', ['id' => $job->id]);
+ }
+
+ public static function scheduleJob(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job/schedule', ['id' => $job->id]);
+ }
+
+ public static function updateSchedule(X509Schedule $schedule): Url
+ {
+ return Url::fromPath('x509/job/update-schedule', ['id' => $schedule->job->id, 'scheduleId' => $schedule->id]);
+ }
+}
diff --git a/library/X509/Controller.php b/library/X509/Controller.php
new file mode 100644
index 0000000..f16787d
--- /dev/null
+++ b/library/X509/Controller.php
@@ -0,0 +1,87 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\File\Csv;
+use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Util\Json;
+use ipl\Html\Html;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Compat\SearchControls;
+use ipl\Web\Filter\QueryString;
+
+class Controller extends CompatController
+{
+ use SearchControls;
+
+ /** @var Filter\Rule */
+ protected $filter;
+
+ protected $format;
+
+ public function fetchFilterColumns(Query $query): array
+ {
+ return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()));
+ }
+
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ protected function handleFormatRequest(Query $query, callable $callback)
+ {
+ if ($this->format !== 'html' && ! $this->params->has('limit')) {
+ $query->limit(null); // Resets any default limit and offset
+ }
+
+ if ($this->format === 'sql') {
+ $this->content->add(Html::tag('pre', $query->dump()[0]));
+ return true;
+ }
+
+ switch ($this->format) {
+ 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(iterator_to_array($callback($query)))
+ )
+ ->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($query)))
+ ->sendResponse();
+ exit;
+ }
+ }
+
+ public function preDispatch()
+ {
+ parent::preDispatch();
+
+ $this->format = $this->params->shift('format', 'html');
+ }
+}
diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php
new file mode 100644
index 0000000..bb82959
--- /dev/null
+++ b/library/X509/DataTable.php
@@ -0,0 +1,150 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+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(X509Certificate $row)
+ {
+ $cells = [];
+
+ foreach ($this->columns as $key => $column) {
+ if (! is_int($key) && $row->hasProperty($key)) {
+ $data = $row->$key;
+ } else {
+ $data = null;
+ if (isset($column['column'])) {
+ if (is_callable($column['column'])) {
+ $data = call_user_func(($column['column']), $row);
+ } elseif (isset($row->{$column['column']})) {
+ $data = $row->{$column['column']};
+ }
+ }
+ }
+
+ if (isset($column['renderer'])) {
+ $content = call_user_func(($column['renderer']), $data, $row);
+ } else {
+ $content = $data;
+ }
+
+ $cells[] = Html::tag('td', $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/DbTool.php b/library/X509/DbTool.php
new file mode 100644
index 0000000..4049c5a
--- /dev/null
+++ b/library/X509/DbTool.php
@@ -0,0 +1,45 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2020 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use ipl\Sql\Connection;
+
+class DbTool
+{
+ protected $pgsql = false;
+
+ public function __construct(Connection $db)
+ {
+ $this->pgsql = $db->getConfig()->db === 'pgsql';
+ }
+
+ /**
+ * @param string $binary
+ *
+ * @return string
+ */
+ public function marshalBinary($binary)
+ {
+ if ($this->pgsql) {
+ return sprintf('\\x%s', bin2hex(static::unmarshalBinary($binary)));
+ }
+
+ return $binary;
+ }
+
+ /**
+ * @param resource|string $binary
+ *
+ * @return string
+ */
+ public static function unmarshalBinary($binary)
+ {
+ if (is_resource($binary)) {
+ return stream_get_contents($binary);
+ }
+
+ return $binary;
+ }
+}
diff --git a/library/X509/Donut.php b/library/X509/Donut.php
new file mode 100644
index 0000000..fe8b748
--- /dev/null
+++ b/library/X509/Donut.php
@@ -0,0 +1,92 @@
+<?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()
+ {
+ $donut = new \Icinga\Chart\Donut();
+ $legend = new Table();
+
+ foreach ($this->data as $index => $data) {
+ $donut->addSlice((int) $data['cnt'], ['class' => 'segment-' . $index]);
+ $legend->addRow(
+ [
+ Html::tag('span', ['class' => 'badge badge-' . $index]),
+ 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..dffc3a8
--- /dev/null
+++ b/library/X509/ExpirationWidget.php
@@ -0,0 +1,80 @@
+<?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;
+use ipl\Web\Compat\StyleWithNonce;
+
+class ExpirationWidget extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'expiration-widget'];
+
+ 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->getTimestamp() > $now) {
+ $ratio = 0;
+ $dateTip = $from->format('Y-m-d H:i:s');
+ $message = sprintf(mt('x509', 'not until after %s'), DateFormatter::timeUntil($from->getTimestamp(), true));
+ } else {
+ $to = $this->to;
+
+ $secondsRemaining = $to->getTimestamp() - $now;
+ $daysRemaining = ($secondsRemaining - $secondsRemaining % 86400) / 86400;
+ if ($daysRemaining > 0) {
+ $secondsTotal = $to->getTimestamp() - $from->getTimestamp();
+ $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 = $to->format('Y-m-d H:i:s');
+ }
+
+ if ($ratio >= 75) {
+ if ($ratio >= 90) {
+ $state = 'state-critical';
+ } else {
+ $state = 'state-warning';
+ }
+ } else {
+ $state = 'state-ok';
+ }
+
+ $progressBar = Html::tag('div', ['class' => "bg-stateful $state"], new HtmlString('&nbsp;'));
+ $progressBarStyle = (new StyleWithNonce())
+ ->setModule('x509')
+ ->addFor($progressBar, ['width' => sprintf('%F%%', $ratio)]);
+
+ $this->addHtml(Html::tag('span', ['class' => 'progress-bar-label', 'title' => $dateTip], $message));
+ $this->addHtml($progressBarStyle, Html::tag('div', ['class' => 'progress-bar dont-print'], $progressBar));
+ }
+}
diff --git a/library/X509/FilterAdapter.php b/library/X509/FilterAdapter.php
new file mode 100644
index 0000000..5a43071
--- /dev/null
+++ b/library/X509/FilterAdapter.php
@@ -0,0 +1,56 @@
+<?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..0b707b6
--- /dev/null
+++ b/library/X509/Hook/SniHook.php
@@ -0,0 +1,54 @@
+<?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 = [];
+
+ /** @var self $hook */
+ foreach (Hook::all('X509\Sni') as $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..1e0b3f7
--- /dev/null
+++ b/library/X509/Job.php
@@ -0,0 +1,755 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use DateTime;
+use Exception;
+use Generator;
+use GMP;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\JobOptions;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateChain;
+use Icinga\Module\X509\Model\X509JobRun;
+use Icinga\Module\X509\Model\X509Target;
+use Icinga\Module\X509\React\StreamOptsCaptureConnector;
+use Icinga\Util\Json;
+use ipl\Scheduler\Common\TaskProperties;
+use ipl\Scheduler\Contract\Task;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+use LogicException;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+use React\EventLoop\Loop;
+use React\Promise;
+use React\Socket\ConnectionInterface;
+use React\Socket\Connector;
+use React\Socket\ConnectorInterface;
+use React\Socket\SecureConnector;
+use React\Socket\TimeoutConnector;
+use Throwable;
+
+class Job implements Task
+{
+ use JobOptions;
+ use TaskProperties;
+
+ /** @var int Number of targets to be scanned in parallel by default */
+ public const DEFAULT_PARALLEL = 256;
+
+ /** @var string Default since last scan threshold used to filter out scan targets */
+ public const DEFAULT_SINCE_LAST_SCAN = '-24 hours';
+
+ /** @var int The database id of this job */
+ protected $id;
+
+ /** @var Connection x509 database connection */
+ private $db;
+
+ /** @var DbTool Database utils for marshalling and unmarshalling binary data */
+ private $dbTool;
+
+ /** @var int Number of pending targets to be scanned */
+ private $pendingTargets = 0;
+
+ /** @var int Total number of scan targets */
+ private $totalTargets = 0;
+
+ /** @var int Number of scanned targets */
+ private $finishedTargets = 0;
+
+ /** @var Generator Scan targets generator */
+ private $targets;
+
+ /** @var array<string, array<string>> The configured SNI maps */
+ private $snimap;
+
+ /** @var int The id of the last inserted job run entry */
+ private $jobRunId;
+
+ /** @var Promise\Deferred React promise deferred instance used to resolve the running promise */
+ protected $deferred;
+
+ /** @var DateTime The start time of this job */
+ protected $jobRunStart;
+
+ /** @var array<string> A list of excluded IP addresses and host names */
+ private $excludedTargets = [];
+
+ /** @var array<string, array<int, int|string>> The configured CIDRs of this job */
+ private $cidrs;
+
+ /** @var array<int, array<string>> The configured ports of this job */
+ private $ports;
+
+ /**
+ * Construct a new Job instance
+ *
+ * @param string $name The name of this job
+ * @param array<string, array<int, int|string>> $cidrs The configured CIDRs to be used by this job
+ * @param array<int, array<string>> $ports The configured ports to be used by this job
+ * @param array<string, array<string>> $snimap The configured SNI maps to be used by this job
+ * @param ?Schedule $schedule
+ */
+ public function __construct(string $name, array $cidrs, array $ports, array $snimap, Schedule $schedule = null)
+ {
+ $this->name = $name;
+ $this->db = Database::get();
+ $this->dbTool = new DbTool($this->db);
+ $this->snimap = $snimap;
+ $this->cidrs = $cidrs;
+ $this->ports = $ports;
+
+ if ($schedule) {
+ $this->setSchedule($schedule);
+ }
+
+ $this->setName($name);
+ }
+
+ /**
+ * Transform the given human-readable IP address into a binary format
+ *
+ * @param string $addr
+ *
+ * @return string
+ */
+ public static function binary(string $addr): string
+ {
+ return str_pad(inet_pton($addr), 16, "\0", STR_PAD_LEFT);
+ }
+
+ /**
+ * Transform the given human-readable IP address into GMP number
+ *
+ * @param string $addr
+ *
+ * @return ?GMP
+ */
+ public static function addrToNumber(string $addr): ?GMP
+ {
+ return gmp_import(static::binary($addr));
+ }
+
+ /**
+ * Transform the given number into human-readable IP address
+ *
+ * @param $num
+ * @param bool $ipv6
+ *
+ * @return false|string
+ */
+ public static function numberToAddr($num, bool $ipv6 = true)
+ {
+ if ($ipv6) {
+ return inet_ntop(str_pad(gmp_export($num), 16, "\0", STR_PAD_LEFT));
+ } else {
+ return inet_ntop(gmp_export($num));
+ }
+ }
+
+ /**
+ * Check whether the given IP is inside the specified CIDR
+ *
+ * @param GMP $addr
+ * @param string $subnet
+ * @param int $mask
+ *
+ * @return bool
+ */
+ public static function isAddrInside(GMP $addr, string $subnet, int $mask): bool
+ {
+ // `gmp_pow()` is like PHP's pow() function, but handles also very large numbers
+ // and `gmp_com()` is like the bitwise NOT (~) operator.
+ $mask = gmp_com(gmp_pow(2, (static::isIPV6($subnet) ? 128 : 32) - $mask) - 1);
+ return gmp_strval(gmp_and($addr, $mask)) === gmp_strval(gmp_and(static::addrToNumber($subnet), $mask));
+ }
+
+ /**
+ * Get whether the given IP address is IPV6 address
+ *
+ * @param $addr
+ *
+ * @return bool
+ */
+ public static function isIPV6($addr): bool
+ {
+ return filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ }
+
+ /**
+ * Get the database id of this job
+ *
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set the database id of this job
+ *
+ * @param int $id
+ *
+ * @return $this
+ */
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getUuid(): UuidInterface
+ {
+ if (! $this->uuid) {
+ $this->setUuid(Uuid::fromBytes($this->getChecksum()));
+ }
+
+ return $this->uuid;
+ }
+
+ /**
+ * Get the configured job CIDRS
+ *
+ * @return array<string, array<int, int|string>>
+ */
+ public function getCIDRs(): array
+ {
+ return $this->cidrs;
+ }
+
+ /**
+ * Set the CIDRs of this job
+ *
+ * @param array<string, array<int, int|string>> $cidrs
+ *
+ * @return $this
+ */
+ public function setCIDRs(array $cidrs): self
+ {
+ $this->cidrs = $cidrs;
+
+ return $this;
+ }
+
+ /**
+ * Get the configured ports of this job
+ *
+ * @return array<int, array<string>>
+ */
+ public function getPorts(): array
+ {
+ return $this->ports;
+ }
+
+ /**
+ * Set the ports of this job to be scanned
+ *
+ * @param array<int, array<string>> $ports
+ *
+ * @return $this
+ */
+ public function setPorts(array $ports): self
+ {
+ $this->ports = $ports;
+
+ return $this;
+ }
+
+ /**
+ * Get excluded IPs and host names
+ *
+ * @return array<string>
+ */
+ public function getExcludes(): array
+ {
+ return $this->excludedTargets;
+ }
+
+ /**
+ * Set a set of IPs and host names to be excluded from scan
+ *
+ * @param array<string> $targets
+ *
+ * @return $this
+ */
+ public function setExcludes(array $targets): self
+ {
+ $this->excludedTargets = $targets;
+
+ return $this;
+ }
+
+ private function getConnector($peerName): array
+ {
+ $simpleConnector = new Connector();
+ $streamCaptureConnector = new StreamOptsCaptureConnector($simpleConnector);
+ $secureConnector = new SecureConnector($streamCaptureConnector, null, [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'capture_peer_cert_chain' => true,
+ 'SNI_enabled' => true,
+ 'peer_name' => $peerName
+ ]);
+ return [new TimeoutConnector($secureConnector, 5.0), $streamCaptureConnector];
+ }
+
+ /**
+ * Get whether this job has been completed scanning all targets
+ *
+ * @return bool
+ */
+ public function isFinished(): bool
+ {
+ return ! $this->targets->valid() && $this->pendingTargets === 0;
+ }
+
+ public function updateLastScan($target)
+ {
+ if (! $this->isRescan() && ! isset($target->id)) {
+ return;
+ }
+
+ $this->db->update('x509_target', [
+ 'last_scan' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ], ['id = ?' => $target->id]);
+ }
+
+ public function getChecksum(): string
+ {
+ $data = [
+ 'name' => $this->getName(),
+ 'cidrs' => $this->getCIDRs(),
+ 'ports' => $this->getPorts(),
+ 'exclude_targets' => $this->getExcludes(),
+ ];
+
+ $schedule = null;
+ if ($this->schedule) {
+ $schedule = $this->getSchedule();
+ }
+
+ return md5(Json::encode($data) . ($schedule ? bin2hex($schedule->getChecksum()) : ''), true);
+ }
+
+ protected function getScanTargets(): Generator
+ {
+ $generate = $this->fullScan || ! $this->isRescan();
+ if (! $generate) {
+ $run = X509JobRun::on($this->db)
+ ->columns([new Expression('1')])
+ ->filter(Filter::equal('schedule.job_id', $this->getId()))
+ ->filter(Filter::unequal('total_targets', 0))
+ ->limit(1)
+ ->execute();
+
+ $generate = ! $run->hasResult();
+ }
+
+ if ($generate) {
+ yield from $this->generateTargets();
+ }
+
+ $sinceLastScan = $this->getSinceLastScan();
+ if ((! $this->fullScan && $sinceLastScan !== null) || $this->isRescan()) {
+ $targets = X509Target::on($this->db)->columns(['id', 'ip', 'hostname', 'port']);
+ if (! $this->fullScan && $sinceLastScan) {
+ $targets->filter(Filter::lessThan('last_scan', $sinceLastScan));
+ }
+
+ foreach ($targets as $target) {
+ $addr = static::addrToNumber($target->ip);
+ $addrFound = false;
+ foreach ($this->getCIDRs() as $cidr) {
+ list($subnet, $mask) = $cidr;
+ if (static::isAddrInside($addr, (string) $subnet, (int) $mask)) {
+ $target->ip = static::numberToAddr($addr, static::isIPV6($subnet));
+ $addrFound = true;
+
+ break;
+ }
+ }
+
+ if ($addrFound) {
+ yield $target;
+ }
+ }
+ }
+ }
+
+ private function generateTargets(): Generator
+ {
+ $excludes = $this->getExcludes();
+ foreach ($this->getCIDRs() as $cidr) {
+ list($startIp, $prefix) = $cidr;
+ $ipv6 = static::isIPV6($startIp);
+ $subnet = $ipv6 ? 128 : 32;
+ $numIps = pow(2, ($subnet - (int) $prefix));
+
+ Logger::info('Scanning %d IPs in the CIDR %s', $numIps, implode('/', $cidr));
+
+ $start = static::addrToNumber((string) $startIp);
+ for ($i = 0; $i < $numIps; $i++) {
+ $ip = static::numberToAddr(gmp_add($start, $i), $ipv6);
+ if (isset($excludes[$ip])) {
+ Logger::debug('Excluding IP %s from scan', $ip);
+ continue;
+ }
+
+ foreach ($this->getPorts() as $portRange) {
+ list($startPort, $endPort) = $portRange;
+ foreach (range($startPort, $endPort) as $port) {
+ foreach ($this->snimap[$ip] ?? [null] as $hostname) {
+ if (array_key_exists((string) $hostname, $excludes)) {
+ Logger::debug('Excluding host %s from scan', $hostname);
+ continue;
+ }
+
+ if (! $this->fullScan) {
+ $targets = X509Target::on($this->db)
+ ->columns([new Expression('1')])
+ ->filter(
+ Filter::all(
+ Filter::equal('ip', $ip),
+ Filter::equal('port', $port),
+ $hostname !== null
+ ? Filter::equal('hostname', $hostname)
+ : Filter::unlike('hostname', '*')
+ )
+ );
+
+ if ($targets->execute()->hasResult()) {
+ continue;
+ }
+ }
+
+ yield (object) [
+ 'ip' => $ip,
+ 'port' => $port,
+ 'hostname' => $hostname
+ ];
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public function updateJobStats(bool $finished = false): void
+ {
+ $fields = ['finished_targets' => $this->finishedTargets];
+ if ($finished) {
+ $fields['end_time'] = new Expression('UNIX_TIMESTAMP() * 1000');
+ $fields['total_targets'] = $this->totalTargets;
+ }
+
+ $this->db->update('x509_job_run', $fields, ['id = ?' => $this->jobRunId]);
+ }
+
+ private static function formatTarget($target): string
+ {
+ $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->isFinished()) {
+ // No targets to process anymore, so we can now resolve the promise
+ $this->deferred->resolve($this->finishedTargets);
+
+ return;
+ }
+
+ if (! $this->targets->valid()) {
+ // When nothing is yielded, and it's still not finished yet, just get the next target
+ return;
+ }
+
+ $target = $this->targets->current();
+ $this->targets->next();
+
+ $this->totalTargets++;
+ $this->pendingTargets++;
+
+ $url = "tls://[{$target->ip}]:{$target->port}";
+ Logger::debug("Connecting to %s", self::formatTarget($target));
+
+ /** @var ConnectorInterface $connector */
+ /** @var StreamOptsCaptureConnector $streamCapture */
+ list($connector, $streamCapture) = $this->getConnector($target->hostname);
+ $connector->connect($url)->then(
+ function (ConnectionInterface $conn) use ($target, $streamCapture) {
+ Logger::info("Connected to %s", self::formatTarget($target));
+
+ // Close connection in order to capture stream context options
+ $conn->close();
+
+ $capturedStreamOptions = $streamCapture->getCapturedStreamOptions();
+
+ $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']);
+
+ $this->finishTarget();
+ },
+ function (Exception $exception) use ($target, $streamCapture) {
+ Logger::debug("Cannot connect to server: %s", $exception->getMessage());
+
+ $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,
+ 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ],
+ [
+ 'hostname = ?' => $target->hostname,
+ 'ip = ?' => $this->dbTool->marshalBinary(static::binary($target->ip)),
+ 'port = ?' => $target->port
+ ]
+ );
+ }
+
+ $step = max($this->totalTargets / 100, 1);
+
+ if ($this->finishedTargets % (int) $step == 0) {
+ $this->updateJobStats();
+ }
+
+ $this->finishTarget();
+ }
+ )->always(function () use ($target) {
+ $this->updateLastScan($target);
+ })->otherwise(function (Throwable $e) {
+ Logger::error($e->getMessage());
+ Logger::error($e->getTraceAsString());
+ });
+ }
+
+ public function run(): Promise\ExtendedPromiseInterface
+ {
+ $this->jobRunStart = new DateTime();
+ // Update the job statistics regardless of whether the job was successful, failed, or canceled.
+ // Otherwise, some database columns might remain null.
+ $updateJobStats = function () {
+ $this->updateJobStats(true);
+ };
+ $this->deferred = new Promise\Deferred($updateJobStats);
+ $this->deferred->promise()->always($updateJobStats);
+
+ Loop::futureTick(function () {
+ if (! $this->db->ping()) {
+ $this->deferred->reject(new LogicException('Lost connection to database and failed to reconnect'));
+
+ return;
+ }
+
+ // Reset those statistics for the next run! Is only necessary when
+ // running this job using the scheduler
+ $this->totalTargets = 0;
+ $this->finishedTargets = 0;
+ $this->pendingTargets = 0;
+
+ if ($this->schedule) {
+ $scheduleId = $this->getSchedule()->getId();
+ } else {
+ $scheduleId = new Expression('NULL');
+ }
+
+ $this->db->insert('x509_job_run', [
+ 'job_id' => $this->getId(),
+ 'schedule_id' => $scheduleId,
+ 'start_time' => $this->jobRunStart->getTimestamp() * 1000.0,
+ 'total_targets' => 0,
+ 'finished_targets' => 0
+ ]);
+ $this->jobRunId = (int) $this->db->lastInsertId();
+
+ $this->targets = $this->getScanTargets();
+
+ if ($this->isFinished()) {
+ // There are no targets to scan, so we can resolve the promise earlier
+ $this->deferred->resolve(0);
+
+ return;
+ }
+
+ // Start scanning the first couple of targets...
+ for ($i = 0; $i < $this->getParallel() && ! $this->isFinished(); $i++) {
+ $this->startNextTarget();
+ }
+ });
+
+ /** @var Promise\ExtendedPromiseInterface $promise */
+ $promise = $this->deferred->promise();
+ return $promise;
+ }
+
+ 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 = X509Target::on($this->db)
+ ->columns(['id'])
+ ->filter(
+ Filter::all(
+ Filter::equal('ip', $target->ip),
+ Filter::equal('port', $target->port),
+ Filter::equal('hostname', $target->hostname)
+ )
+ )->first();
+
+ if (! $row) {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $this->db->insert(
+ 'x509_target',
+ [
+ 'ip' => $this->dbTool->marshalBinary(static::binary($target->ip)),
+ 'port' => $target->port,
+ 'hostname' => $target->hostname,
+ 'last_scan' => new Expression('UNIX_TIMESTAMP() * 1000'),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ $targetId = $this->db->lastInsertId();
+ } else {
+ $targetId = $row->id;
+ }
+
+ $chainUptodate = false;
+
+ $lastChain = X509CertificateChain::on($this->db)
+ ->columns(['id'])
+ ->filter(Filter::equal('target_id', $targetId))
+ ->orderBy('id', SORT_DESC)
+ ->limit(1)
+ ->first();
+
+ if ($lastChain) {
+ $lastFingerprints = X509Certificate::on($this->db)->utilize('chain');
+ $lastFingerprints
+ ->columns(['fingerprint'])
+ ->getSelectBase()
+ ->where(new Expression(
+ 'certificate_link.certificate_chain_id = %d',
+ [$lastChain->id]
+ ))
+ ->orderBy('certificate_link.order');
+
+ $lastFingerprintsArr = [];
+ foreach ($lastFingerprints as $lastFingerprint) {
+ $lastFingerprintsArr[] = $lastFingerprint->fingerprint;
+ }
+
+ $currentFingerprints = [];
+
+ foreach ($chain as $cert) {
+ $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true);
+ }
+
+ $chainUptodate = $currentFingerprints === $lastFingerprintsArr;
+ }
+
+ if ($lastChain && $chainUptodate) {
+ $chainId = $lastChain->id;
+ } else {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $this->db->insert(
+ 'x509_certificate_chain',
+ [
+ 'target_id' => $targetId,
+ 'length' => count($chain),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $chainId = $this->db->lastInsertId();
+
+ $lastCertInfo = [];
+ foreach ($chain as $index => $cert) {
+ $lastCertInfo = CertificateUtils::findOrInsertCert($this->db, $cert);
+ list($certId, $_) = $lastCertInfo;
+
+ $this->db->insert(
+ 'x509_certificate_chain_link',
+ [
+ 'certificate_chain_id' => $chainId,
+ $this->db->quoteIdentifier('order') => $index,
+ 'certificate_id' => $certId,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $lastCertInfo[] = $index;
+ }
+
+ // There might be chains that do not include the self-signed top-level Ca,
+ // so we need to include it manually here, as we need to display the full
+ // chain in the UI.
+ $rootCa = X509Certificate::on($this->db)
+ ->columns(['id'])
+ ->filter(Filter::equal('subject_hash', $lastCertInfo[1]))
+ ->filter(Filter::equal('self_signed', true))
+ ->first();
+
+ if ($rootCa && $rootCa->id !== $lastCertInfo[0]) {
+ $this->db->update(
+ 'x509_certificate_chain',
+ ['length' => count($chain) + 1],
+ ['id = ?' => $chainId]
+ );
+
+ $this->db->insert(
+ 'x509_certificate_chain_link',
+ [
+ 'certificate_chain_id' => $chainId,
+ $this->db->quoteIdentifier('order') => $lastCertInfo[2] + 1,
+ 'certificate_id' => $rootCa->id,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ }
+ }
+
+ $this->db->update(
+ 'x509_target',
+ [
+ 'latest_certificate_chain_id' => $chainId,
+ 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ],
+ ['id = ?' => $targetId]
+ );
+ });
+ }
+}
diff --git a/library/X509/Model/Behavior/DERBase64.php b/library/X509/Model/Behavior/DERBase64.php
new file mode 100644
index 0000000..f7b7215
--- /dev/null
+++ b/library/X509/Model/Behavior/DERBase64.php
@@ -0,0 +1,44 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+/**
+ * Support automatically transformation of DER-encoded certificates to PEM and vice versa.
+ */
+class DERBase64 extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ if (! $value) {
+ return null;
+ }
+
+ $block = chunk_split(base64_encode($value), 64, "\n");
+
+ return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----";
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if (! $value) {
+ return null;
+ }
+
+ $lines = explode("\n", $value);
+ $der = '';
+
+ foreach ($lines as $line) {
+ if (strpos($line, '-----') === 0) {
+ continue;
+ }
+
+ $der .= base64_decode($line);
+ }
+
+ return $der;
+ }
+}
diff --git a/library/X509/Model/Behavior/ExpressionInjector.php b/library/X509/Model/Behavior/ExpressionInjector.php
new file mode 100644
index 0000000..c3fa2cb
--- /dev/null
+++ b/library/X509/Model/Behavior/ExpressionInjector.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model\Behavior;
+
+use ipl\Orm\Contract\QueryAwareBehavior;
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Orm\Query;
+use ipl\Sql\ExpressionInterface;
+use ipl\Stdlib\Filter;
+
+/**
+ * Support expression columns (which don't really exist in the database, but rather
+ * resulted e.g. from a `case..when` expression), being used as filter columns
+ */
+class ExpressionInjector implements RewriteFilterBehavior, QueryAwareBehavior
+{
+ /** @var array */
+ protected $columns;
+
+ /** @var Query */
+ protected $query;
+
+ public function __construct(...$columns)
+ {
+ $this->columns = $columns;
+ }
+
+ public function setQuery(Query $query)
+ {
+ $this->query = $query;
+
+ return $this;
+ }
+
+ public function rewriteCondition(Filter\Condition $condition, $relation = null)
+ {
+ $columnName = $condition->metaData()->get('columnName');
+ if (in_array($columnName, $this->columns, true)) {
+ $relationPath = $condition->metaData()->get('relationPath');
+ if ($relationPath && $relationPath !== $this->query->getModel()->getTableAlias()) {
+ $subject = $this->query->getResolver()->resolveRelation($relationPath)->getTarget();
+ } else {
+ $subject = $this->query->getModel();
+ }
+
+ /** @var ExpressionInterface $column */
+ $column = $subject->getColumns()[$columnName];
+ $expression = clone $column;
+ $expression->setColumns($this->query->getResolver()->qualifyColumns(
+ $this->query->getResolver()->requireAndResolveColumns(
+ $expression->getColumns(),
+ $subject
+ ),
+ $subject
+ ));
+
+ $condition->setColumn($this->query->getDb()->getQueryBuilder()->buildExpression($expression));
+ }
+ }
+}
diff --git a/library/X509/Model/Behavior/Ip.php b/library/X509/Model/Behavior/Ip.php
new file mode 100644
index 0000000..79c9e80
--- /dev/null
+++ b/library/X509/Model/Behavior/Ip.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model\Behavior;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Contract\PropertyBehavior;
+
+/**
+ * Support automatically transformation of human-readable IP addresses into their respective packed
+ * binary representation and vice versa.
+ */
+class Ip extends Binary
+{
+ public function fromDb($value, $key, $_)
+ {
+ $value = parent::fromDb($value, $key, $_);
+ if ($value === null) {
+ return null;
+ }
+
+ $ipv4 = ltrim($value, "\0");
+ if (strlen($ipv4) === 4) {
+ $value = $ipv4;
+ }
+
+ return inet_ntop($value);
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if ($value === null || $value === '*' || ! ctype_print($value)) {
+ return $value;
+ }
+
+ return parent::toDb(str_pad(inet_pton($value), 16, "\0", STR_PAD_LEFT), $key, $_);
+ }
+}
diff --git a/library/X509/Model/Schema.php b/library/X509/Model/Schema.php
new file mode 100644
index 0000000..02ec0c0
--- /dev/null
+++ b/library/X509/Model/Schema.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+
+/**
+ * A database model for x509 schema version table
+ *
+ * @property int $id Unique identifier of the database schema entries
+ * @property string $version The current schema version of Icinga Web
+ * @property DateTime $timestamp The insert/modify time of the schema entry
+ * @property bool $success Whether the database migration of the current version was successful
+ * @property ?string $reason The reason why the database migration has failed
+ */
+class Schema extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_schema';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'version',
+ 'timestamp',
+ 'success',
+ 'reason'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new BoolCast(['success']));
+ $behaviors->add(new MillisecondTimestamp(['timestamp']));
+ }
+}
diff --git a/library/X509/Model/X509Certificate.php b/library/X509/Model/X509Certificate.php
new file mode 100644
index 0000000..63bdf95
--- /dev/null
+++ b/library/X509/Model/X509Certificate.php
@@ -0,0 +1,159 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use Icinga\Module\X509\Model\Behavior\DERBase64;
+use Icinga\Module\X509\Model\Behavior\ExpressionInjector;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+use ipl\Sql\Expression;
+
+class X509Certificate extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'certificate';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'subject',
+ 'subject_hash',
+ 'issuer',
+ 'issuer_hash',
+ 'issuer_certificate_id',
+ 'version',
+ 'self_signed',
+ 'ca',
+ 'trusted',
+ 'pubkey_algo',
+ 'pubkey_bits',
+ 'signature_algo',
+ 'signature_hash_algo',
+ 'valid_from',
+ 'valid_to',
+ 'fingerprint',
+ 'serial',
+ 'certificate',
+ 'ctime',
+ 'mtime',
+ 'duration' => new Expression('%s - %s', ['valid_to', 'valid_from'])
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'subject' => t('Certificate'),
+ 'issuer' => t('Issuer'),
+ 'version' => t('Version'),
+ 'self_signed' => t('Is Self-Signed'),
+ 'ca' => t('Is Certificate Authority'),
+ 'trusted' => t('Is Trusted'),
+ 'pubkey_algo' => t('Public Key Algorithm'),
+ 'pubkey_bits' => t('Public Key Strength'),
+ 'signature_algo' => t('Signature Algorithm'),
+ 'signature_hash_algo' => t('Signature Hash Algorithm'),
+ 'valid_from' => t('Valid From'),
+ 'valid_to' => t('Valid To'),
+ 'duration' => t('Duration'),
+ 'subject_hash' => t('Subject Hash'),
+ 'issuer_hash' => t('Issuer Hash'),
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['subject', 'issuer'];
+ }
+
+ /**
+ * Get list of allowed columns to be exported
+ *
+ * @return string[]
+ */
+ public function getExportableColumns(): array
+ {
+ return [
+ 'id',
+ 'subject',
+ 'issuer',
+ 'version',
+ 'self_signed',
+ 'ca',
+ 'trusted',
+ 'pubkey_algo',
+ 'pubkey_bits',
+ 'signature_algo',
+ 'signature_hash_algo',
+ 'valid_from',
+ 'valid_to'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'subject_hash',
+ 'issuer_hash',
+ 'fingerprint',
+ 'serial',
+ 'certificate'
+ ]));
+
+ $behaviors->add(new DERBase64(['certificate']));
+
+ $behaviors->add(new BoolCast([
+ 'ca',
+ 'trusted',
+ 'self_signed'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'valid_from',
+ 'valid_to',
+ 'ctime',
+ 'mtime',
+ 'duration'
+ ]));
+
+ $behaviors->add(new ExpressionInjector('duration'));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('issuer_certificate', static::class)
+ ->setForeignKey('subject_hash')
+ ->setCandidateKey('issuer_hash');
+ $relations->belongsToMany('chain', X509CertificateChain::class)
+ ->through(X509CertificateChainLink::class)
+ ->setForeignKey('certificate_id');
+
+ $relations->hasMany('certificate', static::class)
+ ->setForeignKey('issuer_hash')
+ ->setCandidateKey('subject_hash');
+ $relations->hasMany('alt_name', X509CertificateSubjectAltName::class)
+ ->setJoinType('LEFT');
+ $relations->hasMany('dn', X509Dn::class)
+ ->setForeignKey('hash')
+ ->setCandidateKey('subject_hash')
+ ->setJoinType('LEFT');
+ }
+}
diff --git a/library/X509/Model/X509CertificateChain.php b/library/X509/Model/X509CertificateChain.php
new file mode 100644
index 0000000..189c38d
--- /dev/null
+++ b/library/X509/Model/X509CertificateChain.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509CertificateChain extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate_chain';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'chain';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'target_id',
+ 'length',
+ 'valid',
+ 'invalid_reason',
+ 'ctime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast(['valid']));
+
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('target', X509Target::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('latest_certificate_chain_id');
+
+ $relations->belongsToMany('certificate', X509Certificate::class)
+ ->through(X509CertificateChainLink::class)
+ ->setForeignKey('certificate_chain_id');
+ }
+}
diff --git a/library/X509/Model/X509CertificateChainLink.php b/library/X509/Model/X509CertificateChainLink.php
new file mode 100644
index 0000000..d093793
--- /dev/null
+++ b/library/X509/Model/X509CertificateChainLink.php
@@ -0,0 +1,46 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509CertificateChainLink extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate_chain_link';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'link';
+ }
+
+ public function getKeyName()
+ {
+ return ['certificate_chain_id', 'certificate_id', 'order'];
+ }
+
+ public function getColumns()
+ {
+ return ['ctime'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('certificate', X509Certificate::class)
+ ->setCandidateKey('certificate_id');
+ $relations->belongsTo('chain', X509CertificateChain::class)
+ ->setCandidateKey('certificate_chain_id');
+ }
+}
diff --git a/library/X509/Model/X509CertificateSubjectAltName.php b/library/X509/Model/X509CertificateSubjectAltName.php
new file mode 100644
index 0000000..62aac5c
--- /dev/null
+++ b/library/X509/Model/X509CertificateSubjectAltName.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509CertificateSubjectAltName extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate_subject_alt_name';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'alt_name';
+ }
+
+ public function getKeyName()
+ {
+ return ['certificate_id', 'hash'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'type',
+ 'value',
+ 'ctime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary(['hash']));
+
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('certificate', X509Certificate::class);
+ }
+}
diff --git a/library/X509/Model/X509Dn.php b/library/X509/Model/X509Dn.php
new file mode 100644
index 0000000..fa0406f
--- /dev/null
+++ b/library/X509/Model/X509Dn.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509Dn extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_dn';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'dn';
+ }
+
+ public function getKeyName()
+ {
+ return ['hash', 'type', 'order'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'key',
+ 'value',
+ 'ctime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary(['hash']));
+
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('certificate', X509Certificate::class)
+ ->setForeignKey('subject_hash');
+ }
+}
diff --git a/library/X509/Model/X509Job.php b/library/X509/Model/X509Job.php
new file mode 100644
index 0000000..1b3a855
--- /dev/null
+++ b/library/X509/Model/X509Job.php
@@ -0,0 +1,73 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\Relations;
+
+/**
+ * A database model for all x509 jobs
+ *
+ * @property int $id Unique identifier of this job
+ * @property string $name The name of this job
+ * @property string $author The author of this job
+ * @property string $cidrs The configured cidrs of this job
+ * @property string $ports The configured ports of this job
+ * @property ?string $exclude_targets The configured excluded targets of this job
+ * @property DateTime $ctime The creation time of this job
+ * @property DateTime $mtime The modification time of this job
+ * @property Query|X509Schedule $schedule The configured schedules of this job
+ * @property Query|X509JobRun $job_run Job activities
+ */
+class X509Job extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_job';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'job';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'name',
+ 'author',
+ 'cidrs',
+ 'ports',
+ 'exclude_targets',
+ 'ctime',
+ 'mtime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'ctime',
+ 'mtime'
+ ]));
+ }
+
+ public function createRelations(Relations $relations): void
+ {
+ $relations->hasMany('schedule', X509Schedule::class)
+ ->setForeignKey('job_id');
+ $relations->hasMany('job_run', X509JobRun::class)
+ ->setForeignKey('job_id');
+ }
+}
diff --git a/library/X509/Model/X509JobRun.php b/library/X509/Model/X509JobRun.php
new file mode 100644
index 0000000..d776622
--- /dev/null
+++ b/library/X509/Model/X509JobRun.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\Relations;
+
+/**
+ * A database model for all x509 job schedules
+ *
+ * @property int $id Unique identifier of this job
+ * @property ?int $job_id The id of the x509 job this job run belongs to
+ * @property ?int $schedule_id The id of the x509 job schedule this run belongs to
+ * @property int $total_targets All the x509 targets found by this job run
+ * @property int $finished_targets All the x509 targets scanned by this job run
+ * @property DateTime $start_time The start time of this job run
+ * @property DateTime $end_time The end time of this job run
+ * @property Query|X509Job $job The x509 job this job run belongs to
+ * @property Query|X509Schedule $schedule The x509 job schedule this job run belongs to
+ */
+class X509JobRun extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_job_run';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'job_run';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'job_id',
+ 'schedule_id',
+ 'total_targets',
+ 'finished_targets',
+ 'start_time',
+ 'end_time'
+ ];
+ }
+
+ public function getDefaultSort(): string
+ {
+ return 'start_time desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'start_time',
+ 'end_time',
+ ]));
+ }
+
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('job', X509Job::class)
+ ->setCandidateKey('job_id');
+ $relations->belongsTo('schedule', X509Schedule::class)
+ ->setJoinType('LEFT')
+ ->setCandidateKey('schedule_id');
+ }
+}
diff --git a/library/X509/Model/X509Schedule.php b/library/X509/Model/X509Schedule.php
new file mode 100644
index 0000000..476641a
--- /dev/null
+++ b/library/X509/Model/X509Schedule.php
@@ -0,0 +1,70 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * A database model for all x509 job schedules
+ *
+ * @property int $id Unique identifier of this job
+ * @property int $job_id The id of the x509 job this schedule belongs to
+ * @property string $name The name of this job schedule
+ * @property string $author The author of this job schedule
+ * @property string $config The config of this job schedule
+ * @property DateTime $ctime The creation time of this job
+ * @property DateTime $mtime The modification time of this job
+ * @property X509Job $job The x509 job this schedule belongs to
+ * @property X509JobRun $job_run Schedule activities
+ */
+class X509Schedule extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_schedule';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'schedule';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'job_id',
+ 'name',
+ 'author',
+ 'config',
+ 'ctime',
+ 'mtime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'ctime',
+ 'mtime'
+ ]));
+ }
+
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('job', X509Job::class)
+ ->setCandidateKey('job_id');
+ $relations->hasMany('job_run', X509JobRun::class)
+ ->setForeignKey('schedule_id');
+ }
+}
diff --git a/library/X509/Model/X509Target.php b/library/X509/Model/X509Target.php
new file mode 100644
index 0000000..7705d57
--- /dev/null
+++ b/library/X509/Model/X509Target.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use Icinga\Module\X509\Model\Behavior\Ip;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509Target extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_target';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'target';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'ip',
+ 'port',
+ 'hostname',
+ 'latest_certificate_chain_id',
+ 'last_scan',
+ 'ctime',
+ 'mtime'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'hostname' => t('Host Name'),
+ 'ip' => t('IP'),
+ 'port' => t('Port')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['hostname'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Ip(['ip']));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'ctime',
+ 'mtime',
+ 'last_scan'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('chain', X509CertificateChain::class)
+ ->setCandidateKey('latest_certificate_chain_id');
+ }
+}
diff --git a/library/X509/ProvidedHook/DbMigration.php b/library/X509/ProvidedHook/DbMigration.php
new file mode 100644
index 0000000..8314e3c
--- /dev/null
+++ b/library/X509/ProvidedHook/DbMigration.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\Schema;
+use ipl\Orm\Query;
+use ipl\Sql;
+use ipl\Sql\Adapter\Pgsql;
+
+class DbMigration extends DbMigrationHook
+{
+ public function getName(): string
+ {
+ return $this->translate('Icinga Certificate Monitoring');
+ }
+
+ public function providedDescriptions(): array
+ {
+ return [
+ '1.0.0' => $this->translate(
+ 'Adjusts the database type of several columns and changes some composed primary keys.'
+ ),
+ '1.1.0' => $this->translate(
+ 'Changes the composed x509_target index and x509_certificate valid from/to types to bigint.'
+ ),
+ '1.2.0' => $this->translate(
+ 'Changes all timestamp columns to bigint and adjusts enum types of "yes/no" to "n/y".'
+ ),
+ '1.3.0' => $this->translate(
+ 'Introduces the required tables to store jobs and job schedules in the database.'
+ )
+ ];
+ }
+
+ public function getVersion(): string
+ {
+ if ($this->version === null) {
+ $conn = $this->getDb();
+ $schema = $this->getSchemaQuery()
+ ->columns(['version', 'success'])
+ ->orderBy('id', SORT_DESC)
+ ->limit(2);
+
+ if (static::tableExists($conn, $schema->getModel()->getTableName())) {
+ /** @var Schema $version */
+ foreach ($schema as $version) {
+ if ($version->success) {
+ $this->version = $version->version;
+
+ break;
+ }
+ }
+
+ if (! $this->version) {
+ // Schema version table exist, but the user has probably deleted the entry!
+ $this->version = '1.3.0';
+ }
+ } elseif (
+ $this->getDb()->getAdapter() instanceof Pgsql
+ || static::getColumnType($conn, 'x509_certificate', 'ctime') === 'bigint(20) unsigned'
+ ) {
+ // We modified a bunch of timestamp columns to bigint in x509 version 1.2.0.
+ // We have also added Postgres support with x509 version 1.2 and never had an upgrade scripts until now.
+ $this->version = '1.2.0';
+ } elseif (static::getColumnType($conn, 'x509_certificate_subject_alt_name', 'hash') !== null) {
+ if (static::getColumnType($conn, 'x509_certificate', 'valid_from') === 'bigint(20) unsigned') {
+ $this->version = '1.0.0';
+ } else {
+ $this->version = '1.1.0';
+ }
+ } else {
+ // X509 version 1.0 was the first release of this module, but due to some reason it also contains
+ // an upgrade script and adds `hash` column. However, if this column doesn't exist yet, we need
+ // to use the lowest possible release value as the initial (last migrated) version.
+ $this->version = '0.0.0';
+ }
+ }
+
+ return $this->version;
+ }
+
+ public function getDb(): Sql\Connection
+ {
+ return Database::get();
+ }
+
+ protected function getSchemaQuery(): Query
+ {
+ return Schema::on($this->getDb());
+ }
+}
diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php
new file mode 100644
index 0000000..70d584c
--- /dev/null
+++ b/library/X509/ProvidedHook/HostsImportSource.php
@@ -0,0 +1,91 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Sql;
+
+class HostsImportSource extends X509ImportSource
+{
+ public function fetchData()
+ {
+ $conn = Database::get();
+ $targets = X509Target::on($conn)
+ ->utilize('chain')
+ ->utilize('chain.certificate')
+ ->columns([
+ 'ip',
+ 'host_name' => 'hostname'
+ ]);
+
+ $targets
+ ->getSelectBase()
+ ->where(new Sql\Expression('target_chain_link.order = 0'))
+ ->groupBy(['ip', 'hostname']);
+
+ if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) {
+ $targets->withColumns([
+ 'host_ports' => new Sql\Expression("ARRAY_TO_STRING(ARRAY_AGG(DISTINCT port), ',')")
+ ]);
+ } else {
+ $targets->withColumns([
+ 'host_ports' => new Sql\Expression("GROUP_CONCAT(DISTINCT port SEPARATOR ',')")
+ ]);
+ }
+
+ $results = [];
+ $foundDupes = [];
+ /** @var X509Target $target */
+ foreach ($targets as $target) {
+ $isV6 = Job::isIPV6($target->ip);
+ $target->host_ip = $target->ip;
+ $target->host_address = $isV6 ? null : $target->ip;
+ $target->host_address6 = $isV6 ? $target->ip : null;
+
+ 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;
+ }
+
+ // Target ip is now obsolete and must not be included in the results.
+ // The relation is only used to utilize the query and must not be in the result set as well.
+ unset($target->ip);
+ unset($target->chain);
+
+ $results[$target->host_name_or_ip] = (object) iterator_to_array($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..7b87cd8
--- /dev/null
+++ b/library/X509/ProvidedHook/ServicesImportSource.php
@@ -0,0 +1,143 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509CertificateSubjectAltName;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Sql;
+
+class ServicesImportSource extends X509ImportSource
+{
+ public function fetchData()
+ {
+ $conn = Database::get();
+ $targets = X509Target::on($conn)
+ ->with([
+ 'chain',
+ 'chain.certificate',
+ 'chain.certificate.dn',
+ 'chain.certificate.issuer_certificate'
+ ])
+ ->columns([
+ 'ip',
+ 'host_name' => 'hostname',
+ 'host_port' => 'port',
+ 'cert_subject' => 'chain.certificate.subject',
+ 'cert_issuer' => 'chain.certificate.issuer',
+ 'cert_trusted' => 'chain.certificate.trusted',
+ 'cert_valid_from' => 'chain.certificate.valid_from',
+ 'cert_valid_to' => 'chain.certificate.valid_to',
+ 'cert_self_signed' => new Sql\Expression('COALESCE(%s, %s)', [
+ 'chain.certificate.issuer_certificate.self_signed',
+ 'chain.certificate.self_signed'
+ ])
+ ]);
+
+ $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT');
+ $targets
+ ->getSelectBase()
+ ->where(new Sql\Expression('target_chain_link.order = 0'))
+ ->groupBy(['ip, hostname, port']);
+
+ $certAltName = X509CertificateSubjectAltName::on($conn);
+ $certAltName
+ ->getSelectBase()
+ ->where(new Sql\Expression('certificate_id = target_chain_certificate.id'))
+ ->groupBy(['alt_name.certificate_id']);
+
+ if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) {
+ $targets
+ ->withColumns([
+ 'cert_fingerprint' => new Sql\Expression("ENCODE(%s, 'hex')", [
+ 'chain.certificate.fingerprint'
+ ]),
+ 'cert_dn' => new Sql\Expression(
+ "ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, '=', %s)), ',')",
+ [
+ 'chain.certificate.dn.key',
+ 'chain.certificate.dn.value'
+ ]
+ )
+ ])
+ ->getSelectBase()
+ ->groupBy(['target_chain_certificate.id', 'target_chain_certificate_issuer_certificate.id']);
+
+ $certAltName->columns([
+ new Sql\Expression("ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, ':', %s)), ',')", ['type', 'value'])
+ ]);
+ } else {
+ $targets->withColumns([
+ 'cert_fingerprint' => new Sql\Expression('HEX(%s)', ['chain.certificate.fingerprint']),
+ 'cert_dn' => new Sql\Expression(
+ "GROUP_CONCAT(CONCAT(%s, '=', %s) SEPARATOR ',')",
+ [
+ 'chain.certificate.dn.key',
+ 'chain.certificate.dn.value'
+ ]
+ )
+ ]);
+
+ $certAltName->columns([
+ new Sql\Expression("GROUP_CONCAT(CONCAT(%s, ':', %s) SEPARATOR ',')", ['type', 'value'])
+ ]);
+ }
+
+ list($select, $values) = $certAltName->dump();
+ $targets->withColumns(['cert_subject_alt_name' => new Sql\Expression("$select", null, ...$values)]);
+
+ $results = [];
+ /** @var X509Target $target */
+ foreach ($targets as $target) {
+ $isV6 = Job::isIPV6($target->ip);
+ $target->host_ip = $target->ip;
+ $target->host_address = $isV6 ? null : $target->ip;
+ $target->host_address6 = $isV6 ? $target->ip : null;
+
+ $target->host_name_ip_and_port = sprintf(
+ '%s/%s:%d',
+ $target->host_name,
+ $target->host_ip,
+ $target->host_port
+ );
+
+ // Target ip is now obsolete and must not be included in the results.
+ // The relation is only used to utilize the query and must not be in the result set as well.
+ unset($target->ip);
+ unset($target->chain);
+
+ $results[$target->host_name_ip_and_port] = (object) iterator_to_array($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..dc280c0
--- /dev/null
+++ b/library/X509/ProvidedHook/X509ImportSource.php
@@ -0,0 +1,11 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Module\Director\Hook\ImportSourceHook;
+
+abstract class X509ImportSource extends ImportSourceHook
+{
+}
diff --git a/library/X509/React/StreamOptsCaptureConnector.php b/library/X509/React/StreamOptsCaptureConnector.php
new file mode 100644
index 0000000..56a44e4
--- /dev/null
+++ b/library/X509/React/StreamOptsCaptureConnector.php
@@ -0,0 +1,60 @@
+<?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/Schedule.php b/library/X509/Schedule.php
new file mode 100644
index 0000000..3f80932
--- /dev/null
+++ b/library/X509/Schedule.php
@@ -0,0 +1,125 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\Util\Json;
+use stdClass;
+
+class Schedule
+{
+ /** @var int The database id of this schedule */
+ protected $id;
+
+ /** @var string The name of this job schedule */
+ protected $name;
+
+ /** @var object The config of this schedule */
+ protected $config;
+
+ public function __construct(string $name, int $id, object $config)
+ {
+ $this->id = $id;
+ $this->name = $name;
+ $this->config = $config;
+ }
+
+ public static function fromModel(X509Schedule $schedule): self
+ {
+ /** @var stdClass $config */
+ $config = Json::decode($schedule->config);
+ if (isset($config->rescan)) {
+ $config->rescan = $config->rescan === 'y';
+ }
+
+ if (isset($config->full_scan)) {
+ $config->full_scan = $config->full_scan === 'y';
+ }
+
+ return new static($schedule->name, $schedule->id, $config);
+ }
+
+ /**
+ * Get the name of this schedule
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of this schedule
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the database id of this job
+ *
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set the database id of this job
+ *
+ * @param int $id
+ *
+ * @return $this
+ */
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ /**
+ * Get the config of this schedule
+ *
+ * @return object
+ */
+ public function getConfig(): object
+ {
+ return $this->config;
+ }
+
+ /**
+ * Set the config of this schedule
+ *
+ * @param object $config
+ *
+ * @return $this
+ */
+ public function setConfig(object $config): self
+ {
+ $this->config = $config;
+
+ return $this;
+ }
+
+ /**
+ * Get the checksum of this schedule
+ *
+ * @return string
+ */
+ public function getChecksum(): string
+ {
+ return md5($this->getName() . Json::encode($this->getConfig()), true);
+ }
+}
diff --git a/library/X509/SniIniRepository.php b/library/X509/SniIniRepository.php
new file mode 100644
index 0000000..432494b
--- /dev/null
+++ b/library/X509/SniIniRepository.php
@@ -0,0 +1,21 @@
+<?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/Table.php b/library/X509/Table.php
new file mode 100644
index 0000000..00fe6cf
--- /dev/null
+++ b/library/X509/Table.php
@@ -0,0 +1,39 @@
+<?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..109e5ee
--- /dev/null
+++ b/library/X509/UsageTable.php
@@ -0,0 +1,91 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Web\Widget\Icon;
+
+/**
+ * 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'],
+ 'column' => function ($data) {
+ return $data->chain->valid;
+ },
+ 'renderer' => function ($valid) {
+ return new Icon($valid ? 'circle-check' : 'ban', ['class' => $valid ? '-ok' : '-critical']);
+ }
+ ],
+
+ 'hostname' => [
+ 'label' => mt('x509', 'Hostname'),
+ 'column' => function ($data) {
+ return $data->chain->target->hostname;
+ }
+ ],
+
+ 'ip' => [
+ 'label' => mt('x509', 'IP'),
+ 'column' => function ($data) {
+ return $data->chain->target->ip;
+ },
+ ],
+
+ 'port' => [
+ 'label' => mt('x509', 'Port'),
+ 'column' => function ($data) {
+ return $data->chain->target->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(X509Certificate $row)
+ {
+ $tr = parent::renderRow($row);
+
+ $url = Url::fromPath('x509/chain', ['id' => $row->chain->id]);
+
+ $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]);
+
+ return $tr;
+ }
+}
diff --git a/library/X509/Web/Control/SearchBar/ObjectSuggestions.php b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php
new file mode 100644
index 0000000..ca9630f
--- /dev/null
+++ b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Icinga\Module\X509\Web\Control\SearchBar;
+
+use Exception;
+use Icinga\Module\X509\Common\Database;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Model;
+use ipl\Orm\Relation;
+use ipl\Orm\Relation\HasOne;
+use ipl\Orm\Resolver;
+use ipl\Orm\UnionModel;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Seq;
+use ipl\Stdlib\Str;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Control\SearchBar\Suggestions;
+
+class ObjectSuggestions extends Suggestions
+{
+ /** @var Model */
+ protected $model;
+
+ /**
+ * Set the model to show suggestions for
+ *
+ * @param string|Model $model
+ *
+ * @return $this
+ */
+ public function setModel($model): self
+ {
+ if (is_string($model)) {
+ /** @var Model $model */
+ $model = new $model();
+ }
+
+ $this->model = $model;
+
+ return $this;
+ }
+
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ $columns = Str::trimSplit($column, '.');
+
+ switch (count($columns)) {
+ case 2:
+ return $columns[0] !== $this->model->getTableAlias();
+ default:
+ return true;
+ }
+ }
+
+ protected function createQuickSearchFilter($searchTerm)
+ {
+ $model = $this->model;
+ $resolver = $model::on(Database::get())->getResolver();
+
+ $quickFilter = Filter::any();
+ foreach ($model->getSearchColumns() as $column) {
+ $where = Filter::like($resolver->qualifyColumn($column, $model->getTableAlias()), $searchTerm);
+ $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel());
+ $quickFilter->add($where);
+ }
+
+ return $quickFilter;
+ }
+
+ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
+ {
+ $model = $this->model;
+ $query = $model::on(Database::get());
+ $query->limit(static::DEFAULT_LIMIT);
+
+ if (strpos($column, ' ') !== false) {
+ // Searching for `Host Name` and continue typing without accepting/clicking the suggested
+ // column name will cause the search bar to use a label as a filter column
+ list($path, $_) = Seq::find(
+ self::collectFilterColumns($query->getModel(), $query->getResolver()),
+ $column,
+ false
+ );
+ if ($path !== null) {
+ $column = $path;
+ }
+ }
+
+ $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableAlias());
+ $inputFilter = Filter::like($columnPath, $searchTerm);
+
+ $query->columns($columnPath);
+ $query->orderBy($columnPath);
+
+ if ($searchFilter instanceof Filter\None) {
+ $query->filter($inputFilter);
+ } elseif ($searchFilter instanceof Filter\All) {
+ $searchFilter->add($inputFilter);
+
+ // When 10 hosts are sharing the same certificate, filtering in the search bar by
+ // `Host Name=foo&Host Name=` will suggest only `foo` for the second filter. So, we have
+ // to force the filter processor to optimize search bar filter
+ $searchFilter->metaData()->set('forceOptimization', true);
+ $inputFilter->metaData()->set('forceOptimization', false);
+ } else {
+ $searchFilter = $inputFilter;
+ }
+
+ $query->filter($searchFilter);
+ // Not to suggest something like Port=443,443,443....
+ $query->getSelectBase()->distinct();
+
+ try {
+ $steps = Str::trimSplit($column, '.');
+ $columnName = array_pop($steps);
+ if ($steps[0] === $model->getTableAlias()) {
+ array_shift($steps);
+ }
+
+ foreach ($query as $row) {
+ $model = $row;
+ foreach ($steps as $step) {
+ try {
+ $model = $model->$step;
+ } catch (Exception $_) {
+ // pass
+ break;
+ }
+ }
+
+ $value = $model->$columnName;
+ if ($value && is_string($value) && ! ctype_print($value)) { // Is binary
+ $value = bin2hex($value);
+ } elseif ($value === false || $value === true) {
+ // TODO: The search bar is never going to suggest boolean types, so this
+ // is a hack to workaround this limitation!!
+ $value = $value ? 'y' : 'n';
+ }
+
+ yield $value;
+ }
+ } catch (InvalidColumnException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn()));
+ }
+ }
+
+ protected function fetchColumnSuggestions($searchTerm)
+ {
+ $model = $this->model;
+ $query = $model::on(Database::get());
+
+ yield from self::collectFilterColumns($model, $query->getResolver());
+ }
+
+ public static function collectFilterColumns(Model $model, Resolver $resolver)
+ {
+ if ($model instanceof UnionModel) {
+ $models = [];
+ foreach ($model->getUnions() as $union) {
+ /** @var Model $unionModel */
+ $unionModel = new $union[0]();
+ $models[$unionModel->getTableAlias()] = $unionModel;
+ self::collectRelations($resolver, $unionModel, $models, []);
+ }
+ } else {
+ $models = [$model->getTableAlias() => $model];
+ self::collectRelations($resolver, $model, $models, []);
+ }
+
+ /** @var Model $targetModel */
+ foreach ($models as $path => $targetModel) {
+ foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
+ yield "$path.$columnName" => $definition->getLabel();
+ }
+ }
+ }
+
+ protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
+ {
+ foreach ($resolver->getRelations($subject) as $name => $relation) {
+ /** @var Relation $relation */
+ $isHasOne = $relation instanceof HasOne;
+ $relationPath = [$name];
+
+ if (! isset($models[$name]) && ! in_array($name, $path, true)) {
+ if ($isHasOne || empty($path)) {
+ array_unshift($relationPath, $subject->getTableAlias());
+ }
+
+ $relationPath = array_merge($path, $relationPath);
+ $targetPath = implode('.', $relationPath);
+
+ if (! isset($models[$targetPath])) {
+ $models[$targetPath] = $relation->getTarget();
+ self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
+ return;
+ }
+ } else {
+ $path = [];
+ }
+ }
+ }
+}
diff --git a/library/X509/Widget/JobDetails.php b/library/X509/Widget/JobDetails.php
new file mode 100644
index 0000000..c1e3843
--- /dev/null
+++ b/library/X509/Widget/JobDetails.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Widget;
+
+use Icinga\Module\X509\Model\X509JobRun;
+use ipl\Html\Table;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Widget\EmptyStateBar;
+
+class JobDetails extends Table
+{
+ use Translation;
+
+ protected $defaultAttributes = ['class' => 'common-table'];
+
+ /** @var Query */
+ protected $runs;
+
+ public function __construct(Query $runs)
+ {
+ $this->runs = $runs;
+ }
+
+ protected function assemble(): void
+ {
+ /** @var X509JobRun $run */
+ foreach ($this->runs as $run) {
+ $row = static::tr();
+ $row->addHtml(
+ static::td($run->job->name),
+ static::td($run->schedule->name ?: $this->translate('N/A')),
+ static::td((string) $run->total_targets),
+ static::td((string) $run->finished_targets),
+ static::td($run->start_time->format('Y-m-d H:i')),
+ static::td($run->end_time ? $run->end_time->format('Y-m-d H:i') : 'N/A')
+ );
+
+ $this->addHtml($row);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar($this->translate('Job never run.')));
+ } else {
+ $row = static::tr();
+ $row->addHtml(
+ static::th($this->translate('Name')),
+ static::th($this->translate('Schedule Name')),
+ static::th($this->translate('Total')),
+ static::th($this->translate('Scanned')),
+ static::th($this->translate('Started')),
+ static::th($this->translate('Finished'))
+ );
+
+ $this->getHeader()->addHtml($row);
+ }
+ }
+}
diff --git a/library/X509/Widget/Jobs.php b/library/X509/Widget/Jobs.php
new file mode 100644
index 0000000..997e7ef
--- /dev/null
+++ b/library/X509/Widget/Jobs.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Widget;
+
+use Icinga\Module\X509\Common\Links;
+use Icinga\Module\X509\Model\X509Job;
+use ipl\Html\Table;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Widget\EmptyStateBar;
+use ipl\Web\Widget\Link;
+
+class Jobs extends Table
+{
+ use Translation;
+
+ /** @var Query */
+ protected $jobs;
+
+ protected $defaultAttributes = [
+ 'class' => 'common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ public function __construct(Query $jobs)
+ {
+ $this->jobs = $jobs;
+ }
+
+ protected function assemble(): void
+ {
+ $jobs = $this->jobs->execute();
+ if (! $jobs->hasResult()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar($this->translate('No jobs configured yet.')));
+
+ return;
+ }
+
+ $headers = static::tr();
+ $headers->addHtml(
+ static::th($this->translate('Name')),
+ static::th($this->translate('Author')),
+ static::th($this->translate('Date Created')),
+ static::th($this->translate('Date Modified'))
+ );
+ $this->getHeader()->addHtml($headers);
+
+ /** @var X509Job $job */
+ foreach ($jobs as $job) {
+ $row = static::tr();
+ $row->addHtml(
+ static::td(new Link($job->name, Links::job($job))),
+ static::td($job->author),
+ static::td($job->ctime->format('Y-m-d H:i')),
+ static::td($job->mtime->format('Y-m-d H:i'))
+ );
+
+ $this->addHtml($row);
+ }
+ }
+}
diff --git a/library/X509/Widget/Schedules.php b/library/X509/Widget/Schedules.php
new file mode 100644
index 0000000..9f37986
--- /dev/null
+++ b/library/X509/Widget/Schedules.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Widget;
+
+use Icinga\Module\X509\Common\Links;
+use Icinga\Module\X509\Model\X509Schedule;
+use ipl\Html\Table;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Widget\EmptyStateBar;
+use ipl\Web\Widget\Link;
+
+class Schedules extends Table
+{
+ use Translation;
+
+ protected $defaultAttributes = [
+ 'class' => 'common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ /** @var Query */
+ protected $schedules;
+
+ public function __construct(Query $schedules)
+ {
+ $this->schedules = $schedules;
+ }
+
+ protected function assemble(): void
+ {
+ /** @var X509Schedule $schedule */
+ foreach ($this->schedules as $schedule) {
+ $row = static::tr();
+ $row->addHtml(
+ static::td(new Link($schedule->name, Links::updateSchedule($schedule))),
+ static::td($schedule->author),
+ static::td($schedule->ctime->format('Y-m-d H:i')),
+ static::td($schedule->mtime->format('Y-m-d H:i'))
+ );
+
+ $this->addHtml($row);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar($this->translate('No job schedules.')));
+ } else {
+ $row = static::tr();
+ $row->addHtml(
+ static::th($this->translate('Name')),
+ static::th($this->translate('Author')),
+ static::th($this->translate('Date Created')),
+ static::th($this->translate('Date Modified'))
+ );
+ $this->getHeader()->addHtml($row);
+ }
+ }
+}