summaryrefslogtreecommitdiffstats
path: root/application
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:47:35 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:47:35 +0000
commit5f112e7d0464d98282443b78870cdccabe42aae9 (patch)
treeaac24e989ceebb84c04de382960608c3fcef7313 /application
parentInitial commit. (diff)
downloadicingaweb2-module-x509-5f112e7d0464d98282443b78870cdccabe42aae9.tar.xz
icingaweb2-module-x509-5f112e7d0464d98282443b78870cdccabe42aae9.zip
Adding upstream version 1:1.1.2.upstream/1%1.1.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'application')
-rw-r--r--application/clicommands/CheckCommand.php236
-rw-r--r--application/clicommands/ImportCommand.php57
-rw-r--r--application/clicommands/JobsCommand.php73
-rw-r--r--application/clicommands/ScanCommand.php67
-rw-r--r--application/clicommands/VerifyCommand.php27
-rw-r--r--application/controllers/CertificateController.php40
-rw-r--r--application/controllers/CertificatesController.php127
-rw-r--r--application/controllers/ChainController.php83
-rw-r--r--application/controllers/ConfigController.php29
-rw-r--r--application/controllers/DashboardController.php134
-rw-r--r--application/controllers/IconsController.php31
-rw-r--r--application/controllers/JobsController.php83
-rw-r--r--application/controllers/SniController.php83
-rw-r--r--application/controllers/UsageController.php155
-rw-r--r--application/forms/Config/BackendConfigForm.php28
-rw-r--r--application/forms/Config/JobConfigForm.php96
-rw-r--r--application/forms/Config/SniConfigForm.php79
-rw-r--r--application/views/scripts/certificate/index.phtml6
-rw-r--r--application/views/scripts/certificates/index.phtml14
-rw-r--r--application/views/scripts/chain/index.phtml8
-rw-r--r--application/views/scripts/config/backend.phtml6
-rw-r--r--application/views/scripts/dashboard/index.phtml13
-rw-r--r--application/views/scripts/jobs/index.phtml46
-rw-r--r--application/views/scripts/missing-resource.phtml12
-rw-r--r--application/views/scripts/simple-form.phtml6
-rw-r--r--application/views/scripts/sni/index.phtml46
-rw-r--r--application/views/scripts/usage/index.phtml14
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>