diff options
Diffstat (limited to '')
27 files changed, 1599 insertions, 0 deletions
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php new file mode 100644 index 0000000..ae7f641 --- /dev/null +++ b/application/clicommands/CheckCommand.php @@ -0,0 +1,236 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Job; +use ipl\Sql\Select; + +class CheckCommand extends Command +{ + const UNIT_PERCENT = 'percent'; + const UNIT_INTERVAL = 'interval'; + + /** + * Check a host's certificate + * + * This command utilizes this module's database to check if the given host serves valid certificates. + * + * USAGE + * + * icingacli x509 check host [options] + * + * OPTIONS + * + * You can either pass --ip or --host or both at the same time but at least one is mandatory. + * + * --ip A hosts IP address + * --host A hosts name + * --port The port to check in particular + * --warning Less remaining time results in state WARNING + * Default: 25% + * --critical Less remaining time results in state CRITICAL + * Default: 10% + * --allow-self-signed Ignore if a certificate or its issuer has been + * self-signed + * + * EXAMPLES + * + * icingacli x509 check host --ip 10.0.10.78 + * icingacli x509 check host --host mail.example.org + * icingacli x509 check host --ip 10.0.10.78 --host mail.example.org --port 993 + * + * THRESHOLD DEFINITION + * + * Thresholds can either be defined relative (in percent) or absolute + * (time interval). Time intervals consist of a digit and an accompanying + * unit (e.g. "3M" are three months). Supported units are: + * + * Year: y, Y + * Month: M + * Day: d, D + * Hour: h, H + * Minute: m + * Second: s, S + */ + public function hostAction() + { + $ip = $this->params->get('ip'); + $hostname = $this->params->get('host'); + if ($ip === null && $hostname === null) { + $this->showUsage('host'); + exit(3); + } + + $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)', + 'c.valid_from', + 'c.valid_to' + ]) + ->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]); + + if ($ip !== null) { + $targets->where(['t.ip = ?' => Job::binary($ip)]); + } + if ($hostname !== null) { + $targets->where(['t.hostname = ?' => $hostname]); + } + if ($this->params->has('port')) { + $targets->where(['t.port = ?' => $this->params->get('port')]); + } + + $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); + list($warningThreshold, $warningUnit) = $this->splitThreshold($this->params->get('warning', '25%')); + list($criticalThreshold, $criticalUnit) = $this->splitThreshold($this->params->get('critical', '10%')); + + $output = []; + $perfData = []; + + $state = 3; + foreach ($this->getDb()->select($targets) as $target) { + if ($target['valid'] === 'no' && ($target['self_signed'] === 'no' || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target['invalid_reason']; + $output[$invalidMessage] = $invalidMessage; + $state = 2; + } + + $now = new \DateTime(); + $validFrom = (new \DateTime())->setTimestamp($target['valid_from']); + $validTo = (new \DateTime())->setTimestamp($target['valid_to']); + $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold, $criticalUnit); + $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold, $warningUnit); + + if ($now > $criticalAfter) { + $state = 2; + } elseif ($state !== 2 && $now > $warningAfter) { + $state = 1; + } elseif ($state === 3) { + $state = 0; + } + + $remainingTime = $now->diff($validTo); + if (! $remainingTime->invert) { + // The certificate has not expired yet + $output[$target['subject']] = sprintf( + '%s expires in %d days', + $target['subject'], + $remainingTime->days + ); + } else { + $output[$target['subject']] = sprintf( + '%s has expired since %d days', + $target['subject'], + $remainingTime->days + ); + } + + $perfData[$target['subject']] = sprintf( + "'%s'=%ds;%d;%d;0;%d", + $target['subject'], + $remainingTime->invert + ? 0 + : $target['valid_to'] - time(), + $target['valid_to'] - $warningAfter->getTimestamp(), + $target['valid_to'] - $criticalAfter->getTimestamp(), + $target['valid_to'] - $target['valid_from'] + ); + } + + echo ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN'][$state]; + echo ' - '; + + if (! empty($output)) { + echo join('; ', $output); + } elseif ($state === 3) { + echo 'Host not found'; + } + + if (! empty($perfData)) { + echo '|' . join(' ', $perfData); + } + + echo PHP_EOL; + exit($state); + } + + /** + * Parse the given threshold definition + * + * @param string $threshold + * + * @return array + */ + protected function splitThreshold($threshold) + { + $match = preg_match('/(\d+)([%\w]{1})/', $threshold, $matches); + if (! $match) { + Logger::error('Invalid threshold definition: %s', $threshold); + exit(3); + } + + switch ($matches[2]) { + case '%': + return [(int) $matches[1], self::UNIT_PERCENT]; + case 'y': + case 'Y': + $intervalSpec = 'P' . $matches[1] . 'Y'; + break; + case 'M': + $intervalSpec = 'P' . $matches[1] . 'M'; + break; + case 'd': + case 'D': + $intervalSpec = 'P' . $matches[1] . 'D'; + break; + case 'h': + case 'H': + $intervalSpec = 'PT' . $matches[1] . 'H'; + break; + case 'm': + $intervalSpec = 'PT' . $matches[1] . 'M'; + break; + case 's': + case 'S': + $intervalSpec = 'PT' . $matches[1] . 'S'; + break; + default: + Logger::error('Unknown threshold unit given: %s', $threshold); + exit(3); + } + + return [new \DateInterval($intervalSpec), self::UNIT_INTERVAL]; + } + + /** + * Convert the given threshold information to a DateTime object + * + * @param \DateTime $from + * @param \DateTime $to + * @param int|\DateInterval $thresholdValue + * @param string $thresholdUnit + * + * @return \DateTime + */ + protected function thresholdToDateTime(\DateTime $from, \DateTime $to, $thresholdValue, $thresholdUnit) + { + $to = clone $to; + if ($thresholdUnit === self::UNIT_INTERVAL) { + return $to->sub($thresholdValue); + } + + $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100)); + return $to->sub(new \DateInterval('P' . $coveredDays . 'D')); + } +} diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php new file mode 100644 index 0000000..1f9d1ef --- /dev/null +++ b/application/clicommands/ImportCommand.php @@ -0,0 +1,57 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use ipl\Sql\Connection; + +class ImportCommand extends Command +{ + /** + * Import all X.509 certificates from the given file and mark them as trusted + * + * USAGE: + * + * icingacli x509 import --file <file> + * + * EXAMPLES: + * + * icingacli x509 import --file /etc/ssl/certs/ca-bundle.crt + */ + public function indexAction() + { + $file = $this->params->getRequired('file'); + + if (! file_exists($file)) { + Logger::warning('The specified certificate file does not exist.'); + exit(1); + } + + $db = $this->getDb(); + + $bundle = CertificateUtils::parseBundle($file); + + $count = 0; + + $db->transaction(function (Connection $db) use ($bundle, &$count) { + foreach ($bundle as $data) { + $cert = openssl_x509_read($data); + + $id = CertificateUtils::findOrInsertCert($db, $cert); + + $db->update( + 'x509_certificate', + ['trusted' => 'yes'], + ['id = ?' => $id] + ); + + $count++; + } + }); + + printf("Processed %d X.509 certificate%s.\n", $count, $count !== 1 ? 's' : ''); + } +} diff --git a/application/clicommands/JobsCommand.php b/application/clicommands/JobsCommand.php new file mode 100644 index 0000000..0e1d599 --- /dev/null +++ b/application/clicommands/JobsCommand.php @@ -0,0 +1,73 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Hook\SniHook; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Scheduler; + +class JobsCommand extends Command +{ + /** + * Run all configured jobs based on their schedule + * + * USAGE: + * + * icingacli x509 jobs run + */ + public function runAction() + { + $parallel = (int) $this->Config()->get('scan', 'parallel', 256); + + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1."); + } + + $scheduler = new Scheduler(); + + $defaultSchedule = $this->Config()->get('jobs', 'default_schedule'); + + $db = $this->getDb(); + + foreach ($this->Config('jobs') as $name => $jobDescription) { + $schedule = $jobDescription->get('schedule', $defaultSchedule); + + if (! $schedule) { + Logger::debug("The job '%s' is not scheduled.", $name); + continue; + } + + $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel); + + $scheduler->add($name, $schedule, function () use ($job, $name, $db) { + if (! $db->ping()) { + Logger::error('Lost connection to database and failed to re-connect. Skipping this job run.'); + return; + } + + $finishedTargets = $job->run(); + + if ($finishedTargets === null) { + Logger::warning("The job '%s' does not have any targets.", $name); + } else { + Logger::info( + "Scanned %s target%s in job '%s'.\n", + $finishedTargets, + $finishedTargets != 1 ? 's' : '', + $name + ); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } + }); + } + + $scheduler->run(); + } +} diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php new file mode 100644 index 0000000..fd92c7a --- /dev/null +++ b/application/clicommands/ScanCommand.php @@ -0,0 +1,67 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Hook\SniHook; +use Icinga\Module\X509\Job; + +class ScanCommand extends Command +{ + /** + * Scans IP and port ranges to find X.509 certificates. + * + * This command starts scanning the IP and port ranges which belong to the job that was specified with the + * --job parameter. + * + * USAGE + * + * icingacli x509 scan --job <name> + */ + public function indexAction() + { + $name = $this->params->shiftRequired('job'); + + $parallel = (int) $this->Config()->get('scan', 'parallel', 256); + + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1."); + } + + $jobs = $this->Config('jobs'); + + if (! $jobs->hasSection($name)) { + $this->fail('Job not found.'); + } + + $jobDescription = $this->Config('jobs')->getSection($name); + + if (! strlen($jobDescription->get('cidrs'))) { + $this->fail('The job does not specify any CIDRs.'); + } + + $db = $this->getDb(); + + $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel); + + $finishedTargets = $job->run(); + + if ($finishedTargets === null) { + Logger::warning("The job '%s' does not have any targets.", $name); + } else { + Logger::info( + "Scanned %s target%s in job '%s'.\n", + $finishedTargets, + $finishedTargets != 1 ? 's' : '', + $name + ); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } + } +} diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php new file mode 100644 index 0000000..a76c100 --- /dev/null +++ b/application/clicommands/VerifyCommand.php @@ -0,0 +1,27 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; + +class VerifyCommand extends Command +{ + /** + * Verify all currently collected X.509 certificates + * + * USAGE: + * + * icingacli x509 verify + */ + public function indexAction() + { + $db = $this->getDb(); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } +} diff --git a/application/controllers/CertificateController.php b/application/controllers/CertificateController.php new file mode 100644 index 0000000..414d1f3 --- /dev/null +++ b/application/controllers/CertificateController.php @@ -0,0 +1,40 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\CertificateDetails; +use Icinga\Module\X509\Controller; +use ipl\Sql; + +class CertificateController extends Controller +{ + public function indexAction() + { + $certId = $this->params->getRequired('cert'); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $cert = $conn->select( + (new Sql\Select()) + ->from('x509_certificate') + ->columns('*') + ->where(['id = ?' => $certId]) + )->fetch(); + + if ($cert === false) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->setTitle($this->translate('X.509 Certificate')); + + $this->view->certificateDetails = (new CertificateDetails()) + ->setCert($cert); + } +} diff --git a/application/controllers/CertificatesController.php b/application/controllers/CertificatesController.php new file mode 100644 index 0000000..c145b03 --- /dev/null +++ b/application/controllers/CertificatesController.php @@ -0,0 +1,127 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +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; + +class CertificatesController extends Controller +{ + public function indexAction() + { + $this + ->initTabs() + ->setTitle($this->translate('Certificates')); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + 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'), + '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') + ]; + + $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'] + ); + SqlFilter::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; + } + }); + + $this->handleFormatRequest($conn, $select, function (\PDOStatement $stmt) { + foreach ($stmt as $cert) { + $cert['valid_from'] = (new \DateTime()) + ->setTimestamp($cert['valid_from']) + ->format('l F jS, Y H:i:s e'); + $cert['valid_to'] = (new \DateTime()) + ->setTimestamp($cert['valid_to']) + ->format('l F jS, Y H:i:s e'); + + yield $cert; + } + }); + + $this->view->certificatesTable = (new CertificatesTable())->setData($conn->select($select)); + } +} diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php new file mode 100644 index 0000000..870fa81 --- /dev/null +++ b/application/controllers/ChainController.php @@ -0,0 +1,83 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\ChainDetails; +use Icinga\Module\X509\Controller; +use ipl\Html\Attribute; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Sql; + +class ChainController extends Controller +{ + public function indexAction() + { + $id = $this->params->getRequired('id'); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $chainSelect = (new Sql\Select()) + ->from('x509_certificate_chain ch') + ->columns('*') + ->join('x509_target t', 't.id = ch.target_id') + ->where(['ch.id = ?' => $id]); + + $chain = $conn->select($chainSelect)->fetch(); + + if ($chain === false) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->setTitle($this->translate('X.509 Certificate Chain')); + + $ip = $chain['ip']; + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + + $chainInfo = Html::tag('div'); + $chainInfo->add(Html::tag('dl', [ + Html::tag('dt', $this->translate('Host')), + Html::tag('dd', $chain['hostname']), + Html::tag('dt', $this->translate('IP')), + Html::tag('dd', inet_ntop($ip)), + Html::tag('dt', $this->translate('Port')), + Html::tag('dd', $chain['port']) + ])); + + $valid = Html::tag('div', ['class' => 'cert-chain']); + + if ($chain['valid'] === 'yes') { + $valid->getAttributes()->add('class', '-valid'); + $valid->add(Html::tag('p', $this->translate('Certificate chain is valid.'))); + } else { + $valid->getAttributes()->add('class', '-invalid'); + $valid->add(Html::tag('p', sprintf( + $this->translate('Certificate chain is invalid: %s.'), + $chain['invalid_reason'] + ))); + } + + $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'); + + $this->view->chain = (new HtmlDocument()) + ->add($chainInfo) + ->add($valid) + ->add((new ChainDetails())->setData($conn->select($certsSelect))); + } +} diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php new file mode 100644 index 0000000..c64cd5c --- /dev/null +++ b/application/controllers/ConfigController.php @@ -0,0 +1,29 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Application\Config; +use Icinga\Module\X509\Forms\Config\BackendConfigForm; +use Icinga\Web\Controller; + +class ConfigController extends Controller +{ + public function init() + { + $this->assertPermission('config/modules'); + + parent::init(); + } + + public function backendAction() + { + $form = (new BackendConfigForm()) + ->setIniConfig(Config::module('x509')); + + $form->handleRequest(); + + $this->view->tabs = $this->Module()->getConfigTabs()->activate('backend'); + $this->view->form = $form; + } +} diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php new file mode 100644 index 0000000..a48bb98 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,134 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Donut; +use Icinga\Web\Url; +use ipl\Html\Html; +use ipl\Sql\Select; + +class DashboardController extends Controller +{ + public function indexAction() + { + $this->setTitle($this->translate('Certificate Dashboard')); + + try { + $db = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + 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 = ?' => 'yes']) + ->groupBy(['i.id']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $this->view->byCa = (new Donut()) + ->setHeading($this->translate('Certificates by CA'), 2) + ->setData($byCa) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath('x509/certificates', ['issuer' => $data['subject']])->getAbsoluteUrl() + ], + $data['subject'] + ); + }); + + $duration = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns([ + 'duration' => 'valid_to - valid_from', + 'cnt' => 'COUNT(*)' + ]) + ->where(['ca = ?' => 'no']) + ->groupBy(['duration']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $this->view->duration = (new Donut()) + ->setHeading($this->translate('Certificates by Duration'), 2) + ->setData($duration) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + "x509/certificates?duration={$data['duration']}&ca=no" + )->getAbsoluteUrl() + ], + CertificateUtils::duration($data['duration']) + ); + }); + + $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) + ); + + $this->view->keyStrength = (new Donut()) + ->setHeading($this->translate('Key Strength'), 2) + ->setData($keyStrength) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'pubkey_algo' => $data['pubkey_algo'], + 'pubkey_bits' => $data['pubkey_bits'] + ] + )->getAbsoluteUrl() + ], + "{$data['pubkey_algo']} {$data['pubkey_bits']} bits" + ); + }); + + $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) + ); + + $this->view->sigAlgos = (new Donut()) + ->setHeading($this->translate('Signature Algorithms'), 2) + ->setData($sigAlgos) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'signature_hash_algo' => $data['signature_hash_algo'], + 'signature_algo' => $data['signature_algo'] + ] + )->getAbsoluteUrl() + ], + "{$data['signature_hash_algo']} with {$data['signature_algo']}" + ); + }); + } +} diff --git a/application/controllers/IconsController.php b/application/controllers/IconsController.php new file mode 100644 index 0000000..422dcd5 --- /dev/null +++ b/application/controllers/IconsController.php @@ -0,0 +1,31 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Web\Controller; + +class IconsController extends Controller +{ + /** + * Disable layout rendering as this controller doesn't provide any html layouts + */ + public function init() + { + $this->_helper->viewRenderer->setNoRender(true); + $this->_helper->layout()->disableLayout(); + } + + public function indexAction() + { + $file = realpath( + $this->Module()->getBaseDir() . '/public/font/icons.' . $this->params->get('q', 'svg') + ); + + if ($file === false) { + $this->httpNotFound('File does not exist'); + } + + readfile($file); + } +} diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php new file mode 100644 index 0000000..0df196b --- /dev/null +++ b/application/controllers/JobsController.php @@ -0,0 +1,83 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\X509\Forms\Config\JobConfigForm; +use Icinga\Module\X509\JobsIniRepository; +use Icinga\Web\Controller; +use Icinga\Web\Url; + +class JobsController extends Controller +{ + /** + * List all jobs + */ + public function indexAction() + { + $this->view->tabs = $this->Module()->getConfigTabs()->activate('jobs'); + + $repo = new JobsIniRepository(); + + $this->view->jobs = $repo->select(array('name')); + } + + /** + * Create a job + */ + public function newAction() + { + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->renderForm($form, $this->translate('New Job')); + } + + /** + * Update a job + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('name')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->renderForm($form, $this->translate('Update Job')); + } + + /** + * Remove a job + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('name')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->renderForm($form, $this->translate('Remove Job')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return JobConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new JobConfigForm()) + ->setRepository(new JobsIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/jobs')); + } +} diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php new file mode 100644 index 0000000..21da41f --- /dev/null +++ b/application/controllers/SniController.php @@ -0,0 +1,83 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\X509\Forms\Config\SniConfigForm; +use Icinga\Module\X509\SniIniRepository; +use Icinga\Web\Controller; +use Icinga\Web\Url; + +class SniController extends Controller +{ + /** + * List all maps + */ + public function indexAction() + { + $this->view->tabs = $this->Module()->getConfigTabs()->activate('sni'); + + $repo = new SniIniRepository(); + + $this->view->sni = $repo->select(array('ip')); + } + + /** + * Create a map + */ + public function newAction() + { + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->renderForm($form, $this->translate('New SNI Map')); + } + + /** + * Update a map + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Update SNI Map')); + } + + /** + * Remove a map + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Remove SNI Map')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return SniConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new SniConfigForm()) + ->setRepository(new SniIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/sni')); + } +} diff --git a/application/controllers/UsageController.php b/application/controllers/UsageController.php new file mode 100644 index 0000000..287b979 --- /dev/null +++ b/application/controllers/UsageController.php @@ -0,0 +1,155 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\FilterAdapter; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\SortAdapter; +use Icinga\Module\X509\SqlFilter; +use Icinga\Module\X509\UsageTable; +use ipl\Web\Control\PaginationControl; +use ipl\Sql; +use ipl\Web\Url; + +class UsageController extends Controller +{ + public function indexAction() + { + $this + ->initTabs() + ->setTitle($this->translate('Certificate Usage')); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + 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'), + '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') + ]; + + $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'] + ); + SqlFilter::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; + } + }); + + $formatQuery = clone $select; + $formatQuery->resetColumns()->columns([ + 'valid', 'hostname', 'ip', 'port', 'subject', 'issuer', 'version', + 'self_signed', 'ca', 'trusted', 'pubkey_algo', 'pubkey_bits', + 'signature_algo', 'signature_hash_algo', 'valid_from', 'valid_to' + ]); + + $this->handleFormatRequest($conn, $formatQuery, function (\PDOStatement $stmt) { + foreach ($stmt as $usage) { + $usage['valid_from'] = (new \DateTime()) + ->setTimestamp($usage['valid_from']) + ->format('l F jS, Y H:i:s e'); + $usage['valid_to'] = (new \DateTime()) + ->setTimestamp($usage['valid_to']) + ->format('l F jS, Y H:i:s e'); + + $ip = $usage['ip']; + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + $usage['ip'] = inet_ntop($ip); + + yield $usage; + } + }); + + $this->view->usageTable = (new UsageTable())->setData($conn->select($select)); + } +} diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php new file mode 100644 index 0000000..28b2c79 --- /dev/null +++ b/application/forms/Config/BackendConfigForm.php @@ -0,0 +1,28 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Forms\Config; + +use Icinga\Data\ResourceFactory; +use Icinga\Forms\ConfigForm; + +class BackendConfigForm extends ConfigForm +{ + public function init() + { + $this->setName('x509_backend'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + public function createElements(array $formData) + { + $dbResources = ResourceFactory::getResourceConfigs('db')->keys(); + + $this->addElement('select', 'backend_resource', [ + 'label' => $this->translate('Database'), + 'description' => $this->translate('Database resource'), + 'multiOptions' => array_combine($dbResources, $dbResources), + 'required' => true + ]); + } +} diff --git a/application/forms/Config/JobConfigForm.php b/application/forms/Config/JobConfigForm.php new file mode 100644 index 0000000..2f0c018 --- /dev/null +++ b/application/forms/Config/JobConfigForm.php @@ -0,0 +1,96 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Forms\Config; + +use Icinga\Data\Filter\Filter; +use Icinga\Forms\RepositoryForm; + +/** + * Create, update and delete jobs + */ +class JobConfigForm extends RepositoryForm +{ + protected function createInsertElements(array $formData) + { + $this->addElements([ + [ + 'text', + 'name', + [ + 'description' => $this->translate('Job name'), + 'label' => $this->translate('Name'), + 'required' => true + ] + ], + [ + 'textarea', + 'cidrs', + [ + 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'), + 'label' => $this->translate('CIDRs'), + 'required' => true + ] + ], + [ + 'textarea', + 'ports', + [ + 'description' => $this->translate('Comma-separated list of ports to scan'), + 'label' => $this->translate('Ports'), + 'required' => true + ] + ], + [ + 'text', + 'schedule', + [ + 'description' => $this->translate('Job cron Schedule'), + 'label' => $this->translate('Schedule') + ] + ], + ]); + + $this->setTitle($this->translate('Create a New Job')); + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit job %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove job %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('name', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Job created') + : $this->translate('Failed to create job'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Job updated') + : $this->translate('Failed to update job'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Job removed') + : $this->translate('Failed to remove job'); + } +} diff --git a/application/forms/Config/SniConfigForm.php b/application/forms/Config/SniConfigForm.php new file mode 100644 index 0000000..6e36110 --- /dev/null +++ b/application/forms/Config/SniConfigForm.php @@ -0,0 +1,79 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Forms\Config; + +use Icinga\Data\Filter\Filter; +use Icinga\Forms\RepositoryForm; + +/** + * Create, update and delete jobs + */ +class SniConfigForm extends RepositoryForm +{ + protected function createInsertElements(array $formData) + { + $this->addElements([ + [ + 'text', + 'ip', + [ + 'description' => $this->translate('IP'), + 'label' => $this->translate('IP'), + 'required' => true + ] + ], + [ + 'textarea', + 'hostnames', + [ + 'description' => $this->translate('Comma-separated list of hostnames'), + 'label' => $this->translate('Hostnames'), + 'required' => true + ] + ] + ]); + + $this->setTitle($this->translate('Create a New SNI Map')); + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit map for %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove map for %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('ip', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Map created') + : $this->translate('Failed to create map'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Map updated') + : $this->translate('Failed to update map'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Map removed') + : $this->translate('Failed to remove map'); + } +} diff --git a/application/views/scripts/certificate/index.phtml b/application/views/scripts/certificate/index.phtml new file mode 100644 index 0000000..08cfadb --- /dev/null +++ b/application/views/scripts/certificate/index.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <?= /** @var \Icinga\Module\X509\CertificateDetails $certificateDetails */ $certificateDetails->render() ?> +</div> diff --git a/application/views/scripts/certificates/index.phtml b/application/views/scripts/certificates/index.phtml new file mode 100644 index 0000000..47eb2b5 --- /dev/null +++ b/application/views/scripts/certificates/index.phtml @@ -0,0 +1,14 @@ +<?php if (! $this->compact): ?> +<div class="controls"> + <?= $this->tabs ?> + <?= $this->paginator ?> + <div class="sort-controls-container"> + <?= $this->limiter ?> + <?= $this->sortBox ?> + </div> + <?= $this->filterEditor ?> +</div> +<?php endif ?> +<div class="content"> + <?= /** @var \Icinga\Module\X509\CertificatesTable $certificatesTable */ $certificatesTable->render() ?> +</div> diff --git a/application/views/scripts/chain/index.phtml b/application/views/scripts/chain/index.phtml new file mode 100644 index 0000000..ffa3872 --- /dev/null +++ b/application/views/scripts/chain/index.phtml @@ -0,0 +1,8 @@ +<?php if (! $this->compact): ?> +<div class="controls"> + <?= $this->tabs ?> +</div> +<?php endif ?> +<div class="content"> + <?= /** @var \ipl\Html\ValidHtml $chain */ $chain->render() ?> +</div> diff --git a/application/views/scripts/config/backend.phtml b/application/views/scripts/config/backend.phtml new file mode 100644 index 0000000..78e312e --- /dev/null +++ b/application/views/scripts/config/backend.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <?= /** @var \Icinga\Module\X509\Forms\Config\BackendConfigForm $form */ $form ?> +</div> diff --git a/application/views/scripts/dashboard/index.phtml b/application/views/scripts/dashboard/index.phtml new file mode 100644 index 0000000..3b6ec0f --- /dev/null +++ b/application/views/scripts/dashboard/index.phtml @@ -0,0 +1,13 @@ +<?php if (! $this->compact): ?> + <div class="controls"> + <?= $this->tabs ?> + </div> +<?php endif ?> +<div class="content"> + <div class="cert-dashboard"> + <?= $byCa->render() ?> + <?= $duration->render() ?> + <?= $keyStrength->render() ?> + <?= $sigAlgos->render() ?> + </div> +</div> diff --git a/application/views/scripts/jobs/index.phtml b/application/views/scripts/jobs/index.phtml new file mode 100644 index 0000000..e86c3a6 --- /dev/null +++ b/application/views/scripts/jobs/index.phtml @@ -0,0 +1,46 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <div class="actions"> + <?= $this->qlink( + $this->translate('Create a New Job') , + 'x509/jobs/new', + null, + [ + 'class' => 'button-link', + 'data-base-target' => '_next', + 'icon' => 'plus', + 'title' => $this->translate('Create a New Job') + ] + ) ?> + </div> +<?php /** @var \Icinga\Repository\RepositoryQuery $jobs */ if (! $jobs->hasResult()): ?> + <p><?= $this->escape($this->translate('No jobs configured yet.')) ?></p> +<?php else: ?> + <table class="common-table table-row-selectable" data-base-target="_next"> + <thead> + <tr> + <th><?= $this->escape($this->translate('Name')) ?></th> + </tr> + </thead> + <tbody> + <?php foreach ($jobs as $job): ?> + <tr> + <td><?= $this->qlink($job->name, 'x509/jobs/update', ['name' => $job->name]) ?></td> + <td class="icon-col"><?= $this->qlink( + null, + 'x509/jobs/remove', + array('name' => $job->name), + array( + 'class' => 'action-link', + 'icon' => 'cancel', + 'title' => $this->translate('Remove this job') + ) + ) ?></td> + </tr> + <?php endforeach ?> + </tbody> + </table> +<?php endif ?> +</div> diff --git a/application/views/scripts/missing-resource.phtml b/application/views/scripts/missing-resource.phtml new file mode 100644 index 0000000..fcfa255 --- /dev/null +++ b/application/views/scripts/missing-resource.phtml @@ -0,0 +1,12 @@ +<div class="controls"> + <?= $this->tabs ?> +</div> +<div class="content"> + <h2><?= $this->translate('Database not configured') ?></h2> + <p data-base-target="_next"><?= sprintf( + $this->translate('You seem to not have configured a database resource yet. Please create one %1$shere%3$s and then set it in this %2$smodule\'s configuration%3$s.'), + '<a class="action-link" href="' . $this->href('config/resource') . '">', + '<a class="action-link" href="' . $this->href('x509/config/backend') . '">', + '</a>' + ) ?></p> +</div> diff --git a/application/views/scripts/simple-form.phtml b/application/views/scripts/simple-form.phtml new file mode 100644 index 0000000..9bcba74 --- /dev/null +++ b/application/views/scripts/simple-form.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <?= $tabs ?> +</div> +<div class="content"> + <?= $form->create()->setTitle(null) // @TODO(el): create() has to be called because the UserForm is setting the title there ?> +</div> diff --git a/application/views/scripts/sni/index.phtml b/application/views/scripts/sni/index.phtml new file mode 100644 index 0000000..09c4de8 --- /dev/null +++ b/application/views/scripts/sni/index.phtml @@ -0,0 +1,46 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <div class="actions"> + <?= $this->qlink( + $this->translate('Create a New SNI Map') , + 'x509/sni/new', + null, + [ + 'class' => 'button-link', + 'data-base-target' => '_next', + 'icon' => 'plus', + 'title' => $this->translate('Create a New SNI Map') + ] + ) ?> + </div> + <?php /** @var \Icinga\Repository\RepositoryQuery $sni */ if (! $sni->hasResult()): ?> + <p><?= $this->escape($this->translate('No SNI maps configured yet.')) ?></p> + <?php else: ?> + <table class="common-table table-row-selectable" data-base-target="_next"> + <thead> + <tr> + <th><?= $this->escape($this->translate('IP')) ?></th> + </tr> + </thead> + <tbody> + <?php foreach ($sni as $data): ?> + <tr> + <td><?= $this->qlink($data->ip, 'x509/sni/update', ['ip' => $data->ip]) ?></td> + <td class="icon-col"><?= $this->qlink( + null, + 'x509/sni/remove', + array('ip' => $data->ip), + array( + 'class' => 'action-link', + 'icon' => 'cancel', + 'title' => $this->translate('Remove this SNI map') + ) + ) ?></td> + </tr> + <?php endforeach ?> + </tbody> + </table> + <?php endif ?> +</div> diff --git a/application/views/scripts/usage/index.phtml b/application/views/scripts/usage/index.phtml new file mode 100644 index 0000000..a0eed09 --- /dev/null +++ b/application/views/scripts/usage/index.phtml @@ -0,0 +1,14 @@ +<?php if (! $this->compact): ?> +<div class="controls"> + <?= $this->tabs ?> + <?= $this->paginator ?> + <div class="sort-controls-container"> + <?= $this->limiter ?> + <?= $this->sortBox ?> + </div> + <?= $this->filterEditor ?> +</div> +<?php endif ?> +<div class="content"> + <?= /** @var \Icinga\Module\X509\UsageTable $usageTable */ $usageTable->render() ?> +</div> |