diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:47:35 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:47:35 +0000 |
commit | 5f112e7d0464d98282443b78870cdccabe42aae9 (patch) | |
tree | aac24e989ceebb84c04de382960608c3fcef7313 | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-x509-5f112e7d0464d98282443b78870cdccabe42aae9.tar.xz icingaweb2-module-x509-5f112e7d0464d98282443b78870cdccabe42aae9.zip |
Adding upstream version 1:1.1.2.upstream/1%1.1.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
112 files changed, 7518 insertions, 0 deletions
diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..726def3 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,33 @@ +<?xml version="1.0"?> +<ruleset name="PHP_CodeSniffer"> + <description>Sniff our code a while</description> + + <file>./</file> + + <exclude-pattern>vendor/*</exclude-pattern> + + <arg value="wps"/> + <arg name="report-width" value="auto"/> + <arg name="report-full"/> + <arg name="report-gitblame"/> + <arg name="report-summary"/> + <arg name="encoding" value="UTF-8"/> + <arg name="extensions" value="php"/> + + <rule ref="PSR2"/> + + <rule ref="Generic.Files.LineLength"> + <properties> + <property name="lineLimit" value="120"/> + <property name="absoluteLineLimit" value="0"/> + </properties> + </rule> + + <rule ref="PSR1.Classes.ClassDeclaration.MultipleClasses"> + <exclude-pattern>library/X509/SqlFilter\.php</exclude-pattern> + </rule> + + <rule ref="Squiz.Classes.ValidClassName.NotCamelCaps"> + <exclude-pattern>library/X509/ProvidedHook/x509ImportSource\.php</exclude-pattern> + </rule> +</ruleset> @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License.
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c27a4e --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Icinga Certificate Monitoring + +[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.0-777BB4?logo=PHP)](https://php.net/) +![Build Status](https://github.com/icinga/icingaweb2-module-x509/workflows/PHP%20Tests/badge.svg?branch=master) +[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-x509.svg)](https://github.com/Icinga/icingaweb2-module-x509) + +![Icinga Logo](https://icinga.com/wp-content/uploads/2014/06/icinga_logo.png) + +The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment. +It does this by scanning networks for TLS services and collects whatever certificates it finds along the way. +The certificates are verified using its own trust store. + +The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information +about any discovered certificate of your landscape: + +![X.509 Usage](doc/res/x509-usage.png "X.509 Usage") + +![X.509 Certificates](doc/res/x509-certificates.png "X.509 Certificates") + +At a glance you see which CAs have issued your certificates and key counters of your environment: + +![X.509 Dashboard](doc/res/x509-dashboard.png "X.509 Dashboard") + +## Documentation + +* [Installation](doc/02-Installation.md) +* [Configuration](doc/03-Configuration.md) +* [Monitoring](doc/10-Monitoring.md) diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php new file mode 100644 index 0000000..ae7f641 --- /dev/null +++ b/application/clicommands/CheckCommand.php @@ -0,0 +1,236 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Job; +use ipl\Sql\Select; + +class CheckCommand extends Command +{ + const UNIT_PERCENT = 'percent'; + const UNIT_INTERVAL = 'interval'; + + /** + * 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 = (new Select()) + ->from('x509_target t') + ->columns([ + 't.port', + 'cc.valid', + 'cc.invalid_reason', + 'c.subject', + 'self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', + 'c.valid_from', + 'c.valid_to' + ]) + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash') + ->where(['ccl.order = ?' => 0]); + + if ($ip !== null) { + $targets->where(['t.ip = ?' => Job::binary($ip)]); + } + if ($hostname !== null) { + $targets->where(['t.hostname = ?' => $hostname]); + } + if ($this->params->has('port')) { + $targets->where(['t.port = ?' => $this->params->get('port')]); + } + + $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); + list($warningThreshold, $warningUnit) = $this->splitThreshold($this->params->get('warning', '25%')); + list($criticalThreshold, $criticalUnit) = $this->splitThreshold($this->params->get('critical', '10%')); + + $output = []; + $perfData = []; + + $state = 3; + foreach ($this->getDb()->select($targets) as $target) { + if ($target['valid'] === 'no' && ($target['self_signed'] === 'no' || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target['invalid_reason']; + $output[$invalidMessage] = $invalidMessage; + $state = 2; + } + + $now = new \DateTime(); + $validFrom = (new \DateTime())->setTimestamp($target['valid_from']); + $validTo = (new \DateTime())->setTimestamp($target['valid_to']); + $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold, $criticalUnit); + $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold, $warningUnit); + + 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 + : $target['valid_to'] - time(), + $target['valid_to'] - $warningAfter->getTimestamp(), + $target['valid_to'] - $criticalAfter->getTimestamp(), + $target['valid_to'] - $target['valid_from'] + ); + } + + 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 array + */ + protected function splitThreshold($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], self::UNIT_PERCENT]; + 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), self::UNIT_INTERVAL]; + } + + /** + * Convert the given threshold information to a DateTime object + * + * @param \DateTime $from + * @param \DateTime $to + * @param int|\DateInterval $thresholdValue + * @param string $thresholdUnit + * + * @return \DateTime + */ + protected function thresholdToDateTime(\DateTime $from, \DateTime $to, $thresholdValue, $thresholdUnit) + { + $to = clone $to; + if ($thresholdUnit === self::UNIT_INTERVAL) { + 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/ImportCommand.php b/application/clicommands/ImportCommand.php new file mode 100644 index 0000000..1f9d1ef --- /dev/null +++ b/application/clicommands/ImportCommand.php @@ -0,0 +1,57 @@ +<?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 ipl\Sql\Connection; + +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); + } + + $db = $this->getDb(); + + $bundle = CertificateUtils::parseBundle($file); + + $count = 0; + + $db->transaction(function (Connection $db) use ($bundle, &$count) { + foreach ($bundle as $data) { + $cert = openssl_x509_read($data); + + $id = CertificateUtils::findOrInsertCert($db, $cert); + + $db->update( + 'x509_certificate', + ['trusted' => 'yes'], + ['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..0e1d599 --- /dev/null +++ b/application/clicommands/JobsCommand.php @@ -0,0 +1,73 @@ +<?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\Hook\SniHook; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Scheduler; + +class JobsCommand extends Command +{ + /** + * Run all configured jobs based on their schedule + * + * USAGE: + * + * icingacli x509 jobs run + */ + public function runAction() + { + $parallel = (int) $this->Config()->get('scan', 'parallel', 256); + + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1."); + } + + $scheduler = new Scheduler(); + + $defaultSchedule = $this->Config()->get('jobs', 'default_schedule'); + + $db = $this->getDb(); + + foreach ($this->Config('jobs') as $name => $jobDescription) { + $schedule = $jobDescription->get('schedule', $defaultSchedule); + + if (! $schedule) { + Logger::debug("The job '%s' is not scheduled.", $name); + continue; + } + + $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel); + + $scheduler->add($name, $schedule, function () use ($job, $name, $db) { + if (! $db->ping()) { + Logger::error('Lost connection to database and failed to re-connect. Skipping this job run.'); + return; + } + + $finishedTargets = $job->run(); + + if ($finishedTargets === null) { + Logger::warning("The job '%s' does not have any targets.", $name); + } else { + Logger::info( + "Scanned %s target%s in job '%s'.\n", + $finishedTargets, + $finishedTargets != 1 ? 's' : '', + $name + ); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } + }); + } + + $scheduler->run(); + } +} diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php new file mode 100644 index 0000000..fd92c7a --- /dev/null +++ b/application/clicommands/ScanCommand.php @@ -0,0 +1,67 @@ +<?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\Hook\SniHook; +use Icinga\Module\X509\Job; + +class ScanCommand extends Command +{ + /** + * Scans IP and port ranges to find X.509 certificates. + * + * This command starts scanning the IP and port ranges which belong to the job that was specified with the + * --job parameter. + * + * USAGE + * + * icingacli x509 scan --job <name> + */ + public function indexAction() + { + $name = $this->params->shiftRequired('job'); + + $parallel = (int) $this->Config()->get('scan', 'parallel', 256); + + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1."); + } + + $jobs = $this->Config('jobs'); + + if (! $jobs->hasSection($name)) { + $this->fail('Job not found.'); + } + + $jobDescription = $this->Config('jobs')->getSection($name); + + if (! strlen($jobDescription->get('cidrs'))) { + $this->fail('The job does not specify any CIDRs.'); + } + + $db = $this->getDb(); + + $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel); + + $finishedTargets = $job->run(); + + if ($finishedTargets === null) { + Logger::warning("The job '%s' does not have any targets.", $name); + } else { + Logger::info( + "Scanned %s target%s in job '%s'.\n", + $finishedTargets, + $finishedTargets != 1 ? 's' : '', + $name + ); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } + } +} diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php new file mode 100644 index 0000000..a76c100 --- /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; + +class VerifyCommand extends Command +{ + /** + * Verify all currently collected X.509 certificates + * + * USAGE: + * + * icingacli x509 verify + */ + public function indexAction() + { + $db = $this->getDb(); + + $verified = CertificateUtils::verifyCertificates($db); + + 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..414d1f3 --- /dev/null +++ b/application/controllers/CertificateController.php @@ -0,0 +1,40 @@ +<?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\Controller; +use ipl\Sql; + +class CertificateController extends Controller +{ + public function indexAction() + { + $certId = $this->params->getRequired('cert'); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $cert = $conn->select( + (new Sql\Select()) + ->from('x509_certificate') + ->columns('*') + ->where(['id = ?' => $certId]) + )->fetch(); + + if ($cert === false) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->setTitle($this->translate('X.509 Certificate')); + + $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..c145b03 --- /dev/null +++ b/application/controllers/CertificatesController.php @@ -0,0 +1,127 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\CertificatesTable; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\FilterAdapter; +use Icinga\Module\X509\SortAdapter; +use Icinga\Module\X509\SqlFilter; +use ipl\Web\Control\PaginationControl; +use ipl\Sql; +use ipl\Web\Url; + +class CertificatesController extends Controller +{ + public function indexAction() + { + $this + ->initTabs() + ->setTitle($this->translate('Certificates')); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $select = (new Sql\Select()) + ->from('x509_certificate c') + ->columns([ + 'c.id', 'c.subject', 'c.issuer', 'c.version', 'c.self_signed', 'c.ca', 'c.trusted', + 'c.pubkey_algo', 'c.pubkey_bits', 'c.signature_algo', 'c.signature_hash_algo', + 'c.valid_from', 'c.valid_to', + ]); + + $this->view->paginator = new PaginationControl(new Sql\Cursor($conn, $select), Url::fromRequest()); + $this->view->paginator->apply(); + + $sortAndFilterColumns = [ + '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'), + 'expires' => $this->translate('Expiration') + ]; + + $this->setupSortControl( + $sortAndFilterColumns, + new SortAdapter($select, function ($field) { + if ($field === 'duration') { + return '(valid_to - valid_from)'; + } elseif ($field === 'expires') { + return 'CASE WHEN UNIX_TIMESTAMP() > valid_to' + . ' THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END'; + } + }) + ); + + $this->setupLimitControl(); + + $filterAdapter = new FilterAdapter(); + $this->setupFilterControl( + $filterAdapter, + $sortAndFilterColumns, + ['subject', 'issuer'], + ['format'] + ); + SqlFilter::apply($select, $filterAdapter->getFilter(), function (FilterExpression $filter) { + switch ($filter->getColumn()) { + case 'issuer_hash': + $value = $filter->getExpression(); + + if (is_array($value)) { + $value = array_map('hex2bin', $value); + } else { + $value = hex2bin($value); + } + + return $filter->setExpression($value); + case 'duration': + return $filter->setColumn('(valid_to - valid_from)'); + case 'expires': + return $filter->setColumn( + 'CASE WHEN UNIX_TIMESTAMP() > valid_to THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END' + ); + case 'valid_from': + case 'valid_to': + $expr = $filter->getExpression(); + if (! is_numeric($expr)) { + return $filter->setExpression(strtotime($expr)); + } + + // expression doesn't need changing + default: + return false; + } + }); + + $this->handleFormatRequest($conn, $select, function (\PDOStatement $stmt) { + foreach ($stmt as $cert) { + $cert['valid_from'] = (new \DateTime()) + ->setTimestamp($cert['valid_from']) + ->format('l F jS, Y H:i:s e'); + $cert['valid_to'] = (new \DateTime()) + ->setTimestamp($cert['valid_to']) + ->format('l F jS, Y H:i:s e'); + + yield $cert; + } + }); + + $this->view->certificatesTable = (new CertificatesTable())->setData($conn->select($select)); + } +} diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php new file mode 100644 index 0000000..870fa81 --- /dev/null +++ b/application/controllers/ChainController.php @@ -0,0 +1,83 @@ +<?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\Controller; +use ipl\Html\Attribute; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Sql; + +class ChainController extends Controller +{ + public function indexAction() + { + $id = $this->params->getRequired('id'); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $chainSelect = (new Sql\Select()) + ->from('x509_certificate_chain ch') + ->columns('*') + ->join('x509_target t', 't.id = ch.target_id') + ->where(['ch.id = ?' => $id]); + + $chain = $conn->select($chainSelect)->fetch(); + + if ($chain === false) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->setTitle($this->translate('X.509 Certificate Chain')); + + $ip = $chain['ip']; + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + + $chainInfo = Html::tag('div'); + $chainInfo->add(Html::tag('dl', [ + Html::tag('dt', $this->translate('Host')), + Html::tag('dd', $chain['hostname']), + Html::tag('dt', $this->translate('IP')), + Html::tag('dd', inet_ntop($ip)), + Html::tag('dt', $this->translate('Port')), + Html::tag('dd', $chain['port']) + ])); + + $valid = Html::tag('div', ['class' => 'cert-chain']); + + if ($chain['valid'] === 'yes') { + $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'] + ))); + } + + $certsSelect = (new Sql\Select()) + ->from('x509_certificate c') + ->columns('*') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') + ->join('x509_certificate_chain cc', 'cc.id = ccl.certificate_chain_id') + ->where(['cc.id = ?' => $id]) + ->orderBy('ccl.order'); + + $this->view->chain = (new HtmlDocument()) + ->add($chainInfo) + ->add($valid) + ->add((new ChainDetails())->setData($conn->select($certsSelect))); + } +} diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php new file mode 100644 index 0000000..c64cd5c --- /dev/null +++ b/application/controllers/ConfigController.php @@ -0,0 +1,29 @@ +<?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..a48bb98 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,134 @@ +<?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\Controller; +use Icinga\Module\X509\Donut; +use Icinga\Web\Url; +use ipl\Html\Html; +use ipl\Sql\Select; + +class DashboardController extends Controller +{ + public function indexAction() + { + $this->setTitle($this->translate('Certificate Dashboard')); + + try { + $db = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $byCa = $db->select( + (new Select()) + ->from('x509_certificate i') + ->columns(['i.subject', 'cnt' => 'COUNT(*)']) + ->join('x509_certificate c', ['c.issuer_hash = i.subject_hash', 'i.ca = ?' => 'yes']) + ->groupBy(['i.id']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $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['subject']])->getAbsoluteUrl() + ], + $data['subject'] + ); + }); + + $duration = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns([ + 'duration' => 'valid_to - valid_from', + 'cnt' => 'COUNT(*)' + ]) + ->where(['ca = ?' => 'no']) + ->groupBy(['duration']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $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']}&ca=no" + )->getAbsoluteUrl() + ], + CertificateUtils::duration($data['duration']) + ); + }); + + $keyStrength = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns(['pubkey_algo', 'pubkey_bits', 'cnt' => 'COUNT(*)']) + ->groupBy(['pubkey_algo', 'pubkey_bits']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $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 = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns(['signature_algo', 'signature_hash_algo', 'cnt' => 'COUNT(*)']) + ->groupBy(['signature_algo', 'signature_hash_algo']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $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/IconsController.php b/application/controllers/IconsController.php new file mode 100644 index 0000000..422dcd5 --- /dev/null +++ b/application/controllers/IconsController.php @@ -0,0 +1,31 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Web\Controller; + +class IconsController extends Controller +{ + /** + * Disable layout rendering as this controller doesn't provide any html layouts + */ + public function init() + { + $this->_helper->viewRenderer->setNoRender(true); + $this->_helper->layout()->disableLayout(); + } + + public function indexAction() + { + $file = realpath( + $this->Module()->getBaseDir() . '/public/font/icons.' . $this->params->get('q', 'svg') + ); + + if ($file === false) { + $this->httpNotFound('File does not exist'); + } + + readfile($file); + } +} diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php new file mode 100644 index 0000000..0df196b --- /dev/null +++ b/application/controllers/JobsController.php @@ -0,0 +1,83 @@ +<?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\JobConfigForm; +use Icinga\Module\X509\JobsIniRepository; +use Icinga\Web\Controller; +use Icinga\Web\Url; + +class JobsController extends Controller +{ + /** + * List all jobs + */ + public function indexAction() + { + $this->view->tabs = $this->Module()->getConfigTabs()->activate('jobs'); + + $repo = new JobsIniRepository(); + + $this->view->jobs = $repo->select(array('name')); + } + + /** + * Create a job + */ + public function newAction() + { + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->renderForm($form, $this->translate('New Job')); + } + + /** + * Update a job + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('name')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->renderForm($form, $this->translate('Update Job')); + } + + /** + * Remove a job + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('name')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->renderForm($form, $this->translate('Remove Job')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return JobConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new JobConfigForm()) + ->setRepository(new JobsIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/jobs')); + } +} diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php new file mode 100644 index 0000000..21da41f --- /dev/null +++ b/application/controllers/SniController.php @@ -0,0 +1,83 @@ +<?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 Icinga\Web\Controller; +use Icinga\Web\Url; + +class SniController extends Controller +{ + /** + * List all maps + */ + public function indexAction() + { + $this->view->tabs = $this->Module()->getConfigTabs()->activate('sni'); + + $repo = new SniIniRepository(); + + $this->view->sni = $repo->select(array('ip')); + } + + /** + * Create a map + */ + public function newAction() + { + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->renderForm($form, $this->translate('New SNI Map')); + } + + /** + * 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..287b979 --- /dev/null +++ b/application/controllers/UsageController.php @@ -0,0 +1,155 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\FilterAdapter; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\SortAdapter; +use Icinga\Module\X509\SqlFilter; +use Icinga\Module\X509\UsageTable; +use ipl\Web\Control\PaginationControl; +use ipl\Sql; +use ipl\Web\Url; + +class UsageController extends Controller +{ + public function indexAction() + { + $this + ->initTabs() + ->setTitle($this->translate('Certificate Usage')); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $select = (new Sql\Select()) + ->from('x509_target t') + ->columns('*') + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->where(['ccl.order = ?' => 0]); + + $sortAndFilterColumns = [ + 'hostname' => $this->translate('Hostname'), + 'ip' => $this->translate('IP'), + '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'), + 'valid' => $this->translate('Chain Is Valid'), + 'duration' => $this->translate('Duration'), + 'expires' => $this->translate('Expiration') + ]; + + $this->view->paginator = new PaginationControl(new Sql\Cursor($conn, $select), Url::fromRequest()); + $this->view->paginator->apply(); + + $this->setupSortControl( + $sortAndFilterColumns, + new SortAdapter($select, function ($field) { + if ($field === 'duration') { + return '(valid_to - valid_from)'; + } elseif ($field === 'expires') { + return 'CASE WHEN UNIX_TIMESTAMP() > valid_to' + . ' THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END'; + } + }) + ); + + $this->setupLimitControl(); + + $filterAdapter = new FilterAdapter(); + $this->setupFilterControl( + $filterAdapter, + $sortAndFilterColumns, + ['hostname', 'subject'], + ['format'] + ); + SqlFilter::apply($select, $filterAdapter->getFilter(), function (FilterExpression $filter) { + switch ($filter->getColumn()) { + case 'ip': + $value = $filter->getExpression(); + + if (is_array($value)) { + $value = array_map('Job::binary', $value); + } else { + $value = Job::binary($value); + } + + return $filter->setExpression($value); + case 'issuer_hash': + $value = $filter->getExpression(); + + if (is_array($value)) { + $value = array_map('hex2bin', $value); + } else { + $value = hex2bin($value); + } + + return $filter->setExpression($value); + case 'duration': + return $filter->setColumn('(valid_to - valid_from)'); + case 'expires': + return $filter->setColumn( + 'CASE WHEN UNIX_TIMESTAMP() > valid_to THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END' + ); + case 'valid_from': + case 'valid_to': + $expr = $filter->getExpression(); + if (! is_numeric($expr)) { + return $filter->setExpression(strtotime($expr)); + } + + // expression doesn't need changing + default: + return false; + } + }); + + $formatQuery = clone $select; + $formatQuery->resetColumns()->columns([ + 'valid', 'hostname', 'ip', 'port', 'subject', 'issuer', 'version', + 'self_signed', 'ca', 'trusted', 'pubkey_algo', 'pubkey_bits', + 'signature_algo', 'signature_hash_algo', 'valid_from', 'valid_to' + ]); + + $this->handleFormatRequest($conn, $formatQuery, function (\PDOStatement $stmt) { + foreach ($stmt as $usage) { + $usage['valid_from'] = (new \DateTime()) + ->setTimestamp($usage['valid_from']) + ->format('l F jS, Y H:i:s e'); + $usage['valid_to'] = (new \DateTime()) + ->setTimestamp($usage['valid_to']) + ->format('l F jS, Y H:i:s e'); + + $ip = $usage['ip']; + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + $usage['ip'] = inet_ntop($ip); + + yield $usage; + } + }); + + $this->view->usageTable = (new UsageTable())->setData($conn->select($select)); + } +} diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php new file mode 100644 index 0000000..28b2c79 --- /dev/null +++ b/application/forms/Config/BackendConfigForm.php @@ -0,0 +1,28 @@ +<?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/JobConfigForm.php b/application/forms/Config/JobConfigForm.php new file mode 100644 index 0000000..2f0c018 --- /dev/null +++ b/application/forms/Config/JobConfigForm.php @@ -0,0 +1,96 @@ +<?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 JobConfigForm extends RepositoryForm +{ + protected function createInsertElements(array $formData) + { + $this->addElements([ + [ + 'text', + 'name', + [ + 'description' => $this->translate('Job name'), + 'label' => $this->translate('Name'), + 'required' => true + ] + ], + [ + 'textarea', + 'cidrs', + [ + 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'), + 'label' => $this->translate('CIDRs'), + 'required' => true + ] + ], + [ + 'textarea', + 'ports', + [ + 'description' => $this->translate('Comma-separated list of ports to scan'), + 'label' => $this->translate('Ports'), + 'required' => true + ] + ], + [ + 'text', + 'schedule', + [ + 'description' => $this->translate('Job cron Schedule'), + 'label' => $this->translate('Schedule') + ] + ], + ]); + + $this->setTitle($this->translate('Create a New Job')); + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit job %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove job %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('name', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Job created') + : $this->translate('Failed to create job'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Job updated') + : $this->translate('Failed to update job'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Job removed') + : $this->translate('Failed to remove job'); + } +} diff --git a/application/forms/Config/SniConfigForm.php b/application/forms/Config/SniConfigForm.php new file mode 100644 index 0000000..6e36110 --- /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->setTitle($this->translate('Create a New SNI Map')); + $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/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/certificates/index.phtml b/application/views/scripts/certificates/index.phtml new file mode 100644 index 0000000..47eb2b5 --- /dev/null +++ b/application/views/scripts/certificates/index.phtml @@ -0,0 +1,14 @@ +<?php if (! $this->compact): ?> +<div class="controls"> + <?= $this->tabs ?> + <?= $this->paginator ?> + <div class="sort-controls-container"> + <?= $this->limiter ?> + <?= $this->sortBox ?> + </div> + <?= $this->filterEditor ?> +</div> +<?php endif ?> +<div class="content"> + <?= /** @var \Icinga\Module\X509\CertificatesTable $certificatesTable */ $certificatesTable->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/jobs/index.phtml b/application/views/scripts/jobs/index.phtml new file mode 100644 index 0000000..e86c3a6 --- /dev/null +++ b/application/views/scripts/jobs/index.phtml @@ -0,0 +1,46 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <div class="actions"> + <?= $this->qlink( + $this->translate('Create a New Job') , + 'x509/jobs/new', + null, + [ + 'class' => 'button-link', + 'data-base-target' => '_next', + 'icon' => 'plus', + 'title' => $this->translate('Create a New Job') + ] + ) ?> + </div> +<?php /** @var \Icinga\Repository\RepositoryQuery $jobs */ if (! $jobs->hasResult()): ?> + <p><?= $this->escape($this->translate('No jobs configured yet.')) ?></p> +<?php else: ?> + <table class="common-table table-row-selectable" data-base-target="_next"> + <thead> + <tr> + <th><?= $this->escape($this->translate('Name')) ?></th> + </tr> + </thead> + <tbody> + <?php foreach ($jobs as $job): ?> + <tr> + <td><?= $this->qlink($job->name, 'x509/jobs/update', ['name' => $job->name]) ?></td> + <td class="icon-col"><?= $this->qlink( + null, + 'x509/jobs/remove', + array('name' => $job->name), + array( + 'class' => 'action-link', + 'icon' => 'cancel', + 'title' => $this->translate('Remove this job') + ) + ) ?></td> + </tr> + <?php endforeach ?> + </tbody> + </table> +<?php endif ?> +</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..09c4de8 --- /dev/null +++ b/application/views/scripts/sni/index.phtml @@ -0,0 +1,46 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <div class="actions"> + <?= $this->qlink( + $this->translate('Create a New SNI Map') , + 'x509/sni/new', + null, + [ + 'class' => 'button-link', + 'data-base-target' => '_next', + 'icon' => 'plus', + 'title' => $this->translate('Create a New SNI Map') + ] + ) ?> + </div> + <?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> diff --git a/application/views/scripts/usage/index.phtml b/application/views/scripts/usage/index.phtml new file mode 100644 index 0000000..a0eed09 --- /dev/null +++ b/application/views/scripts/usage/index.phtml @@ -0,0 +1,14 @@ +<?php if (! $this->compact): ?> +<div class="controls"> + <?= $this->tabs ?> + <?= $this->paginator ?> + <div class="sort-controls-container"> + <?= $this->limiter ?> + <?= $this->sortBox ?> + </div> + <?= $this->filterEditor ?> +</div> +<?php endif ?> +<div class="content"> + <?= /** @var \Icinga\Module\X509\UsageTable $usageTable */ $usageTable->render() ?> +</div> diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f69d281 --- /dev/null +++ b/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "dragonmantank/cron-expression": "^2.3.1" + }, + "scripts": { + "git-archive": "git archive --format=tar --prefix=x509/ HEAD >icingaweb2-module-x509.tar && tar --transform 's,^,x509/,' --exclude-vcs --exclude '.*' -rf icingaweb2-module-x509.tar vendor composer.lock && gzip -f icingaweb2-module-x509.tar" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..88f915e --- /dev/null +++ b/composer.lock @@ -0,0 +1,79 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "37bc3620fcf5e5bf942cda3048e34017", + "packages": [ + { + "name": "dragonmantank/cron-expression", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2020-10-13T00:52:37+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "1.1.0" +} diff --git a/config/systemd/icinga-x509.service b/config/systemd/icinga-x509.service new file mode 100644 index 0000000..01cda66 --- /dev/null +++ b/config/systemd/icinga-x509.service @@ -0,0 +1,10 @@ +[Unit] +Description=Icinga Certificate Monitoring Module Jobs Runner + +[Service] +Type=simple +ExecStart=/usr/bin/icingacli x509 jobs run +Restart=on-success + +[Install] +WantedBy=multi-user.target diff --git a/configuration.php b/configuration.php new file mode 100644 index 0000000..4182884 --- /dev/null +++ b/configuration.php @@ -0,0 +1,40 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +/** @var \Icinga\Application\Modules\Module $this */ + +$section = $this->menuSection(N_('Certificate Monitoring'), array( + 'icon' => 'check', + 'url' => 'x509/dashboard', + 'priority' => 40 +)); + +$section->add(N_('Certificate Overview'), array( + 'url' => 'x509/certificates', + 'priority' => 10 +)); + +$section->add(N_('Certificate Usage'), array( + 'url' => 'x509/usage', + 'priority' => 20 +)); + +$this->provideConfigTab('backend', array( + 'title' => $this->translate('Configure the database backend'), + 'label' => $this->translate('Backend'), + 'url' => 'config/backend' +)); + +$this->provideConfigTab('jobs', array( + 'title' => $this->translate('Configure the scan jobs'), + 'label' => $this->translate('Jobs'), + 'url' => 'jobs' +)); + +$this->provideConfigTab('sni', array( + 'title' => $this->translate('Configure SNI'), + 'label' => $this->translate('SNI'), + 'url' => 'sni' +)); + +$this->provideCssFile('icons.less'); diff --git a/doc/01-About.md b/doc/01-About.md new file mode 100644 index 0000000..38fa06a --- /dev/null +++ b/doc/01-About.md @@ -0,0 +1,22 @@ +# Icinga Certificate Monitoring + +The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment. +It does this by scanning networks for TLS services and collects whatever certificates it finds along the way. +The certificates are verified using its own trust store. + +The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information +about any discovered certificate of your landscape: + +![X.509 Usage](res/x509-usage.png "X.509 Usage") + +![X.509 Certificates](res/x509-certificates.png "X.509 Certificates") + +At a glance you see which CAs have issued your certificates and key counters of your environment: + +![X.509 Dashboard](res/x509-dashboard.png "X.509 Dashboard") + +## Documentation + +* [Installation](02-Installation.md) +* [Configuration](03-Configuration.md) +* [Monitoring](10-Monitoring.md) diff --git a/doc/02-Installation.md b/doc/02-Installation.md new file mode 100644 index 0000000..ab06e5d --- /dev/null +++ b/doc/02-Installation.md @@ -0,0 +1,43 @@ +# <a id="Installation"></a>Installation + +## Requirements + +* PHP (>= 7.0) +* Icinga Web 2 (>= 2.9) +* Icinga Web 2 libraries: + * [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (>= 0.8.1) + * [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (>= 0.10) +* php-gmp +* OpenSSL +* MySQL or MariaDB + +## Database Setup + +The module needs a MySQL/MariaDB database with the schema that's provided in the `etc/schema/mysql.schema.sql` file. + +You may use the following example command for creating the MySQL/MariaDB database. Please change the password: + +``` +CREATE DATABASE x509; +GRANT SELECT, INSERT, UPDATE, DELETE, DROP, CREATE VIEW, INDEX, EXECUTE ON x509.* TO x509@localhost IDENTIFIED BY 'secret'; +``` + +After, you can import the schema using the following command: + +``` +mysql -p -u root x509 < etc/schema/mysql.schema.sql +``` + +## Installation + +1. Install it [like any other module](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation). +Use `x509` as name. + +2. Once you've set up the database, create a new Icinga Web 2 resource for it using the +`Configuration -> Application -> Resources` menu. + +3. The next step involves telling the module which database resource to use. This can be done in +`Configuration -> Modules -> x509 -> Backend`. + +This concludes the installation. You should now be able to import CA certificates and set up scan jobs. +Please read the [Configuration](03-Configuration.md) section for details. diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md new file mode 100644 index 0000000..a70bf0b --- /dev/null +++ b/doc/03-Configuration.md @@ -0,0 +1,104 @@ +# <a id="Configuration"></a>Configuration + +## Importing CA certificates + +The module tries to verify certificates using its own trust store. By default this trust store is empty and it +is up to the Icinga Web 2 admin to import CA certificates into it. + +Using the `icingacli x509 import` command CA certificates can be imported. The certificate chain file that is specified +with the `--file` option should contain a PEM-encoded list of X.509 certificates which should be added to the trust +store: + +``` +icingacli x509 import --file /etc/ssl/certs/ca-certificates.crt +``` + +## Scan Jobs + +The module needs to know which IP address ranges and ports to scan. These can be configured in +`Configuration -> Modules -> x509 -> Jobs`. + +Scan jobs have a name which uniquely identifies them, e.g. `lan`. These names are used by the CLI command to start +scanning for specific jobs. + +Each scan job can have one or more IP address ranges and one or more port ranges. The module scans each port in +a job's port ranges for all the individual IP addresses in the IP ranges. + +IP address ranges have to be specified using the CIDR format. Multiple IP address ranges can be separated with commas, +e.g.: + +`192.0.2.0/24,10.0.10.0/24` + +Port ranges are separated with dashes (`-`). If you only want to scan a single port you don't need to specify the second +port: + +`443,5665-5669` + +Scan jobs can be executed using the `icingacli x509 scan` CLI command. The `--job` option is used to specify the scan +job which should be run: + +``` +icingacli x509 scan --job lan +``` + +## Scheduling Jobs + +Each job may specify a `cron` compatible `schedule` to run periodically at the given interval. The `cron` format is as +follows: + +``` +* * * * * +- - - - - +| | | | | +| | | | | +| | | | +----- day of week (0 - 6) (Sunday to Saturday) +| | | +---------- month (1 - 12) +| | +--------------- day of month (1 - 31) +| +-------------------- hour (0 - 23) ++------------------------- minute (0 - 59) +``` + +Example definitions: + +Description | Definition +------------------------------------------------------------| ---------- +Run once a year at midnight of 1 January | 0 0 1 1 * +Run once a month at midnight of the first day of the month | 0 0 1 * * +Run once a week at midnight on Sunday morning | 0 0 * * 0 +Run once a day at midnight | 0 0 * * * +Run once an hour at the beginning of the hour | 0 * * * * + +Jobs are executed on CLI with the `jobs` command: + +``` +icingacli x509 jobs run +``` + +This command runs all jobs which are currently due and schedules the next execution of all jobs. + +You may configure this command as `systemd` service. Just copy the example service definition from +`config/systemd/icinga-x509.service` to `/etc/systemd/system/icinga-x509.service` and enable it afterwards: + +``` +systemctl enable icinga-x509.service +``` + +As an alternative if you want scan jobs to be run periodically, you can use the `cron(8)` daemon to run them on a +schedule: + +``` +vi /etc/crontab +[...] + +# Runs job 'lan' daily at 2:30 AM +30 2 * * * www-data icingacli x509 scan --job lan +``` + +## Server Name Indication + +In case you are serving multiple virtual hosts under a single IP you can configure those in +`Configuration -> Modules -> x509 -> SNI`. + +Each entry defines an IP with multiple hostnames associated with it. These are then utilized when jobs run. + +Modules may also provide sources for SNI. At this time the module monitoring is the only one with known support. diff --git a/doc/10-Monitoring.md b/doc/10-Monitoring.md new file mode 100644 index 0000000..7ef3abd --- /dev/null +++ b/doc/10-Monitoring.md @@ -0,0 +1,212 @@ +# <a id="Monitoring"></a>Monitoring + +## Host Check Command + +The module provides a CLI command to check a host's certificate. It does so by +fetching all the necessary information from this module's own database. + +### Usage + +General: `icingacli x509 check host [options]` + +Options: + +``` +--ip A hosts IP address +--host A hosts name +--port The port to check in particular +--warning Less remaining time results in state WARNING [25%] +--critical Less remaining time results in state CRITICAL [10%] +--allow-self-signed Ignore if a certificate or its issuer has been self-signed +``` + +### 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: + + Identifier | Description +------------|------------ +y, Y | Year +M | Month +d, D | Day +h, H | Hour +m | Minute +s, S | Second + +**Example:** + +``` +$ icingacli x509 check host --host example.org --warning 1y +WARNING - *.example.org expires in 219 days|'*.example.org'=18985010s;25574400;10281600;0;102470399 +``` + +### Performance Data + +The command outputs a performance data value for each certificate that is +served by the host. The value measured is the amount of seconds remaining +until the certificate expires. + +![check host perf data](res/check-host-perf-data.png) + +The value of `max` is the total amount of seconds the certificate is valid. +`warning` and `critical` are the seconds remaining after which the respective +state is reported. + +## Icinga 2 Integration + +First off, this chapter relies on the fact that you're using the Director +already and that you're familiar with some of the terms and functionalities +used there. + +If you don't want to use the Director, know that Icinga 2 already provides +an appropriate template for the host check command in its template library: +https://icinga.com/docs/icinga2/latest/doc/10-icinga-template-library/#x509 + +### Director Import Sources + +The module provides two different import sources: + +#### Hosts (X509) + +Focuses on the hosts the module found when scanning the networks. Use this +for the most straightforward way of integrating the results into your +environment. It's also the utilized source in the example further below. + +Columns provided by this source: + +Name | Description +----------------|-------------------------------------------------------------- +host_name_or_ip | Default key column. This is primarily `host_name`, though if this is not unique it falls back to `host_ip` for individual results +host_ip | A host's IP address by which it is known to this module. May be IPv4 or IPv6 +host_name | A host's name as detected by SNI or a reverse DNS lookup during the scan process +host_ports | Separated by comma. All ports where certificates were found +host_address | Set to `host_ip` if it is IPv4 else `null` +host_address6 | Set to `host_ip` if it is IPv6 else `null` + +#### Services (X509) + +While the hosts import source does not provide any details about the found +certificates this one does. This also means that this source may generate +multiple results for a single host since it focuses on the found certificates. + +Use this source if you want to import service objects directly and relate them +to already existing hosts by their utilized certificates. The Director's many +utilities provided in this regard will again come in handy here. + +Columns provided by this source: + +Name | Description +----------------------|-------------------------------------------------------- +host_name_ip_and_port | Default key column. This is a combination of `host_name`, `host_ip` and `host_port` in the format `name/ip:port` +host_ip | A host's IP address by which it is known to this module. May be IPv4 or IPv6 +host_name | A host's name as detected by SNI or a reverse DNS lookup during the scan process +host_port | A host's port where a certificate has been found +host_address | Set to `host_ip` if it is IPv4 else `null` +host_address6 | Set to `host_ip` if it is IPv6 else `null` +cert_subject | A certificate's common name +cert_issuer | The issuer's common name +cert_self_signed | Whether the certificate is self-signed (`yes` or `no`) +cert_trusted | Whether the certificate is trusted (`yes` or `no`) +cert_valid_from | The certificate's start time of validity (UNIX timestamp) +cert_valid_to | The certificate's end time of validity (UNIX timestamp) +cert_fingerprint | The certificate's fingerprint (hex-encoded) +cert_dn | The certificate's distinguished name +cert_subject_alt_name | The certificate's alternative subject names (Comma separated pairs of `type:name`) + +### Service Checks With the Hosts Import Source + +This example covers the setup of service checks by using a particular host +template and suggests then two options utilizing service apply rules. + +#### Preparations + +Assuming the check command definition `icingacli-x509` has already been imported +you need to define a few data fields now: + +Field name | Data type +---------------------------------|---------- +certified_ports | Array +icingacli_x509_ip | String +icingacli_x509_host | String +icingacli_x509_port | String +icingacli_x509_warning | String +icingacli_x509_critical | String +icingacli_x509_allow_self_signed | Boolean + +Then please create a new host template with a name of your choosing. We've chosen +`x509-host`. We're also importing our base template `base-host` here which defines +all the default properties of our hosts. + +![new host template](res/new-host-template.png) + +This host template also requires three data fields which are shown below. + +![host template fields](res/host-template-fields.png) + +A service template is also needed. We chose the name `x509-host-check` and +`icingacli-x509` as check command. + +![new service template](res/new-service-template.png) + +The service template now requires all data fields which correspond to the +check command's parameters. + +![service template fields](res/service-template-fields.png) + +#### Import Source Setup + +Create a new import source of type `Hosts (X509)`. +![hosts import source](res/hosts-import-source.png) + +Configure a property modifier for column `host_ports` of type `Split` and use +the comma `,` as delimiter. +![ports property modifier](res/ports-property-modifier.png) + +The preview should now produce a similar result to this: +![hosts import result](res/hosts-import-result.png) + +#### Sync Rule Setup + +Create a new sync rule for objects of type `Host`. Depending on your environment +you may choose either `Merge` or `Replace` as update policy. Choose `Merge` to +continue with this example. + +Which properties this rule defines is also very dependent on what you want to +achieve. We now assume that you already have host objects whose object names +match exactly those the import source provides. (Hence you should choose +`Merge` as update policy) + +![sync rule properties](res/sync-rule-properties.png) + +#### Service Check Setup + +There are two choices now. The first checks a host's certificates as a single +service. The second creates for each individual certificate (port) a service. + +##### Single Service + +This is done by defining a new service as part of the host template created +earlier. There add a service and choose the service template also created +previously. + +![host check single service](res/host-check-single-service.png) + +Once you've triggered the import and synchronisation as well as deployed +the resulting changes you should see this in Icinga Web 2: + +![single service result](res/single-service-result.png) + +##### Multiple Services + +This utilizes a service apply rule. Trigger the import and synchronisation +first, otherwise you can't choose a custom variable for the *apply for* rule. + +Once the synchronisation is finished, set up the service apply rule like this: + +![host check multiple services](res/host-check-multiple-services.png) + +After deploying the resulting changes you should see this in Icinga Web 2: + +![multiple services result](res/multiple-services-result.png) diff --git a/doc/80-Upgrading.md b/doc/80-Upgrading.md new file mode 100644 index 0000000..85c5fa8 --- /dev/null +++ b/doc/80-Upgrading.md @@ -0,0 +1,29 @@ +# Upgrading Icinga Certificate Monitoring + +Upgrading Icinga Certificate Monitoring is straightforward. +Usually the only manual steps involved are schema updates for the database. + +## Upgrading to version 1.1.0 + +Icinga Certificate Monitoring version 1.1.0 fixes issues that affect the database schema. +To have these issues really fixed in your environment, the schema must be upgraded. +Please find the upgrade script in **etc/schema/mysql-upgrade**. + +You may use the following command to apply the database schema upgrade file: + +``` +# mysql -u root -p x509 < etc/schema/mysql-upgrade/v1.1.0.sql +``` + +## Upgrading to version 1.0.0 + +Icinga Certificate Monitoring version 1.0.0 requires a schema update for the database. +The schema has been adjusted so that it is no longer necessary to adjust server settings +if you're using a version of MySQL < 5.7 or MariaDB < 10.2. +Please find the upgrade script in **etc/schema/mysql-upgrade**. + +You may use the following command to apply the database schema upgrade file: + +``` +# mysql -u root -p x509 < etc/schema/mysql-upgrade/v1.0.0.sql +``` diff --git a/doc/res/check-host-perf-data.png b/doc/res/check-host-perf-data.png Binary files differnew file mode 100644 index 0000000..958a226 --- /dev/null +++ b/doc/res/check-host-perf-data.png diff --git a/doc/res/host-check-multiple-services.png b/doc/res/host-check-multiple-services.png Binary files differnew file mode 100644 index 0000000..a153e2e --- /dev/null +++ b/doc/res/host-check-multiple-services.png diff --git a/doc/res/host-check-single-service.png b/doc/res/host-check-single-service.png Binary files differnew file mode 100644 index 0000000..1f4bdec --- /dev/null +++ b/doc/res/host-check-single-service.png diff --git a/doc/res/host-template-fields.png b/doc/res/host-template-fields.png Binary files differnew file mode 100644 index 0000000..a6aa438 --- /dev/null +++ b/doc/res/host-template-fields.png diff --git a/doc/res/hosts-import-result.png b/doc/res/hosts-import-result.png Binary files differnew file mode 100644 index 0000000..c19b1f2 --- /dev/null +++ b/doc/res/hosts-import-result.png diff --git a/doc/res/hosts-import-source.png b/doc/res/hosts-import-source.png Binary files differnew file mode 100644 index 0000000..fe525c7 --- /dev/null +++ b/doc/res/hosts-import-source.png diff --git a/doc/res/multiple-services-result.png b/doc/res/multiple-services-result.png Binary files differnew file mode 100644 index 0000000..8bec4b9 --- /dev/null +++ b/doc/res/multiple-services-result.png diff --git a/doc/res/new-host-template.png b/doc/res/new-host-template.png Binary files differnew file mode 100644 index 0000000..2ff9074 --- /dev/null +++ b/doc/res/new-host-template.png diff --git a/doc/res/new-service-template.png b/doc/res/new-service-template.png Binary files differnew file mode 100644 index 0000000..fec3d22 --- /dev/null +++ b/doc/res/new-service-template.png diff --git a/doc/res/ports-property-modifier.png b/doc/res/ports-property-modifier.png Binary files differnew file mode 100644 index 0000000..7bb3ecc --- /dev/null +++ b/doc/res/ports-property-modifier.png diff --git a/doc/res/service-template-fields.png b/doc/res/service-template-fields.png Binary files differnew file mode 100644 index 0000000..17c1cbd --- /dev/null +++ b/doc/res/service-template-fields.png diff --git a/doc/res/single-service-result.png b/doc/res/single-service-result.png Binary files differnew file mode 100644 index 0000000..418f495 --- /dev/null +++ b/doc/res/single-service-result.png diff --git a/doc/res/sync-rule-properties.png b/doc/res/sync-rule-properties.png Binary files differnew file mode 100644 index 0000000..1b54553 --- /dev/null +++ b/doc/res/sync-rule-properties.png diff --git a/doc/res/x509-certificates.png b/doc/res/x509-certificates.png Binary files differnew file mode 100644 index 0000000..c89e6fb --- /dev/null +++ b/doc/res/x509-certificates.png diff --git a/doc/res/x509-dashboard.png b/doc/res/x509-dashboard.png Binary files differnew file mode 100644 index 0000000..c4a2f5c --- /dev/null +++ b/doc/res/x509-dashboard.png diff --git a/doc/res/x509-usage.png b/doc/res/x509-usage.png Binary files differnew file mode 100644 index 0000000..a4c81b6 --- /dev/null +++ b/doc/res/x509-usage.png diff --git a/etc/schema/mysql-upgrade/v1.0.0.sql b/etc/schema/mysql-upgrade/v1.0.0.sql new file mode 100644 index 0000000..28b3e7d --- /dev/null +++ b/etc/schema/mysql-upgrade/v1.0.0.sql @@ -0,0 +1,27 @@ +ALTER TABLE x509_target MODIFY COLUMN `port` smallint unsigned NOT NULL; + +ALTER TABLE x509_certificate_subject_alt_name DROP FOREIGN KEY x509_fk_certificate_subject_alt_name_certificate_id; + +ALTER TABLE x509_certificate_subject_alt_name DROP PRIMARY KEY; + +ALTER TABLE x509_certificate_subject_alt_name ADD COLUMN hash binary(32) NOT NULL + COMMENT 'sha256 hash of type=value' + AFTER certificate_id; + +UPDATE x509_certificate_subject_alt_name SET hash = UNHEX(SHA2(CONCAT(type, '=', value), 256)); + +ALTER TABLE x509_certificate_subject_alt_name ADD PRIMARY KEY(certificate_id, hash); + +ALTER TABLE x509_certificate_subject_alt_name ADD + CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id + FOREIGN KEY (certificate_id) + REFERENCES x509_certificate (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE x509_certificate_subject_alt_name ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=default; + +ALTER TABLE x509_target DROP INDEX x509_idx_target_ip_port_hostname; + +ALTER TABLE x509_target ADD INDEX x509_idx_target_ip_port_hostname(ip,port,hostname(191)); + +ALTER TABLE x509_target ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=default; diff --git a/etc/schema/mysql-upgrade/v1.1.0.sql b/etc/schema/mysql-upgrade/v1.1.0.sql new file mode 100644 index 0000000..055d783 --- /dev/null +++ b/etc/schema/mysql-upgrade/v1.1.0.sql @@ -0,0 +1,4 @@ +ALTER TABLE x509_target DROP INDEX x509_idx_target_ip_port_hostname; +ALTER TABLE x509_target ADD INDEX x509_idx_target_ip_port (ip, port); +ALTER TABLE x509_certificate MODIFY COLUMN valid_from bigint(20) NOT NULL; +ALTER TABLE x509_certificate MODIFY COLUMN valid_to bigint(20) NOT NULL; diff --git a/etc/schema/mysql.schema.sql b/etc/schema/mysql.schema.sql new file mode 100644 index 0000000..03c1cc1 --- /dev/null +++ b/etc/schema/mysql.schema.sql @@ -0,0 +1,92 @@ +CREATE TABLE x509_certificate ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + `subject` varchar(255) NOT NULL COMMENT 'CN of the subject DN if present else full subject DN', + subject_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full subject DN', + `issuer` varchar(255) NOT NULL COMMENT 'CN of the issuer DN if present else full issuer DN', + issuer_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full issuer DN', + issuer_certificate_id int(10) unsigned DEFAULT NULL, + version enum('1','2','3') NOT NULL, + self_signed enum('yes','no') NOT NULL DEFAULT 'no', + ca enum('yes','no') NOT NULL, + trusted enum('yes','no') NOT NULL DEFAULT 'no', + pubkey_algo enum('unknown','RSA','DSA','DH','EC') NOT NULL, + pubkey_bits smallint(6) unsigned NOT NULL, + signature_algo varchar(255) NOT NULL, + signature_hash_algo varchar(255) NOT NULL, + valid_from bigint(20) NOT NULL, + valid_to bigint(20) NOT NULL, + fingerprint binary(32) NOT NULL COMMENT 'sha256 hash', + `serial` blob NOT NULL, + certificate blob NOT NULL COMMENT 'DER encoded certificate', + ctime timestamp NULL DEFAULT NULL, + mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY x509_idx_certificate_fingerprint (fingerprint), + KEY x509_fk_certificate_issuer_certificate_id (issuer_certificate_id), + CONSTRAINT x509_fk_certificate_issuer_certificate_id FOREIGN KEY (issuer_certificate_id) REFERENCES x509_certificate (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_certificate_chain ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + target_id int(10) unsigned NOT NULL, + length smallint(6) NOT NULL, + valid enum('yes','no') NOT NULL DEFAULT 'no', + invalid_reason varchar(255) NULL DEFAULT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_certificate_chain_link ( + certificate_chain_id int(10) unsigned NOT NULL, + certificate_id int(10) unsigned NOT NULL, + `order` tinyint(4) NOT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (certificate_chain_id,certificate_id,`order`), + KEY x509_fk_certificate_chain_link_certificate_id (certificate_id), + CONSTRAINT x509_fk_certificate_chain_link_certificate_chain_id FOREIGN KEY (certificate_chain_id) REFERENCES x509_certificate_chain (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT x509_fk_certificate_chain_link_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_certificate_subject_alt_name ( + certificate_id int(10) unsigned NOT NULL, + hash binary(32) NOT NULL COMMENT 'sha256 hash of type=value', + `type` varchar(255) NOT NULL, + `value` varchar(255) NOT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (certificate_id,hash), + CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_dn ( + `hash` binary(32) NOT NULL, + `type` enum('issuer','subject') NOT NULL, + `order` tinyint(4) unsigned NOT NULL, + `key` varchar(255) NOT NULL, + `value` varchar(255) NOT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`hash`,`type`,`order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_job_run ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + total_targets int(10) NOT NULL, + finished_targets int(10) NOT NULL, + start_time timestamp NULL DEFAULT NULL, + end_time timestamp NULL DEFAULT NULL, + ctime timestamp NULL DEFAULT NULL, + mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_target ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + ip binary(16) NOT NULL, + `port` smallint unsigned NOT NULL, + hostname varchar(255) NULL DEFAULT NULL, + latest_certificate_chain_id int(10) unsigned NULL DEFAULT NULL, + ctime timestamp NULL DEFAULT NULL, + mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX x509_idx_target_ip_port (ip, port) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php new file mode 100644 index 0000000..3ec2d54 --- /dev/null +++ b/library/X509/CertificateDetails.php @@ -0,0 +1,105 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use DateTime; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +/** + * Widget to display X.509 certificate details + */ +class CertificateDetails extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'cert-details']; + + /** + * @var array + */ + protected $cert; + + public function setCert(array $cert) + { + $this->cert = $cert; + + return $this; + } + + protected function assemble() + { + $pem = CertificateUtils::der2pem($this->cert['certificate']); + $cert = openssl_x509_parse($pem); +// $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem)); + + $subject = Html::tag('dl'); + foreach ($cert['subject'] as $key => $value) { + $subject->add([ + Html::tag('dt', $key), + Html::tag('dd', $value) + ]); + } + + $issuer = Html::tag('dl'); + foreach ($cert['issuer'] as $key => $value) { + $issuer->add([ + Html::tag('dt', $key), + Html::tag('dd', $value) + ]); + } + + $certInfo = Html::tag('dl'); + $certInfo->add([ + Html::tag('dt', mt('x509', 'Serial Number')), + Html::tag('dd', bin2hex($this->cert['serial'])), + Html::tag('dt', mt('x509', 'Version')), + Html::tag('dd', $this->cert['version']), + Html::tag('dt', mt('x509', 'Signature Algorithm')), + Html::tag('dd', $this->cert['signature_algo'] . ' with ' . $this->cert['signature_hash_algo']), + Html::tag('dt', mt('x509', 'Not Valid Before')), + Html::tag('dd', (new DateTime())->setTimestamp($this->cert['valid_from'])->format('l F jS, Y H:i:s e')), + Html::tag('dt', mt('x509', 'Not Valid After')), + Html::tag('dd', (new DateTime())->setTimestamp($this->cert['valid_to'])->format('l F jS, Y H:i:s e')), + ]); + + $pubkeyInfo = Html::tag('dl'); + $pubkeyInfo->add([ + Html::tag('dt', mt('x509', 'Algorithm')), + Html::tag('dd', $this->cert['pubkey_algo']), + Html::tag('dt', mt('x509', 'Key Size')), + Html::tag('dd', $this->cert['pubkey_bits']) + ]); + + $extensions = Html::tag('dl'); + foreach ($cert['extensions'] as $key => $value) { + $extensions->add([ + Html::tag('dt', ucwords(implode(' ', preg_split('/(?=[A-Z])/', $key)))), + Html::tag('dd', $value) + ]); + } + + $fingerprints = Html::tag('dl'); + $fingerprints->add([ + Html::tag('dt', 'SHA-256'), + Html::tag('dd', wordwrap(strtoupper(bin2hex($this->cert['fingerprint'])), 2, ' ', true)) + ]); + + $this->add([ + Html::tag('h2', [Html::tag('i', ['class' => 'x509-icon-cert']), $this->cert['subject']]), + Html::tag('h3', mt('x509', 'Subject Name')), + $subject, + Html::tag('h3', mt('x509', 'Issuer Name')), + $issuer, + Html::tag('h3', mt('x509', 'Certificate Info')), + $certInfo, + Html::tag('h3', mt('x509', 'Public Key Info')), + $pubkeyInfo, + Html::tag('h3', mt('x509', 'Extensions')), + $extensions, + Html::tag('h3', mt('x509', 'Fingerprints')), + $fingerprints + ]); + } +} diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php new file mode 100644 index 0000000..c538444 --- /dev/null +++ b/library/X509/CertificateUtils.php @@ -0,0 +1,460 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Exception; +use Icinga\Application\Logger; +use Icinga\File\Storage\TemporaryLocalFileStorage; +use ipl\Sql\Connection; +use ipl\Sql\Select; + +class CertificateUtils +{ + /** + * Possible public key types + * + * @var string[] + */ + protected static $pubkeyTypes = [ + -1 => 'unknown', + OPENSSL_KEYTYPE_RSA => 'RSA', + OPENSSL_KEYTYPE_DSA => 'DSA', + OPENSSL_KEYTYPE_DH => 'DH', + OPENSSL_KEYTYPE_EC => 'EC' + ]; + + /** + * Convert the given chunk from PEM to DER + * + * @param string $pem + * + * @return string + */ + public static function pem2der($pem) + { + $lines = explode("\n", $pem); + + $der = ''; + + foreach ($lines as $line) { + if (strpos($line, '-----') === 0) { + continue; + } + + $der .= base64_decode($line); + } + + return $der; + } + + /** + * Convert the given chunk from DER to PEM + * + * @param string $der + * + * @return string + */ + public static function der2pem($der) + { + $block = chunk_split(base64_encode($der), 64, "\n"); + + return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----"; + } + + /** + * Format seconds to human-readable duration + * + * @param int $seconds + * + * @return string + */ + public static function duration($seconds) + { + if ($seconds < 60) { + return "$seconds Seconds"; + } + + if ($seconds < 3600) { + $minutes = round($seconds / 60); + + return "$minutes Minutes"; + } + + if ($seconds < 86400) { + $hours = round($seconds / 3600); + + return "$hours Hours"; + } + + if ($seconds < 604800) { + $days = round($seconds / 86400); + + return "$days Days"; + } + + if ($seconds < 2592000) { + $weeks = round($seconds / 604800); + + return "$weeks Weeks"; + } + + if ($seconds < 31536000) { + $months = round($seconds / 2592000); + + return "$months Months"; + } + + $years = round($seconds / 31536000); + + return "$years Years"; + } + + /** + * Get the short name from the given DN + * + * If the given DN contains a CN, the CN is returned. Else, the DN is returned as string. + * + * @param array $dn + * + * @return string The CN if it exists or the full DN as string + */ + private static function shortNameFromDN(array $dn) + { + if (isset($dn['CN'])) { + $cn = (array) $dn['CN']; + return $cn[0]; + } else { + $result = []; + foreach ($dn as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $result[] = "{$key}={$item}"; + } + } else { + $result[] = "{$key}={$value}"; + } + } + + return implode(', ', $result); + } + } + + /** + * Split the given Subject Alternative Names into key-value pairs + * + * @param string $sans + * + * @return \Generator + */ + private static function splitSANs($sans) + { + preg_match_all('/(?:^|, )([^:]+):/', $sans, $keys); + $values = preg_split('/(^|, )[^:]+:\s*/', $sans); + for ($i = 0; $i < count($keys[1]); $i++) { + yield [$keys[1][$i], $values[$i + 1]]; + } + } + + /** + * Yield certificates in the given bundle + * + * @param string $file Path to the bundle + * + * @return \Generator + */ + public static function parseBundle($file) + { + $content = file_get_contents($file); + + $blocks = explode('-----BEGIN CERTIFICATE-----', $content); + + foreach ($blocks as $block) { + $end = strrpos($block, '-----END CERTIFICATE-----'); + + if ($end !== false) { + yield '-----BEGIN CERTIFICATE-----' . substr($block, 0, $end) . '-----END CERTIFICATE-----'; + } + } + } + + /** + * Find or insert the given certificate and return its ID + * + * @param Connection $db + * @param mixed $cert + * + * @return int + */ + public static function findOrInsertCert(Connection $db, $cert) + { + $certInfo = openssl_x509_parse($cert); + + $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true); + + $row = $db->select( + (new Select()) + ->columns(['id']) + ->from('x509_certificate') + ->where(['fingerprint = ?' => $fingerprint]) + )->fetch(); + + if ($row !== false) { + return (int) $row['id']; + } + + Logger::debug("Importing certificate: %s", $certInfo['name']); + + $pem = null; + if (! openssl_x509_export($cert, $pem)) { + die('Failed to encode X.509 certificate.'); + } + $der = CertificateUtils::pem2der($pem); + + $ca = false; + if (isset($certInfo['extensions']['basicConstraints'])) { + if (strpos($certInfo['extensions']['basicConstraints'], 'CA:TRUE') !== false) { + $ca = true; + } + } + + $subjectHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'subject'); + $issuerHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'issuer'); + $pubkey = openssl_pkey_get_details(openssl_pkey_get_public($cert)); + $signature = explode('-', $certInfo['signatureTypeSN']); + + $db->insert( + 'x509_certificate', + [ + 'subject' => CertificateUtils::shortNameFromDN($certInfo['subject']), + 'subject_hash' => $subjectHash, + 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']), + 'issuer_hash' => $issuerHash, + 'version' => $certInfo['version'] + 1, + 'self_signed' => $subjectHash === $issuerHash ? 'yes' : 'no', + 'ca' => $ca ? 'yes' : 'no', + 'pubkey_algo' => CertificateUtils::$pubkeyTypes[$pubkey['type']], + 'pubkey_bits' => $pubkey['bits'], + 'signature_algo' => array_shift($signature), // Support formats like RSA-SHA1 and + 'signature_hash_algo' => array_pop($signature), // ecdsa-with-SHA384 + 'valid_from' => $certInfo['validFrom_time_t'], + 'valid_to' => $certInfo['validTo_time_t'], + 'fingerprint' => $fingerprint, + 'serial' => gmp_export($certInfo['serialNumber']), + 'certificate' => $der + ] + ); + + $certId = (int) $db->lastInsertId(); + + CertificateUtils::insertSANs($db, $certId, $certInfo); + + return $certId; + } + + private static function insertSANs($db, $certId, array $certInfo) + { + if (isset($certInfo['extensions']['subjectAltName'])) { + foreach (CertificateUtils::splitSANs($certInfo['extensions']['subjectAltName']) as $san) { + list($type, $value) = $san; + + $hash = hash('sha256', sprintf('%s=%s', $type, $value), true); + + $row = $db->select( + (new Select()) + ->from('x509_certificate_subject_alt_name') + ->columns('certificate_id') + ->where([ + 'certificate_id = ?' => $certId, + 'hash = ?' => $hash + ]) + )->fetch(); + + // Ignore duplicate SANs + if ($row !== false) { + continue; + } + + $db->insert( + 'x509_certificate_subject_alt_name', + [ + 'certificate_id' => $certId, + 'hash' => $hash, + 'type' => $type, + 'value' => $value + ] + ); + } + } + } + + private static function findOrInsertDn($db, $certInfo, $type) + { + $dn = $certInfo[$type]; + + $data = ''; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $data .= "{$key}=${value}, "; + } + } + $hash = hash('sha256', $data, true); + + $row = $db->select( + (new Select()) + ->from('x509_dn') + ->columns('hash') + ->where([ 'hash = ?' => $hash, 'type = ?' => $type ]) + ->limit(1) + )->fetch(); + + if ($row !== false) { + return $row['hash']; + } + + $index = 0; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $db->insert( + 'x509_dn', + [ + 'hash' => $hash, + '`key`' => $key, + '`value`' => $value, + '`order`' => $index, + 'type' => $type + ] + ); + $index++; + } + } + + return $hash; + } + + /** + * Verify certificates + * + * @param Connection $db Connection to the X.509 database + * + * @return int + */ + public static function verifyCertificates(Connection $db) + { + $files = new TemporaryLocalFileStorage(); + + $caFile = uniqid('ca'); + + $cas = $db->select( + (new Select) + ->from('x509_certificate') + ->columns(['certificate']) + ->where(['ca = ?' => 'yes', 'trusted = ?' => 'yes']) + ); + + $contents = []; + + foreach ($cas as $ca) { + $contents[] = static::der2pem($ca['certificate']); + } + + if (empty($contents)) { + throw new \RuntimeException('Trust store is empty'); + } + + $files->create($caFile, implode("\n", $contents)); + + $count = 0; + + $db->beginTransaction(); + + try { + $chains = $db->select( + (new Select) + ->from('x509_certificate_chain c') + ->join('x509_target t', ['t.latest_certificate_chain_id = c.id', 'c.valid = ?' => 'no']) + ->columns('c.id') + ); + + foreach ($chains as $chain) { + ++$count; + + $certs = $db->select( + (new Select) + ->from('x509_certificate c') + ->columns('c.certificate') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') + ->where(['ccl.certificate_chain_id = ?' => $chain['id']]) + ->orderBy(['ccl.order' => 'DESC']) + ); + + $collection = []; + + foreach ($certs as $cert) { + $collection[] = CertificateUtils::der2pem($cert['certificate']); + } + + $certFile = uniqid('cert'); + + $files->create($certFile, array_pop($collection)); + + $untrusted = ''; + foreach ($collection as $intermediate) { + $intermediateFile = uniqid('intermediate'); + $files->create($intermediateFile, $intermediate); + $untrusted .= ' -untrusted ' . escapeshellarg($files->resolvePath($intermediateFile)); + } + + $command = sprintf( + 'openssl verify -CAfile %s%s %s 2>&1', + escapeshellarg($files->resolvePath($caFile)), + $untrusted, + escapeshellarg($files->resolvePath($certFile)) + ); + + $output = null; + + exec($command, $output, $exitcode); + + $output = implode("\n", $output); + + if ($exitcode !== 0) { + Logger::warning('openssl verify failed for command %s: %s', $command, $output); + } + + preg_match('/^error \d+ at \d+ depth lookup:(.+)$/m', $output, $match); + + if (!empty($match)) { + $set = ['invalid_reason' => trim($match[1])]; + } else { + $set = ['valid' => 'yes', 'invalid_reason' => null]; + } + + $db->update( + 'x509_certificate_chain', + $set, + ['id = ?' => $chain['id']] + ); + } + + $db->commitTransaction(); + } catch (Exception $e) { + Logger::error($e); + $db->rollBackTransaction(); + } + + return $count; + } +} diff --git a/library/X509/CertificatesTable.php b/library/X509/CertificatesTable.php new file mode 100644 index 0000000..3900221 --- /dev/null +++ b/library/X509/CertificatesTable.php @@ -0,0 +1,109 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Web\Url; +use ipl\Html\Html; + +/** + * Table widget to display X.509 certificates + */ +class CertificatesTable extends DataTable +{ + protected $defaultAttributes = [ + 'class' => 'cert-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + protected function createColumns() + { + return [ + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => mt('x509', 'Certificate'), + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if ($ca === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-ca', 'title' => mt('x509', 'Is Certificate Authority')] + ); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if ($selfSigned === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-self-signed', 'title' => mt('x509', 'Is Self-Signed')] + ); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if ($trusted === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'icon icon-thumbs-up', 'title' => mt('x509', 'Is Trusted')] + ); + } + ], + + 'issuer' => mt('x509', 'Issuer'), + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/certificate', ['cert' => $row['id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/ChainDetails.php b/library/X509/ChainDetails.php new file mode 100644 index 0000000..527e06c --- /dev/null +++ b/library/X509/ChainDetails.php @@ -0,0 +1,116 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Web\Url; +use ipl\Html\Html; + +/** + * Table widget to display X.509 chain details + */ +class ChainDetails extends DataTable +{ + protected $defaultAttributes = [ + 'class' => 'cert-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function createColumns() + { + return [ + [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function () { + return Html::tag('i', ['class' => 'x509-icon-cert']); + } + ], + + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => [ + 'label' => mt('x509', 'Subject') + ], + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if ($ca === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-ca', 'title' => mt('x509', 'Is Certificate Authority')] + ); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if ($selfSigned === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-self-signed', 'title' => mt('x509', 'Is Self-Signed')] + ); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if ($trusted === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'icon icon-thumbs-up', 'title' => mt('x509', 'Is Trusted')] + ); + } + ], + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/certificate', ['cert' => $row['certificate_id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/ColorScheme.php b/library/X509/ColorScheme.php new file mode 100644 index 0000000..94a3bf7 --- /dev/null +++ b/library/X509/ColorScheme.php @@ -0,0 +1,36 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ArrayIterator; +use InfiniteIterator; + +class ColorScheme +{ + /** + * The colors of this scheme + * + * @var array + */ + protected $colors; + + public function __construct(array $colors) + { + $this->colors = $colors; + } + + public function scheme() + { + $iter = new InfiniteIterator(new ArrayIterator($this->colors)); + $iter->rewind(); + + return function () use ($iter) { + $color = $iter->current(); + + $iter->next(); + + return $color; + }; + } +} diff --git a/library/X509/Command.php b/library/X509/Command.php new file mode 100644 index 0000000..7737119 --- /dev/null +++ b/library/X509/Command.php @@ -0,0 +1,35 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Application\Icinga; +use Icinga\Data\ResourceFactory; +use ipl\Sql; + +class Command extends \Icinga\Cli\Command +{ + // Fix Web 2 issue where $configs is not properly initialized + protected $configs = []; + + public function init() + { + Icinga::app()->getModuleManager()->loadEnabledModules(); + } + + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + public function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + $this->Config()->get('backend', 'resource') + )); + + $conn = new Sql\Connection($config); + + return $conn; + } +} diff --git a/library/X509/Controller.php b/library/X509/Controller.php new file mode 100644 index 0000000..bb798a0 --- /dev/null +++ b/library/X509/Controller.php @@ -0,0 +1,121 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Data\ResourceFactory; +use Icinga\File\Csv; +use Icinga\Util\Json; +use Icinga\Web\Widget\Tabextension\DashboardAction; +use Icinga\Web\Widget\Tabextension\MenuAction; +use Icinga\Web\Widget\Tabextension\OutputFormat; +use ipl\Sql; +use PDO; + +class Controller extends \Icinga\Web\Controller +{ + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + protected function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + $this->Config()->get('backend', 'resource') + )); + + $config->options = [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION SQL_MODE='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE" + . ",ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'" + ]; + + $conn = new Sql\Connection($config); + + return $conn; + } + + /** + * Set the title tab of this view + * + * @param string $label + * + * @return $this + */ + protected function setTitle($label) + { + $this->getTabs()->add(uniqid(), [ + 'active' => true, + 'label' => (string) $label, + 'url' => $this->getRequest()->getUrl() + ]); + + return $this; + } + + protected function handleFormatRequest(Sql\Connection $db, Sql\Select $select, callable $callback = null) + { + $desiredContentType = $this->getRequest()->getHeader('Accept'); + if ($desiredContentType === 'application/json') { + $desiredFormat = 'json'; + } elseif ($desiredContentType === 'text/csv') { + $desiredFormat = 'csv'; + } else { + $desiredFormat = strtolower($this->params->get('format', 'html')); + } + + if ($desiredFormat !== 'html' && ! $this->params->has('limit')) { + $select->limit(null); // Resets any default limit and offset + } + + switch ($desiredFormat) { + case 'sql': + echo '<pre>' + . var_export((new Sql\QueryBuilder())->assembleSelect($select), true) + . '</pre>'; + exit; + case 'json': + $response = $this->getResponse(); + $response + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'inline; filename=' . $this->getRequest()->getActionName() . '.json' + ) + ->appendBody( + Json::encode( + $callback !== null + ? iterator_to_array($callback($db->select($select))) + : $db->select($select)->fetchAll() + ) + ) + ->sendResponse(); + exit; + case 'csv': + $response = $this->getResponse(); + $response + ->setHeader('Content-Type', 'text/csv') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv' + ) + ->appendBody( + (string) Csv::fromQuery( + $callback !== null ? $callback($db->select($select)) : $db->select($select) + ) + ) + ->sendResponse(); + exit; + } + } + + protected function initTabs() + { + $this->getTabs()->extend(new OutputFormat())->extend(new DashboardAction())->extend(new MenuAction()); + + return $this; + } +} diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php new file mode 100644 index 0000000..edcbf88 --- /dev/null +++ b/library/X509/DataTable.php @@ -0,0 +1,145 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; + +class DataTable extends BaseHtmlElement +{ + protected $tag = 'table'; + + /** + * Columns of the table + * + * @var array + */ + protected $columns; + + /** + * The data to display + * + * @var array|\Traversable + */ + protected $data = []; + + /** + * Get data to display + * + * @return array|\Traversable + */ + public function getData() + { + return $this->data; + } + + /** + * Set the data to display + * + * @param array|\Traversable $data + * + * @return $this + */ + public function setData($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + return $this; + } + + protected function createColumns() + { + } + + public function renderHeader() + { + $cells = []; + + foreach ($this->columns as $column) { + if (is_array($column)) { + if (isset($column['label'])) { + $label = $column['label']; + } else { + $label = new HtmlString(' '); + } + } else { + $label = $column; + } + + $cells[] = Html::tag('th', $label); + } + + return Html::tag('thead', Html::tag('tr', $cells)); + } + + protected function renderRow($row) + { + $cells = []; + + foreach ($this->columns as $key => $column) { + if (! is_int($key) && array_key_exists($key, $row)) { + $data = $row[$key]; + } else { + if (isset($column['column']) && array_key_exists($column['column'], $row)) { + $data = $row[$column['column']]; + } else { + $data = null; + } + } + + if (isset($column['renderer'])) { + $content = call_user_func(($column['renderer']), $data, $row); + } else { + $content = $data; + } + + $cells[] = Html::tag('td', isset($column['attributes']) ? $column['attributes'] : null, $content); + } + + return Html::tag('tr', $cells); + } + + protected function renderBody($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $rows = []; + + foreach ($data as $row) { + $rows[] = $this->renderRow($row); + } + + if (empty($rows)) { + $colspan = count($this->columns); + + $rows = Html::tag( + 'tr', + Html::tag( + 'td', + ['colspan' => $colspan], + mt('x509', 'No results found.') + ) + ); + } + + return Html::tag('tbody', $rows); + } + + protected function assemble() + { + $this->columns = $this->createColumns(); + + $this->add(array_filter([ + $this->renderHeader(), + $this->renderBody($this->getData()) + ])); + } +} diff --git a/library/X509/Donut.php b/library/X509/Donut.php new file mode 100644 index 0000000..f3e199f --- /dev/null +++ b/library/X509/Donut.php @@ -0,0 +1,93 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; +use ipl\Html\Text; + +class Donut extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'cert-donut']; + + /** + * The donut data + * + * @var array|\Traversable + */ + protected $data = []; + + protected $heading; + + protected $headingLevel; + + protected $labelCallback; + + /** + * Get data to display + * + * @return array|\Traversable + */ + public function getData() + { + return $this->data; + } + + /** + * Set the data to display + * + * @param array|\Traversable $data + * + * @return $this + */ + public function setData($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + return $this; + } + + public function setHeading($heading, $level) + { + $this->heading = $heading; + $this->headingLevel = (int) $level; + + return $this; + } + + public function setLabelCallback(callable $callback) + { + $this->labelCallback = $callback; + + return $this; + } + + public function assemble() + { + $colorScheme = (new ColorScheme(['#014573', '#3588A5', '#BBD9B0', '#F5CC0A', '#F04B0D']))->scheme(); + $donut = new \Icinga\Chart\Donut(); + $legend = new Table(); + + foreach ($this->data as $data) { + $color = $colorScheme(); + $donut->addSlice((int) $data['cnt'], ['stroke' => $color]); + $legend->addRow( + [ + Html::tag('span', ['class' => 'badge', 'style' => "background-color: $color; height: 1.75em;"]), + call_user_func($this->labelCallback, $data), + $data['cnt'] + ] + ); + } + + $this->add([Html::tag("h{$this->headingLevel}", $this->heading), new HtmlString($donut->render()), $legend]); + } +} diff --git a/library/X509/ExpirationWidget.php b/library/X509/ExpirationWidget.php new file mode 100644 index 0000000..b3c3081 --- /dev/null +++ b/library/X509/ExpirationWidget.php @@ -0,0 +1,85 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; + +class ExpirationWidget extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $from; + + protected $to; + + public function __construct($from, $to) + { + $this->from = $from; + $this->to = $to; + } + + protected function assemble() + { + $now = time(); + + $from = $this->from; + + if ($from > $now) { + $ratio = 0; + $dateTip = DateFormatter::formatDateTime($from); + $message = sprintf(mt('x509', 'not until after %s'), DateFormatter::timeUntil($from, true)); + } else { + $to = $this->to; + + $secondsRemaining = $to - $now; + $daysRemaining = ($secondsRemaining - $secondsRemaining % 86400) / 86400; + if ($daysRemaining > 0) { + $secondsTotal = $to - $from; + $daysTotal = ($secondsTotal - $secondsTotal % 86400) / 86400; + + $ratio = min(100, 100 - round(($daysRemaining * 100) / $daysTotal, 2)); + $message = sprintf(mt('x509', 'in %d days'), $daysRemaining); + } else { + $ratio = 100; + if ($daysRemaining < 0) { + $message = sprintf(mt('x509', '%d days ago'), $daysRemaining * -1); + } else { + $message = mt('x509', 'today'); + } + } + + $dateTip = DateFormatter::formatDateTime($to); + } + + if ($ratio >= 75) { + if ($ratio >= 90) { + $state = 'state-critical'; + } else { + $state = 'state-warning'; + } + } else { + $state = 'state-ok'; + } + + $this->add([ + Html::tag( + 'span', + ['class' => '', 'style' => 'font-size: 0.9em;', 'title' => $dateTip], + $message + ), + Html::tag( + 'div', + ['class' => 'progress-bar dont-print'], + Html::tag( + 'div', + ['style' => sprintf('width: %.2F%%;', $ratio), 'class' => "bg-stateful {$state}"], + new HtmlString(' ') + ) + ) + ]); + } +} diff --git a/library/X509/FilterAdapter.php b/library/X509/FilterAdapter.php new file mode 100644 index 0000000..1c7dcb0 --- /dev/null +++ b/library/X509/FilterAdapter.php @@ -0,0 +1,55 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filterable; + +/** + * @internal + */ +class FilterAdapter implements Filterable +{ + /** + * @var Filter + */ + protected $filter; + + public function applyFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + + return $this; + } + + public function getFilter() + { + return $this->filter; + } + + public function addFilter(Filter $filter) + { + if (! $filter->isEmpty()) { + if ($this->filter === null) { + $this->filter = $filter; + } else { + $this->filter->andFilter($filter); + } + } + + return $this; + } + + public function where($condition, $value = null) + { + $this->addFilter(Filter::expression($condition, '=', $value)); + + return $this; + } +} diff --git a/library/X509/Hook/SniHook.php b/library/X509/Hook/SniHook.php new file mode 100644 index 0000000..417200f --- /dev/null +++ b/library/X509/Hook/SniHook.php @@ -0,0 +1,53 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Hook; + +use Icinga\Application\Config; +use Icinga\Application\Hook; +use Icinga\Data\Filter\Filter; +use Icinga\Util\StringHelper; + +/** + * Hook for SNI maps + */ +abstract class SniHook +{ + /** + * Return the SNI maps of all hooks + * + * ['192.0.2.1' => ['example.com', 'mail.example.com']] + * + * @return string[][] + */ + public static function getAll() + { + // This is implemented as map of maps to avoid duplicates, + // the caller is expected to handle it as map of sequences though + $sni = []; + + foreach (Hook::all('X509\Sni') as $hook) { + /** @var self $hook */ + foreach ($hook->getHosts() as $ip => $hostname) { + $sni[$ip][$hostname] = $hostname; + } + } + + foreach (Config::module('x509', 'sni') as $ip => $config) { + foreach (array_filter(StringHelper::trimSplit($config->get('hostnames', []))) as $hostname) { + $sni[$ip][$hostname] = $hostname; + } + } + + return $sni; + } + + /** + * Aggregate pairs of ip => hostname + * + * @param Filter $filter + * + * @return \Generator + */ + abstract public function getHosts(Filter $filter = null); +} 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] + ); + }); + } +} diff --git a/library/X509/JobsIniRepository.php b/library/X509/JobsIniRepository.php new file mode 100644 index 0000000..9a261ee --- /dev/null +++ b/library/X509/JobsIniRepository.php @@ -0,0 +1,20 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Repository\IniRepository; + +/** + * Collection of jobs stored in the jobs.ini file + */ +class JobsIniRepository extends IniRepository +{ + protected $queryColumns = array('jobs' => array('name', 'cidrs', 'ports', 'schedule')); + + protected $configs = array('jobs' => array( + 'module' => 'x509', + 'name' => 'jobs', + 'keyColumn' => 'name' + )); +} diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php new file mode 100644 index 0000000..6f7cfb3 --- /dev/null +++ b/library/X509/ProvidedHook/HostsImportSource.php @@ -0,0 +1,70 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use ipl\Sql; + +class HostsImportSource extends x509ImportSource +{ + public function fetchData() + { + $targets = (new Sql\Select()) + ->from('x509_target t') + ->columns([ + 'host_ip' => 't.ip', + 'host_name' => 't.hostname', + 'host_ports' => 'GROUP_CONCAT(DISTINCT t.port SEPARATOR ",")' + ]) + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->where(['ccl.order = ?' => 0]) + ->groupBy(['t.ip', 't.hostname']); + + $results = []; + $foundDupes = []; + foreach ($this->getDb()->select($targets) as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + $target->host_ip = $ipv4 ?: $ipv6; + $target->host_address = $ipv4; + $target->host_address6 = $ipv6; + + if (isset($foundDupes[$target->host_name])) { + // For load balanced systems the IP address is the better choice + $target->host_name_or_ip = $target->host_ip; + } elseif (! isset($results[$target->host_name])) { + // Hostnames are usually preferred, especially in the case of SNI + $target->host_name_or_ip = $target->host_name; + } else { + $dupe = $results[$target->host_name]; + unset($results[$target->host_name]); + $foundDupes[$dupe->host_name] = true; + $dupe->host_name_or_ip = $dupe->host_ip; + $results[$dupe->host_name_or_ip] = $dupe; + $target->host_name_or_ip = $target->host_ip; + } + + $results[$target->host_name_or_ip] = $target; + } + + return $results; + } + + public function listColumns() + { + return [ + 'host_name_or_ip', + 'host_ip', + 'host_name', + 'host_ports', + 'host_address', + 'host_address6' + ]; + } + + public static function getDefaultKeyColumnName() + { + return 'host_name_or_ip'; + } +} diff --git a/library/X509/ProvidedHook/ServicesImportSource.php b/library/X509/ProvidedHook/ServicesImportSource.php new file mode 100644 index 0000000..19f9de9 --- /dev/null +++ b/library/X509/ProvidedHook/ServicesImportSource.php @@ -0,0 +1,85 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use ipl\Sql; + +class ServicesImportSource extends x509ImportSource +{ + public function fetchData() + { + $targets = (new Sql\Select()) + ->from('x509_target t') + ->columns([ + 'host_ip' => 't.ip', + 'host_name' => 't.hostname', + 'host_port' => 't.port', + 'cert_subject' => 'c.subject', + 'cert_issuer' => 'c.issuer', + 'cert_self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', + 'cert_trusted' => 'c.trusted', + 'cert_valid_from' => 'c.valid_from', + 'cert_valid_to' => 'c.valid_to', + 'cert_fingerprint' => 'HEX(c.fingerprint)', + 'cert_dn' => 'GROUP_CONCAT(CONCAT(dn.key, \'=\', dn.value) SEPARATOR \',\')', + 'cert_subject_alt_name' => (new Sql\Select()) + ->from('x509_certificate_subject_alt_name can') + ->columns('GROUP_CONCAT(CONCAT(can.type, \':\', can.value) SEPARATOR \',\')') + ->where(['can.certificate_id = c.id']) + ->groupBy(['can.certificate_id']) + ]) + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash') + ->joinLeft('x509_dn dn', 'dn.hash = c.subject_hash') + ->where(['ccl.order = ?' => 0]) + ->groupBy(['t.ip', 't.hostname', 't.port']); + + $results = []; + foreach ($this->getDb()->select($targets) as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + $target->host_ip = $ipv4 ?: $ipv6; + $target->host_address = $ipv4; + $target->host_address6 = $ipv6; + + $target->host_name_ip_and_port = sprintf( + '%s/%s:%d', + $target->host_name, + $target->host_ip, + $target->host_port + ); + + $results[$target->host_name_ip_and_port] = $target; + } + + return $results; + } + + public function listColumns() + { + return [ + 'host_name_ip_and_port', + 'host_ip', + 'host_name', + 'host_port', + 'host_address', + 'host_address6', + 'cert_subject', + 'cert_issuer', + 'cert_self_signed', + 'cert_trusted', + 'cert_valid_from', + 'cert_valid_to', + 'cert_fingerprint', + 'cert_dn', + 'cert_subject_alt_name' + ]; + } + + public static function getDefaultKeyColumnName() + { + return 'host_name_ip_and_port'; + } +} diff --git a/library/X509/ProvidedHook/x509ImportSource.php b/library/X509/ProvidedHook/x509ImportSource.php new file mode 100644 index 0000000..184744b --- /dev/null +++ b/library/X509/ProvidedHook/x509ImportSource.php @@ -0,0 +1,49 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use Icinga\Application\Config; +use Icinga\Data\ResourceFactory; +use Icinga\Module\Director\Hook\ImportSourceHook; +use ipl\Sql; +use PDO; + +abstract class x509ImportSource extends ImportSourceHook +{ + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + protected function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + Config::module('x509')->get('backend', 'resource') + )); + $config->options = [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ + ]; + + $conn = new Sql\Connection($config); + + return $conn; + } + + /** + * Transform the given binary IP address in a human readable format + * + * @param string $ip + * + * @return array The first element is IPv4, the second IPv6 + */ + protected function transformIpAddress($ip) + { + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + return [inet_ntop($ipv4), null]; + } else { + return [null, inet_ntop($ip)]; + } + } +} diff --git a/library/X509/React/StreamOptsCaptureConnector.php b/library/X509/React/StreamOptsCaptureConnector.php new file mode 100644 index 0000000..81cd8aa --- /dev/null +++ b/library/X509/React/StreamOptsCaptureConnector.php @@ -0,0 +1,59 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2020 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\React; + +use React\Socket\ConnectionInterface; +use React\Socket\ConnectorInterface; + +use function React\Promise\resolve; + +/** + * Connector that captures stream context options upon close of the underlying connection + */ +class StreamOptsCaptureConnector implements ConnectorInterface +{ + /** @var array|null */ + protected $capturedStreamOptions; + + /** @var ConnectorInterface */ + protected $connector; + + public function __construct(ConnectorInterface $connector) + { + $this->connector = $connector; + } + + /** + * @return array + */ + public function getCapturedStreamOptions() + { + return (array) $this->capturedStreamOptions; + } + + /** + * @param array $capturedStreamOptions + * + * @return $this + */ + public function setCapturedStreamOptions($capturedStreamOptions) + { + $this->capturedStreamOptions = $capturedStreamOptions; + + return $this; + } + + public function connect($uri) + { + return $this->connector->connect($uri)->then(function (ConnectionInterface $conn) { + $conn->on('close', function () use ($conn) { + if (is_resource($conn->stream)) { + $this->setCapturedStreamOptions(stream_context_get_options($conn->stream)); + } + }); + + return resolve($conn); + }); + } +} diff --git a/library/X509/Scheduler.php b/library/X509/Scheduler.php new file mode 100644 index 0000000..0963016 --- /dev/null +++ b/library/X509/Scheduler.php @@ -0,0 +1,59 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Cron\CronExpression; +use Icinga\Application\Logger; +use React\EventLoop\Factory as Loop; + +class Scheduler +{ + protected $loop; + + public function __construct() + { + $this->loop = Loop::create(); + } + + public function add($name, $cronSchedule, callable $callback) + { + if (! CronExpression::isValidExpression($cronSchedule)) { + throw new \RuntimeException('Invalid cron expression'); + } + + $now = new \DateTime(); + + $expression = new CronExpression($cronSchedule); + + if ($expression->isDue($now)) { + $this->loop->futureTick($callback); + } + + $nextRuns = $expression->getMultipleRunDates(2, $now); + + $interval = $nextRuns[0]->getTimestamp() - $now->getTimestamp(); + + $period = $nextRuns[1]->getTimestamp() - $nextRuns[0]->getTimestamp(); + + Logger::info('Scheduling job %s to run at %s.', $name, $nextRuns[0]->format('Y-m-d H:i:s')); + + $loop = function () use (&$loop, $name, $callback, $period) { + $callback(); + + $nextRun = (new \DateTime()) + ->add(new \DateInterval("PT{$period}S")); + + Logger::info('Scheduling job %s to run at %s.', $name, $nextRun->format('Y-m-d H:i:s')); + + $this->loop->addTimer($period, $loop); + }; + + $this->loop->addTimer($interval, $loop); + } + + public function run() + { + $this->loop->run(); + } +} diff --git a/library/X509/SniIniRepository.php b/library/X509/SniIniRepository.php new file mode 100644 index 0000000..ba4c4ba --- /dev/null +++ b/library/X509/SniIniRepository.php @@ -0,0 +1,20 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Repository\IniRepository; + +/** + * Collection of hostnames stored in the sni.ini file + */ +class SniIniRepository extends IniRepository +{ + protected $queryColumns = array('sni' => array('ip', 'hostnames')); + + protected $configs = array('sni' => array( + 'module' => 'x509', + 'name' => 'sni', + 'keyColumn' => 'ip' + )); +} diff --git a/library/X509/SortAdapter.php b/library/X509/SortAdapter.php new file mode 100644 index 0000000..5adb86b --- /dev/null +++ b/library/X509/SortAdapter.php @@ -0,0 +1,46 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Data\Sortable; +use ipl\Sql; + +/** + * @internal + */ +class SortAdapter implements Sortable +{ + protected $select; + + protected $callback; + + public function __construct(Sql\Select $select, \Closure $callback = null) + { + $this->select = $select; + $this->callback = $callback; + } + + public function order($field, $direction = null) + { + if ($this->callback !== null) { + $field = call_user_func($this->callback, $field) ?: $field; + } + + if ($direction === null) { + $this->select->orderBy($field); + } else { + $this->select->orderBy($field, $direction); + } + } + + public function hasOrder() + { + return $this->select->hasOrderBy(); + } + + public function getOrder() + { + return $this->select->getOrderBy(); + } +} diff --git a/library/X509/SqlFilter.php b/library/X509/SqlFilter.php new file mode 100644 index 0000000..40da0d9 --- /dev/null +++ b/library/X509/SqlFilter.php @@ -0,0 +1,84 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ReflectionClass; +use Icinga\Data\ConfigObject; +use Icinga\Data\Db\DbConnection; +use Icinga\Data\Filter\Filter; +use ipl\Sql\Select; + +/** + * @internal + */ +class Quoter +{ + public function quote($identifier, $quoteCharacter = '"') + { + if (strlen($quoteCharacter) === 1) { + return $quoteCharacter . $identifier . $quoteCharacter; + } else { + return $quoteCharacter[0] . $identifier . $quoteCharacter[1]; + } + } +} + +/** + * @internal + */ +class NoImplicitConnectDbConnection extends DbConnection +{ + protected $renderFilterCallback; + + public function __construct(ConfigObject $config = null) + { + } + + public function setRenderFilterCallback(callable $callback = null) + { + $this->renderFilterCallback = $callback; + + return $this; + } + + protected function renderFilterExpression(Filter $filter) + { + $hit = false; + + if (isset($this->renderFilterCallback)) { + $hit = call_user_func($this->renderFilterCallback, clone $filter); + } + + if ($hit !== false) { + $filter = $hit; + } + + return parent::renderFilterExpression($filter); + } +} + +/** + * @internal + */ +class SqlFilter +{ + public static function apply(Select $select, Filter $filter = null, callable $renderFilterCallback = null) + { + if ($filter === null || $filter->isEmpty()) { + return; + } + + if (! $filter->isEmpty()) { + $conn = (new NoImplicitConnectDbConnection())->setRenderFilterCallback($renderFilterCallback); + + $reflection = new ReflectionClass('\Icinga\Data\Db\DbConnection'); + + $dbAdapter = $reflection->getProperty('dbAdapter'); + $dbAdapter->setAccessible(true); + $dbAdapter->setValue($conn, new Quoter()); + + $select->where($conn->renderFilter($filter)); + } + } +} diff --git a/library/X509/Table.php b/library/X509/Table.php new file mode 100644 index 0000000..1281668 --- /dev/null +++ b/library/X509/Table.php @@ -0,0 +1,38 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class Table extends BaseHtmlElement +{ + protected $tag = 'table'; + + protected $rows = []; + + public function addRow(array $cells, $attributes = null) + { + $row = Html::tag('tr', $attributes); + + foreach ($cells as $cell) { + $row->add(Html::tag('td', $cell)); + } + + $this->rows[] = $row; + } + + public function renderContent() + { + $tbody = Html::tag('tbody'); + + foreach ($this->rows as $row) { + $tbody->add($row); + } + + $this->add($tbody); + + return parent::renderContent(); // TODO: Change the autogenerated stub + } +} diff --git a/library/X509/UsageTable.php b/library/X509/UsageTable.php new file mode 100644 index 0000000..20fd147 --- /dev/null +++ b/library/X509/UsageTable.php @@ -0,0 +1,83 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Web\Url; +use ipl\Html\Html; + +/** + * Table widget to display X.509 certificate usage + */ +class UsageTable extends DataTable +{ + protected $defaultAttributes = [ + 'class' => 'usage-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function createColumns() + { + return [ + 'valid' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($valid) { + $icon = $valid === 'yes' ? 'check -ok' : 'block -critical'; + + return Html::tag('i', ['class' => "icon icon-{$icon}"]); + } + ], + + 'hostname' => mt('x509', 'Hostname'), + + 'ip' => [ + 'label' => mt('x509', 'IP'), + 'renderer' => function ($ip) { + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + + return inet_ntop($ip); + } + ], + + 'port' => mt('x509', 'Port'), + + 'subject' => mt('x509', 'Certificate'), + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/chain', ['id' => $row['certificate_chain_id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/module.info b/module.info new file mode 100644 index 0000000..389383a --- /dev/null +++ b/module.info @@ -0,0 +1,6 @@ +Module: Certificate Monitoring +Version: 1.1.2 +Requires: + Libraries: icinga-php-library (>=0.8.1), icinga-php-thirdparty (>=0.10.0) + Modules: monitoring (>=2.9.0), icingadb (>=1.0.0) +Description: Scan and view X.509 certificate usage diff --git a/public/css/icons.less b/public/css/icons.less new file mode 100644 index 0000000..73701b5 --- /dev/null +++ b/public/css/icons.less @@ -0,0 +1,52 @@ +/* Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 */ + +[class^='x509-icon-'], [class*=' x509-icon-'] { + &:before { + font-style: normal; + font-weight: normal; + speak: none; + + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ + } +} + +@font-face { + font-family: 'Icinga Web 2 Module X.509'; + src: url('../x509/icons?q=eot#iefix') format('embedded-opentype'), + url('../x509/icons?q=woff') format('woff'), + url('../x509/icons?q=ttf') format('truetype'), + url('../x509/icons?q=svg#icinga-icons') format('svg'); + font-weight: normal; + font-style: normal; +} + +[class^='x509-icon-'], [class*=' x509-icon-'] { + font-family: 'Icinga Web 2 Module X.509'; +} + +.x509-icon-ca:before { content: '\e000'; } +.x509-icon-self-signed:before { content: '\e001'; } +.x509-icon-cert:before { content: '\e002'; } diff --git a/public/css/module.less b/public/css/module.less new file mode 100644 index 0000000..adad589 --- /dev/null +++ b/public/css/module.less @@ -0,0 +1,107 @@ +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +.cert-details { + .x509-icon-cert { + font-size: 5em; + } + + h3 { + text-align: right; + //text-decoration: underline; + width: 10.25em; + border-bottom: 1px solid @gray-lighter; + } + + dl { + > dd { + // Reset default margin + margin: 0; + } + + > dt { + margin-right: 1em; + text-align: right; + width: 12em; + + float: left; + clear: left; + } + } +} + +.progress-bar { + background-color: @gray-lighter; + border-radius: 2px; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.25) inset; + height: 0.5em; + + > div { + border-radius: 2px; + height: 100%; + } +} + +.certificate-days-remaining { + font-size: @font-size-small; + margin-left: 2em; +} + +.expiration-col { + width: 18em; +} + +.icon-col > i { + font-size: 120%; +} + +.version-col { + width: 1em; +} + +.cert-table, .usage-table { + width: 98%; +} + +.cert-dashboard { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; +} + +.cert-donut { + align-self: flex-start; + padding: 1em; +} + +.cert-chain { + .rounded-corners(); + + color: @text-color-inverted; + font-size: 120%; + font-weight: @font-weight-bold; + padding: 0.75em; + text-align: center; + + > p { + margin: 0; + } + + &.-valid { + background-color: @color-ok; + } + + &.-invalid { + background-color: @color-critical; + } +} + +.icon { + &.-ok { + color: @color-ok; + } + + &.-critical { + color: @color-critical; + } +} diff --git a/public/font/icons.eot b/public/font/icons.eot Binary files differnew file mode 100644 index 0000000..96af737 --- /dev/null +++ b/public/font/icons.eot diff --git a/public/font/icons.svg b/public/font/icons.svg new file mode 100644 index 0000000..195eeb8 --- /dev/null +++ b/public/font/icons.svg @@ -0,0 +1,44 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg"> +<metadata></metadata> +<defs> +<font id="icinga-icons" horiz-adv-x="1000" > + +<font-face + font-family="Icinga Web 2" + font-weight="400" + font-stretch="normal" + units-per-em="1000" + ascent="850" + descent="-150" + /> + +<missing-glyph horiz-adv-x="1000" /> + + +<glyph + glyph-name="ca-valid" + unicode="" + d="M233.4 211.3C225.5 210.4 217.4 210 209 210 180.9 210 155 215 131.2 225 107.4 234.9 86.8 249.1 69.3 267.4 51.9 285.6 38.2 307.5 28.2 333 18.3 358.5 13.3 386.6 13.3 417.2 13.3 448.6 18.4 477.1 28.5 502.6 38.6 528 52.5 549.7 70.3 567.7 88.1 585.6 109 599.3 133 608.9 157 618.5 182.8 623.3 210.5 623.3 236.2 623.3 261.5 618.4 286.3 608.7 311.1 598.9 331.2 584.5 346.6 565.7L287.1 501.4C278.8 513.6 268.1 522.7 254.7 528.6 241.3 534.5 227.6 537.5 213.6 537.5 198.2 537.5 184.1 534.4 171.2 528.3 158.4 522.2 147.4 513.8 138.1 503.1 128.9 492.4 121.7 479.7 116.6 465.1S108.9 434.6 108.9 417.2C108.9 399.5 111.4 383.2 116.6 368.5 121.7 353.7 128.8 341 137.9 330.5 147 320 157.8 311.7 170.5 305.8 183.2 299.9 197 297 212.1 297 229.6 297 244.8 300.7 257.8 308 268.6 314.2 277.6 321.8 284.8 330.9ZM680 327.6C662.6 299.7 652.5 266.5 652.5 231 652.5 223.9 652.9 216.9 653.7 210H622.3L591.7 288.5H439.2L410.2 210H306.7L472 610H564.5ZM516.6 503.2L466.5 365.9H565.6ZM936.4 262.3C936.4 265.7 935.2 269.1 932.8 271.5L914.6 289.5C912.2 291.9 909 293.3 905.6 293.3 902.2 293.3 899 291.9 896.6 289.5L815.2 208.2 770.1 253.4C767.7 255.8 764.5 257.1 761.1 257.1S754.5 255.8 752.1 253.4L733.9 235.4C731.5 233 730.3 229.6 730.3 226.2S731.5 219.6 733.9 217.2L806.2 145C808.6 142.6 812 141.2 815.2 141.2 818.6 141.2 822 142.6 824.4 144.9L932.8 253.4C935.2 255.8 936.4 259 936.4 262.4ZM986.7 230C986.7 145.3 918 76.7 833.3 76.7S680 145.3 680 230 748.7 383.3 833.3 383.3 986.7 314.7 986.7 230Z" + horiz-adv-x="1000" + /> + +<glyph + glyph-name="cert-refresh" + unicode="" + d="M199.1 50.8C201.6 48.4 204.3 45.8 207.1 42.9 405-155 808.8-74.6 882.2 235.4 882.2 235.4 889 260.6 915.5 258.4 1020.7 238.9 851-45.4 631.8-115.1 464.5-168.3 269.5-115.1 149.7 16.1L66.5-42.4 64.6 220 290.5 115.1ZM655.9 136H337.3C335.9 136 334.5 136.1 333.1 136.3H305.5V390.8H363.4V454.5C363.4 536.1 414.9 594.4 496.6 594.4 578.2 594.4 623.2 536.1 623.2 454.5V390.8H687.7V136.3H660.1C658.7 136.1 657.3 136 655.9 136ZM568.9 390.8V454.5C568.9 501.3 543.4 539.4 496.6 539.4 449.8 539.4 420.1 501.3 420.1 454.5V390.8ZM796.1 635.5C793.6 637.9 790.9 640.5 788.1 643.4 590 841.4 186.4 760.9 113 450.8 113 450.8 40.8 407.3 56.4 473.1 108.3 674.6 301.1 824.9 511.5 823 637.8 819.8 760 763.9 845.5 670.2L928.7 728.7 930.6 466.2 704.6 571.1Z" + horiz-adv-x="1000" + /> + +<glyph + glyph-name="certificate" + unicode="" + d="M160 103.3H83.1C45.5 100.8 26.7 117 26.7 152V783.3C26.7 827.8 45.5 850 83.1 850H906.7C942.2 850 960 830.8 960 792.4 960 734.7 960 201.4 960 156.7S933.3 103.3 906.7 103.3H586.7V170H893.3V783.3H93.3V170H160ZM781.9 716.7H498.1C473.4 716.7 453.3 692.8 453.3 663.4 453.3 633.9 473.4 610 498.1 610H781.9C806.6 610 826.7 633.9 826.7 663.3 826.7 692.8 806.6 716.7 781.9 716.7ZM826.7 503.4C826.7 517.5 820.2 531.1 808.8 541.1 797.3 551.1 781.8 556.7 765.6 556.7H647.7C631.5 556.7 616 551.1 604.5 541.1 593.1 531.1 586.7 517.5 586.7 503.3V503.3C586.7 489.1 593.1 475.6 604.5 465.6 616 455.6 631.5 450 647.7 450H765.6C781.8 450 797.3 455.6 808.8 465.6 820.2 475.6 826.7 489.2 826.7 503.3V503.4ZM520 210V-136.7L373.3 10 226.7-136.7 227.2 205.7C267.3 178.2 321.1 156.7 373.3 156.7 428.5 156.7 478.7 179.7 520 210ZM373.3 583.3C476.4 583.3 560 499.7 560 396.7S476.4 210 373.3 210 186.7 293.6 186.7 396.7 270.3 583.3 373.3 583.3Z" + horiz-adv-x="1000" + /> + + +</font> +</defs> +</svg> diff --git a/public/font/icons.ttf b/public/font/icons.ttf Binary files differnew file mode 100644 index 0000000..7301cb5 --- /dev/null +++ b/public/font/icons.ttf diff --git a/public/font/icons.woff b/public/font/icons.woff Binary files differnew file mode 100644 index 0000000..6c74bc3 --- /dev/null +++ b/public/font/icons.woff @@ -0,0 +1,7 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +require_once __DIR__ . '/vendor/autoload.php'; + +$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\HostsImportSource'); +$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\ServicesImportSource'); diff --git a/vendor/autoload.php b/vendor/autoload.php new file mode 100644 index 0000000..72995ae --- /dev/null +++ b/vendor/autoload.php @@ -0,0 +1,7 @@ +<?php + +// autoload.php @generated by Composer + +require_once __DIR__ . '/composer/autoload_real.php'; + +return ComposerAutoloaderInit52105223ed18fb0487ba02f5d0fcfd9e::getLoader(); diff --git a/vendor/composer/ClassLoader.php b/vendor/composer/ClassLoader.php new file mode 100644 index 0000000..03b9bb9 --- /dev/null +++ b/vendor/composer/ClassLoader.php @@ -0,0 +1,445 @@ +<?php + +/* + * This file is part of Composer. + * + * (c) Nils Adermann <naderman@naderman.de> + * Jordi Boggiano <j.boggiano@seld.be> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier <fabien@symfony.com> + * @author Jordi Boggiano <j.boggiano@seld.be> + * @see http://www.php-fig.org/psr/psr-0/ + * @see http://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + // PSR-4 + private $prefixLengthsPsr4 = array(); + private $prefixDirsPsr4 = array(); + private $fallbackDirsPsr4 = array(); + + // PSR-0 + private $prefixesPsr0 = array(); + private $fallbackDirsPsr0 = array(); + + private $useIncludePath = false; + private $classMap = array(); + private $classMapAuthoritative = false; + private $missingClasses = array(); + private $apcuPrefix; + + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param array|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + */ + public function add($prefix, $paths, $prepend = false) + { + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + (array) $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + (array) $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = (array) $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + (array) $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param array|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + (array) $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + (array) $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + (array) $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param array|string $paths The PSR-0 base directories + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param array|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + } + + /** + * Unregisters this instance as an autoloader. + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return bool|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + includeFile($file); + + return true; + } + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } +} + +/** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + */ +function includeFile($file) +{ + include $file; +} diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE new file mode 100644 index 0000000..f27399a --- /dev/null +++ b/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..7a91153 --- /dev/null +++ b/vendor/composer/autoload_classmap.php @@ -0,0 +1,9 @@ +<?php + +// autoload_classmap.php @generated by Composer + +$vendorDir = dirname(dirname(__FILE__)); +$baseDir = dirname($vendorDir); + +return array( +); diff --git a/vendor/composer/autoload_namespaces.php b/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000..b7fc012 --- /dev/null +++ b/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ +<?php + +// autoload_namespaces.php @generated by Composer + +$vendorDir = dirname(dirname(__FILE__)); +$baseDir = dirname($vendorDir); + +return array( +); diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php new file mode 100644 index 0000000..f53c6ff --- /dev/null +++ b/vendor/composer/autoload_psr4.php @@ -0,0 +1,10 @@ +<?php + +// autoload_psr4.php @generated by Composer + +$vendorDir = dirname(dirname(__FILE__)); +$baseDir = dirname($vendorDir); + +return array( + 'Cron\\' => array($vendorDir . '/dragonmantank/cron-expression/src/Cron'), +); diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php new file mode 100644 index 0000000..02bd074 --- /dev/null +++ b/vendor/composer/autoload_real.php @@ -0,0 +1,55 @@ +<?php + +// autoload_real.php @generated by Composer + +class ComposerAutoloaderInit52105223ed18fb0487ba02f5d0fcfd9e +{ + private static $loader; + + public static function loadClassLoader($class) + { + if ('Composer\Autoload\ClassLoader' === $class) { + require __DIR__ . '/ClassLoader.php'; + } + } + + /** + * @return \Composer\Autoload\ClassLoader + */ + public static function getLoader() + { + if (null !== self::$loader) { + return self::$loader; + } + + spl_autoload_register(array('ComposerAutoloaderInit52105223ed18fb0487ba02f5d0fcfd9e', 'loadClassLoader'), true, true); + self::$loader = $loader = new \Composer\Autoload\ClassLoader(); + spl_autoload_unregister(array('ComposerAutoloaderInit52105223ed18fb0487ba02f5d0fcfd9e', 'loadClassLoader')); + + $useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded()); + if ($useStaticLoader) { + require_once __DIR__ . '/autoload_static.php'; + + call_user_func(\Composer\Autoload\ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e::getInitializer($loader)); + } else { + $map = require __DIR__ . '/autoload_namespaces.php'; + foreach ($map as $namespace => $path) { + $loader->set($namespace, $path); + } + + $map = require __DIR__ . '/autoload_psr4.php'; + foreach ($map as $namespace => $path) { + $loader->setPsr4($namespace, $path); + } + + $classMap = require __DIR__ . '/autoload_classmap.php'; + if ($classMap) { + $loader->addClassMap($classMap); + } + } + + $loader->register(true); + + return $loader; + } +} diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php new file mode 100644 index 0000000..755f718 --- /dev/null +++ b/vendor/composer/autoload_static.php @@ -0,0 +1,31 @@ +<?php + +// autoload_static.php @generated by Composer + +namespace Composer\Autoload; + +class ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e +{ + public static $prefixLengthsPsr4 = array ( + 'C' => + array ( + 'Cron\\' => 5, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'Cron\\' => + array ( + 0 => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron', + ), + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e::$prefixDirsPsr4; + + }, null, ClassLoader::class); + } +} diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json new file mode 100644 index 0000000..0b82b8e --- /dev/null +++ b/vendor/composer/installed.json @@ -0,0 +1,64 @@ +[ + { + "name": "dragonmantank/cron-expression", + "version": "v2.3.1", + "version_normalized": "2.3.1.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" + }, + "time": "2020-10-13T00:52:37+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ] + } +] diff --git a/vendor/dragonmantank/cron-expression/CHANGELOG.md b/vendor/dragonmantank/cron-expression/CHANGELOG.md new file mode 100644 index 0000000..4e207aa --- /dev/null +++ b/vendor/dragonmantank/cron-expression/CHANGELOG.md @@ -0,0 +1,84 @@ +# Change Log + +## [2.3.1] - 2020-10-12 +### Added +- Added support for PHP 8 (#92) +### Changed +- N/A +### Fixed +- N/A + +## [2.3.0] - 2019-03-30 +### Added +- Added support for DateTimeImmutable via DateTimeInterface +- Added support for PHP 7.3 +- Started listing projects that use the library +### Changed +- Errors should now report a human readable position in the cron expression, instead of starting at 0 +### Fixed +- N/A + +## [2.2.0] - 2018-06-05 +### Added +- Added support for steps larger than field ranges (#6) +## Changed +- N/A +### Fixed +- Fixed validation for numbers with leading 0s (#12) + +## [2.1.0] - 2018-04-06 +### Added +- N/A +### Changed +- Upgraded to PHPUnit 6 (#2) +### Fixed +- Refactored timezones to deal with some inconsistent behavior (#3) +- Allow ranges and lists in same expression (#5) +- Fixed regression where literals were not converted to their numerical counterpart (#) + +## [2.0.0] - 2017-10-12 +### Added +- N/A + +### Changed +- Dropped support for PHP 5.x +- Dropped support for the YEAR field, as it was not part of the cron standard + +### Fixed +- Reworked validation for all the field types +- Stepping should now work for 1-indexed fields like Month (#153) + +## [1.2.0] - 2017-01-22 +### Added +- Added IDE, CodeSniffer, and StyleCI.IO support + +### Changed +- Switched to PSR-4 Autoloading + +### Fixed +- 0 step expressions are handled better +- Fixed `DayOfMonth` validation to be more strict +- Typos + +## [1.1.0] - 2016-01-26 +### Added +- Support for non-hourly offset timezones +- Checks for valid expressions + +### Changed +- Max Iterations no longer hardcoded for `getRunDate()` +- Supports DateTimeImmutable for newer PHP verions + +### Fixed +- Fixed looping bug for PHP 7 when determining the last specified weekday of a month + +## [1.0.3] - 2013-11-23 +### Added +- Now supports expressions with any number of extra spaces, tabs, or newlines + +### Changed +- Using static instead of self in `CronExpression::factory` + +### Fixed +- Fixes issue [#28](https://github.com/mtdowling/cron-expression/issues/28) where PHP increments of ranges were failing due to PHP casting hyphens to 0 +- Only set default timezone if the given $currentTime is not a DateTime instance ([#34](https://github.com/mtdowling/cron-expression/issues/34)) diff --git a/vendor/dragonmantank/cron-expression/LICENSE b/vendor/dragonmantank/cron-expression/LICENSE new file mode 100644 index 0000000..3e38bbc --- /dev/null +++ b/vendor/dragonmantank/cron-expression/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Michael Dowling <mtdowling@gmail.com>, 2016 Chris Tankersley <chris@ctankersley.com>, and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/dragonmantank/cron-expression/README.md b/vendor/dragonmantank/cron-expression/README.md new file mode 100644 index 0000000..8e8021b --- /dev/null +++ b/vendor/dragonmantank/cron-expression/README.md @@ -0,0 +1,78 @@ +PHP Cron Expression Parser +========================== + +[![Latest Stable Version](https://poser.pugx.org/dragonmantank/cron-expression/v/stable.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Total Downloads](https://poser.pugx.org/dragonmantank/cron-expression/downloads.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Build Status](https://secure.travis-ci.org/dragonmantank/cron-expression.png)](http://travis-ci.org/dragonmantank/cron-expression) + +The PHP cron expression parser can parse a CRON expression, determine if it is +due to run, calculate the next run date of the expression, and calculate the previous +run date of the expression. You can calculate dates far into the future or past by +skipping **n** number of matching dates. + +The parser can handle increments of ranges (e.g. */12, 2-59/3), intervals (e.g. 0-9), +lists (e.g. 1,2,3), **W** to find the nearest weekday for a given day of the month, **L** to +find the last day of the month, **L** to find the last given weekday of a month, and hash +(#) to find the nth weekday of a given month. + +More information about this fork can be found in the blog post [here](http://ctankersley.com/2017/10/12/cron-expression-update/). tl;dr - v2.0.0 is a major breaking change, and @dragonmantank can better take care of the project in a separate fork. + +Installing +========== + +Add the dependency to your project: + +```bash +composer require dragonmantank/cron-expression +``` + +Usage +===== +```php +<?php + +require_once '/vendor/autoload.php'; + +// Works with predefined scheduling definitions +$cron = Cron\CronExpression::factory('@daily'); +$cron->isDue(); +echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); +echo $cron->getPreviousRunDate()->format('Y-m-d H:i:s'); + +// Works with complex expressions +$cron = Cron\CronExpression::factory('3-59/15 6-12 */15 1 2-5'); +echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); + +// Calculate a run date two iterations into the future +$cron = Cron\CronExpression::factory('@daily'); +echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s'); + +// Calculate a run date relative to a specific time +$cron = Cron\CronExpression::factory('@monthly'); +echo $cron->getNextRunDate('2010-01-12 00:00:00')->format('Y-m-d H:i:s'); +``` + +CRON Expressions +================ + +A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows: + + * * * * * + - - - - - + | | | | | + | | | | | + | | | | +----- day of week (0 - 7) (Sunday=0 or 7) + | | | +---------- month (1 - 12) + | | +--------------- day of month (1 - 31) + | +-------------------- hour (0 - 23) + +------------------------- min (0 - 59) + +Requirements +============ + +- PHP 7.0+ +- PHPUnit is required to run the unit tests +- Composer is required to run the unit tests + +Projects that Use cron-expression +================================= +* Part of the [Laravel Framework](https://github.com/laravel/framework/) +* Available as a [Symfony Bundle - setono/cron-expression-bundle](https://github.com/Setono/CronExpressionBundle)
\ No newline at end of file diff --git a/vendor/dragonmantank/cron-expression/composer.json b/vendor/dragonmantank/cron-expression/composer.json new file mode 100644 index 0000000..6fcf818 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/composer.json @@ -0,0 +1,40 @@ +{ + "name": "dragonmantank/cron-expression", + "type": "library", + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": ["cron", "schedule"], + "license": "MIT", + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/Cron/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php b/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php new file mode 100644 index 0000000..8b1072a --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php @@ -0,0 +1,286 @@ +<?php + +namespace Cron; + +/** + * Abstract CRON expression field + */ +abstract class AbstractField implements FieldInterface +{ + /** + * Full range of values that are allowed for this field type + * @var array + */ + protected $fullRange = []; + + /** + * Literal values we need to convert to integers + * @var array + */ + protected $literals = []; + + /** + * Start value of the full range + * @var integer + */ + protected $rangeStart; + + /** + * End value of the full range + * @var integer + */ + protected $rangeEnd; + + /** + * Constructor + */ + public function __construct() + { + $this->fullRange = range($this->rangeStart, $this->rangeEnd); + } + + /** + * Check to see if a field is satisfied by a value + * + * @param string $dateValue Date value to check + * @param string $value Value to test + * + * @return bool + */ + public function isSatisfied($dateValue, $value) + { + if ($this->isIncrementsOfRanges($value)) { + return $this->isInIncrementsOfRanges($dateValue, $value); + } elseif ($this->isRange($value)) { + return $this->isInRange($dateValue, $value); + } + + return $value == '*' || $dateValue == $value; + } + + /** + * Check if a value is a range + * + * @param string $value Value to test + * + * @return bool + */ + public function isRange($value) + { + return strpos($value, '-') !== false; + } + + /** + * Check if a value is an increments of ranges + * + * @param string $value Value to test + * + * @return bool + */ + public function isIncrementsOfRanges($value) + { + return strpos($value, '/') !== false; + } + + /** + * Test if a value is within a range + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInRange($dateValue, $value) + { + $parts = array_map(function($value) { + $value = trim($value); + $value = $this->convertLiterals($value); + return $value; + }, + explode('-', $value, 2) + ); + + + return $dateValue >= $parts[0] && $dateValue <= $parts[1]; + } + + /** + * Test if a value is within an increments of ranges (offset[-to]/step size) + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInIncrementsOfRanges($dateValue, $value) + { + $chunks = array_map('trim', explode('/', $value, 2)); + $range = $chunks[0]; + $step = isset($chunks[1]) ? $chunks[1] : 0; + + // No step or 0 steps aren't cool + if (is_null($step) || '0' === $step || 0 === $step) { + return false; + } + + // Expand the * to a full range + if ('*' == $range) { + $range = $this->rangeStart . '-' . $this->rangeEnd; + } + + // Generate the requested small range + $rangeChunks = explode('-', $range, 2); + $rangeStart = $rangeChunks[0]; + $rangeEnd = isset($rangeChunks[1]) ? $rangeChunks[1] : $rangeStart; + + if ($rangeStart < $this->rangeStart || $rangeStart > $this->rangeEnd || $rangeStart > $rangeEnd) { + throw new \OutOfRangeException('Invalid range start requested'); + } + + if ($rangeEnd < $this->rangeStart || $rangeEnd > $this->rangeEnd || $rangeEnd < $rangeStart) { + throw new \OutOfRangeException('Invalid range end requested'); + } + + // Steps larger than the range need to wrap around and be handled slightly differently than smaller steps + if ($step >= $this->rangeEnd) { + $thisRange = [$this->fullRange[$step % count($this->fullRange)]]; + } else { + $thisRange = range($rangeStart, $rangeEnd, $step); + } + + return in_array($dateValue, $thisRange); + } + + /** + * Returns a range of values for the given cron expression + * + * @param string $expression The expression to evaluate + * @param int $max Maximum offset for range + * + * @return array + */ + public function getRangeForExpression($expression, $max) + { + $values = array(); + $expression = $this->convertLiterals($expression); + + if (strpos($expression, ',') !== false) { + $ranges = explode(',', $expression); + $values = []; + foreach ($ranges as $range) { + $expanded = $this->getRangeForExpression($range, $this->rangeEnd); + $values = array_merge($values, $expanded); + } + return $values; + } + + if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) { + if (!$this->isIncrementsOfRanges($expression)) { + list ($offset, $to) = explode('-', $expression); + $offset = $this->convertLiterals($offset); + $to = $this->convertLiterals($to); + $stepSize = 1; + } + else { + $range = array_map('trim', explode('/', $expression, 2)); + $stepSize = isset($range[1]) ? $range[1] : 0; + $range = $range[0]; + $range = explode('-', $range, 2); + $offset = $range[0]; + $to = isset($range[1]) ? $range[1] : $max; + } + $offset = $offset == '*' ? $this->rangeStart : $offset; + if ($stepSize >= $this->rangeEnd) { + $values = [$this->fullRange[$stepSize % count($this->fullRange)]]; + } else { + for ($i = $offset; $i <= $to; $i += $stepSize) { + $values[] = (int)$i; + } + } + sort($values); + } + else { + $values = array($expression); + } + + return $values; + } + + /** + * Convert literal + * + * @param string $value + * @return string + */ + protected function convertLiterals($value) + { + if (count($this->literals)) { + $key = array_search($value, $this->literals); + if ($key !== false) { + return (string) $key; + } + } + + return $value; + } + + /** + * Checks to see if a value is valid for the field + * + * @param string $value + * @return bool + */ + public function validate($value) + { + $value = $this->convertLiterals($value); + + // All fields allow * as a valid value + if ('*' === $value) { + return true; + } + + if (strpos($value, '/') !== false) { + list($range, $step) = explode('/', $value); + return $this->validate($range) && filter_var($step, FILTER_VALIDATE_INT); + } + + // Validate each chunk of a list individually + if (strpos($value, ',') !== false) { + foreach (explode(',', $value) as $listItem) { + if (!$this->validate($listItem)) { + return false; + } + } + return true; + } + + if (strpos($value, '-') !== false) { + if (substr_count($value, '-') > 1) { + return false; + } + + $chunks = explode('-', $value); + $chunks[0] = $this->convertLiterals($chunks[0]); + $chunks[1] = $this->convertLiterals($chunks[1]); + + if ('*' == $chunks[0] || '*' == $chunks[1]) { + return false; + } + + return $this->validate($chunks[0]) && $this->validate($chunks[1]); + } + + if (!is_numeric($value)) { + return false; + } + + if (is_float($value) || strpos($value, '.') !== false) { + return false; + } + + // We should have a numeric by now, so coerce this into an integer + $value = (int) $value; + + return in_array($value, $this->fullRange, true); + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php b/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php new file mode 100644 index 0000000..dbb93ce --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php @@ -0,0 +1,413 @@ +<?php + +namespace Cron; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Exception; +use InvalidArgumentException; +use RuntimeException; + +/** + * CRON expression parser that can determine whether or not a CRON expression is + * due to run, the next run date and previous run date of a CRON expression. + * The determinations made by this class are accurate if checked run once per + * minute (seconds are dropped from date time comparisons). + * + * Schedule parts must map to: + * minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week + * [1-7|MON-SUN], and an optional year. + * + * @link http://en.wikipedia.org/wiki/Cron + */ +class CronExpression +{ + const MINUTE = 0; + const HOUR = 1; + const DAY = 2; + const MONTH = 3; + const WEEKDAY = 4; + const YEAR = 5; + + /** + * @var array CRON expression parts + */ + private $cronParts; + + /** + * @var FieldFactory CRON field factory + */ + private $fieldFactory; + + /** + * @var int Max iteration count when searching for next run date + */ + private $maxIterationCount = 1000; + + /** + * @var array Order in which to test of cron parts + */ + private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE); + + /** + * Factory method to create a new CronExpression. + * + * @param string $expression The CRON expression to create. There are + * several special predefined values which can be used to substitute the + * CRON expression: + * + * `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - 0 0 1 1 * + * `@monthly` - Run once a month, midnight, first of month - 0 0 1 * * + * `@weekly` - Run once a week, midnight on Sun - 0 0 * * 0 + * `@daily` - Run once a day, midnight - 0 0 * * * + * `@hourly` - Run once an hour, first minute - 0 * * * * + * @param FieldFactory|null $fieldFactory Field factory to use + * + * @return CronExpression + */ + public static function factory($expression, FieldFactory $fieldFactory = null) + { + $mappings = array( + '@yearly' => '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@hourly' => '0 * * * *' + ); + + if (isset($mappings[$expression])) { + $expression = $mappings[$expression]; + } + + return new static($expression, $fieldFactory ?: new FieldFactory()); + } + + /** + * Validate a CronExpression. + * + * @param string $expression The CRON expression to validate. + * + * @return bool True if a valid CRON expression was passed. False if not. + * @see \Cron\CronExpression::factory + */ + public static function isValidExpression($expression) + { + try { + self::factory($expression); + } catch (InvalidArgumentException $e) { + return false; + } + + return true; + } + + /** + * Parse a CRON expression + * + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param FieldFactory|null $fieldFactory Factory to create cron fields + */ + public function __construct($expression, FieldFactory $fieldFactory = null) + { + $this->fieldFactory = $fieldFactory ?: new FieldFactory(); + $this->setExpression($expression); + } + + /** + * Set or change the CRON expression + * + * @param string $value CRON expression (e.g. 8 * * * *) + * + * @return CronExpression + * @throws \InvalidArgumentException if not a valid CRON expression + */ + public function setExpression($value) + { + $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + if (count($this->cronParts) < 5) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + foreach ($this->cronParts as $position => $part) { + $this->setPart($position, $part); + } + + return $this; + } + + /** + * Set part of the CRON expression + * + * @param int $position The position of the CRON expression to set + * @param string $value The value to set + * + * @return CronExpression + * @throws \InvalidArgumentException if the value is not valid for the part + */ + public function setPart($position, $value) + { + if (!$this->fieldFactory->getField($position)->validate($value)) { + throw new InvalidArgumentException( + 'Invalid CRON field value ' . $value . ' at position ' . $position + ); + } + + $this->cronParts[$position] = $value; + + return $this; + } + + /** + * Set max iteration count for searching next run dates + * + * @param int $maxIterationCount Max iteration count when searching for next run date + * + * @return CronExpression + */ + public function setMaxIterationCount($maxIterationCount) + { + $this->maxIterationCount = $maxIterationCount; + + return $this; + } + + /** + * Get a next run date relative to the current date or a specific date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning a + * matching next run date. 0, the default, will return the + * current date and time if the next run date falls on the + * current date and time. Setting this value to 1 will + * skip the first match and go to the second match. + * Setting this value to 2 will skip the first 2 + * matches and so on. + * @param bool $allowCurrentDate Set to TRUE to return the current date if + * it matches the cron expression. + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + */ + public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null) + { + return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone); + } + + /** + * Get a previous run date relative to the current date or a specific date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + * @see \Cron\CronExpression::getNextRunDate + */ + public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null) + { + return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone); + } + + /** + * Get multiple run dates starting at the current date or a specific date + * + * @param int $total Set the total number of dates to calculate + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param bool $invert Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime[] Returns an array of run dates + */ + public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false, $timeZone = null) + { + $matches = array(); + for ($i = 0; $i < max(0, $total); $i++) { + try { + $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate, $timeZone); + } catch (RuntimeException $e) { + break; + } + } + + return $matches; + } + + /** + * Get all or part of the CRON expression + * + * @param string $part Specify the part to retrieve or NULL to get the full + * cron schedule string. + * + * @return string|null Returns the CRON expression, a part of the + * CRON expression, or NULL if the part was specified but not found + */ + public function getExpression($part = null) + { + if (null === $part) { + return implode(' ', $this->cronParts); + } elseif (array_key_exists($part, $this->cronParts)) { + return $this->cronParts[$part]; + } + + return null; + } + + /** + * Helper method to output the full expression. + * + * @return string Full CRON expression + */ + public function __toString() + { + return $this->getExpression(); + } + + /** + * Determine if the cron is due to run based on the current date or a + * specific date. This method assumes that the current number of + * seconds are irrelevant, and should be called once per minute. + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return bool Returns TRUE if the cron is due to run or FALSE if not + */ + public function isDue($currentTime = 'now', $timeZone = null) + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ('now' === $currentTime) { + $currentTime = new DateTime(); + } elseif ($currentTime instanceof DateTime) { + // + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); + } else { + $currentTime = new DateTime($currentTime); + } + $currentTime->setTimeZone(new DateTimeZone($timeZone)); + + // drop the seconds to 0 + $currentTime = DateTime::createFromFormat('Y-m-d H:i', $currentTime->format('Y-m-d H:i')); + + try { + return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp(); + } catch (Exception $e) { + return false; + } + } + + /** + * Get the next or previous run date of the expression relative to a date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $invert Set to TRUE to go backwards in time + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + */ + protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false, $timeZone = null) + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ($currentTime instanceof DateTime) { + $currentDate = clone $currentTime; + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); + } else { + $currentDate = new DateTime($currentTime ?: 'now'); + } + + $currentDate->setTimeZone(new DateTimeZone($timeZone)); + $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); + $nextRun = clone $currentDate; + $nth = (int) $nth; + + // We don't have to satisfy * or null fields + $parts = array(); + $fields = array(); + foreach (self::$order as $position) { + $part = $this->getExpression($position); + if (null === $part || '*' === $part) { + continue; + } + $parts[$position] = $part; + $fields[$position] = $this->fieldFactory->getField($position); + } + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < $this->maxIterationCount; $i++) { + + foreach ($parts as $position => $part) { + $satisfied = false; + // Get the field object used to validate this part + $field = $fields[$position]; + // Check if this is singular or a list + if (strpos($part, ',') === false) { + $satisfied = $field->isSatisfiedBy($nextRun, $part); + } else { + foreach (array_map('trim', explode(',', $part)) as $listPart) { + if ($field->isSatisfiedBy($nextRun, $listPart)) { + $satisfied = true; + break; + } + } + } + + // If the field is not satisfied, then start over + if (!$satisfied) { + $field->increment($nextRun, $invert, $part); + continue 2; + } + } + + // Skip this match if needed + if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { + $this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null); + continue; + } + + return $nextRun; + } + + // @codeCoverageIgnoreStart + throw new RuntimeException('Impossible CRON expression'); + // @codeCoverageIgnoreEnd + } + + /** + * Workout what timeZone should be used. + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @return string + */ + protected function determineTimeZone($currentTime, $timeZone) + { + if (! is_null($timeZone)) { + return $timeZone; + } + + if ($currentTime instanceOf DateTimeInterface) { + return $currentTime->getTimeZone()->getName(); + } + + return date_default_timezone_get(); + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php b/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php new file mode 100644 index 0000000..d4552e0 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php @@ -0,0 +1,145 @@ +<?php + +namespace Cron; + +use DateTime; +use DateTimeInterface; + +/** + * Day of month field. Allows: * , / - ? L W + * + * 'L' stands for "last" and specifies the last day of the month. + * + * The 'W' character is used to specify the weekday (Monday-Friday) nearest the + * given day. As an example, if you were to specify "15W" as the value for the + * day-of-month field, the meaning is: "the nearest weekday to the 15th of the + * month". So if the 15th is a Saturday, the trigger will fire on Friday the + * 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If + * the 15th is a Tuesday, then it will fire on Tuesday the 15th. However if you + * specify "1W" as the value for day-of-month, and the 1st is a Saturday, the + * trigger will fire on Monday the 3rd, as it will not 'jump' over the boundary + * of a month's days. The 'W' character can only be specified when the + * day-of-month is a single day, not a range or list of days. + * + * @author Michael Dowling <mtdowling@gmail.com> + */ +class DayOfMonthField extends AbstractField +{ + /** + * @inheritDoc + */ + protected $rangeStart = 1; + + /** + * @inheritDoc + */ + protected $rangeEnd = 31; + + /** + * Get the nearest day of the week for a given day in a month + * + * @param int $currentYear Current year + * @param int $currentMonth Current month + * @param int $targetDay Target day of the month + * + * @return \DateTime Returns the nearest date + */ + private static function getNearestWeekday($currentYear, $currentMonth, $targetDay) + { + $tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT); + $target = DateTime::createFromFormat('Y-m-d', "$currentYear-$currentMonth-$tday"); + $currentWeekday = (int) $target->format('N'); + + if ($currentWeekday < 6) { + return $target; + } + + $lastDayOfMonth = $target->format('t'); + + foreach (array(-1, 1, -2, 2) as $i) { + $adjusted = $targetDay + $i; + if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { + $target->setDate($currentYear, $currentMonth, $adjusted); + if ($target->format('N') < 6 && $target->format('m') == $currentMonth) { + return $target; + } + } + } + } + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + // ? states that the field value is to be skipped + if ($value == '?') { + return true; + } + + $fieldValue = $date->format('d'); + + // Check to see if this is the last day of the month + if ($value == 'L') { + return $fieldValue == $date->format('t'); + } + + // Check to see if this is the nearest weekday to a particular value + if (strpos($value, 'W')) { + // Parse the target day + $targetDay = substr($value, 0, strpos($value, 'W')); + // Find out if the current day is the nearest day of the week + return $date->format('j') == self::getNearestWeekday( + $date->format('Y'), + $date->format('m'), + $targetDay + )->format('j'); + } + + return $this->isSatisfied($date->format('d'), $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable &$date + */ + public function increment(DateTimeInterface &$date, $invert = false) + { + if ($invert) { + $date = $date->modify('previous day')->setTime(23, 59); + } else { + $date = $date->modify('next day')->setTime(0, 0); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function validate($value) + { + $basicChecks = parent::validate($value); + + // Validate that a list don't have W or L + if (strpos($value, ',') !== false && (strpos($value, 'W') !== false || strpos($value, 'L') !== false)) { + return false; + } + + if (!$basicChecks) { + + if ($value === 'L') { + return true; + } + + if (preg_match('/^(.*)W$/', $value, $matches)) { + return $this->validate($matches[1]); + } + + return false; + } + + return $basicChecks; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php b/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php new file mode 100644 index 0000000..d4ba315 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php @@ -0,0 +1,196 @@ +<?php + +namespace Cron; + +use DateTime; +use DateTimeInterface; +use InvalidArgumentException; + +/** + * Day of week field. Allows: * / , - ? L # + * + * Days of the week can be represented as a number 0-7 (0|7 = Sunday) + * or as a three letter string: SUN, MON, TUE, WED, THU, FRI, SAT. + * + * 'L' stands for "last". It allows you to specify constructs such as + * "the last Friday" of a given month. + * + * '#' is allowed for the day-of-week field, and must be followed by a + * number between one and five. It allows you to specify constructs such as + * "the second Friday" of a given month. + */ +class DayOfWeekField extends AbstractField +{ + /** + * @inheritDoc + */ + protected $rangeStart = 0; + + /** + * @inheritDoc + */ + protected $rangeEnd = 7; + + /** + * @var array Weekday range + */ + protected $nthRange; + + /** + * @inheritDoc + */ + protected $literals = [1 => 'MON', 2 => 'TUE', 3 => 'WED', 4 => 'THU', 5 => 'FRI', 6 => 'SAT', 7 => 'SUN']; + + /** + * Constructor + */ + public function __construct() + { + $this->nthRange = range(1, 5); + parent::__construct(); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable $date + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + if ($value == '?') { + return true; + } + + // Convert text day of the week values to integers + $value = $this->convertLiterals($value); + + $currentYear = $date->format('Y'); + $currentMonth = $date->format('m'); + $lastDayOfMonth = $date->format('t'); + + // Find out if this is the last specific weekday of the month + if (strpos($value, 'L')) { + $weekday = (int) $this->convertLiterals(substr($value, 0, strpos($value, 'L'))); + $weekday %= 7; + + $tdate = clone $date; + $tdate = $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); + while ($tdate->format('w') != $weekday) { + $tdateClone = new DateTime(); + $tdate = $tdateClone + ->setTimezone($tdate->getTimezone()) + ->setDate($currentYear, $currentMonth, --$lastDayOfMonth); + } + + return $date->format('j') == $lastDayOfMonth; + } + + // Handle # hash tokens + if (strpos($value, '#')) { + list($weekday, $nth) = explode('#', $value); + + if (!is_numeric($nth)) { + throw new InvalidArgumentException("Hashed weekdays must be numeric, {$nth} given"); + } else { + $nth = (int) $nth; + } + + // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601 + if ($weekday === '0') { + $weekday = 7; + } + + $weekday = $this->convertLiterals($weekday); + + // Validate the hash fields + if ($weekday < 0 || $weekday > 7) { + throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given"); + } + + if (!in_array($nth, $this->nthRange)) { + throw new InvalidArgumentException("There are never more than 5 or less than 1 of a given weekday in a month, {$nth} given"); + } + + // The current weekday must match the targeted weekday to proceed + if ($date->format('N') != $weekday) { + return false; + } + + $tdate = clone $date; + $tdate = $tdate->setDate($currentYear, $currentMonth, 1); + $dayCount = 0; + $currentDay = 1; + while ($currentDay < $lastDayOfMonth + 1) { + if ($tdate->format('N') == $weekday) { + if (++$dayCount >= $nth) { + break; + } + } + $tdate = $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + } + + return $date->format('j') == $currentDay; + } + + // Handle day of the week values + if (strpos($value, '-')) { + $parts = explode('-', $value); + if ($parts[0] == '7') { + $parts[0] = '0'; + } elseif ($parts[1] == '0') { + $parts[1] = '7'; + } + $value = implode('-', $parts); + } + + // Test to see which Sunday to use -- 0 == 7 == Sunday + $format = in_array(7, str_split($value)) ? 'N' : 'w'; + $fieldValue = $date->format($format); + + return $this->isSatisfied($fieldValue, $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable &$date + */ + public function increment(DateTimeInterface &$date, $invert = false) + { + if ($invert) { + $date = $date->modify('-1 day')->setTime(23, 59, 0); + } else { + $date = $date->modify('+1 day')->setTime(0, 0, 0); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function validate($value) + { + $basicChecks = parent::validate($value); + + if (!$basicChecks) { + // Handle the # value + if (strpos($value, '#') !== false) { + $chunks = explode('#', $value); + $chunks[0] = $this->convertLiterals($chunks[0]); + + if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && in_array($chunks[1], $this->nthRange)) { + return true; + } + } + + if (preg_match('/^(.*)L$/', $value, $matches)) { + return $this->validate($matches[1]); + } + + return false; + } + + return $basicChecks; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php new file mode 100644 index 0000000..545e4b8 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php @@ -0,0 +1,54 @@ +<?php + +namespace Cron; + +use InvalidArgumentException; + +/** + * CRON field factory implementing a flyweight factory + * @link http://en.wikipedia.org/wiki/Cron + */ +class FieldFactory +{ + /** + * @var array Cache of instantiated fields + */ + private $fields = array(); + + /** + * Get an instance of a field object for a cron expression position + * + * @param int $position CRON expression position value to retrieve + * + * @return FieldInterface + * @throws InvalidArgumentException if a position is not valid + */ + public function getField($position) + { + if (!isset($this->fields[$position])) { + switch ($position) { + case 0: + $this->fields[$position] = new MinutesField(); + break; + case 1: + $this->fields[$position] = new HoursField(); + break; + case 2: + $this->fields[$position] = new DayOfMonthField(); + break; + case 3: + $this->fields[$position] = new MonthField(); + break; + case 4: + $this->fields[$position] = new DayOfWeekField(); + break; + default: + throw new InvalidArgumentException( + ($position + 1) . ' is not a valid position' + ); + } + } + + return $this->fields[$position]; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php b/vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php new file mode 100644 index 0000000..f8366ea --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php @@ -0,0 +1,41 @@ +<?php + +namespace Cron; + +use DateTimeInterface; + +/** + * CRON field interface + */ +interface FieldInterface +{ + /** + * Check if the respective value of a DateTime field satisfies a CRON exp + * + * @param DateTimeInterface $date DateTime object to check + * @param string $value CRON expression to test against + * + * @return bool Returns TRUE if satisfied, FALSE otherwise + */ + public function isSatisfiedBy(DateTimeInterface $date, $value); + + /** + * When a CRON expression is not satisfied, this method is used to increment + * or decrement a DateTime object by the unit of the cron field + * + * @param DateTimeInterface &$date DateTime object to change + * @param bool $invert (optional) Set to TRUE to decrement + * + * @return FieldInterface + */ + public function increment(DateTimeInterface &$date, $invert = false); + + /** + * Validates a CRON expression for a given field + * + * @param string $value CRON expression value to validate + * + * @return bool Returns TRUE if valid, FALSE otherwise + */ + public function validate($value); +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/HoursField.php b/vendor/dragonmantank/cron-expression/src/Cron/HoursField.php new file mode 100644 index 0000000..628218a --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/HoursField.php @@ -0,0 +1,85 @@ +<?php + +namespace Cron; + +use DateTimeInterface; +use DateTimeZone; + +/** + * Hours field. Allows: * , / - + */ +class HoursField extends AbstractField +{ + /** + * @inheritDoc + */ + protected $rangeStart = 0; + + /** + * @inheritDoc + */ + protected $rangeEnd = 23; + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + if ($value == '?') { + return true; + } + + return $this->isSatisfied($date->format('H'), $value); + } + + /** + * {@inheritDoc} + * + * @param \DateTime|\DateTimeImmutable &$date + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null) + { + // Change timezone to UTC temporarily. This will + // allow us to go back or forwards and hour even + // if DST will be changed between the hours. + if (is_null($parts) || $parts == '*') { + $timezone = $date->getTimezone(); + $date = $date->setTimezone(new DateTimeZone('UTC')); + $date = $date->modify(($invert ? '-' : '+') . '1 hour'); + $date = $date->setTimezone($timezone); + + $date = $date->setTime($date->format('H'), $invert ? 59 : 0); + return $this; + } + + $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts); + $hours = array(); + foreach ($parts as $part) { + $hours = array_merge($hours, $this->getRangeForExpression($part, 23)); + } + + $current_hour = $date->format('H'); + $position = $invert ? count($hours) - 1 : 0; + if (count($hours) > 1) { + for ($i = 0; $i < count($hours) - 1; $i++) { + if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) || + ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) { + $position = $invert ? $i : $i + 1; + break; + } + } + } + + $hour = $hours[$position]; + if ((!$invert && $date->format('H') >= $hour) || ($invert && $date->format('H') <= $hour)) { + $date = $date->modify(($invert ? '-' : '+') . '1 day'); + $date = $date->setTime($invert ? 23 : 0, $invert ? 59 : 0); + } + else { + $date = $date->setTime($hour, $invert ? 59 : 0); + } + + return $this; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php b/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php new file mode 100644 index 0000000..fecc9b6 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php @@ -0,0 +1,75 @@ +<?php + +namespace Cron; + +use DateTimeInterface; + +/** + * Minutes field. Allows: * , / - + */ +class MinutesField extends AbstractField +{ + /** + * @inheritDoc + */ + protected $rangeStart = 0; + + /** + * @inheritDoc + */ + protected $rangeEnd = 59; + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + if ($value == '?') { + return true; + } + + return $this->isSatisfied($date->format('i'), $value); + } + + /** + * {@inheritDoc} + * + * @param \DateTime|\DateTimeImmutable &$date + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null) + { + if (is_null($parts)) { + $date = $date->modify(($invert ? '-' : '+') . '1 minute'); + return $this; + } + + $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts); + $minutes = array(); + foreach ($parts as $part) { + $minutes = array_merge($minutes, $this->getRangeForExpression($part, 59)); + } + + $current_minute = $date->format('i'); + $position = $invert ? count($minutes) - 1 : 0; + if (count($minutes) > 1) { + for ($i = 0; $i < count($minutes) - 1; $i++) { + if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) || + ($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) { + $position = $invert ? $i : $i + 1; + break; + } + } + } + + if ((!$invert && $current_minute >= $minutes[$position]) || ($invert && $current_minute <= $minutes[$position])) { + $date = $date->modify(($invert ? '-' : '+') . '1 hour'); + $date = $date->setTime($date->format('H'), $invert ? 59 : 0); + } + else { + $date = $date->setTime($date->format('H'), $minutes[$position]); + } + + return $this; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php b/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php new file mode 100644 index 0000000..afc9caf --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php @@ -0,0 +1,59 @@ +<?php + +namespace Cron; + +use DateTimeInterface; + +/** + * Month field. Allows: * , / - + */ +class MonthField extends AbstractField +{ + /** + * @inheritDoc + */ + protected $rangeStart = 1; + + /** + * @inheritDoc + */ + protected $rangeEnd = 12; + + /** + * @inheritDoc + */ + protected $literals = [1 => 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAY', 6 => 'JUN', 7 => 'JUL', + 8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC']; + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + if ($value == '?') { + return true; + } + + $value = $this->convertLiterals($value); + + return $this->isSatisfied($date->format('m'), $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable &$date + */ + public function increment(DateTimeInterface &$date, $invert = false) + { + if ($invert) { + $date = $date->modify('last day of previous month')->setTime(23, 59); + } else { + $date = $date->modify('first day of next month')->setTime(0, 0); + } + + return $this; + } + + +} |