summaryrefslogtreecommitdiffstats
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--application/clicommands/CheckCommand.php268
-rw-r--r--application/clicommands/CleanupCommand.php95
-rw-r--r--application/clicommands/ImportCommand.php61
-rw-r--r--application/clicommands/JobsCommand.php279
-rw-r--r--application/clicommands/MigrateCommand.php121
-rw-r--r--application/clicommands/ScanCommand.php163
-rw-r--r--application/clicommands/VerifyCommand.php27
-rw-r--r--application/controllers/CertificateController.php43
-rw-r--r--application/controllers/CertificatesController.php117
-rw-r--r--application/controllers/ChainController.php77
-rw-r--r--application/controllers/ConfigController.php30
-rw-r--r--application/controllers/DashboardController.php153
-rw-r--r--application/controllers/JobController.php226
-rw-r--r--application/controllers/JobsController.php66
-rw-r--r--application/controllers/SniController.php103
-rw-r--r--application/controllers/UsageController.php141
-rw-r--r--application/forms/Config/BackendConfigForm.php29
-rw-r--r--application/forms/Config/SniConfigForm.php79
-rw-r--r--application/forms/Jobs/JobConfigForm.php154
-rw-r--r--application/forms/Jobs/ScheduleForm.php201
-rw-r--r--application/views/scripts/certificate/index.phtml6
-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/missing-resource.phtml12
-rw-r--r--application/views/scripts/simple-form.phtml6
-rw-r--r--application/views/scripts/sni/index.phtml31
27 files changed, 2515 insertions, 0 deletions
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php
new file mode 100644
index 0000000..0c369d9
--- /dev/null
+++ b/application/clicommands/CheckCommand.php
@@ -0,0 +1,268 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateInterval;
+use DateTime;
+use DateTimeInterface;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+
+class CheckCommand extends Command
+{
+ /**
+ * 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 = X509Target::on(Database::get())->with([
+ 'chain',
+ 'chain.certificate',
+ 'chain.certificate.issuer_certificate'
+ ]);
+
+ $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT');
+
+ $targets->columns([
+ 'port',
+ 'chain.valid',
+ 'chain.invalid_reason',
+ 'subject' => 'chain.certificate.subject',
+ 'self_signed' => new Expression('COALESCE(%s, %s)', [
+ 'chain.certificate.issuer_certificate.self_signed',
+ 'chain.certificate.self_signed'
+ ])
+ ]);
+
+ // Sub query for `valid_from` column
+ $validFrom = $targets->createSubQuery(new X509Certificate(), 'chain.certificate');
+ $validFrom
+ ->columns([new Expression('MAX(GREATEST(%s, %s))', ['valid_from', 'issuer_certificate.valid_from'])])
+ ->getSelectBase()
+ ->resetWhere()
+ ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id'));
+
+ // Sub query for `valid_to` column
+ $validTo = $targets->createSubQuery(new X509Certificate(), 'chain.certificate');
+ $validTo
+ ->columns([new Expression('MIN(LEAST(%s, %s))', ['valid_to', 'issuer_certificate.valid_to'])])
+ ->getSelectBase()
+ // Reset the where clause generated within the createSubQuery() method.
+ ->resetWhere()
+ ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id'));
+
+ list($validFromSelect, $_) = $validFrom->dump();
+ list($validToSelect, $_) = $validTo->dump();
+ $targets
+ ->withColumns([
+ 'valid_from' => new Expression($validFromSelect),
+ 'valid_to' => new Expression($validToSelect)
+ ])
+ ->getSelectBase()
+ ->where(new Expression('target_chain_link.order = 0'));
+
+ if ($ip !== null) {
+ $targets->filter(Filter::equal('ip', $ip));
+ }
+ if ($hostname !== null) {
+ $targets->filter(Filter::equal('hostname', $hostname));
+ }
+ if ($this->params->has('port')) {
+ $targets->filter(Filter::equal('port', $this->params->get('port')));
+ }
+
+ $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false);
+ $warningThreshold = $this->splitThreshold($this->params->get('warning', '25%'));
+ $criticalThreshold = $this->splitThreshold($this->params->get('critical', '10%'));
+
+ $output = [];
+ $perfData = [];
+
+ $state = 3;
+ foreach ($targets as $target) {
+ if (! $target->chain->valid && (! $target['self_signed'] || ! $allowSelfSigned)) {
+ $invalidMessage = $target['subject'] . ': ' . $target->chain->invalid_reason;
+ $output[$invalidMessage] = $invalidMessage;
+ $state = 2;
+ }
+
+ $now = new DateTime();
+ $validFrom = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_from / 1000.0));
+ $validTo = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_to / 1000.0));
+ $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold);
+ $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold);
+
+ 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
+ : $validTo->getTimestamp() - time(),
+ $validTo->getTimestamp() - $warningAfter->getTimestamp(),
+ $validTo->getTimestamp() - $criticalAfter->getTimestamp(),
+ $validTo->getTimestamp() - $validFrom->getTimestamp()
+ );
+ }
+
+ 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 int|DateInterval
+ */
+ protected function splitThreshold(string $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];
+ 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);
+ }
+
+ /**
+ * Convert the given threshold information to a DateTime object
+ *
+ * @param DateTime $from
+ * @param DateTime $to
+ * @param int|DateInterval $thresholdValue
+ *
+ * @return DateTimeInterface
+ */
+ protected function thresholdToDateTime(DateTime $from, DateTime $to, $thresholdValue): DateTimeInterface
+ {
+ $to = clone $to;
+ if ($thresholdValue instanceof DateInterval) {
+ 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/CleanupCommand.php b/application/clicommands/CleanupCommand.php
new file mode 100644
index 0000000..61c43d4
--- /dev/null
+++ b/application/clicommands/CleanupCommand.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateTime;
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use InvalidArgumentException;
+use Throwable;
+
+class CleanupCommand extends Command
+{
+ /**
+ * Remove targets whose last scan is older than a certain date/time and certificates that are no longer used.
+ *
+ * By default, any targets whose last scan is older than 1 month are removed. The last scan information is
+ * always updated when scanning a target, regardless of whether a successful connection is made or not.
+ * Therefore, targets that have been decommissioned or are no longer part of a job configuration are removed
+ * after the specified period. Any certificates that are no longer used are also removed. This can either be
+ * because the associated target has been removed or because it is presenting a new certificate chain.
+ *
+ * This command will also remove jobs activities created before the given date/time. Jobs activities are usually
+ * some stats about the job runs performed by the scheduler or/and manually executed using the `scan` and/or
+ * `jobs` command.
+ *
+ * USAGE
+ *
+ * icingacli x509 cleanup [OPTIONS]
+ *
+ * OPTIONS
+ *
+ * --since-last-scan=<datetime>
+ * Clean up targets whose last scan is older than the specified date/time,
+ * which can also be an English textual datetime description like "2 days".
+ * Defaults to "1 month".
+ *
+ * EXAMPLES
+ *
+ * Remove any targets that have not been scanned for at least two months and any certificates that are no longer
+ * used.
+ *
+ * icingacli x509 cleanup --since-last-scan="2 months"
+ *
+ */
+ public function indexAction()
+ {
+ /** @var string $sinceLastScan */
+ $sinceLastScan = $this->params->get('since-last-scan', '-1 month');
+ $lastScan = $sinceLastScan;
+ if ($lastScan[0] !== '-') {
+ // When the user specified "2 days" as a threshold strtotime() will compute the
+ // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days)
+ $lastScan = "-$lastScan";
+ }
+
+ try {
+ $sinceLastScan = new DateTime($lastScan);
+ } catch (Exception $_) {
+ throw new InvalidArgumentException(sprintf(
+ 'The specified last scan time is in an unknown format: %s',
+ $sinceLastScan
+ ));
+ }
+
+ try {
+ $conn = Database::get();
+ $query = $conn->delete(
+ 'x509_target',
+ ['last_scan < ?' => $sinceLastScan->format('Uv')]
+ );
+
+ if ($query->rowCount() > 0) {
+ Logger::info(
+ 'Removed %d targets matching since last scan filter: %s',
+ $query->rowCount(),
+ $sinceLastScan->format('Y-m-d H:i:s')
+ );
+ }
+
+ $query = $conn->delete('x509_job_run', ['start_time < ?' => $sinceLastScan->getTimestamp() * 1000]);
+ if ($query->rowCount() > 0) {
+ Logger::info('Removed %d jobs activities', $query->rowCount());
+ }
+
+ CertificateUtils::cleanupNoLongerUsedCertificates($conn);
+ } catch (Throwable $err) {
+ Logger::error($err);
+ }
+ }
+}
diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php
new file mode 100644
index 0000000..2e7b157
--- /dev/null
+++ b/application/clicommands/ImportCommand.php
@@ -0,0 +1,61 @@
+<?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\Common\Database;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+
+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);
+ }
+
+ $bundle = CertificateUtils::parseBundle($file);
+
+ $count = 0;
+
+ Database::get()->transaction(function (Connection $db) use ($bundle, &$count) {
+ foreach ($bundle as $data) {
+ $cert = openssl_x509_read($data);
+
+ list($id, $_) = CertificateUtils::findOrInsertCert($db, $cert);
+
+ $db->update(
+ 'x509_certificate',
+ [
+ 'trusted' => 'y',
+ 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ],
+ ['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..27f7202
--- /dev/null
+++ b/application/clicommands/JobsCommand.php
@@ -0,0 +1,279 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateTime;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ResourceFactory;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\JobUtils;
+use Icinga\Module\X509\Hook\SniHook;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\Module\X509\Schedule;
+use InvalidArgumentException;
+use ipl\Orm\Query;
+use ipl\Scheduler\Contract\Frequency;
+use ipl\Scheduler\Scheduler;
+use ipl\Stdlib\Filter;
+use React\EventLoop\Loop;
+use React\Promise\ExtendedPromiseInterface;
+use stdClass;
+use Throwable;
+
+class JobsCommand extends Command
+{
+ use JobUtils;
+
+ /**
+ * Run all configured jobs based on their schedule
+ *
+ * USAGE:
+ *
+ * icingacli x509 jobs run [OPTIONS]
+ *
+ * OPTIONS
+ *
+ * --job=<name>
+ * Run all configured schedules only of the specified job.
+ *
+ * --schedule=<name>
+ * Run only the given schedule of the specified job. Providing a schedule name
+ * without a job will fail immediately.
+ *
+ * --parallel=<number>
+ * Allow parallel scanning of targets up to the specified number. Defaults to 256.
+ * May cause **too many open files** error if set to a number higher than the configured one (ulimit).
+ */
+ public function runAction(): void
+ {
+ $parallel = (int) $this->params->get('parallel', Job::DEFAULT_PARALLEL);
+ if ($parallel <= 0) {
+ $this->fail("The 'parallel' option must be set to at least 1");
+ }
+
+ $jobName = (string) $this->params->get('job');
+ $scheduleName = (string) $this->params->get('schedule');
+ if (! $jobName && $scheduleName) {
+ throw new InvalidArgumentException('You cannot provide a schedule without a job');
+ }
+
+ $scheduler = new Scheduler();
+ $this->attachJobsLogging($scheduler);
+
+ $signalHandler = function () use ($scheduler) {
+ $scheduler->removeTasks();
+
+ Loop::futureTick(function () {
+ Loop::stop();
+ });
+ };
+ Loop::addSignal(SIGINT, $signalHandler);
+ Loop::addSignal(SIGTERM, $signalHandler);
+
+ /** @var Job[] $scheduled Caches scheduled jobs */
+ $scheduled = [];
+ // Periodically check configuration changes to ensure that new jobs are scheduled, jobs are updated,
+ // and deleted jobs are canceled.
+ $watchdog = function () use (&$watchdog, &$scheduled, $scheduler, $parallel, $jobName, $scheduleName) {
+ $jobs = [];
+ try {
+ // Since this is a long-running daemon, the resources or module config may change meanwhile.
+ // Therefore, reload the resources and module config from disk each time (at 5m intervals)
+ // before reconnecting to the database.
+ ResourceFactory::setConfig(Config::app('resources', true));
+ Config::module('x509', 'config', true);
+
+ $jobs = $this->fetchSchedules($jobName, $scheduleName);
+ } catch (Throwable $err) {
+ Logger::error('Failed to fetch job schedules from the database: %s', $err);
+ Logger::debug($err->getTraceAsString());
+ }
+
+ $outdatedJobs = array_diff_key($scheduled, $jobs);
+ foreach ($outdatedJobs as $job) {
+ Logger::info(
+ 'Removing schedule %s of job %s, as it either no longer exists in the configuration or its'
+ . ' config has been changed',
+ $job->getSchedule()->getName(),
+ $job->getName()
+ );
+
+ $scheduler->remove($job);
+
+ unset($scheduled[$job->getUuid()->toString()]);
+ }
+
+ $newJobs = array_diff_key($jobs, $scheduled);
+ foreach ($newJobs as $key => $job) {
+ $job->setParallel($parallel);
+
+ /** @var stdClass $config */
+ $config = $job->getSchedule()->getConfig();
+ try {
+ /** @var Frequency $type */
+ $type = $config->type;
+ $frequency = $type::fromJson($config->frequency);
+ } catch (Throwable $err) {
+ Logger::error(
+ 'Cannot create schedule %s of job %s: %s',
+ $job->getSchedule()->getName(),
+ $job->getName(),
+ $err->getMessage()
+ );
+
+ continue;
+ }
+
+ $scheduler->schedule($job, $frequency);
+
+ $scheduled[$key] = $job;
+ }
+
+ Loop::addTimer(5 * 60, $watchdog);
+ };
+ // Check configuration and add jobs directly after starting the scheduler.
+ Loop::futureTick($watchdog);
+ }
+
+ /**
+ * Fetch job schedules from database
+ *
+ * @param ?string $jobName
+ * @param ?string $scheduleName
+ *
+ * @return Job[]
+ */
+ protected function fetchSchedules(?string $jobName, ?string $scheduleName): array
+ {
+ $conn = Database::get();
+ // Even if the Job class regularly pings the same connection whenever its frequency becomes due and is run by
+ // the scheduler, we need to explicitly ping that same connection here, as the interval of the schedule jobs
+ // could be larger than the daemon configuration reload interval (5m).
+ $conn->ping();
+
+ $jobs = X509Job::on($conn);
+ if ($jobName) {
+ $jobs->filter(Filter::equal('name', $jobName));
+ }
+
+ $jobSchedules = [];
+ $snimap = SniHook::getAll();
+ /** @var X509Job $jobConfig */
+ foreach ($jobs as $jobConfig) {
+ $cidrs = $this->parseCIDRs($jobConfig->cidrs);
+ $ports = $this->parsePorts($jobConfig->ports);
+
+ /** @var Query $schedules */
+ $schedules = $jobConfig->schedule;
+ if ($scheduleName) {
+ $schedules->filter(Filter::equal('name', $scheduleName));
+ }
+
+ $schedules = $schedules->execute();
+ $hasSchedules = $schedules->hasResult();
+
+ /** @var X509Schedule $scheduleModel */
+ foreach ($schedules as $scheduleModel) {
+ $job = (new Job($jobConfig->name, $cidrs, $ports, $snimap, Schedule::fromModel($scheduleModel)))
+ ->setId($jobConfig->id)
+ ->setExcludes($this->parseExcludes($jobConfig->exclude_targets));
+
+ $jobSchedules[$job->getUuid()->toString()] = $job;
+ }
+
+ if (! $hasSchedules) {
+ Logger::info('Skipping job %s because no schedules are configured', $jobConfig->name);
+ }
+ }
+
+ return $jobSchedules;
+ }
+
+ /**
+ * Set up logging of jobs states based on scheduler events
+ *
+ * @param Scheduler $scheduler
+ */
+ protected function attachJobsLogging(Scheduler $scheduler): void
+ {
+ $scheduler->on(Scheduler::ON_TASK_CANCEL, function (Job $task, array $_) {
+ Logger::info('Schedule %s of job %s canceled', $task->getSchedule()->getName(), $task->getName());
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_DONE, function (Job $task, $targets = 0) {
+ if ($targets === 0) {
+ $sinceLastScan = $task->getSinceLastScan();
+ if ($sinceLastScan) {
+ Logger::info(
+ 'Schedule %s of job %s does not have any targets to be rescanned matching since last scan: %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $sinceLastScan->format('Y-m-d H:i:s')
+ );
+ } else {
+ Logger::warning(
+ 'Schedule %s of job %s does not have any targets',
+ $task->getSchedule()->getName(),
+ $task->getName()
+ );
+ }
+ } else {
+ Logger::info(
+ 'Scanned %d target(s) by schedule %s of job %s',
+ $targets,
+ $task->getSchedule()->getName(),
+ $task->getName()
+ );
+
+ try {
+ $verified = CertificateUtils::verifyCertificates(Database::get());
+
+ Logger::info('Checked %d certificate chain(s)', $verified);
+ } catch (Exception $err) {
+ Logger::error($err->getMessage());
+ Logger::debug($err->getTraceAsString());
+ }
+ }
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_FAILED, function (Job $task, Throwable $e) {
+ Logger::error(
+ 'Failed to run schedule %s of job %s: %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $e->getMessage()
+ );
+ Logger::debug($e->getTraceAsString());
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_RUN, function (Job $task, ExtendedPromiseInterface $_) {
+ Logger::info('Running schedule %s of job %s', $task->getSchedule()->getName(), $task->getName());
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_SCHEDULED, function (Job $task, DateTime $dateTime) {
+ Logger::info(
+ 'Scheduling %s of job %s to run at %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $dateTime->format('Y-m-d H:i:s')
+ );
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Job $task, DateTime $dateTime) {
+ Logger::info(
+ 'Detaching expired schedule %s of job %s at %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $dateTime->format('Y-m-d H:i:s')
+ );
+ });
+ }
+}
diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php
new file mode 100644
index 0000000..cb4e389
--- /dev/null
+++ b/application/clicommands/MigrateCommand.php
@@ -0,0 +1,121 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateTime;
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Job;
+use Icinga\Repository\IniRepository;
+use Icinga\User;
+use Icinga\Util\Json;
+use ipl\Scheduler\Cron;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use stdClass;
+
+use function ipl\Stdlib\get_php_type;
+
+class MigrateCommand extends Command
+{
+ /**
+ * Migrate the jobs config rom INI to the database
+ *
+ * USAGE
+ *
+ * icingacli x509 migrate jobs --author=<name>
+ *
+ * OPTIONS
+ *
+ * --author=<name>
+ * An Icinga Web 2 user used to mark as an author for all the migrated jobs.
+ */
+ public function jobsAction(): void
+ {
+ /** @var string $author */
+ $author = $this->params->getRequired('author');
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ $user->setUsername($author);
+
+ $this->migrateJobs();
+
+ Logger::info('Successfully applied all pending migrations');
+ }
+
+ protected function migrateJobs(): void
+ {
+ $repo = new class () extends IniRepository {
+ /** @var array<string, array<int, string>> */
+ protected $queryColumns = [
+ 'jobs' => ['name', 'cidrs', 'ports', 'exclude_targets', 'schedule', 'frequencyType']
+ ];
+
+ /** @var array<string, array<string, string>> */
+ protected $configs = [
+ 'jobs' => [
+ 'module' => 'x509',
+ 'name' => 'jobs',
+ 'keyColumn' => 'name'
+ ]
+ ];
+ };
+
+ $conn = Database::get();
+ $conn->transaction(function (Connection $conn) use ($repo) {
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ /** @var stdClass $data */
+ foreach ($repo->select() as $data) {
+ $config = [];
+ if (! isset($data->frequencyType) && ! empty($data->schedule)) {
+ $frequency = new Cron($data->schedule);
+ $config = [
+ 'type' => get_php_type($frequency),
+ 'frequency' => Json::encode($frequency)
+ ];
+ } elseif (! empty($data->schedule)) {
+ $config = [
+ 'type' => $data->frequencyType,
+ 'frequency' => $data->schedule // Is already json encoded
+ ];
+ }
+
+ $excludes = $data->exclude_targets;
+ if (empty($excludes)) {
+ $excludes = new Expression('NULL');
+ }
+
+ $conn->insert('x509_job', [
+ 'name' => $data->name,
+ 'author' => $user->getUsername(),
+ 'cidrs' => $data->cidrs,
+ 'ports' => $data->ports,
+ 'exclude_targets' => $excludes,
+ 'ctime' => (new DateTime())->getTimestamp() * 1000,
+ 'mtime' => (new DateTime())->getTimestamp() * 1000
+ ]);
+
+ $jobId = (int) $conn->lastInsertId();
+ if (! empty($config)) {
+ $config['rescan'] = 'n';
+ $config['full_scan'] = 'n';
+ $config['since_last_scan'] = Job::DEFAULT_SINCE_LAST_SCAN;
+
+ $conn->insert('x509_schedule', [
+ 'job_id' => $jobId,
+ 'name' => $data->name . ' Schedule',
+ 'author' => $user->getUsername(),
+ 'config' => Json::encode($config),
+ 'ctime' => (new DateTime())->getTimestamp() * 1000,
+ 'mtime' => (new DateTime())->getTimestamp() * 1000,
+ ]);
+ }
+ }
+ });
+ }
+}
diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php
new file mode 100644
index 0000000..3743adc
--- /dev/null
+++ b/application/clicommands/ScanCommand.php
@@ -0,0 +1,163 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\JobUtils;
+use Icinga\Module\X509\Hook\SniHook;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509Job;
+use ipl\Stdlib\Filter;
+use React\EventLoop\Loop;
+use Throwable;
+
+class ScanCommand extends Command
+{
+ use JobUtils;
+
+ /**
+ * Scan targets to find their X.509 certificates and track changes to them.
+ *
+ * A target is an IP-port combination that is generated from the job configuration, taking into account
+ * configured SNI maps, so that targets with multiple certificates are also properly scanned.
+ *
+ * By default, successive calls to the scan command perform partial scans, checking both targets not yet scanned
+ * and targets whose scan is older than 24 hours, to ensure that all targets are rescanned over time and new
+ * certificates are collected. This behavior can be customized through the command options.
+ *
+ * Note that when rescanning due targets, they will be rescanned regardless of whether the target previously
+ * provided a certificate or not, to collect new certificates, track changed certificates, and remove
+ * decommissioned certificates.
+ *
+ * USAGE
+ *
+ * icingacli x509 scan --job <name> [OPTIONS]
+ *
+ * OPTIONS
+ *
+ * --job=<name>
+ * Scan targets that belong to the specified job.
+ *
+ * --since-last-scan=<datetime>
+ * Scan targets whose last scan is older than the specified date/time,
+ * which can also be an English textual datetime description like "2 days".
+ * Defaults to "-24 hours".
+ *
+ * --parallel=<number>
+ * Allow parallel scanning of targets up to the specified number. Defaults to 256.
+ * May cause **too many open files** error if set to a number higher than the configured one (ulimit).
+ *
+ * --rescan
+ * Rescan only targets that have been scanned before.
+ *
+ * --full
+ * (Re)scan all known and unknown targets.
+ * This will override the "rescan" and "since-last-scan" options.
+ *
+ * EXAMPLES
+ *
+ * Scan all targets that have not yet been scanned, or whose last scan is older than a certain date/time:
+ *
+ * icingacli x509 scan --job <name> --since-last-scan="3 days"
+ *
+ * Scan only unknown targets
+ *
+ * icingacli x509 scan --job <name> --since-last-scan=null
+ *
+ * Scan only known targets
+ *
+ * icingacli x509 scan --job <name> --rescan
+ *
+ * Scan only known targets whose last scan is older than a certain date/time:
+ *
+ * icingacli x509 scan --job <name> --rescan --since-last-scan="5 days"
+ *
+ * Scan all known and unknown targets:
+ *
+ * icingacli x509 scan --job <name> --full
+ */
+ public function indexAction(): void
+ {
+ /** @var string $name */
+ $name = $this->params->shiftRequired('job');
+ $fullScan = (bool) $this->params->get('full', false);
+ $rescan = (bool) $this->params->get('rescan', false);
+
+ /** @var string $sinceLastScan */
+ $sinceLastScan = $this->params->get('since-last-scan', Job::DEFAULT_SINCE_LAST_SCAN);
+ if ($sinceLastScan === 'null') {
+ $sinceLastScan = null;
+ }
+
+ /** @var int $parallel */
+ $parallel = $this->params->get('parallel', Job::DEFAULT_PARALLEL);
+ if ($parallel <= 0) {
+ throw new Exception('The \'parallel\' option must be set to at least 1');
+ }
+
+ /** @var X509Job $jobConfig */
+ $jobConfig = X509Job::on(Database::get())
+ ->filter(Filter::equal('name', $name))
+ ->first();
+ if ($jobConfig === null) {
+ throw new Exception(sprintf('Job %s not found', $name));
+ }
+
+ if (! strlen($jobConfig->cidrs)) {
+ throw new Exception(sprintf('The job %s does not specify any CIDRs', $name));
+ }
+
+ $cidrs = $this->parseCIDRs($jobConfig->cidrs);
+ $ports = $this->parsePorts($jobConfig->ports);
+ $job = (new Job($name, $cidrs, $ports, SniHook::getAll()))
+ ->setId($jobConfig->id)
+ ->setFullScan($fullScan)
+ ->setRescan($rescan)
+ ->setParallel($parallel)
+ ->setExcludes($this->parseExcludes($jobConfig->exclude_targets))
+ ->setLastScan($sinceLastScan);
+
+ $promise = $job->run();
+ $signalHandler = function () use (&$promise, $job) {
+ $promise->cancel();
+
+ Logger::info('Job %s canceled', $job->getName());
+
+ Loop::futureTick(function () {
+ Loop::stop();
+ });
+ };
+ Loop::addSignal(SIGINT, $signalHandler);
+ Loop::addSignal(SIGTERM, $signalHandler);
+
+ $promise->then(function ($targets = 0) use ($job) {
+ if ($targets === 0) {
+ Logger::warning('The job %s does not have any targets', $job->getName());
+ } else {
+ Logger::info('Scanned %d target(s) from job %s', $targets, $job->getName());
+
+ try {
+ $verified = CertificateUtils::verifyCertificates(Database::get());
+
+ Logger::info('Checked %d certificate chain(s)', $verified);
+ } catch (Exception $err) {
+ Logger::error($err->getMessage());
+ Logger::debug($err->getTraceAsString());
+ }
+ }
+ }, function (Throwable $err) use ($job) {
+ Logger::error('Failed to run job %s: %s', $job->getName(), $err->getMessage());
+ Logger::debug($err->getTraceAsString());
+ })->always(function () {
+ Loop::futureTick(function () {
+ Loop::stop();
+ });
+ });
+ }
+}
diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php
new file mode 100644
index 0000000..15976fc
--- /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;
+use Icinga\Module\X509\Common\Database;
+
+class VerifyCommand extends Command
+{
+ /**
+ * Verify all currently collected X.509 certificates
+ *
+ * USAGE:
+ *
+ * icingacli x509 verify
+ */
+ public function indexAction()
+ {
+ $verified = CertificateUtils::verifyCertificates(Database::get());
+
+ 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..016b312
--- /dev/null
+++ b/application/controllers/CertificateController.php
@@ -0,0 +1,43 @@
+<?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\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use ipl\Stdlib\Filter;
+
+class CertificateController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('X.509 Certificate'));
+ $this->getTabs()->disableLegacyExtensions();
+
+ $certId = $this->params->getRequired('cert');
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+
+ return;
+ }
+
+ /** @var ?X509Certificate $cert */
+ $cert = X509Certificate::on($conn)
+ ->filter(Filter::equal('id', $certId))
+ ->first();
+
+ if (! $cert) {
+ $this->httpNotFound($this->translate('Certificate not found.'));
+ }
+
+ $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..37434fa
--- /dev/null
+++ b/application/controllers/CertificatesController.php
@@ -0,0 +1,117 @@
+<?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\CertificatesTable;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Orm\Query;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class CertificatesController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Certificates'));
+ $this->getTabs()->enableDataExports();
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+
+ return;
+ }
+
+ $certificates = X509Certificate::on($conn);
+
+ $sortColumns = [
+ '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')
+ ];
+
+ $limitControl = $this->createLimitControl();
+ $paginator = $this->createPaginationControl($certificates);
+ $sortControl = $this->createSortControl($certificates, $sortColumns);
+
+ $searchBar = $this->createSearchBar($certificates, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $certificates->peekAhead($this->view->compact);
+
+ $certificates->filter($filter);
+
+ $this->addControl($paginator);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->handleFormatRequest($certificates, function (Query $certificates) {
+ /** @var X509Certificate $cert */
+ foreach ($certificates as $cert) {
+ $cert->valid_from = $cert->valid_from->format('l F jS, Y H:i:s e');
+ $cert->valid_to = $cert->valid_to->format('l F jS, Y H:i:s e');
+
+ yield array_intersect_key(iterator_to_array($cert), array_flip($cert->getExportableColumns()));
+ }
+ });
+
+ $this->addContent((new CertificatesTable())->setData($certificates));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate(); // Updates the browser search bar
+ }
+ }
+
+ public function completeAction()
+ {
+ $this->getDocument()->add(
+ (new ObjectSuggestions())
+ ->setModel(X509Certificate::class)
+ ->forRequest($this->getServerRequest())
+ );
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php
new file mode 100644
index 0000000..5408526
--- /dev/null
+++ b/application/controllers/ChainController.php
@@ -0,0 +1,77 @@
+<?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\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateChain;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Stdlib\Filter;
+
+class ChainController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('X.509 Certificate Chain'));
+ $this->getTabs()->disableLegacyExtensions();
+
+ $id = $this->params->getRequired('id');
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+ return;
+ }
+
+ /** @var ?X509CertificateChain $chain */
+ $chain = X509CertificateChain::on($conn)
+ ->with(['target'])
+ ->filter(Filter::equal('id', $id))
+ ->first();
+
+ if (! $chain) {
+ $this->httpNotFound($this->translate('Certificate not found.'));
+ }
+
+ $chainInfo = Html::tag('div');
+ $chainInfo->add(Html::tag('dl', [
+ Html::tag('dt', $this->translate('Host')),
+ Html::tag('dd', $chain->target->hostname),
+ Html::tag('dt', $this->translate('IP')),
+ Html::tag('dd', $chain->target->ip),
+ Html::tag('dt', $this->translate('Port')),
+ Html::tag('dd', $chain->target->port)
+ ]));
+
+ $valid = Html::tag('div', ['class' => 'cert-chain']);
+
+ if ($chain['valid']) {
+ $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']
+ )));
+ }
+
+ $certs = X509Certificate::on($conn)->with(['chain']);
+ $certs
+ ->filter(Filter::equal('chain.id', $id))
+ ->getSelectBase()
+ ->orderBy('certificate_link.order');
+
+ $this->view->chain = (new HtmlDocument())
+ ->add($chainInfo)
+ ->add($valid)
+ ->add((new ChainDetails())->setData($certs));
+ }
+}
diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
new file mode 100644
index 0000000..b4300ef
--- /dev/null
+++ b/application/controllers/ConfigController.php
@@ -0,0 +1,30 @@
+<?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..8b43761
--- /dev/null
+++ b/application/controllers/DashboardController.php
@@ -0,0 +1,153 @@
+<?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\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Donut;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+
+class DashboardController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Certificate Dashboard'));
+ $this->getTabs()->disableLegacyExtensions();
+
+ try {
+ $db = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+ return;
+ }
+
+ $byCa = X509Certificate::on($db)
+ ->columns([
+ 'issuer_certificate.subject',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->orderBy('cnt', SORT_DESC)
+ ->orderBy('issuer_certificate.subject')
+ ->filter(Filter::equal('issuer_certificate.ca', true))
+ ->limit(5);
+
+ $byCa
+ ->getSelectBase()
+ ->groupBy('certificate_issuer_certificate.id');
+
+ $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->issuer_certificate->subject
+ ])->getAbsoluteUrl()
+ ],
+ $data->issuer_certificate->subject
+ );
+ });
+
+ $duration = X509Certificate::on($db)
+ ->columns([
+ 'duration',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->filter(Filter::equal('ca', false))
+ ->orderBy('cnt', SORT_DESC)
+ ->limit(5);
+
+ $duration
+ ->getSelectBase()
+ ->groupBy('duration');
+
+ $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->getTimestamp()}&ca=n"
+ )->getAbsoluteUrl()
+ ],
+ CertificateUtils::duration($data->duration->getTimestamp())
+ );
+ });
+
+ $keyStrength = X509Certificate::on($db)
+ ->columns([
+ 'pubkey_algo',
+ 'pubkey_bits',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->orderBy('cnt', SORT_DESC)
+ ->limit(5);
+
+ $keyStrength
+ ->getSelectBase()
+ ->groupBy(['pubkey_algo', 'pubkey_bits']);
+
+ $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 = X509Certificate::on($db)
+ ->columns([
+ 'signature_algo',
+ 'signature_hash_algo',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->orderBy('cnt', SORT_DESC)
+ ->limit(5);
+
+ $sigAlgos
+ ->getSelectBase()
+ ->groupBy(['signature_algo', 'signature_hash_algo']);
+
+ $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/JobController.php b/application/controllers/JobController.php
new file mode 100644
index 0000000..7655a74
--- /dev/null
+++ b/application/controllers/JobController.php
@@ -0,0 +1,226 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\Links;
+use Icinga\Module\X509\Forms\Jobs\JobConfigForm;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\Module\X509\Forms\Jobs\ScheduleForm;
+use Icinga\Module\X509\Widget\JobDetails;
+use Icinga\Module\X509\Widget\Schedules;
+use Icinga\Util\Json;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\ValidHtml;
+use ipl\Scheduler\Contract\Frequency;
+use ipl\Stdlib\Filter;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+use ipl\Web\Widget\ActionBar;
+use ipl\Web\Widget\ActionLink;
+use ipl\Web\Widget\ButtonLink;
+use stdClass;
+
+class JobController extends CompatController
+{
+ /** @var X509Job */
+ protected $job;
+
+ public function init()
+ {
+ parent::init();
+
+ $this->getTabs()->disableLegacyExtensions();
+
+ /** @var int $jobId */
+ $jobId = $this->params->getRequired('id');
+
+ /** @var X509Job $job */
+ $job = X509Job::on(Database::get())
+ ->filter(Filter::equal('id', $jobId))
+ ->first();
+
+ if ($job === null) {
+ $this->httpNotFound($this->translate('Job not found'));
+ }
+
+ $this->job = $job;
+ }
+
+ public function indexAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->initTabs();
+ $this->getTabs()->activate('job-activities');
+
+ $jobRuns = $this->job->job_run->with(['job', 'schedule']);
+
+ $limitControl = $this->createLimitControl();
+ $sortControl = $this->createSortControl($jobRuns, [
+ 'schedule.name' => $this->translate('Schedule Name'),
+ 'schedule.author' => $this->translate('Author'),
+ 'total_targets' => $this->translate('Total Targets'),
+ 'finished_targets' => $this->translate('Finished Targets'),
+ 'start_time desc' => $this->translate('Started At'),
+ 'end_time' => $this->translate('Ended At')
+ ]);
+
+ $this->controls->getAttributes()->add('class', 'default-layout');
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($this->createActionBar());
+
+ $this->addContent(new JobDetails($jobRuns));
+ }
+
+ public function updateAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('Update Job'));
+
+ $form = (new JobConfigForm($this->job))
+ ->setAction((string) Url::fromRequest())
+ ->populate([
+ 'name' => $this->job->name,
+ 'cidrs' => $this->job->cidrs,
+ 'ports' => $this->job->ports,
+ 'exclude_targets' => $this->job->exclude_targets
+ ])
+ ->on(JobConfigForm::ON_SUCCESS, function (JobConfigForm $form) {
+ /** @var FormSubmitElement $button */
+ $button = $form->getPressedSubmitElement();
+ if ($button->getName() === 'btn_remove') {
+ $this->switchToSingleColumnLayout();
+ } else {
+ $this->closeModalAndRefreshRelatedView(Links::job($this->job));
+ }
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+
+ public function schedulesAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->initTabs();
+ $this->getTabs()->activate('schedules');
+
+ $schedules = $this->job->schedule->with(['job']);
+
+ $sortControl = $this->createSortControl($schedules, [
+ 'name' => $this->translate('Name'),
+ 'author' => $this->translate('Author'),
+ 'ctime' => $this->translate('Date Created'),
+ 'mtime' => $this->translate('Date Modified')
+ ]);
+
+ $this->controls->getAttributes()->add('class', 'default-layout');
+ $this->addControl(
+ (new ButtonLink($this->translate('New Schedule'), Links::scheduleJob($this->job), 'plus'))
+ ->openInModal()
+ );
+ $this->addControl($sortControl);
+
+ $this->addContent(new Schedules($schedules));
+ }
+
+ public function scheduleAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('Schedule Job'));
+
+ $form = (new ScheduleForm())
+ ->setAction((string) Url::fromRequest())
+ ->setJobId($this->job->id)
+ ->on(JobConfigForm::ON_SUCCESS, function () {
+ $this->redirectNow(Links::schedules($this->job));
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $parts = $form->getPartUpdates();
+ if (! empty($parts)) {
+ $this->sendMultipartUpdate(...$parts);
+ }
+
+ $this->addContent($form);
+ }
+
+ public function updateScheduleAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('Update Schedule'));
+
+ /** @var int $id */
+ $id = $this->params->getRequired('scheduleId');
+ /** @var X509Schedule $schedule */
+ $schedule = X509Schedule::on(Database::get())
+ ->filter(Filter::equal('id', $id))
+ ->first();
+ if ($schedule === null) {
+ $this->httpNotFound($this->translate('Schedule not found'));
+ }
+
+ /** @var stdClass $config */
+ $config = Json::decode($schedule->config);
+ /** @var Frequency $type */
+ $type = $config->type;
+ $frequency = $type::fromJson($config->frequency);
+
+ $form = (new ScheduleForm($schedule))
+ ->setAction((string) Url::fromRequest())
+ ->populate([
+ 'name' => $schedule->name,
+ 'full_scan' => $config->full_scan ?? 'n',
+ 'rescan' => $config->rescan ?? 'n',
+ 'since_last_scan' => $config->since_last_scan ?? null,
+ 'schedule_element' => $frequency
+ ])
+ ->on(JobConfigForm::ON_SUCCESS, function () {
+ $this->redirectNow('__BACK__');
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $parts = $form->getPartUpdates();
+ if (! empty($parts)) {
+ $this->sendMultipartUpdate(...$parts);
+ }
+
+ $this->addContent($form);
+ }
+
+ protected function createActionBar(): ValidHtml
+ {
+ $actions = new ActionBar();
+ $actions->addHtml(
+ (new ActionLink($this->translate('Modify'), Links::updateJob($this->job), 'edit'))
+ ->openInModal(),
+ (new ActionLink($this->translate('Schedule'), Links::scheduleJob($this->job), 'calendar'))
+ ->openInModal()
+ );
+
+ return $actions;
+ }
+
+ protected function initTabs(): void
+ {
+ $tabs = $this->getTabs();
+ $tabs
+ ->add('job-activities', [
+ 'label' => $this->translate('Job Activities'),
+ 'url' => Links::job($this->job)
+ ])
+ ->add('schedules', [
+ 'label' => $this->translate('Schedules'),
+ 'url' => Links::schedules($this->job)
+ ]);
+ }
+}
diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php
new file mode 100644
index 0000000..48deede
--- /dev/null
+++ b/application/controllers/JobsController.php
@@ -0,0 +1,66 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Forms\Jobs\JobConfigForm;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Widget\Jobs;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+use ipl\Web\Widget\ButtonLink;
+
+class JobsController extends CompatController
+{
+ /**
+ * List all jobs
+ */
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Jobs'));
+ $this->getTabs()->add('sni', [
+ 'title' => $this->translate('Configure SNI'),
+ 'label' => $this->translate('SNI'),
+ 'url' => 'x509/sni',
+ 'baseTarget' => '_main'
+ ]);
+
+ $jobs = X509Job::on(Database::get());
+ if ($this->hasPermission('config/x509')) {
+ $this->addControl(
+ (new ButtonLink($this->translate('New Job'), Url::fromPath('x509/jobs/new'), 'plus'))
+ ->openInModal()
+ );
+ }
+
+ $sortControl = $this->createSortControl($jobs, [
+ 'name' => $this->translate('Name'),
+ 'author' => $this->translate('Author'),
+ 'ctime' => $this->translate('Date Created'),
+ 'mtime' => $this->translate('Date Modified')
+ ]);
+
+ $this->controls->getAttributes()->add('class', 'default-layout');
+ $this->addControl($sortControl);
+
+ $this->addContent(new Jobs($jobs));
+ }
+
+ public function newAction()
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('New Job'));
+
+ $form = (new JobConfigForm())
+ ->setAction((string) Url::fromRequest())
+ ->on(JobConfigForm::ON_SUCCESS, function () {
+ $this->closeModalAndRefreshRelatedView(Url::fromPath('x509/jobs'));
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+}
diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php
new file mode 100644
index 0000000..cde4807
--- /dev/null
+++ b/application/controllers/SniController.php
@@ -0,0 +1,103 @@
+<?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 ipl\Html\HtmlString;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+use ipl\Web\Widget\ButtonLink;
+
+class SniController extends CompatController
+{
+ /**
+ * List all maps
+ */
+ public function indexAction()
+ {
+ $this->getTabs()->add('jobs', [
+ 'title' => $this->translate('Configure Jobs'),
+ 'label' => $this->translate('Jobs'),
+ 'url' => 'x509/jobs',
+ 'baseTarget' => '_main'
+
+ ]);
+ $this->addTitleTab($this->translate('SNI'));
+
+ $this->addControl(
+ (new ButtonLink($this->translate('New SNI Map'), Url::fromPath('x509/sni/new'), 'plus'))
+ ->openInModal()
+ );
+ $this->controls->getAttributes()->add('class', 'default-layout');
+
+ $this->view->controls = $this->controls;
+
+ $repo = new SniIniRepository();
+
+ $this->view->sni = $repo->select(array('ip'));
+ }
+
+ /**
+ * Create a map
+ */
+ public function newAction()
+ {
+ $this->addTitleTab($this->translate('New SNI Map'));
+
+ $form = $this->prepareForm()->add();
+
+ $form->handleRequest();
+
+ $this->addContent(new HtmlString($form->render()));
+ }
+
+ /**
+ * 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..079d24a
--- /dev/null
+++ b/application/controllers/UsageController.php
@@ -0,0 +1,141 @@
+<?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\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\UsageTable;
+use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Orm\Query;
+use ipl\Sql\Expression;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class UsageController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Certificate Usage'));
+ $this->getTabs()->enableDataExports();
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+ return;
+ }
+
+ $targets = X509Certificate::on($conn)
+ ->with(['chain', 'chain.target'])
+ ->withColumns([
+ 'chain.id',
+ 'chain.valid',
+ 'chain.target.ip',
+ 'chain.target.port',
+ 'chain.target.hostname',
+ ]);
+
+ $targets
+ ->getSelectBase()
+ ->where(new Expression('certificate_link.order = 0'));
+
+ $sortColumns = [
+ 'chain.target.hostname' => $this->translate('Hostname'),
+ 'chain.target.ip' => $this->translate('IP'),
+ 'chain.target.port' => $this->translate('Port'),
+ 'subject' => $this->translate('Certificate'),
+ 'issuer' => $this->translate('Issuer'),
+ 'version' => $this->translate('Version'),
+ 'self_signed' => $this->translate('Is Self-Signed'),
+ 'ca' => $this->translate('Is Certificate Authority'),
+ 'trusted' => $this->translate('Is Trusted'),
+ 'pubkey_algo' => $this->translate('Public Key Algorithm'),
+ 'pubkey_bits' => $this->translate('Public Key Strength'),
+ 'signature_algo' => $this->translate('Signature Algorithm'),
+ 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'),
+ 'valid_from' => $this->translate('Valid From'),
+ 'valid_to' => $this->translate('Valid To'),
+ 'chain.valid' => $this->translate('Chain Is Valid'),
+ 'duration' => $this->translate('Duration')
+ ];
+
+ $limitControl = $this->createLimitControl();
+ $paginator = $this->createPaginationControl($targets);
+ $sortControl = $this->createSortControl($targets, $sortColumns);
+
+ $searchBar = $this->createSearchBar($targets, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $targets->peekAhead($this->view->compact);
+
+ $targets->filter($filter);
+
+ $this->addControl($paginator);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->handleFormatRequest($targets, function (Query $targets) {
+ /** @var X509Certificate $usage */
+ foreach ($targets as $usage) {
+ $usage->valid_from = $usage->valid_from->format('l F jS, Y H:i:s e');
+ $usage->valid_to = $usage->valid_to->format('l F jS, Y H:i:s e');
+
+ $usage->ip = $usage->chain->target->ip;
+ $usage->hostname = $usage->chain->target->hostname;
+ $usage->port = $usage->chain->target->port;
+ $usage->valid = $usage->chain->valid;
+
+ yield array_intersect_key(
+ iterator_to_array($usage),
+ array_flip(array_merge(['valid', 'hostname', 'ip', 'port'], $usage->getExportableColumns()))
+ );
+ }
+ });
+
+ $this->addContent((new UsageTable())->setData($targets));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate(); // Updates the browser search bar
+ }
+ }
+
+ public function completeAction()
+ {
+ $this->getDocument()->add(
+ (new ObjectSuggestions())
+ ->setModel(X509Certificate::class)
+ ->forRequest($this->getServerRequest())
+ );
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php
new file mode 100644
index 0000000..e806d26
--- /dev/null
+++ b/application/forms/Config/BackendConfigForm.php
@@ -0,0 +1,29 @@
+<?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/SniConfigForm.php b/application/forms/Config/SniConfigForm.php
new file mode 100644
index 0000000..27a4823
--- /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->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/forms/Jobs/JobConfigForm.php b/application/forms/Jobs/JobConfigForm.php
new file mode 100644
index 0000000..539bc58
--- /dev/null
+++ b/application/forms/Jobs/JobConfigForm.php
@@ -0,0 +1,154 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Forms\Jobs;
+
+use DateTime;
+use Exception;
+use Icinga\Authentication\Auth;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\User;
+use Icinga\Web\Notification;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Stdlib\Str;
+use ipl\Validator\CallbackValidator;
+use ipl\Validator\CidrValidator;
+use ipl\Web\Compat\CompatForm;
+
+/**
+ * Create, update and delete jobs
+ */
+class JobConfigForm extends CompatForm
+{
+ /** @var ?X509Job */
+ protected $job;
+
+ public function __construct(X509Job $job = null)
+ {
+ $this->job = $job;
+ }
+
+ protected function isUpdating(): bool
+ {
+ return $this->job !== null;
+ }
+
+ public function hasBeenSubmitted(): bool
+ {
+ if (! $this->hasBeenSent()) {
+ return false;
+ }
+
+ $button = $this->getPressedSubmitElement();
+
+ return $button && ($button->getName() === 'btn_submit' || $button->getName() === 'btn_remove');
+ }
+
+ protected function assemble(): void
+ {
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'label' => $this->translate('Name'),
+ 'description' => $this->translate('Job name'),
+ ]);
+
+ $this->addElement('textarea', 'cidrs', [
+ 'required' => true,
+ 'label' => $this->translate('CIDRs'),
+ 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'),
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator): bool {
+ $cidrValidator = new CidrValidator();
+ $cidrs = Str::trimSplit($value);
+
+ foreach ($cidrs as $cidr) {
+ if (! $cidrValidator->isValid($cidr)) {
+ $validator->addMessage(...$cidrValidator->getMessages());
+
+ return false;
+ }
+ }
+
+ return true;
+ })
+ ]
+ ]);
+
+ $this->addElement('textarea', 'ports', [
+ 'required' => true,
+ 'label' => $this->translate('Ports'),
+ 'description' => $this->translate('Comma-separated list of ports to scan'),
+ ]);
+
+ $this->addElement('textarea', 'exclude_targets', [
+ 'required' => false,
+ 'label' => $this->translate('Exclude Targets'),
+ 'description' => $this->translate('Comma-separated list of addresses/hostnames to exclude'),
+ ]);
+
+ $this->addElement('submit', 'btn_submit', [
+ 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Create')
+ ]);
+
+ if ($this->isUpdating()) {
+ $removeButton = $this->createElement('submit', 'btn_remove', [
+ 'class' => 'btn-remove',
+ 'label' => $this->translate('Remove Job'),
+ ]);
+ $this->registerElement($removeButton);
+
+ /** @var HtmlDocument $wrapper */
+ $wrapper = $this->getElement('btn_submit')->getWrapper();
+ $wrapper->prepend($removeButton);
+ }
+ }
+
+ protected function onSuccess(): void
+ {
+ $conn = Database::get();
+ /** @var FormSubmitElement $submitElement */
+ $submitElement = $this->getPressedSubmitElement();
+ if ($submitElement->getName() === 'btn_remove') {
+ try {
+ /** @var X509Job $job */
+ $job = $this->job;
+ $conn->delete('x509_job', ['id = ?' => $job->id]);
+
+ Notification::success($this->translate('Removed job successfully'));
+ } catch (Exception $err) {
+ Notification::error($this->translate('Failed to remove job') . ': ' . $err->getMessage());
+ }
+ } else {
+ $values = $this->getValues();
+
+ try {
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ if ($this->job === null) {
+ $values['author'] = $user->getUsername();
+ $values['ctime'] = (new DateTime())->getTimestamp() * 1000.0;
+ $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0;
+
+ $conn->insert('x509_job', $values);
+ $message = $this->translate('Created job successfully');
+ } else {
+ $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0;
+
+ $conn->update('x509_job', $values, ['id = ?' => $this->job->id]);
+ $message = $this->translate('Updated job successfully');
+ }
+
+ Notification::success($message);
+ } catch (Exception $err) {
+ $message = $this->isUpdating()
+ ? $this->translate('Failed to update job')
+ : $this->translate('Failed to create job');
+
+ Notification::error($message . ': ' . $err->getMessage());
+ }
+ }
+ }
+}
diff --git a/application/forms/Jobs/ScheduleForm.php b/application/forms/Jobs/ScheduleForm.php
new file mode 100644
index 0000000..ae47e58
--- /dev/null
+++ b/application/forms/Jobs/ScheduleForm.php
@@ -0,0 +1,201 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Forms\Jobs;
+
+use DateTime;
+use Exception;
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use Icinga\Authentication\Auth;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\User;
+use Icinga\Util\Json;
+use Icinga\Web\Notification;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\Compat\CompatForm;
+use ipl\Web\FormElement\ScheduleElement;
+use Psr\Http\Message\RequestInterface;
+
+use function ipl\Stdlib\get_php_type;
+
+class ScheduleForm extends CompatForm
+{
+ /** @var int */
+ protected $jobId;
+
+ /** @var ?X509Schedule */
+ protected $schedule;
+
+ /** @var ScheduleElement */
+ protected $scheduleElement;
+
+ public function __construct(X509Schedule $schedule = null)
+ {
+ $this->schedule = $schedule;
+ $this->scheduleElement = new ScheduleElement('schedule_element');
+
+ /** @var Web $app */
+ $app = Icinga::app();
+ $this->scheduleElement->setIdProtector([$app->getRequest(), 'protectId']);
+ }
+
+ protected function isUpdating(): bool
+ {
+ return $this->schedule !== null;
+ }
+
+ public function setJobId(int $jobId): self
+ {
+ $this->jobId = $jobId;
+
+ return $this;
+ }
+
+ /**
+ * Get multipart updates
+ *
+ * @return array<int, BaseHtmlElement>
+ */
+ public function getPartUpdates(): array
+ {
+ /** @var RequestInterface $request */
+ $request = $this->getRequest();
+
+ return $this->scheduleElement->prepareMultipartUpdate($request);
+ }
+
+ public function hasBeenSubmitted(): bool
+ {
+ if (! $this->hasBeenSent()) {
+ return false;
+ }
+
+ $button = $this->getPressedSubmitElement();
+
+ return $button && ($button->getName() === 'submit' || $button->getName() === 'btn_remove');
+ }
+
+ protected function assemble(): void
+ {
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'label' => $this->translate('Name'),
+ 'description' => $this->translate('Schedule name'),
+ ]);
+
+ $this->addElement('checkbox', 'full_scan', [
+ 'required' => false,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Full Scan'),
+ 'description' => $this->translate(
+ 'Scan all known and unknown targets of this job. (Defaults to only scan unknown targets)'
+ )
+ ]);
+
+ if ($this->getPopulatedValue('full_scan', 'n') === 'n') {
+ $this->addElement('checkbox', 'rescan', [
+ 'required' => false,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Rescan'),
+ 'description' => $this->translate('Rescan only targets that have been scanned before')
+ ]);
+
+ $this->addElement('text', 'since_last_scan', [
+ 'required' => false,
+ 'label' => $this->translate('Since Last Scan'),
+ 'placeholder' => '-24 hours',
+ 'description' => $this->translate(
+ 'Scan targets whose last scan is older than the specified date/time, which can also be an'
+ . ' English textual datetime description like "2 days". If you want to scan only unknown targets'
+ . ' you can set this to "null".'
+ ),
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator) {
+ if ($value !== null && $value !== 'null') {
+ try {
+ new DateTime($value);
+ } catch (Exception $_) {
+ $validator->addMessage($this->translate('Invalid textual date time'));
+
+ return false;
+ }
+ }
+
+ return true;
+ })
+ ]
+ ]);
+ }
+
+ $this->addHtml(HtmlElement::create('div', ['class' => 'schedule-element-separator']));
+ $this->addElement($this->scheduleElement);
+
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Schedule')
+ ]);
+
+ if ($this->isUpdating()) {
+ $removeButton = $this->createElement('submit', 'btn_remove', [
+ 'class' => 'btn-remove',
+ 'label' => $this->translate('Remove Schedule'),
+ ]);
+ $this->registerElement($removeButton);
+
+ /** @var HtmlDocument $wrapper */
+ $wrapper = $this->getElement('submit')->getWrapper();
+ $wrapper->prepend($removeButton);
+ }
+ }
+
+ protected function onSuccess(): void
+ {
+ /** @var X509Schedule $schedule */
+ $schedule = $this->schedule;
+ $conn = Database::get();
+ /** @var FormSubmitElement $submitElement */
+ $submitElement = $this->getPressedSubmitElement();
+ if ($submitElement->getName() === 'btn_remove') {
+ $conn->delete('x509_schedule', ['id = ?' => $schedule->id]);
+
+ Notification::success($this->translate('Deleted schedule successfully'));
+ } else {
+ $config = $this->getValues();
+ unset($config['name']);
+ unset($config['schedule_element']);
+
+ $frequency = $this->scheduleElement->getValue();
+ $config['type'] = get_php_type($frequency);
+ $config['frequency'] = Json::encode($frequency);
+
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ if (! $this->isUpdating()) {
+ $conn->insert('x509_schedule', [
+ 'job_id' => $this->schedule ? $this->schedule->job_id : $this->jobId,
+ 'name' => $this->getValue('name'),
+ 'author' => $user->getUsername(),
+ 'config' => Json::encode($config),
+ 'ctime' => (new DateTime())->getTimestamp() * 1000.0,
+ 'mtime' => (new DateTime())->getTimestamp() * 1000.0
+ ]);
+ $message = $this->translate('Created schedule successfully');
+ } else {
+ $conn->update('x509_schedule', [
+ 'name' => $this->getValue('name'),
+ 'config' => Json::encode($config),
+ 'mtime' => (new DateTime())->getTimestamp() * 1000.0
+ ], ['id = ?' => $schedule->id]);
+ $message = $this->translate('Updated schedule successfully');
+ }
+
+ Notification::success($message);
+ }
+ }
+}
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/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/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..2be5280
--- /dev/null
+++ b/application/views/scripts/sni/index.phtml
@@ -0,0 +1,31 @@
+<?= $this->controls->render() ?>
+<div class="content">
+ <?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>