summaryrefslogtreecommitdiffstats
path: root/application/clicommands/ScanCommand.php
blob: 3743adc2553bdb0206f01782ac9b7f73bcd80ecd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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();
            });
        });
    }
}