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