summaryrefslogtreecommitdiffstats
path: root/library/X509/Job.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/X509/Job.php')
-rw-r--r--library/X509/Job.php381
1 files changed, 381 insertions, 0 deletions
diff --git a/library/X509/Job.php b/library/X509/Job.php
new file mode 100644
index 0000000..4c6cb65
--- /dev/null
+++ b/library/X509/Job.php
@@ -0,0 +1,381 @@
+<?php
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Module\X509\React\StreamOptsCaptureConnector;
+use Icinga\Util\StringHelper;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Insert;
+use ipl\Sql\Select;
+use ipl\Sql\Update;
+use React\EventLoop\Factory;
+use React\Socket\ConnectionInterface;
+use React\Socket\Connector;
+use React\Socket\ConnectorInterface;
+use React\Socket\SecureConnector;
+use React\Socket\TimeoutConnector;
+
+class Job
+{
+ /**
+ * @var Connection
+ */
+ private $db;
+ private $loop;
+ private $pendingTargets = 0;
+ private $totalTargets = 0;
+ private $finishedTargets = 0;
+ private $targets;
+ private $jobId;
+ private $jobDescription;
+ private $snimap;
+ private $parallel;
+ private $name;
+
+ public function __construct($name, Connection $db, ConfigObject $jobDescription, array $snimap, $parallel)
+ {
+ $this->db = $db;
+ $this->jobDescription = $jobDescription;
+ $this->snimap = $snimap;
+ $this->parallel = $parallel;
+ $this->name = $name;
+ }
+
+ private function getConnector($peerName)
+ {
+ $simpleConnector = new Connector($this->loop);
+ $streamCaptureConnector = new StreamOptsCaptureConnector($simpleConnector);
+ $secureConnector = new SecureConnector($streamCaptureConnector, $this->loop, array(
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'capture_peer_cert_chain' => true,
+ 'SNI_enabled' => true,
+ 'peer_name' => $peerName
+ ));
+ return [new TimeoutConnector($secureConnector, 5.0, $this->loop), $streamCaptureConnector];
+ }
+
+ public static function binary($addr)
+ {
+ return str_pad(inet_pton($addr), 16, "\0", STR_PAD_LEFT);
+ }
+
+ private static function addrToNumber($addr)
+ {
+ return gmp_import(static::binary($addr));
+ }
+
+ private static function numberToAddr($num, $ipv6 = true)
+ {
+ if ((bool) $ipv6) {
+ return inet_ntop(str_pad(gmp_export($num), 16, "\0", STR_PAD_LEFT));
+ } else {
+ return inet_ntop(gmp_export($num));
+ }
+ }
+
+ private static function generateTargets(ConfigObject $jobDescription, array $hostnamesConfig)
+ {
+ foreach (StringHelper::trimSplit($jobDescription->get('cidrs')) as $cidr) {
+ $pieces = explode('/', $cidr);
+ if (count($pieces) !== 2) {
+ Logger::warning("CIDR '%s' is in the wrong format.", $cidr);
+ continue;
+ }
+ $start_ip = $pieces[0];
+ $prefix = $pieces[1];
+// $subnet = 128;
+// if (substr($start_ip, 0, 2) === '::') {
+// if (strtoupper(substr($start_ip, 0, 7)) !== '::FFFF:') {
+// $subnet = 32;
+// }
+// } elseif (strpos($start_ip, ':') === false) {
+// $subnet = 32;
+// }
+ $ipv6 = filter_var($start_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $subnet = $ipv6 ? 128 : 32;
+ $ip_count = 1 << ($subnet - $prefix);
+ $start = static::addrToNumber($start_ip);
+ for ($i = 0; $i < $ip_count; $i++) {
+ $ip = static::numberToAddr(gmp_add($start, $i), $ipv6);
+ foreach (StringHelper::trimSplit($jobDescription->get('ports')) as $portRange) {
+ $pieces = StringHelper::trimSplit($portRange, '-');
+ if (count($pieces) === 2) {
+ list($start_port, $end_port) = $pieces;
+ } else {
+ $start_port = $pieces[0];
+ $end_port = $pieces[0];
+ }
+
+ foreach (range($start_port, $end_port) as $port) {
+ $hostnames = isset($hostnamesConfig[$ip]) ? $hostnamesConfig[$ip] : [];
+
+ if (empty($hostnames)) {
+ $hostnames[] = null;
+ }
+
+ foreach ($hostnames as $hostname) {
+ $target = (object)[];
+ $target->ip = $ip;
+ $target->port = $port;
+ $target->hostname = $hostname;
+ yield $target;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private function updateJobStats($finished = false)
+ {
+ $fields = ['finished_targets' => $this->finishedTargets];
+
+ if ($finished) {
+ $fields['end_time'] = new Expression('NOW()');
+ }
+
+ $this->db->update(
+ 'x509_job_run',
+ $fields,
+ ['id = ?' => $this->jobId]
+ );
+ }
+
+ private static function formatTarget($target)
+ {
+ $result = "tls://[{$target->ip}]:{$target->port}";
+
+ if ($target->hostname !== null) {
+ $result .= " [SNI hostname: {$target->hostname}]";
+ }
+
+ return $result;
+ }
+
+ private function finishTarget()
+ {
+ $this->pendingTargets--;
+ $this->finishedTargets++;
+ $this->startNextTarget();
+ }
+
+ private function startNextTarget()
+ {
+ if (!$this->targets->valid()) {
+ if ($this->pendingTargets == 0) {
+ $this->updateJobStats(true);
+ $this->loop->stop();
+ }
+
+ return;
+ }
+
+ $target = $this->targets->current();
+ $this->targets->next();
+
+ $url = "tls://[{$target->ip}]:{$target->port}";
+ Logger::debug("Connecting to %s", static::formatTarget($target));
+ $this->pendingTargets++;
+ /** @var ConnectorInterface $connector */
+ /** @var StreamOptsCaptureConnector $streamCapture */
+ list($connector, $streamCapture) = $this->getConnector($target->hostname);
+ $connector->connect($url)->then(
+ function (ConnectionInterface $conn) use ($target, $streamCapture) {
+ $this->finishTarget();
+
+ Logger::info("Connected to %s", static::formatTarget($target));
+
+ // Close connection in order to capture stream context options
+ $conn->close();
+
+ $capturedStreamOptions = $streamCapture->getCapturedStreamOptions();
+
+ $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']);
+ },
+ function (\Exception $exception) use ($target, $streamCapture) {
+ Logger::debug("Cannot connect to server: %s", $exception->getMessage());
+
+ $this->finishTarget();
+
+ $capturedStreamOptions = $streamCapture->getCapturedStreamOptions();
+
+ if (isset($capturedStreamOptions['ssl']['peer_certificate_chain'])) {
+ // The scanned target presented its certificate chain despite throwing an error
+ // This is the case for targets which require client certificates for example
+ $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']);
+ } else {
+ $this->db->update(
+ 'x509_target',
+ ['latest_certificate_chain_id' => null],
+ [
+ 'hostname = ?' => $target->hostname,
+ 'ip = ?' => static::binary($target->ip),
+ 'port = ?' => $target->port
+ ]
+ );
+ }
+
+ $step = max($this->totalTargets / 100, 1);
+
+ if ($this->finishedTargets % (int) $step == 0) {
+ $this->updateJobStats();
+ }
+ //$loop->stop();
+ }
+ )->otherwise(function (\Exception $e) {
+ echo $e->getMessage() . PHP_EOL;
+ echo $e->getTraceAsString() . PHP_EOL;
+ });
+ }
+
+ public function getJobId()
+ {
+ return $this->jobId;
+ }
+
+ public function run()
+ {
+ $this->loop = Factory::create();
+
+ $this->totalTargets = iterator_count(static::generateTargets($this->jobDescription, $this->snimap));
+
+ if ($this->totalTargets == 0) {
+ return null;
+ }
+
+ $this->targets = static::generateTargets($this->jobDescription, $this->snimap);
+
+ $this->db->insert(
+ 'x509_job_run',
+ [
+ 'name' => $this->name,
+ 'total_targets' => $this->totalTargets,
+ 'finished_targets' => 0
+ ]
+ );
+
+ $this->jobId = $this->db->lastInsertId();
+
+ // Start scanning the first couple of targets...
+ for ($i = 0; $i < $this->parallel; $i++) {
+ $this->startNextTarget();
+ }
+
+ $this->loop->run();
+
+ return $this->totalTargets;
+ }
+
+ protected function processChain($target, $chain)
+ {
+ if ($target->hostname === null) {
+ $hostname = gethostbyaddr($target->ip);
+
+ if ($hostname !== false) {
+ $target->hostname = $hostname;
+ }
+ }
+
+ $this->db->transaction(function () use ($target, $chain) {
+ $row = $this->db->select(
+ (new Select())
+ ->columns(['id'])
+ ->from('x509_target')
+ ->where([
+ 'ip = ?' => static::binary($target->ip),
+ 'port = ?' => $target->port,
+ 'hostname = ?' => $target->hostname
+ ])
+ )->fetch();
+
+ if ($row === false) {
+ $this->db->insert(
+ 'x509_target',
+ [
+ 'ip' => static::binary($target->ip),
+ 'port' => $target->port,
+ 'hostname' => $target->hostname
+ ]
+ );
+ $targetId = $this->db->lastInsertId();
+ } else {
+ $targetId = $row['id'];
+ }
+
+ $chainUptodate = false;
+
+ $lastChain = $this->db->select(
+ (new Select())
+ ->columns(['id'])
+ ->from('x509_certificate_chain')
+ ->where(['target_id = ?' => $targetId])
+ ->orderBy('id', SORT_DESC)
+ ->limit(1)
+ )->fetch();
+
+ if ($lastChain !== false) {
+ $lastFingerprints = $this->db->select(
+ (new Select())
+ ->columns(['c.fingerprint'])
+ ->from('x509_certificate_chain_link l')
+ ->join('x509_certificate c', 'l.certificate_id = c.id')
+ ->where(['l.certificate_chain_id = ?' => $lastChain[0]])
+ ->orderBy('l.`order`')
+ )->fetchAll();
+
+ foreach ($lastFingerprints as &$lastFingerprint) {
+ $lastFingerprint = $lastFingerprint[0];
+ }
+
+ $currentFingerprints = [];
+
+ foreach ($chain as $cert) {
+ $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true);
+ }
+
+ $chainUptodate = $currentFingerprints === $lastFingerprints;
+ }
+
+ if ($chainUptodate) {
+ $chainId = $lastChain[0];
+ } else {
+ $this->db->insert(
+ 'x509_certificate_chain',
+ [
+ 'target_id' => $targetId,
+ 'length' => count($chain)
+ ]
+ );
+
+ $chainId = $this->db->lastInsertId();
+
+ foreach ($chain as $index => $cert) {
+ $certInfo = openssl_x509_parse($cert);
+
+ $certId = CertificateUtils::findOrInsertCert($this->db, $cert, $certInfo);
+
+ $this->db->insert(
+ 'x509_certificate_chain_link',
+ [
+ 'certificate_chain_id' => $chainId,
+ '`order`' => $index,
+ 'certificate_id' => $certId
+ ]
+ );
+ }
+ }
+
+ $this->db->update(
+ 'x509_target',
+ ['latest_certificate_chain_id' => $chainId],
+ ['id = ?' => $targetId]
+ );
+ });
+ }
+}