diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:31:28 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:31:28 +0000 |
commit | 067008c5f094ba9606daacbe540f6b929dc124ea (patch) | |
tree | 3092ce2cd8bf1ac6db6c97f4c98c7f71a51c6ac8 /application/clicommands | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-x509-067008c5f094ba9606daacbe540f6b929dc124ea.tar.xz icingaweb2-module-x509-067008c5f094ba9606daacbe540f6b929dc124ea.zip |
Adding upstream version 1:1.3.2.upstream/1%1.3.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'application/clicommands')
-rw-r--r-- | application/clicommands/CheckCommand.php | 268 | ||||
-rw-r--r-- | application/clicommands/CleanupCommand.php | 95 | ||||
-rw-r--r-- | application/clicommands/ImportCommand.php | 61 | ||||
-rw-r--r-- | application/clicommands/JobsCommand.php | 279 | ||||
-rw-r--r-- | application/clicommands/MigrateCommand.php | 121 | ||||
-rw-r--r-- | application/clicommands/ScanCommand.php | 163 | ||||
-rw-r--r-- | application/clicommands/VerifyCommand.php | 27 |
7 files changed, 1014 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' : ''); + } +} |