diff options
Diffstat (limited to '')
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(' '); + } + } 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(' ')); + $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); + } + } +} |