diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php index 857dc065..c6ec23ff 100644 --- a/application/clicommands/CheckCommand.php +++ b/application/clicommands/CheckCommand.php @@ -6,9 +6,11 @@ use Icinga\Application\Logger; use Icinga\Module\X509\Command; -use Icinga\Module\X509\DbTool; use Icinga\Module\X509\Job; -use ipl\Sql\Select; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509Target; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; class CheckCommand extends Command { @@ -66,42 +68,65 @@ public function hostAction() exit(3); } - $dbTool = new DbTool($this->getDb()); - $targets = (new Select()) - ->from('x509_target t') - ->columns([ - 't.port', - 'cc.valid', - 'cc.invalid_reason', - 'c.subject', - 'self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', - 'valid_from' => (new Select()) - ->from('x509_certificate_chain_link xccl') - ->columns('MAX(GREATEST(xc.valid_from, xci.valid_from))') - ->join('x509_certificate xc', 'xc.id = xccl.certificate_id') - ->join('x509_certificate xci', 'xci.subject_hash = xc.issuer_hash') - ->where('xccl.certificate_chain_id = cc.id'), - 'valid_to' => (new Select()) - ->from('x509_certificate_chain_link xccl') - ->columns('MIN(LEAST(xc.valid_to, xci.valid_to))') - ->join('x509_certificate xc', 'xc.id = xccl.certificate_id') - ->join('x509_certificate xci', 'xci.subject_hash = xc.issuer_hash') - ->where('xccl.certificate_chain_id = cc.id') + $conn = $this->getDb(); + $targets = X509Target::on($conn)->with([ + 'chain', + 'chain.certificate', + 'chain.certificate.issuer' + ]); + + $targets->getWith()['target.chain.certificate.issuer']->setJoinType('LEFT'); + + $targets->columns([ + 'port', + 'chain.valid', + 'chain.invalid_reason', + 'subject' => 'chain.certificate.subject', + 'self_signed' => new Expression('COALESCE(%s, %s)', [ + 'chain.certificate.issuer.self_signed', + 'chain.certificate.self_signed' ]) - ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') - ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') - ->join('x509_certificate c', 'c.id = ccl.certificate_id') - ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash') - ->where(['ccl.order = ?' => 0]); + ]); + + // Sub queries for (valid_from, valid_to) columns + $validFrom = X509Certificate::on($conn)->with(['chain', 'issuer']); + $validFrom->getResolver()->setAliasPrefix('sub_'); + $validFrom->columns([ + new Expression("MAX(GREATEST(%s, %s))", ['valid_from', 'issuer.valid_from']) + ]); + + $validFrom + ->getSelectBase() + ->where(new Expression( + 'sub_certificate_link.certificate_chain_id = target_chain.id' + )); + + $validTo = clone $validFrom; + $validTo->columns([ + new Expression('MIN(LEAST(%s, %s))', ['valid_to', 'issuer.valid_to']) + ]); + + list($validFromSelect, $validFromValues) = $validFrom->dump(); + list($validToSelect, $validToValues) = $validTo->dump(); + + $validFromAlias = 'valid_from'; + $validToAlias = 'valid_to'; + + $targets->withColumns([ + $validFromAlias => new Expression("$validFromSelect", null, ...$validFromValues), + $validToAlias => new Expression("$validToSelect", null, ...$validToValues) + ]); + + $targets->getSelectBase()->where(new Expression('target_chain_link.order = 0')); if ($ip !== null) { - $targets->where(['t.ip = ?' => $dbTool->marshalBinary(Job::binary($ip))]); + $targets->filter(Filter::equal('ip', Job::binary($ip))); } if ($hostname !== null) { - $targets->where(['t.hostname = ?' => $hostname]); + $targets->filter(Filter::equal('hostname', $hostname)); } if ($this->params->has('port')) { - $targets->where(['t.port = ?' => $this->params->get('port')]); + $targets->filter(Filter::equal('port', (int) $this->params->get('port'))); } $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); @@ -112,9 +137,9 @@ public function hostAction() $perfData = []; $state = 3; - foreach ($this->getDb()->select($targets) as $target) { - if ($target['valid'] === 'n' && ($target['self_signed'] === 'n' || ! $allowSelfSigned)) { - $invalidMessage = $target['subject'] . ': ' . $target['invalid_reason']; + foreach ($targets as $target) { + if (! $target->chain->valid && (! $target['self_signed'] || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target->chain->invalid_reason; $output[$invalidMessage] = $invalidMessage; $state = 2; } diff --git a/application/controllers/CertificateController.php b/application/controllers/CertificateController.php index d083d1ca..9d7ea794 100644 --- a/application/controllers/CertificateController.php +++ b/application/controllers/CertificateController.php @@ -7,7 +7,9 @@ use Icinga\Exception\ConfigurationError; use Icinga\Module\X509\CertificateDetails; use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Model\X509Certificate; use ipl\Sql; +use ipl\Stdlib\Filter; class CertificateController extends Controller { @@ -22,18 +24,17 @@ public function indexAction() return; } - $cert = $conn->select( - (new Sql\Select()) - ->from('x509_certificate') - ->columns('*') - ->where(['id = ?' => $certId]) - )->fetch(); + $certificates = X509Certificate::on($conn); + $certificates->filter(Filter::equal('id', $certId)); - if ($cert === false) { + $cert = $certificates->first(); + + if (! $cert) { $this->httpNotFound($this->translate('Certificate not found.')); } - $this->setTitle($this->translate('X.509 Certificate')); + $this->addTitleTab($this->translate('X.509 Certificate')); + $this->getTabs()->disableLegacyExtensions(); $this->view->certificateDetails = (new CertificateDetails()) ->setCert($cert); diff --git a/application/controllers/CertificatesController.php b/application/controllers/CertificatesController.php index 6a1f85a8..4d997975 100644 --- a/application/controllers/CertificatesController.php +++ b/application/controllers/CertificatesController.php @@ -4,24 +4,21 @@ namespace Icinga\Module\X509\Controllers; -use Icinga\Data\Filter\FilterExpression; use Icinga\Exception\ConfigurationError; use Icinga\Module\X509\CertificatesTable; use Icinga\Module\X509\Controller; -use Icinga\Module\X509\FilterAdapter; -use Icinga\Module\X509\SortAdapter; -use Icinga\Module\X509\SqlFilter; -use ipl\Web\Control\PaginationControl; -use ipl\Sql; -use ipl\Web\Url; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions; +use ipl\Orm\Query; +use ipl\Web\Control\LimitControl; +use ipl\Web\Control\SortControl; class CertificatesController extends Controller { public function indexAction() { - $this - ->initTabs() - ->setTitle($this->translate('Certificates')); + $this->initTabs(); + $this->addTitleTab($this->translate('Certificates')); try { $conn = $this->getDb(); @@ -30,89 +27,65 @@ public function indexAction() return; } - $select = (new Sql\Select()) - ->from('x509_certificate c') - ->columns([ - 'c.id', 'c.subject', 'c.issuer', 'c.version', 'c.self_signed', 'c.ca', 'c.trusted', - 'c.pubkey_algo', 'c.pubkey_bits', 'c.signature_algo', 'c.signature_hash_algo', - 'c.valid_from', 'c.valid_to', - ]); - - $this->view->paginator = new PaginationControl(new Sql\Cursor($conn, $select), Url::fromRequest()); - $this->view->paginator->apply(); - - $sortAndFilterColumns = [ - 'subject' => $this->translate('Certificate'), - 'issuer' => $this->translate('Issuer'), - 'version' => $this->translate('Version'), - 'self_signed' => $this->translate('Is Self-Signed'), - 'ca' => $this->translate('Is Certificate Authority'), - 'trusted' => $this->translate('Is Trusted'), - 'pubkey_algo' => $this->translate('Public Key Algorithm'), - 'pubkey_bits' => $this->translate('Public Key Strength'), - 'signature_algo' => $this->translate('Signature Algorithm'), + $certificates = X509Certificate::on($conn); + + $sortColumns = [ + 'subject' => $this->translate('Certificate'), + 'issuer_cn' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), - 'valid_from' => $this->translate('Valid From'), - 'valid_to' => $this->translate('Valid To'), - 'duration' => $this->translate('Duration'), - 'expires' => $this->translate('Expiration') + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'duration' => $this->translate('Duration'), + 'expires' => $this->translate('Expiration') ]; - $this->setupSortControl( - $sortAndFilterColumns, - new SortAdapter($select, function ($field) { - if ($field === 'duration') { - return '(valid_to - valid_from)'; - } elseif ($field === 'expires') { - return 'CASE WHEN UNIX_TIMESTAMP() > valid_to' - . ' THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END'; - } - }) - ); - - $this->setupLimitControl(); - - $filterAdapter = new FilterAdapter(); - $this->setupFilterControl( - $filterAdapter, - $sortAndFilterColumns, - ['subject', 'issuer'], - ['format'] - ); - - (new SqlFilter($conn))->apply($select, $filterAdapter->getFilter(), function (FilterExpression $filter) { - switch ($filter->getColumn()) { - case 'issuer_hash': - $value = $filter->getExpression(); - - if (is_array($value)) { - $value = array_map('hex2bin', $value); - } else { - $value = hex2bin($value); - } - - return $filter->setExpression($value); - case 'duration': - return $filter->setColumn('(valid_to - valid_from)'); - case 'expires': - return $filter->setColumn( - 'CASE WHEN UNIX_TIMESTAMP() > valid_to THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END' - ); - case 'valid_from': - case 'valid_to': - $expr = $filter->getExpression(); - if (! is_numeric($expr)) { - return $filter->setExpression(strtotime($expr)); - } - - // expression doesn't need changing - default: - return false; + $limitControl = $this->createLimitControl(); + $paginator = $this->createPaginationControl($certificates); + $sortControl = $this->createSortControl($certificates, $sortColumns); + + $searchBar = $this->createSearchBar($certificates, [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ]); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + return; } - }); + } else { + $filter = $searchBar->getFilter(); + } + + $certificates->peekAhead($this->view->compact); + + $certificates->filter($filter); - $this->handleFormatRequest($conn, $select, function (\PDOStatement $stmt) { - foreach ($stmt as $cert) { + $this->addControl($paginator); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + + // List of allowed columns to be exported + $exportable = array_flip([ + 'id', 'subject', 'issuer', 'version', 'self_signed', 'ca', 'trusted', + 'pubkey_algo', 'pubkey_bits', 'signature_algo', 'signature_hash_algo', + 'valid_from', 'valid_to' + ]); + + $this->handleFormatRequest($certificates, function (Query $certificates) use ($exportable) { + /** @var X509Certificate $cert */ + foreach ($certificates as $cert) { $cert['valid_from'] = (new \DateTime()) ->setTimestamp($cert['valid_from']) ->format('l F jS, Y H:i:s e'); @@ -120,10 +93,36 @@ public function indexAction() ->setTimestamp($cert['valid_to']) ->format('l F jS, Y H:i:s e'); - yield $cert; + // Issuer is a relation name, so we have to override it with issuer_cn column + $cert->issuer = $cert->issuer_cn; + + yield array_intersect_key(iterator_to_array($cert->getIterator()), $exportable); } }); - $this->view->certificatesTable = (new CertificatesTable())->setData($conn->select($select)); + $this->addContent((new CertificatesTable())->setData($certificates)); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); // Updates the browser search bar + } + } + + public function completeAction() + { + $suggestions = new ObjectSuggestions(); + $suggestions->setModel(X509Certificate::class); + $suggestions->forRequest($this->getServerRequest()); + $this->getDocument()->add($suggestions); + } + + public function searchEditorAction() + { + $editor = $this->createSearchEditor(X509Certificate::on($this->getDb()), [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM + ]); + + $this->getDocument()->add($editor); + $this->setTitle(t('Adjust Filter')); } } diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php index 7f1176eb..a509db24 100644 --- a/application/controllers/ChainController.php +++ b/application/controllers/ChainController.php @@ -7,11 +7,12 @@ use Icinga\Exception\ConfigurationError; use Icinga\Module\X509\ChainDetails; use Icinga\Module\X509\Controller; -use Icinga\Module\X509\DbTool; -use ipl\Html\Attribute; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509CertificateChain; use ipl\Html\Html; use ipl\Html\HtmlDocument; -use ipl\Sql; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; class ChainController extends Controller { @@ -26,21 +27,18 @@ public function indexAction() return; } - $chainSelect = (new Sql\Select()) - ->from('x509_certificate_chain ch') - ->columns('*') - ->join('x509_target t', 't.id = ch.target_id') - ->where(['ch.id = ?' => $id]); + $chains = X509CertificateChain::on($conn)->with(['target']); + $chains->filter(Filter::equal('id', $id)); - $chain = $conn->select($chainSelect)->fetch(); - - if ($chain === false) { + $chain = $chains->first(); + if (! $chain) { $this->httpNotFound($this->translate('Certificate not found.')); } - $this->setTitle($this->translate('X.509 Certificate Chain')); + $this->addTitleTab($this->translate('X.509 Certificate Chain')); + $this->getTabs()->disableLegacyExtensions(); - $ip = DbTool::unmarshalBinary($chain['ip']); + $ip = $chain->target->ip; $ipv4 = ltrim($ip, "\0"); if (strlen($ipv4) === 4) { $ip = $ipv4; @@ -49,16 +47,16 @@ public function indexAction() $chainInfo = Html::tag('div'); $chainInfo->add(Html::tag('dl', [ Html::tag('dt', $this->translate('Host')), - Html::tag('dd', $chain['hostname']), + Html::tag('dd', $chain->target->hostname), Html::tag('dt', $this->translate('IP')), Html::tag('dd', inet_ntop($ip)), Html::tag('dt', $this->translate('Port')), - Html::tag('dd', $chain['port']) + Html::tag('dd', $chain->target->port) ])); $valid = Html::tag('div', ['class' => 'cert-chain']); - if ($chain['valid'] === 'y') { + if ($chain['valid']) { $valid->getAttributes()->add('class', '-valid'); $valid->add(Html::tag('p', $this->translate('Certificate chain is valid.'))); } else { @@ -69,17 +67,15 @@ public function indexAction() ))); } - $certsSelect = (new Sql\Select()) - ->from('x509_certificate c') - ->columns('*') - ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') - ->join('x509_certificate_chain cc', 'cc.id = ccl.certificate_chain_id') - ->where(['cc.id = ?' => $id]) - ->orderBy('ccl.order'); + $certs = X509Certificate::on($conn)->with(['chain']); + $certs + ->filter(Filter::equal('chain.id', $id)) + ->getSelectBase() + ->orderBy('certificate_link.order'); $this->view->chain = (new HtmlDocument()) ->add($chainInfo) ->add($valid) - ->add((new ChainDetails())->setData($conn->select($certsSelect))); + ->add((new ChainDetails())->setData($certs)); } } diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php index d1056086..70bc1ecd 100644 --- a/application/controllers/DashboardController.php +++ b/application/controllers/DashboardController.php @@ -8,15 +8,17 @@ use Icinga\Module\X509\CertificateUtils; use Icinga\Module\X509\Controller; use Icinga\Module\X509\Donut; +use Icinga\Module\X509\Model\X509Certificate; use Icinga\Web\Url; use ipl\Html\Html; -use ipl\Sql\Select; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; class DashboardController extends Controller { public function indexAction() { - $this->setTitle($this->translate('Certificate Dashboard')); + $this->addTitleTab($this->translate('Certificate Dashboard')); try { $db = $this->getDb(); @@ -25,15 +27,17 @@ public function indexAction() return; } - $byCa = $db->select( - (new Select()) - ->from('x509_certificate i') - ->columns(['i.subject', 'cnt' => 'COUNT(*)']) - ->join('x509_certificate c', ['c.issuer_hash = i.subject_hash', 'i.ca = ?' => 'y']) - ->groupBy(['i.id']) - ->orderBy('cnt', SORT_DESC) - ->limit(5) - ); + $byCa = X509Certificate::on($db); + $byCa + ->columns([ + 'issuer.subject', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->filter(Filter::equal('issuer.ca', true)) + ->limit(5) + ->getSelectBase() + ->groupBy('certificate_issuer.id'); $this->view->byCa = (new Donut()) ->setHeading($this->translate('Certificates by CA'), 2) @@ -42,24 +46,25 @@ public function indexAction() return Html::tag( 'a', [ - 'href' => Url::fromPath('x509/certificates', ['issuer' => $data['subject']])->getAbsoluteUrl() + 'href' => Url::fromPath('x509/certificates', [ + 'issuer_cn' => $data->issuer->subject + ])->getAbsoluteUrl() ], - $data['subject'] + $data->issuer->subject ); }); - $duration = $db->select( - (new Select()) - ->from('x509_certificate') - ->columns([ - 'duration' => 'valid_to - valid_from', - 'cnt' => 'COUNT(*)' - ]) - ->where(['ca = ?' => 'n']) - ->groupBy(['duration']) - ->orderBy('cnt', SORT_DESC) - ->limit(5) - ); + $duration = X509Certificate::on($db); + $duration + ->columns([ + 'duration', + 'cnt' => new Expression('COUNT(*)') + ]) + ->filter(Filter::equal('ca', false)) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ->getSelectBase() + ->groupBy('duration'); $this->view->duration = (new Donut()) ->setHeading($this->translate('Certificates by Duration'), 2) @@ -76,14 +81,17 @@ public function indexAction() ); }); - $keyStrength = $db->select( - (new Select()) - ->from('x509_certificate') - ->columns(['pubkey_algo', 'pubkey_bits', 'cnt' => 'COUNT(*)']) - ->groupBy(['pubkey_algo', 'pubkey_bits']) - ->orderBy('cnt', SORT_DESC) - ->limit(5) - ); + $keyStrength = X509Certificate::on($db); + $keyStrength + ->columns([ + 'pubkey_algo', + 'pubkey_bits', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ->getSelectBase() + ->groupBy(['pubkey_algo', 'pubkey_bits']); $this->view->keyStrength = (new Donut()) ->setHeading($this->translate('Key Strength'), 2) @@ -104,14 +112,17 @@ public function indexAction() ); }); - $sigAlgos = $db->select( - (new Select()) - ->from('x509_certificate') - ->columns(['signature_algo', 'signature_hash_algo', 'cnt' => 'COUNT(*)']) - ->groupBy(['signature_algo', 'signature_hash_algo']) - ->orderBy('cnt', SORT_DESC) - ->limit(5) - ); + $sigAlgos = X509Certificate::on($db); + $sigAlgos + ->columns([ + 'signature_algo', + 'signature_hash_algo', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ->getSelectBase() + ->groupBy(['signature_algo', 'signature_hash_algo']); $this->view->sigAlgos = (new Donut()) ->setHeading($this->translate('Signature Algorithms'), 2) @@ -124,7 +135,7 @@ public function indexAction() 'x509/certificates', [ 'signature_hash_algo' => $data['signature_hash_algo'], - 'signature_algo' => $data['signature_algo'] + 'signature_algo' => $data['signature_algo'] ] )->getAbsoluteUrl() ], diff --git a/application/controllers/UsageController.php b/application/controllers/UsageController.php index 7d6e3ff6..5219dc9d 100644 --- a/application/controllers/UsageController.php +++ b/application/controllers/UsageController.php @@ -4,26 +4,22 @@ namespace Icinga\Module\X509\Controllers; -use Icinga\Data\Filter\FilterExpression; use Icinga\Exception\ConfigurationError; use Icinga\Module\X509\Controller; -use Icinga\Module\X509\DbTool; -use Icinga\Module\X509\FilterAdapter; -use Icinga\Module\X509\Job; -use Icinga\Module\X509\SortAdapter; -use Icinga\Module\X509\SqlFilter; +use Icinga\Module\X509\Model\X509Certificate; use Icinga\Module\X509\UsageTable; -use ipl\Web\Control\PaginationControl; -use ipl\Sql; -use ipl\Web\Url; +use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions; +use ipl\Orm\Query; +use ipl\Sql\Expression; +use ipl\Web\Control\LimitControl; +use ipl\Web\Control\SortControl; class UsageController extends Controller { public function indexAction() { - $this - ->initTabs() - ->setTitle($this->translate('Certificate Usage')); + $this->initTabs(); + $this->addTitleTab($this->translate('Certificate Usage')); try { $conn = $this->getDb(); @@ -32,110 +28,79 @@ public function indexAction() return; } - $select = (new Sql\Select()) - ->from('x509_target t') - ->columns('*') - ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') - ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') - ->join('x509_certificate c', 'c.id = ccl.certificate_id') - ->where(['ccl.order = ?' => 0]); - - $sortAndFilterColumns = [ - 'hostname' => $this->translate('Hostname'), - 'ip' => $this->translate('IP'), - 'port' => $this->translate('Port'), - 'subject' => $this->translate('Certificate'), - 'issuer' => $this->translate('Issuer'), - 'version' => $this->translate('Version'), - 'self_signed' => $this->translate('Is Self-Signed'), - 'ca' => $this->translate('Is Certificate Authority'), - 'trusted' => $this->translate('Is Trusted'), - 'pubkey_algo' => $this->translate('Public Key Algorithm'), - 'pubkey_bits' => $this->translate('Public Key Strength'), - 'signature_algo' => $this->translate('Signature Algorithm'), - 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), - 'valid_from' => $this->translate('Valid From'), - 'valid_to' => $this->translate('Valid To'), - 'valid' => $this->translate('Chain Is Valid'), - 'duration' => $this->translate('Duration'), - 'expires' => $this->translate('Expiration') + $targets = X509Certificate::on($conn) + ->with(['chain', 'chain.target']) + ->withColumns([ + 'chain.id', + 'chain.valid', + 'chain.target.ip', + 'chain.target.port', + 'chain.target.hostname', + ]); + + $targets + ->getSelectBase() + ->where(new Expression('certificate_link.order = 0')); + + $sortColumns = [ + 'chain.target.hostname' => $this->translate('Hostname'), + 'chain.target.ip' => $this->translate('IP'), + 'chain.target.port' => $this->translate('Port'), + 'subject' => $this->translate('Certificate'), + 'issuer_cn' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'chain.valid' => $this->translate('Chain Is Valid'), + 'duration' => $this->translate('Duration'), + 'expires' => $this->translate('Expiration') ]; - $this->view->paginator = new PaginationControl(new Sql\Cursor($conn, $select), Url::fromRequest()); - $this->view->paginator->apply(); - - $this->setupSortControl( - $sortAndFilterColumns, - new SortAdapter($select, function ($field) { - if ($field === 'duration') { - return '(valid_to - valid_from)'; - } elseif ($field === 'expires') { - return 'CASE WHEN UNIX_TIMESTAMP() > valid_to' - . ' THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END'; - } - }) - ); - - $this->setupLimitControl(); - - $filterAdapter = new FilterAdapter(); - $this->setupFilterControl( - $filterAdapter, - $sortAndFilterColumns, - ['hostname', 'subject'], - ['format'] - ); - - (new SqlFilter($conn))->apply($select, $filterAdapter->getFilter(), function (FilterExpression $filter) { - switch ($filter->getColumn()) { - case 'ip': - $value = $filter->getExpression(); - - if (is_array($value)) { - $value = array_map('Job::binary', $value); - } else { - $value = Job::binary($value); - } - - return $filter->setExpression($value); - case 'issuer_hash': - $value = $filter->getExpression(); - - if (is_array($value)) { - $value = array_map('hex2bin', $value); - } else { - $value = hex2bin($value); - } - - return $filter->setExpression($value); - case 'duration': - return $filter->setColumn('(valid_to - valid_from)'); - case 'expires': - return $filter->setColumn( - 'CASE WHEN UNIX_TIMESTAMP() > valid_to THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END' - ); - case 'valid_from': - case 'valid_to': - $expr = $filter->getExpression(); - if (! is_numeric($expr)) { - return $filter->setExpression(strtotime($expr)); - } - - // expression doesn't need changing - default: - return false; + $limitControl = $this->createLimitControl(); + $paginator = $this->createPaginationControl($targets); + $sortControl = $this->createSortControl($targets, $sortColumns); + + $searchBar = $this->createSearchBar($targets, [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ]); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + return; } - }); + } else { + $filter = $searchBar->getFilter(); + } - $formatQuery = clone $select; - $formatQuery->resetColumns()->columns([ + $targets->peekAhead($this->view->compact); + + $targets->filter($filter); + + $this->addControl($paginator); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + + $exportable = array_flip([ 'valid', 'hostname', 'ip', 'port', 'subject', 'issuer', 'version', - 'self_signed', 'ca', 'trusted', 'pubkey_algo', 'pubkey_bits', + 'self_signed', 'ca', 'trusted', 'pubkey_algo', 'pubkey_bits', 'signature_algo', 'signature_hash_algo', 'valid_from', 'valid_to' ]); - $this->handleFormatRequest($conn, $formatQuery, function (\PDOStatement $stmt) use ($conn) { - foreach ($stmt as $usage) { + $this->handleFormatRequest($targets, function (Query $targets) use ($conn, $exportable) { + foreach ($targets as $usage) { $usage['valid_from'] = (new \DateTime()) ->setTimestamp($usage['valid_from']) ->format('l F jS, Y H:i:s e'); @@ -143,21 +108,47 @@ public function indexAction() ->setTimestamp($usage['valid_to']) ->format('l F jS, Y H:i:s e'); - $ip = $usage['ip']; - if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) { - $ip = DbTool::unmarshalBinary($ip); - } + $ip = $usage->chain->target->ip; $ipv4 = ltrim($ip, "\0"); if (strlen($ipv4) === 4) { $ip = $ipv4; } - $usage['ip'] = inet_ntop($ip); - yield $usage; + $usage->ip = inet_ntop($ip); + $usage->hostname = $usage->chain->target->hostname; + $usage->port = $usage->chain->target->port; + $usage->valid = $usage->chain->valid; + + $usage->issuer = $usage->issuer_cn; + + yield array_intersect_key(iterator_to_array($usage->getIterator()), $exportable); } }); - $this->view->usageTable = (new UsageTable())->setData($conn->select($select)); + $this->addContent((new UsageTable())->setData($targets)); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); // Updates the browser search bar + } + } + + public function completeAction() + { + $suggestions = new ObjectSuggestions(); + $suggestions->setModel(X509Certificate::class); + $suggestions->forRequest($this->getServerRequest()); + $this->getDocument()->add($suggestions); + } + + public function searchEditorAction() + { + $editor = $this->createSearchEditor(X509Certificate::on($this->getDb()), [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM + ]); + + $this->getDocument()->add($editor); + $this->setTitle(t('Adjust Filter')); } } diff --git a/application/views/scripts/certificates/index.phtml b/application/views/scripts/certificates/index.phtml deleted file mode 100644 index 47eb2b54..00000000 --- a/application/views/scripts/certificates/index.phtml +++ /dev/null @@ -1,14 +0,0 @@ -compact): ?> -
- tabs ?> - paginator ?> -
- limiter ?> - sortBox ?> -
- filterEditor ?> -
- -
- render() ?> -
diff --git a/application/views/scripts/usage/index.phtml b/application/views/scripts/usage/index.phtml deleted file mode 100644 index a0eed098..00000000 --- a/application/views/scripts/usage/index.phtml +++ /dev/null @@ -1,14 +0,0 @@ -compact): ?> -
- tabs ?> - paginator ?> -
- limiter ?> - sortBox ?> -
- filterEditor ?> -
- -
- render() ?> -
diff --git a/etc/schema/mysql-upgrade/v1.2.0.sql b/etc/schema/mysql-upgrade/v1.2.0.sql index ec1b7454..5f1f594c 100644 --- a/etc/schema/mysql-upgrade/v1.2.0.sql +++ b/etc/schema/mysql-upgrade/v1.2.0.sql @@ -23,3 +23,5 @@ UPDATE x509_certificate_chain SET valid = 'y' WHERE valid = 'yes'; UPDATE x509_certificate_chain SET valid = 'n' WHERE valid = 'no'; ALTER TABLE x509_certificate_chain CHANGE valid valid enum('n', 'y') NOT NULL DEFAULT 'n'; + +ALTER TABLE x509_certificate CHANGE issuer issuer_cn varchar(255) NOT NULL COMMENT 'CN of the issuer DN if present else full issuer DN' diff --git a/etc/schema/mysql.schema.sql b/etc/schema/mysql.schema.sql index d377d77f..93cdc5af 100644 --- a/etc/schema/mysql.schema.sql +++ b/etc/schema/mysql.schema.sql @@ -2,7 +2,7 @@ CREATE TABLE x509_certificate ( id int(10) unsigned NOT NULL AUTO_INCREMENT, `subject` varchar(255) NOT NULL COMMENT 'CN of the subject DN if present else full subject DN', subject_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full subject DN', - `issuer` varchar(255) NOT NULL COMMENT 'CN of the issuer DN if present else full issuer DN', + issuer_cn varchar(255) NOT NULL COMMENT 'CN of the issuer DN if present else full issuer DN', issuer_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full issuer DN', issuer_certificate_id int(10) unsigned DEFAULT NULL, version enum('1','2','3') NOT NULL, diff --git a/etc/schema/postgresql.schema.sql b/etc/schema/postgresql.schema.sql index 1231941d..9aac9397 100644 --- a/etc/schema/postgresql.schema.sql +++ b/etc/schema/postgresql.schema.sql @@ -12,15 +12,29 @@ CREATE OR REPLACE FUNCTION UNIX_TIMESTAMP() PARALLEL SAFE AS $$ BEGIN -RETURN EXTRACT(EPOCH FROM now()); + RETURN EXTRACT(EPOCH FROM now()); END; $$; +-- IPL ORM renders SQL queries with LIKE operators for all suggestions in the search bar, +-- which fails for numeric and enum types on PostgreSQL. Just like in Icinga DB Web!! +CREATE OR REPLACE FUNCTION anynonarrayliketext(anynonarray, text) + RETURNS bool + LANGUAGE plpgsql + IMMUTABLE + PARALLEL SAFE + AS $$ +BEGIN + RETURN $1::TEXT LIKE $2; +END; +$$; +CREATE OPERATOR ~~ (LEFTARG=anynonarray, RIGHTARG=text, PROCEDURE=anynonarrayliketext); + CREATE TABLE x509_certificate ( id serial PRIMARY KEY, subject varchar(255) NOT NULL, subject_hash bytea NOT NULL, - issuer varchar(255) NOT NULL, + issuer_cn varchar(255) NOT NULL, issuer_hash bytea NOT NULL, issuer_certificate_id int DEFAULT NULL, version certificate_version NOT NULL, diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php index 5d88371b..0ed136da 100644 --- a/library/X509/CertificateDetails.php +++ b/library/X509/CertificateDetails.php @@ -7,6 +7,7 @@ use DateTime; use ipl\Html\BaseHtmlElement; use ipl\Html\Html; +use ipl\Orm\Model; /** * Widget to display X.509 certificate details @@ -22,7 +23,7 @@ class CertificateDetails extends BaseHtmlElement */ protected $cert; - public function setCert(array $cert) + public function setCert(Model $cert) { $this->cert = $cert; @@ -31,7 +32,7 @@ public function setCert(array $cert) protected function assemble() { - $pem = CertificateUtils::der2pem(DbTool::unmarshalBinary($this->cert['certificate'])); + $pem = $this->cert['certificate']; $cert = openssl_x509_parse($pem); // $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem)); @@ -54,7 +55,7 @@ protected function assemble() $certInfo = Html::tag('dl'); $certInfo->add([ Html::tag('dt', mt('x509', 'Serial Number')), - Html::tag('dd', bin2hex(DbTool::unmarshalBinary($this->cert['serial']))), + 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')), @@ -86,7 +87,7 @@ protected function assemble() Html::tag('dt', 'SHA-256'), Html::tag( 'dd', - wordwrap(strtoupper(bin2hex(DbTool::unmarshalBinary($this->cert['fingerprint']))), 2, ' ', true) + wordwrap(strtoupper(bin2hex($this->cert['fingerprint'])), 2, ' ', true) ) ]); diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php index 05964656..20b492eb 100644 --- a/library/X509/CertificateUtils.php +++ b/library/X509/CertificateUtils.php @@ -7,8 +7,13 @@ use Exception; use Icinga\Application\Logger; use Icinga\File\Storage\TemporaryLocalFileStorage; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509CertificateChain; +use Icinga\Module\X509\Model\X509CrtSubjAltName; +use Icinga\Module\X509\Model\X509DN; use ipl\Sql\Connection; -use ipl\Sql\Select; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; class CertificateUtils { @@ -195,15 +200,14 @@ public static function findOrInsertCert(Connection $db, $cert) $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true); - $row = $db->select( - (new Select()) - ->columns(['id']) - ->from('x509_certificate') - ->where(['fingerprint = ?' => $dbTool->marshalBinary($fingerprint)]) - )->fetch(); + $row = X509Certificate::on($db); + $row + ->columns(['id']) + ->filter(Filter::equal('fingerprint', $fingerprint)); - if ($row !== false) { - return (int) $row->id; + $row = $row->first(); + if ($row) { + return $row->id; } Logger::debug("Importing certificate: %s", $certInfo['name']); @@ -226,6 +230,7 @@ public static function findOrInsertCert(Connection $db, $cert) $pubkey = openssl_pkey_get_details(openssl_pkey_get_public($cert)); $signature = explode('-', $certInfo['signatureTypeSN']); + // TODO: https://github.com/Icinga/ipl-orm/pull/78 $db->insert( 'x509_certificate', [ @@ -248,7 +253,7 @@ public static function findOrInsertCert(Connection $db, $cert) ] ); - $certId = (int) $db->lastInsertId(); + $certId = $db->lastInsertId(); CertificateUtils::insertSANs($db, $certId, $certInfo); @@ -266,21 +271,22 @@ private static function insertSANs($db, $certId, array $certInfo) $hash = hash('sha256', sprintf('%s=%s', $type, $value), true); - $row = $db->select( - (new Select()) - ->from('x509_certificate_subject_alt_name') - ->columns('certificate_id') - ->where([ - 'certificate_id = ?' => $certId, - 'hash = ?' => $dbTool->marshalBinary($hash) - ]) - )->fetch(); + $row = X509CrtSubjAltName::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 !== false) { + if ($row->execute()->hasResult()) { continue; } + // TODO: https://github.com/Icinga/ipl-orm/pull/78 $db->insert( 'x509_certificate_subject_alt_name', [ @@ -315,15 +321,17 @@ private static function findOrInsertDn($db, $certInfo, $type) } $hash = hash('sha256', $data, true); - $row = $db->select( - (new Select()) - ->from('x509_dn') - ->columns('hash') - ->where([ 'hash = ?' => $dbTool->marshalBinary($hash), 'type = ?' => $type ]) - ->limit(1) - )->fetch(); - - if ($row !== false) { + $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; } @@ -336,6 +344,7 @@ private static function findOrInsertDn($db, $certInfo, $type) } foreach ($values as $value) { + // TODO: https://github.com/Icinga/ipl-orm/pull/78 $db->insert( 'x509_dn', [ @@ -362,23 +371,22 @@ private static function findOrInsertDn($db, $certInfo, $type) */ public static function verifyCertificates(Connection $db) { - $dbTool = new DbTool($db); - $files = new TemporaryLocalFileStorage(); $caFile = uniqid('ca'); - $cas = $db->select( - (new Select()) - ->from('x509_certificate') - ->columns(['certificate']) - ->where(['ca = ?' => 'y', 'trusted = ?' => 'y']) - ); + $cas = X509Certificate::on($db); + $cas + ->columns(['certificate']) + ->filter(Filter::all( + Filter::equal('ca', true), + Filter::equal('trusted', true) + )); $contents = []; foreach ($cas as $ca) { - $contents[] = static::der2pem(DbTool::unmarshalBinary($ca->certificate)); + $contents[] = $ca->certificate; } if (empty($contents)) { @@ -392,29 +400,25 @@ public static function verifyCertificates(Connection $db) $db->beginTransaction(); try { - $chains = $db->select( - (new Select()) - ->from('x509_certificate_chain c') - ->join('x509_target t', ['t.latest_certificate_chain_id = c.id', 'c.valid = ?' => 'n']) - ->columns('c.id') - ); + $chains = X509CertificateChain::on($db)->utilize('target'); + $chains + ->columns(['id']) + ->filter(Filter::equal('valid', false)); foreach ($chains as $chain) { ++$count; - $certs = $db->select( - (new Select()) - ->from('x509_certificate c') - ->columns('c.certificate') - ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') - ->where(['ccl.certificate_chain_id = ?' => $chain->id]) - ->orderBy(['ccl.order' => 'DESC']) - ); + $certs = X509Certificate::on($db)->utilize('chain'); + $certs + ->columns(['certificate']) + ->getSelectBase() + ->where(new Expression('%s = %d', ['certificate_chain.id', $chain->id])) + ->orderBy('certificate_link.order', 'DESC'); $collection = []; foreach ($certs as $cert) { - $collection[] = CertificateUtils::der2pem(DbTool::unmarshalBinary($cert->certificate)); + $collection[] = $cert->certificate; } $certFile = uniqid('cert'); @@ -458,6 +462,7 @@ public static function verifyCertificates(Connection $db) $set = ['valid' => 'y', 'invalid_reason' => null]; } + // TODO: https://github.com/Icinga/ipl-orm/pull/78 $db->update( 'x509_certificate_chain', $set, diff --git a/library/X509/CertificatesTable.php b/library/X509/CertificatesTable.php index 67c6e5c5..4962b492 100644 --- a/library/X509/CertificatesTable.php +++ b/library/X509/CertificatesTable.php @@ -32,7 +32,7 @@ protected function createColumns() 'ca' => [ 'attributes' => ['class' => 'icon-col'], 'renderer' => function ($ca) { - if ($ca === 'n') { + if (! $ca) { return null; } @@ -46,7 +46,7 @@ protected function createColumns() 'self_signed' => [ 'attributes' => ['class' => 'icon-col'], 'renderer' => function ($selfSigned) { - if ($selfSigned === 'n') { + if (! $selfSigned) { return null; } @@ -60,7 +60,7 @@ protected function createColumns() 'trusted' => [ 'attributes' => ['class' => 'icon-col'], 'renderer' => function ($trusted) { - if ($trusted === 'n') { + if (! $trusted) { return null; } @@ -71,7 +71,7 @@ protected function createColumns() } ], - 'issuer' => mt('x509', 'Issuer'), + 'issuer_cn' => mt('x509', 'Issuer'), 'signature_algo' => [ 'label' => mt('x509', 'Signature Algorithm'), diff --git a/library/X509/ChainDetails.php b/library/X509/ChainDetails.php index caff5bd5..18d50f48 100644 --- a/library/X509/ChainDetails.php +++ b/library/X509/ChainDetails.php @@ -41,7 +41,7 @@ public function createColumns() 'ca' => [ 'attributes' => ['class' => 'icon-col'], 'renderer' => function ($ca) { - if ($ca === 'n') { + if (! $ca) { return null; } @@ -55,7 +55,7 @@ public function createColumns() 'self_signed' => [ 'attributes' => ['class' => 'icon-col'], 'renderer' => function ($selfSigned) { - if ($selfSigned === 'n') { + if (! $selfSigned) { return null; } @@ -69,7 +69,7 @@ public function createColumns() 'trusted' => [ 'attributes' => ['class' => 'icon-col'], 'renderer' => function ($trusted) { - if ($trusted === 'n') { + if (! $trusted) { return null; } @@ -108,7 +108,7 @@ protected function renderRow($row) { $tr = parent::renderRow($row); - $url = Url::fromPath('x509/certificate', ['cert' => $row['certificate_id']]); + $url = Url::fromPath('x509/certificate', ['cert' => $row->id]); $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); diff --git a/library/X509/Controller.php b/library/X509/Controller.php index aa959496..6828a5b1 100644 --- a/library/X509/Controller.php +++ b/library/X509/Controller.php @@ -4,21 +4,35 @@ namespace Icinga\Module\X509; -use Icinga\Data\ResourceFactory; use Icinga\File\Csv; use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Util\Json; use Icinga\Web\Widget\Tabextension\DashboardAction; use Icinga\Web\Widget\Tabextension\MenuAction; use Icinga\Web\Widget\Tabextension\OutputFormat; +use ipl\Html\Html; +use ipl\Orm\Query; use ipl\Sql; +use ipl\Stdlib\Filter; +use ipl\Web\Compat\CompatController; +use ipl\Web\Compat\SearchControls; +use ipl\Web\Filter\QueryString; use PDO; -class Controller extends \Icinga\Web\Controller +class Controller extends CompatController { use Database { getDb as private getDbWithOptions; } + use SearchControls { + SearchControls::createSearchBar as private webCreateSearchBar; + } + + /** @var Filter\Rule */ + protected $filter; + + protected $format; /** * Get the connection to the X.509 database @@ -34,45 +48,32 @@ protected function getDb() return $this->getDbWithOptions($options); } - /** - * Set the title tab of this view - * - * @param string $label - * - * @return $this - */ - protected function setTitle($label) + public function fetchFilterColumns(Query $query): array { - $this->getTabs()->add(uniqid(), [ - 'active' => true, - 'label' => (string) $label, - 'url' => $this->getRequest()->getUrl() - ]); + return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver())); + } - return $this; + public function getFilter(): Filter\Rule + { + if ($this->filter === null) { + $this->filter = QueryString::parse((string) $this->params); + } + + return $this->filter; } - protected function handleFormatRequest(Sql\Connection $db, Sql\Select $select, callable $callback = null) + protected function handleFormatRequest(Query $query, callable $callback) { - $desiredContentType = $this->getRequest()->getHeader('Accept'); - if ($desiredContentType === 'application/json') { - $desiredFormat = 'json'; - } elseif ($desiredContentType === 'text/csv') { - $desiredFormat = 'csv'; - } else { - $desiredFormat = strtolower($this->params->get('format', 'html')); + if ($this->format !== 'html' && ! $this->params->has('limit')) { + $query->limit(null); // Resets any default limit and offset } - if ($desiredFormat !== 'html' && ! $this->params->has('limit')) { - $select->limit(null); // Resets any default limit and offset + if ($this->format === 'sql') { + $this->content->add(Html::tag('pre', $query->dump()[0])); + return true; } - switch ($desiredFormat) { - case 'sql': - echo '
'
-                    . var_export((new Sql\QueryBuilder($db->getAdapter()))->assembleSelect($select), true)
-                    . '
'; - exit; + switch ($this->format) { case 'json': $response = $this->getResponse(); $response @@ -83,11 +84,7 @@ protected function handleFormatRequest(Sql\Connection $db, Sql\Select $select, c 'inline; filename=' . $this->getRequest()->getActionName() . '.json' ) ->appendBody( - Json::encode( - $callback !== null - ? iterator_to_array($callback($db->select($select))) - : $db->select($select)->fetchAll() - ) + Json::encode(iterator_to_array($callback($query))) ) ->sendResponse(); exit; @@ -100,11 +97,7 @@ protected function handleFormatRequest(Sql\Connection $db, Sql\Select $select, c 'Content-Disposition', 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv' ) - ->appendBody( - (string) Csv::fromQuery( - $callback !== null ? $callback($db->select($select)) : $db->select($select) - ) - ) + ->appendBody((string) Csv::fromQuery($callback($query))) ->sendResponse(); exit; } @@ -116,4 +109,11 @@ protected function initTabs() return $this; } + + public function preDispatch() + { + parent::preDispatch(); + + $this->format = $this->params->shift('format', 'html'); + } } diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php index 329bebe5..bc1cb7f3 100644 --- a/library/X509/DataTable.php +++ b/library/X509/DataTable.php @@ -84,13 +84,16 @@ protected function renderRow($row) $cells = []; foreach ($this->columns as $key => $column) { - if (! is_int($key) && array_key_exists($key, $row)) { + if (! is_int($key) && isset($row->$key)) { $data = $row[$key]; } else { - if (isset($column['column']) && array_key_exists($column['column'], $row)) { - $data = $row[$column['column']]; - } else { - $data = null; + $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']]; + } } } @@ -100,7 +103,7 @@ protected function renderRow($row) $content = $data; } - $cells[] = Html::tag('td', isset($column['attributes']) ? $column['attributes'] : null, $content); + $cells[] = Html::tag('td', $column['attributes'] ?? null, $content); } return Html::tag('tr', $cells); diff --git a/library/X509/Job.php b/library/X509/Job.php index 6f97c3bc..91bc70b0 100644 --- a/library/X509/Job.php +++ b/library/X509/Job.php @@ -8,6 +8,9 @@ use Icinga\Application\Logger; use Icinga\Data\ConfigObject; use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509CertificateChain; +use Icinga\Module\X509\Model\X509Target; use Icinga\Module\X509\React\StreamOptsCaptureConnector; use Icinga\Util\StringHelper; use ipl\Sql\Connection; @@ -15,6 +18,7 @@ use ipl\Sql\Insert; use ipl\Sql\Select; use ipl\Sql\Update; +use ipl\Stdlib\Filter; use React\EventLoop\Factory; use React\Socket\ConnectionInterface; use React\Socket\Connector; @@ -292,18 +296,17 @@ protected function processChain($target, $chain) } $this->db->transaction(function () use ($target, $chain) { - $row = $this->db->select( - (new Select()) - ->columns(['id']) - ->from('x509_target') - ->where([ - 'ip = ?' => $this->dbTool->marshalBinary(static::binary($target->ip)), - 'port = ?' => $target->port, - 'hostname = ?' => $target->hostname - ]) - )->fetch(); - - if ($row === false) { + $row = X509Target::on($this->db)->columns(['id']); + + $filter = Filter::all(); + $filter->add(Filter::equal('ip', static::binary($target->ip))); + $filter->add(Filter::equal('port', $target->port)); + $filter->add(Filter::equal('hostname', $target->hostname)); + + $row->filter($filter); + + if (! ($row = $row->first())) { + // TODO: https://github.com/Icinga/ipl-orm/pull/78 $this->db->insert( 'x509_target', [ @@ -314,32 +317,31 @@ protected function processChain($target, $chain) ); $targetId = $this->db->lastInsertId(); } else { - $targetId = $row->id; + $targetId = (int) $row->id; } $chainUptodate = false; - $lastChain = $this->db->select( - (new Select()) - ->columns(['id']) - ->from('x509_certificate_chain') - ->where(['target_id = ?' => $targetId]) - ->orderBy('id', SORT_DESC) - ->limit(1) - )->fetch(); - - if ($lastChain !== false) { - $lastFingerprints = $this->db->select( - (new Select()) - ->columns(['c.fingerprint']) - ->from('x509_certificate_chain_link l') - ->join('x509_certificate c', 'l.certificate_id = c.id') - ->where(['l.certificate_chain_id = ?' => $lastChain->id]) - ->orderBy('l.order') - )->fetchAll(); - - foreach ($lastFingerprints as &$lastFingerprint) { - $lastFingerprint = $lastFingerprint->fingerprint; + $lastChain = X509CertificateChain::on($this->db)->columns(['id']); + $lastChain + ->filter(Filter::equal('target_id', $targetId)) + ->orderBy('id', SORT_DESC) + ->limit(1); + + if (($lastChain = $lastChain->first())) { + $lastFingerprints = X509Certificate::on($this->db)->utilize('chain'); + $lastFingerprints + ->columns(['fingerprint']) + ->getSelectBase() + ->where(new Expression( + 'x509_certificate_x509_certificate_chain_link.certificate_chain_id = %d', + [(int) $lastChain->id] + )) + ->orderBy('x509_certificate_x509_certificate_chain_link.order'); + + $lastFingerprintsArr = []; + foreach ($lastFingerprints as $lastFingerprint) { + $lastFingerprintsArr[] = $lastFingerprint->fingerprint; } $currentFingerprints = []; @@ -348,12 +350,13 @@ protected function processChain($target, $chain) $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true); } - $chainUptodate = $currentFingerprints === $lastFingerprints; + $chainUptodate = $currentFingerprints === $lastFingerprintsArr; } if ($chainUptodate) { - $chainId = $lastChain->id; + $chainId = (int) $lastChain->id; } else { + // TODO: https://github.com/Icinga/ipl-orm/pull/78 $this->db->insert( 'x509_certificate_chain', [ diff --git a/library/X509/Model/Behavior/BoolCast.php b/library/X509/Model/Behavior/BoolCast.php new file mode 100644 index 00000000..743c7e82 --- /dev/null +++ b/library/X509/Model/Behavior/BoolCast.php @@ -0,0 +1,29 @@ + new Expression('%s - %s', ['valid_to', 'valid_from']), + 'expires' => new Expression( + 'CASE WHEN UNIX_TIMESTAMP() > %s THEN 0 ELSE (%s - UNIX_TIMESTAMP()) / 86400 END', + ['valid_to', 'valid_to'] + ) + ]; + } + + public function getColumnDefinitions() + { + return [ + 'subject' => t('Certificate'), + 'issuer_cn' => 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'), + 'expires' => t('Expiration'), + 'subject_hash' => t('Subject Hash'), + 'issuer_hash' => t('Issuer Hash'), + ]; + } + + public function getSearchColumns() + { + return ['subject', 'issuer_cn']; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'subject_hash', + 'issuer_hash', + 'fingerprint', + 'serial', + 'certificate' + ])); + + $behaviors->add(new DistinguishedEncodingRules(['certificate'])); + + $behaviors->add(new BoolCast([ + 'ca', + 'trusted', + 'self_signed' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('issuer', self::class) + ->setForeignKey('subject_hash') + ->setCandidateKey('issuer_hash'); + $relations->belongsToMany('chain', X509CertificateChain::class) + ->through(X509CertificateChainLink::class) + ->setForeignKey('certificate_id'); + + $relations->hasMany('alt_name', X509CrtSubjAltName::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 00000000..a2fc914d --- /dev/null +++ b/library/X509/Model/X509CertificateChain.php @@ -0,0 +1,54 @@ +add(new BoolCast(['valid'])); + } + + 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 00000000..cb43655e --- /dev/null +++ b/library/X509/Model/X509CertificateChainLink.php @@ -0,0 +1,42 @@ +belongsTo('certificate', X509Certificate::class) + ->setCandidateKey('certificate_id'); + $relations->belongsTo('chain', X509CertificateChain::class) + ->setCandidateKey('certificate_chain_id'); + } +} diff --git a/library/X509/Model/X509CrtSubjAltName.php b/library/X509/Model/X509CrtSubjAltName.php new file mode 100644 index 00000000..f6c3492c --- /dev/null +++ b/library/X509/Model/X509CrtSubjAltName.php @@ -0,0 +1,47 @@ +add(new Binary(['hash'])); + } + + 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 00000000..de383ee3 --- /dev/null +++ b/library/X509/Model/X509DN.php @@ -0,0 +1,49 @@ +add(new Binary(['hash'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('certificate', X509Certificate::class) + ->setForeignKey('subject_hash'); + } +} diff --git a/library/X509/Model/X509JobRun.php b/library/X509/Model/X509JobRun.php new file mode 100644 index 00000000..6ddd66b1 --- /dev/null +++ b/library/X509/Model/X509JobRun.php @@ -0,0 +1,32 @@ + t('Host Name'), + 'ip' => t('IP'), + 'port' => t('Port') + ]; + } + + public function getSearchColumns() + { + return ['hostname']; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary(['ip'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('chain', X509CertificateChain::class) + ->setCandidateKey('latest_certificate_chain_id'); + } +} diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php index eac5a1b2..c2eec693 100644 --- a/library/X509/ProvidedHook/HostsImportSource.php +++ b/library/X509/ProvidedHook/HostsImportSource.php @@ -5,38 +5,41 @@ namespace Icinga\Module\X509\ProvidedHook; use Icinga\Module\X509\DbTool; +use Icinga\Module\X509\Model\X509Target; use ipl\Sql; +use ipl\Stdlib\Filter; class HostsImportSource extends X509ImportSource { public function fetchData() { - $targets = (new Sql\Select()) - ->from('x509_target t') + $targets = X509Target::on($this->getDb()) + ->utilize('chain') + ->utilize('chain.certificate'); + + $targets ->columns([ - 'host_ip' => 't.ip', - 'host_name' => 't.hostname' + 'ip', + 'host_name' => 'hostname' ]) - ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') - ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') - ->join('x509_certificate c', 'c.id = ccl.certificate_id') - ->where(['ccl.order = ?' => 0]) - ->groupBy(['t.ip', 't.hostname']); + ->getSelectBase() + ->where(new Sql\Expression('target_chain_link.order = 0')) + ->groupBy(['ip', 'hostname']); - if ($this->getDb()->getConfig()->db === 'pgsql') { - $targets->columns(['host_ports' => 'ARRAY_TO_STRING(ARRAY_AGG(DISTINCT t.port), \',\')']); + if ($this->getDb()->getAdapter() instanceof Sql\Adapter\Pgsql) { + $targets->withColumns([ + 'host_ports' => new Sql\Expression('ARRAY_TO_STRING(ARRAY_AGG(DISTINCT port), \',\')') + ]); } else { - $targets->columns(['host_ports' => 'GROUP_CONCAT(DISTINCT t.port SEPARATOR ",")']); + $targets->withColumns([ + 'host_ports' => new Sql\Expression('GROUP_CONCAT(DISTINCT port SEPARATOR \',\')') + ]); } $results = []; $foundDupes = []; - foreach ($this->getDb()->select($targets) as $target) { - if ($this->getDb()->getConfig()->db === 'pgsql') { - $target->host_ip = DbTool::unmarshalBinary($target->host_ip); - } - - list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + foreach ($targets as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->ip); $target->host_ip = $ipv4 ?: $ipv6; $target->host_address = $ipv4; $target->host_address6 = $ipv6; @@ -56,7 +59,12 @@ public function fetchData() $target->host_name_or_ip = $target->host_ip; } - $results[$target->host_name_or_ip] = $target; + unset($target->ip); // Isn't needed any more!! + unset($target->chain); // We don't need any relation properties anymore + + $properties = iterator_to_array($target->getIterator()); + + $results[$target->host_name_or_ip] = (object) $properties; } return $results; diff --git a/library/X509/ProvidedHook/ServicesImportSource.php b/library/X509/ProvidedHook/ServicesImportSource.php index 82072fda..44f7ca4d 100644 --- a/library/X509/ProvidedHook/ServicesImportSource.php +++ b/library/X509/ProvidedHook/ServicesImportSource.php @@ -4,65 +4,90 @@ namespace Icinga\Module\X509\ProvidedHook; -use Icinga\Module\X509\DbTool; +use Icinga\Module\X509\Model\X509CrtSubjAltName; +use Icinga\Module\X509\Model\X509Target; use ipl\Sql; class ServicesImportSource extends X509ImportSource { public function fetchData() { - $targets = (new Sql\Select()) - ->from('x509_target t') + $targets = X509Target::on($this->getDb())->with([ + 'chain', + 'chain.certificate', + 'chain.certificate.dn', + 'chain.certificate.issuer', + ]); + + $targets->getWith()['target.chain.certificate.issuer']->setJoinType('LEFT'); + $targets ->columns([ - 'host_ip' => 't.ip', - 'host_name' => 't.hostname', - 'host_port' => 't.port', - 'cert_subject' => 'c.subject', - 'cert_issuer' => 'c.issuer', - 'cert_self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', - 'cert_trusted' => 'c.trusted', - 'cert_valid_from' => 'c.valid_from', - 'cert_valid_to' => 'c.valid_to' + 'ip', + 'host_name' => 'hostname', + 'host_port' => 'port', + 'cert_subject' => 'chain.certificate.subject', + 'cert_issuer' => 'chain.certificate.issuer_cn', + '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.self_signed', + 'chain.certificate.self_signed' + ]) ]) - ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') - ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') - ->join('x509_certificate c', 'c.id = ccl.certificate_id') - ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash') - ->joinLeft('x509_dn dn', 'dn.hash = c.subject_hash') - ->where(['ccl.order = ?' => 0]) - ->groupBy(['t.ip', 't.hostname', 't.port']); - - $certAltName = (new Sql\Select()) - ->from('x509_certificate_subject_alt_name can') - ->where(['can.certificate_id = c.id']) - ->groupBy(['can.certificate_id']); - - if ($this->getDb()->getConfig()->db === 'pgsql') { - $targets->columns([ - 'cert_fingerprint' => 'ENCODE(c.fingerprint, \'hex\')', - 'cert_dn' => 'ARRAY_TO_STRING(ARRAY_AGG(CONCAT(dn.key, \'=\', dn.value)), \',\')' - ]) - ->groupBy(['c.id', 'ci.id']); - - $certAltName->columns('ARRAY_TO_STRING(ARRAY_AGG(CONCAT(can.type, \':\', can.value)), \',\')'); + ->getSelectBase() + ->where(new Sql\Expression('target_chain_link.order = 0')) + ->groupBy(['ip, hostname, port']); + + $certAltName = X509CrtSubjAltName::on($this->getDb()); + $certAltName + ->getSelectBase() + ->where(new Sql\Expression('certificate_id = target_chain_certificate.id')) + ->groupBy(['alt_name.certificate_id']); + + if ($this->getDb()->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.id']); + + $certAltName->columns([ + new Sql\Expression('ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, \':\', %s)), \',\')', ['type', 'value']) + ]); } else { - $targets->columns([ - 'cert_fingerprint' => 'HEX(c.fingerprint)', - 'cert_dn' => 'GROUP_CONCAT(CONCAT(dn.key, \'=\', dn.value) SEPARATOR \',\')' + $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('GROUP_CONCAT(CONCAT(can.type, \':\', can.value) SEPARATOR \',\')'); + $certAltName->columns([ + new Sql\Expression('GROUP_CONCAT(CONCAT(%s, \':\', %s) SEPARATOR \',\')', ['type', 'value']) + ]); } - $targets->columns(['cert_subject_alt_name' => $certAltName]); + list($select, $values) = $certAltName->dump(); + $targets->withColumns(['cert_subject_alt_name' => new Sql\Expression("$select", null, ...$values)]); $results = []; - foreach ($this->getDb()->select($targets) as $target) { - if ($this->getDb()->getConfig()->db === 'pgsql') { - $target->host_ip = DbTool::unmarshalBinary($target->host_ip); - } - - list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + foreach ($targets as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->ip); $target->host_ip = $ipv4 ?: $ipv6; $target->host_address = $ipv4; $target->host_address6 = $ipv6; @@ -74,7 +99,12 @@ public function fetchData() $target->host_port ); - $results[$target->host_name_ip_and_port] = $target; + unset($target->ip); // Isn't needed any more!! + unset($target->chain); // We don't need any relation properties anymore + + $properties = iterator_to_array($target->getIterator()); + + $results[$target->host_name_ip_and_port] = (object) $properties; } return $results; diff --git a/library/X509/UsageTable.php b/library/X509/UsageTable.php index e1a9135b..924636a6 100644 --- a/library/X509/UsageTable.php +++ b/library/X509/UsageTable.php @@ -13,7 +13,7 @@ class UsageTable extends DataTable { protected $defaultAttributes = [ - 'class' => 'usage-table common-table table-row-selectable', + 'class' => 'usage-table common-table table-row-selectable', 'data-base-target' => '_next' ]; @@ -22,19 +22,29 @@ public function createColumns() return [ 'valid' => [ 'attributes' => ['class' => 'icon-col'], - 'renderer' => function ($valid) { - $icon = $valid === 'y' ? 'check -ok' : 'block -critical'; + 'column' => function ($data) { + return $data->chain->valid; + }, + 'renderer' => function ($valid) { + $icon = $valid ? 'check -ok' : 'block -critical'; return Html::tag('i', ['class' => "icon icon-{$icon}"]); } ], - 'hostname' => mt('x509', 'Hostname'), + 'hostname' => [ + 'label' => mt('x509', 'Hostname'), + 'column' => function ($data) { + return $data->chain->target->hostname; + } + ], 'ip' => [ - 'label' => mt('x509', 'IP'), + 'label' => mt('x509', 'IP'), + 'column' => function ($data) { + return $data->chain->target->ip; + }, 'renderer' => function ($ip) { - $ip = DbTool::unmarshalBinary($ip); $ipv4 = ltrim($ip, "\0"); if (strlen($ipv4) === 4) { $ip = $ipv4; @@ -44,19 +54,24 @@ public function createColumns() } ], - 'port' => mt('x509', 'Port'), + 'port' => [ + 'label' => mt('x509', 'Port'), + 'column' => function ($data) { + return $data->chain->target->port; + } + ], 'subject' => mt('x509', 'Certificate'), 'signature_algo' => [ - 'label' => mt('x509', 'Signature Algorithm'), + 'label' => mt('x509', 'Signature Algorithm'), 'renderer' => function ($algo, $data) { return "{$data['signature_hash_algo']} with $algo"; } ], 'pubkey_algo' => [ - 'label' => mt('x509', 'Public Key'), + 'label' => mt('x509', 'Public Key'), 'renderer' => function ($algo, $data) { return "$algo {$data['pubkey_bits']} bits"; } @@ -64,8 +79,8 @@ public function createColumns() 'valid_to' => [ 'attributes' => ['class' => 'expiration-col'], - 'label' => mt('x509', 'Expiration'), - 'renderer' => function ($to, $data) { + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { return new ExpirationWidget($data['valid_from'], $to); } ] @@ -76,7 +91,7 @@ protected function renderRow($row) { $tr = parent::renderRow($row); - $url = Url::fromPath('x509/chain', ['id' => $row['certificate_chain_id']]); + $url = Url::fromPath('x509/chain', ['id' => $row->chain->id]); $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); diff --git a/library/X509/Web/Control/SearchBar/ObjectSuggestions.php b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php new file mode 100644 index 00000000..777c1e38 --- /dev/null +++ b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php @@ -0,0 +1,203 @@ +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($this->getDb())->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($this->getDb()); + $query->limit(self::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 { + foreach ($query as $item) { + $columns = Str::trimSplit($column, '.'); + if ($columns[0] === $model->getTableAlias()) { + array_shift($columns); + } + + $value = $item; + + try { + do { + $col = array_shift($columns); + $value = $value->$col; + } while (! empty($columns)); + + if ($value && ! ctype_print($value)) { // Is binary + $value = sprintf('\\x%s', bin2hex($value)); + } elseif (is_bool($value)) { + // 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 (Exception $e) { + // Fatal error!! Should never happen + } + } + } 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($this->getDb()); + + 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, []); + } + + foreach ($models as $path => $targetModel) { + /** @var Model $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 = []; + } + } + } +}