diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:31:28 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:31:28 +0000 |
commit | 067008c5f094ba9606daacbe540f6b929dc124ea (patch) | |
tree | 3092ce2cd8bf1ac6db6c97f4c98c7f71a51c6ac8 | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-x509-upstream.tar.xz icingaweb2-module-x509-upstream.zip |
Adding upstream version 1:1.3.2.upstream/1%1.3.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
124 files changed, 10485 insertions, 0 deletions
diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..b83b0a3 --- /dev/null +++ b/.mailmap @@ -0,0 +1,17 @@ +Alexander A. Klimov <alexander.klimov@icinga.com> <alexander.klimov@icinga.com> +Eric Lippmann <eric.lippmann@icinga.com> <eric.lippmann@netways.de> +Florian Strohmaier <florian.strohmaier@icinga.com> <florian.strohmaier@netways.de> +Gunnar Beutner <gunnar.beutner@icinga.com> +Jan Wagner <waja@cyconet.org> +Jens Meißner <meissner@b1-systems.de> +Johannes Meyer <johannes.meyer@icinga.com> <johannes.meyer@netways.de> +Michael Friedrich <michael.friedrich@netways.de> +Ravi Kumar Kempapura Srinivasa <ravi.srinivasa@icinga.com> <33730024+raviks789@users.noreply.github.com> +Robert Rettig <robert@rettig.bayern> +Sukhwinder Dhillon <sukhwinder.Dhillon@icinga.com> +Timm Ortloff <tim.ortloff@icinga.com> <tim.ortloff@netways.de> +Yonas Habteab <yonas.habteab@icinga.com> <yonas.habteab@netways.de> +lx <pdolinic@netways.de> +moreamazingnick <github@nicolas-schneider.at> +nmartinii <51709615+nmartinii@users.noreply.github.com> +pgress <pgress@noris.de> @@ -0,0 +1,17 @@ +Alexander A. Klimov <alexander.klimov@icinga.com> +Eric Lippmann <eric.lippmann@icinga.com> +Florian Strohmaier <florian.strohmaier@icinga.com> +Gunnar Beutner <gunnar.beutner@icinga.com> +Jan Wagner <waja@cyconet.org> +Jens Meißner <meissner@b1-systems.de> +Johannes Meyer <johannes.meyer@icinga.com> +Michael Friedrich <michael.friedrich@netways.de> +Ravi Kumar Kempapura Srinivasa <ravi.srinivasa@icinga.com> +Robert Rettig <robert@rettig.bayern> +Sukhwinder Dhillon <sukhwinder.Dhillon@icinga.com> +Timm Ortloff <timm.ortloff@icinga.com> +Yonas Habteab <yonas.habteab@icinga.com> +lx <pdolinic@netways.de> +moreamazingnick <github@nicolas-schneider.at> +nmartinii <51709615+nmartinii@users.noreply.github.com> +pgress <pgress@noris.de> @@ -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..35f1d6f --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Icinga Certificate Monitoring + +[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.2-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..0c369d9 --- /dev/null +++ b/application/clicommands/CheckCommand.php @@ -0,0 +1,268 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use DateInterval; +use DateTime; +use DateTimeInterface; +use Icinga\Application\Logger; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509Target; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; + +class CheckCommand extends Command +{ + /** + * Check a host's certificate + * + * This command utilizes this module's database to check if the given host serves valid certificates. + * + * USAGE + * + * icingacli x509 check host [options] + * + * OPTIONS + * + * You can either pass --ip or --host or both at the same time but at least one is mandatory. + * + * --ip A hosts IP address + * --host A hosts name + * --port The port to check in particular + * --warning Less remaining time results in state WARNING + * Default: 25% + * --critical Less remaining time results in state CRITICAL + * Default: 10% + * --allow-self-signed Ignore if a certificate or its issuer has been + * self-signed + * + * EXAMPLES + * + * icingacli x509 check host --ip 10.0.10.78 + * icingacli x509 check host --host mail.example.org + * icingacli x509 check host --ip 10.0.10.78 --host mail.example.org --port 993 + * + * THRESHOLD DEFINITION + * + * Thresholds can either be defined relative (in percent) or absolute + * (time interval). Time intervals consist of a digit and an accompanying + * unit (e.g. "3M" are three months). Supported units are: + * + * Year: y, Y + * Month: M + * Day: d, D + * Hour: h, H + * Minute: m + * Second: s, S + */ + public function hostAction() + { + $ip = $this->params->get('ip'); + $hostname = $this->params->get('host'); + if ($ip === null && $hostname === null) { + $this->showUsage('host'); + exit(3); + } + + $targets = X509Target::on(Database::get())->with([ + 'chain', + 'chain.certificate', + 'chain.certificate.issuer_certificate' + ]); + + $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT'); + + $targets->columns([ + 'port', + 'chain.valid', + 'chain.invalid_reason', + 'subject' => 'chain.certificate.subject', + 'self_signed' => new Expression('COALESCE(%s, %s)', [ + 'chain.certificate.issuer_certificate.self_signed', + 'chain.certificate.self_signed' + ]) + ]); + + // Sub query for `valid_from` column + $validFrom = $targets->createSubQuery(new X509Certificate(), 'chain.certificate'); + $validFrom + ->columns([new Expression('MAX(GREATEST(%s, %s))', ['valid_from', 'issuer_certificate.valid_from'])]) + ->getSelectBase() + ->resetWhere() + ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id')); + + // Sub query for `valid_to` column + $validTo = $targets->createSubQuery(new X509Certificate(), 'chain.certificate'); + $validTo + ->columns([new Expression('MIN(LEAST(%s, %s))', ['valid_to', 'issuer_certificate.valid_to'])]) + ->getSelectBase() + // Reset the where clause generated within the createSubQuery() method. + ->resetWhere() + ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id')); + + list($validFromSelect, $_) = $validFrom->dump(); + list($validToSelect, $_) = $validTo->dump(); + $targets + ->withColumns([ + 'valid_from' => new Expression($validFromSelect), + 'valid_to' => new Expression($validToSelect) + ]) + ->getSelectBase() + ->where(new Expression('target_chain_link.order = 0')); + + if ($ip !== null) { + $targets->filter(Filter::equal('ip', $ip)); + } + if ($hostname !== null) { + $targets->filter(Filter::equal('hostname', $hostname)); + } + if ($this->params->has('port')) { + $targets->filter(Filter::equal('port', $this->params->get('port'))); + } + + $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); + $warningThreshold = $this->splitThreshold($this->params->get('warning', '25%')); + $criticalThreshold = $this->splitThreshold($this->params->get('critical', '10%')); + + $output = []; + $perfData = []; + + $state = 3; + foreach ($targets as $target) { + if (! $target->chain->valid && (! $target['self_signed'] || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target->chain->invalid_reason; + $output[$invalidMessage] = $invalidMessage; + $state = 2; + } + + $now = new DateTime(); + $validFrom = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_from / 1000.0)); + $validTo = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_to / 1000.0)); + $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold); + $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold); + + if ($now > $criticalAfter) { + $state = 2; + } elseif ($state !== 2 && $now > $warningAfter) { + $state = 1; + } elseif ($state === 3) { + $state = 0; + } + + $remainingTime = $now->diff($validTo); + if (! $remainingTime->invert) { + // The certificate has not expired yet + $output[$target->subject] = sprintf( + '%s expires in %d days', + $target->subject, + $remainingTime->days + ); + } else { + $output[$target->subject] = sprintf( + '%s has expired since %d days', + $target->subject, + $remainingTime->days + ); + } + + $perfData[$target->subject] = sprintf( + "'%s'=%ds;%d:;%d:;0;%d", + $target->subject, + $remainingTime->invert + ? 0 + : $validTo->getTimestamp() - time(), + $validTo->getTimestamp() - $warningAfter->getTimestamp(), + $validTo->getTimestamp() - $criticalAfter->getTimestamp(), + $validTo->getTimestamp() - $validFrom->getTimestamp() + ); + } + + echo ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN'][$state]; + echo ' - '; + + if (! empty($output)) { + echo join('; ', $output); + } elseif ($state === 3) { + echo 'Host not found'; + } + + if (! empty($perfData)) { + echo '|' . join(' ', $perfData); + } + + echo PHP_EOL; + exit($state); + } + + /** + * Parse the given threshold definition + * + * @param string $threshold + * + * @return int|DateInterval + */ + protected function splitThreshold(string $threshold) + { + $match = preg_match('/(\d+)([%\w]{1})/', $threshold, $matches); + if (! $match) { + Logger::error('Invalid threshold definition: %s', $threshold); + exit(3); + } + + switch ($matches[2]) { + case '%': + return (int) $matches[1]; + case 'y': + case 'Y': + $intervalSpec = 'P' . $matches[1] . 'Y'; + break; + case 'M': + $intervalSpec = 'P' . $matches[1] . 'M'; + break; + case 'd': + case 'D': + $intervalSpec = 'P' . $matches[1] . 'D'; + break; + case 'h': + case 'H': + $intervalSpec = 'PT' . $matches[1] . 'H'; + break; + case 'm': + $intervalSpec = 'PT' . $matches[1] . 'M'; + break; + case 's': + case 'S': + $intervalSpec = 'PT' . $matches[1] . 'S'; + break; + default: + Logger::error('Unknown threshold unit given: %s', $threshold); + exit(3); + } + + return new DateInterval($intervalSpec); + } + + /** + * Convert the given threshold information to a DateTime object + * + * @param DateTime $from + * @param DateTime $to + * @param int|DateInterval $thresholdValue + * + * @return DateTimeInterface + */ + protected function thresholdToDateTime(DateTime $from, DateTime $to, $thresholdValue): DateTimeInterface + { + $to = clone $to; + if ($thresholdValue instanceof DateInterval) { + return $to->sub($thresholdValue); + } + + $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100)); + return $to->sub(new DateInterval('P' . $coveredDays . 'D')); + } +} diff --git a/application/clicommands/CleanupCommand.php b/application/clicommands/CleanupCommand.php new file mode 100644 index 0000000..61c43d4 --- /dev/null +++ b/application/clicommands/CleanupCommand.php @@ -0,0 +1,95 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Clicommands; + +use DateTime; +use Exception; +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use InvalidArgumentException; +use Throwable; + +class CleanupCommand extends Command +{ + /** + * Remove targets whose last scan is older than a certain date/time and certificates that are no longer used. + * + * By default, any targets whose last scan is older than 1 month are removed. The last scan information is + * always updated when scanning a target, regardless of whether a successful connection is made or not. + * Therefore, targets that have been decommissioned or are no longer part of a job configuration are removed + * after the specified period. Any certificates that are no longer used are also removed. This can either be + * because the associated target has been removed or because it is presenting a new certificate chain. + * + * This command will also remove jobs activities created before the given date/time. Jobs activities are usually + * some stats about the job runs performed by the scheduler or/and manually executed using the `scan` and/or + * `jobs` command. + * + * USAGE + * + * icingacli x509 cleanup [OPTIONS] + * + * OPTIONS + * + * --since-last-scan=<datetime> + * Clean up targets whose last scan is older than the specified date/time, + * which can also be an English textual datetime description like "2 days". + * Defaults to "1 month". + * + * EXAMPLES + * + * Remove any targets that have not been scanned for at least two months and any certificates that are no longer + * used. + * + * icingacli x509 cleanup --since-last-scan="2 months" + * + */ + public function indexAction() + { + /** @var string $sinceLastScan */ + $sinceLastScan = $this->params->get('since-last-scan', '-1 month'); + $lastScan = $sinceLastScan; + if ($lastScan[0] !== '-') { + // When the user specified "2 days" as a threshold strtotime() will compute the + // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days) + $lastScan = "-$lastScan"; + } + + try { + $sinceLastScan = new DateTime($lastScan); + } catch (Exception $_) { + throw new InvalidArgumentException(sprintf( + 'The specified last scan time is in an unknown format: %s', + $sinceLastScan + )); + } + + try { + $conn = Database::get(); + $query = $conn->delete( + 'x509_target', + ['last_scan < ?' => $sinceLastScan->format('Uv')] + ); + + if ($query->rowCount() > 0) { + Logger::info( + 'Removed %d targets matching since last scan filter: %s', + $query->rowCount(), + $sinceLastScan->format('Y-m-d H:i:s') + ); + } + + $query = $conn->delete('x509_job_run', ['start_time < ?' => $sinceLastScan->getTimestamp() * 1000]); + if ($query->rowCount() > 0) { + Logger::info('Removed %d jobs activities', $query->rowCount()); + } + + CertificateUtils::cleanupNoLongerUsedCertificates($conn); + } catch (Throwable $err) { + Logger::error($err); + } + } +} diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php new file mode 100644 index 0000000..2e7b157 --- /dev/null +++ b/application/clicommands/ImportCommand.php @@ -0,0 +1,61 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use ipl\Sql\Connection; +use ipl\Sql\Expression; + +class ImportCommand extends Command +{ + /** + * Import all X.509 certificates from the given file and mark them as trusted + * + * USAGE: + * + * icingacli x509 import --file <file> + * + * EXAMPLES: + * + * icingacli x509 import --file /etc/ssl/certs/ca-bundle.crt + */ + public function indexAction() + { + $file = $this->params->getRequired('file'); + + if (! file_exists($file)) { + Logger::warning('The specified certificate file does not exist.'); + exit(1); + } + + $bundle = CertificateUtils::parseBundle($file); + + $count = 0; + + Database::get()->transaction(function (Connection $db) use ($bundle, &$count) { + foreach ($bundle as $data) { + $cert = openssl_x509_read($data); + + list($id, $_) = CertificateUtils::findOrInsertCert($db, $cert); + + $db->update( + 'x509_certificate', + [ + 'trusted' => 'y', + 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000') + ], + ['id = ?' => $id] + ); + + $count++; + } + }); + + printf("Processed %d X.509 certificate%s.\n", $count, $count !== 1 ? 's' : ''); + } +} diff --git a/application/clicommands/JobsCommand.php b/application/clicommands/JobsCommand.php new file mode 100644 index 0000000..27f7202 --- /dev/null +++ b/application/clicommands/JobsCommand.php @@ -0,0 +1,279 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use DateTime; +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Data\ResourceFactory; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Common\JobUtils; +use Icinga\Module\X509\Hook\SniHook; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Model\X509Job; +use Icinga\Module\X509\Model\X509Schedule; +use Icinga\Module\X509\Schedule; +use InvalidArgumentException; +use ipl\Orm\Query; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\Scheduler; +use ipl\Stdlib\Filter; +use React\EventLoop\Loop; +use React\Promise\ExtendedPromiseInterface; +use stdClass; +use Throwable; + +class JobsCommand extends Command +{ + use JobUtils; + + /** + * Run all configured jobs based on their schedule + * + * USAGE: + * + * icingacli x509 jobs run [OPTIONS] + * + * OPTIONS + * + * --job=<name> + * Run all configured schedules only of the specified job. + * + * --schedule=<name> + * Run only the given schedule of the specified job. Providing a schedule name + * without a job will fail immediately. + * + * --parallel=<number> + * Allow parallel scanning of targets up to the specified number. Defaults to 256. + * May cause **too many open files** error if set to a number higher than the configured one (ulimit). + */ + public function runAction(): void + { + $parallel = (int) $this->params->get('parallel', Job::DEFAULT_PARALLEL); + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1"); + } + + $jobName = (string) $this->params->get('job'); + $scheduleName = (string) $this->params->get('schedule'); + if (! $jobName && $scheduleName) { + throw new InvalidArgumentException('You cannot provide a schedule without a job'); + } + + $scheduler = new Scheduler(); + $this->attachJobsLogging($scheduler); + + $signalHandler = function () use ($scheduler) { + $scheduler->removeTasks(); + + Loop::futureTick(function () { + Loop::stop(); + }); + }; + Loop::addSignal(SIGINT, $signalHandler); + Loop::addSignal(SIGTERM, $signalHandler); + + /** @var Job[] $scheduled Caches scheduled jobs */ + $scheduled = []; + // Periodically check configuration changes to ensure that new jobs are scheduled, jobs are updated, + // and deleted jobs are canceled. + $watchdog = function () use (&$watchdog, &$scheduled, $scheduler, $parallel, $jobName, $scheduleName) { + $jobs = []; + try { + // Since this is a long-running daemon, the resources or module config may change meanwhile. + // Therefore, reload the resources and module config from disk each time (at 5m intervals) + // before reconnecting to the database. + ResourceFactory::setConfig(Config::app('resources', true)); + Config::module('x509', 'config', true); + + $jobs = $this->fetchSchedules($jobName, $scheduleName); + } catch (Throwable $err) { + Logger::error('Failed to fetch job schedules from the database: %s', $err); + Logger::debug($err->getTraceAsString()); + } + + $outdatedJobs = array_diff_key($scheduled, $jobs); + foreach ($outdatedJobs as $job) { + Logger::info( + 'Removing schedule %s of job %s, as it either no longer exists in the configuration or its' + . ' config has been changed', + $job->getSchedule()->getName(), + $job->getName() + ); + + $scheduler->remove($job); + + unset($scheduled[$job->getUuid()->toString()]); + } + + $newJobs = array_diff_key($jobs, $scheduled); + foreach ($newJobs as $key => $job) { + $job->setParallel($parallel); + + /** @var stdClass $config */ + $config = $job->getSchedule()->getConfig(); + try { + /** @var Frequency $type */ + $type = $config->type; + $frequency = $type::fromJson($config->frequency); + } catch (Throwable $err) { + Logger::error( + 'Cannot create schedule %s of job %s: %s', + $job->getSchedule()->getName(), + $job->getName(), + $err->getMessage() + ); + + continue; + } + + $scheduler->schedule($job, $frequency); + + $scheduled[$key] = $job; + } + + Loop::addTimer(5 * 60, $watchdog); + }; + // Check configuration and add jobs directly after starting the scheduler. + Loop::futureTick($watchdog); + } + + /** + * Fetch job schedules from database + * + * @param ?string $jobName + * @param ?string $scheduleName + * + * @return Job[] + */ + protected function fetchSchedules(?string $jobName, ?string $scheduleName): array + { + $conn = Database::get(); + // Even if the Job class regularly pings the same connection whenever its frequency becomes due and is run by + // the scheduler, we need to explicitly ping that same connection here, as the interval of the schedule jobs + // could be larger than the daemon configuration reload interval (5m). + $conn->ping(); + + $jobs = X509Job::on($conn); + if ($jobName) { + $jobs->filter(Filter::equal('name', $jobName)); + } + + $jobSchedules = []; + $snimap = SniHook::getAll(); + /** @var X509Job $jobConfig */ + foreach ($jobs as $jobConfig) { + $cidrs = $this->parseCIDRs($jobConfig->cidrs); + $ports = $this->parsePorts($jobConfig->ports); + + /** @var Query $schedules */ + $schedules = $jobConfig->schedule; + if ($scheduleName) { + $schedules->filter(Filter::equal('name', $scheduleName)); + } + + $schedules = $schedules->execute(); + $hasSchedules = $schedules->hasResult(); + + /** @var X509Schedule $scheduleModel */ + foreach ($schedules as $scheduleModel) { + $job = (new Job($jobConfig->name, $cidrs, $ports, $snimap, Schedule::fromModel($scheduleModel))) + ->setId($jobConfig->id) + ->setExcludes($this->parseExcludes($jobConfig->exclude_targets)); + + $jobSchedules[$job->getUuid()->toString()] = $job; + } + + if (! $hasSchedules) { + Logger::info('Skipping job %s because no schedules are configured', $jobConfig->name); + } + } + + return $jobSchedules; + } + + /** + * Set up logging of jobs states based on scheduler events + * + * @param Scheduler $scheduler + */ + protected function attachJobsLogging(Scheduler $scheduler): void + { + $scheduler->on(Scheduler::ON_TASK_CANCEL, function (Job $task, array $_) { + Logger::info('Schedule %s of job %s canceled', $task->getSchedule()->getName(), $task->getName()); + }); + + $scheduler->on(Scheduler::ON_TASK_DONE, function (Job $task, $targets = 0) { + if ($targets === 0) { + $sinceLastScan = $task->getSinceLastScan(); + if ($sinceLastScan) { + Logger::info( + 'Schedule %s of job %s does not have any targets to be rescanned matching since last scan: %s', + $task->getSchedule()->getName(), + $task->getName(), + $sinceLastScan->format('Y-m-d H:i:s') + ); + } else { + Logger::warning( + 'Schedule %s of job %s does not have any targets', + $task->getSchedule()->getName(), + $task->getName() + ); + } + } else { + Logger::info( + 'Scanned %d target(s) by schedule %s of job %s', + $targets, + $task->getSchedule()->getName(), + $task->getName() + ); + + try { + $verified = CertificateUtils::verifyCertificates(Database::get()); + + Logger::info('Checked %d certificate chain(s)', $verified); + } catch (Exception $err) { + Logger::error($err->getMessage()); + Logger::debug($err->getTraceAsString()); + } + } + }); + + $scheduler->on(Scheduler::ON_TASK_FAILED, function (Job $task, Throwable $e) { + Logger::error( + 'Failed to run schedule %s of job %s: %s', + $task->getSchedule()->getName(), + $task->getName(), + $e->getMessage() + ); + Logger::debug($e->getTraceAsString()); + }); + + $scheduler->on(Scheduler::ON_TASK_RUN, function (Job $task, ExtendedPromiseInterface $_) { + Logger::info('Running schedule %s of job %s', $task->getSchedule()->getName(), $task->getName()); + }); + + $scheduler->on(Scheduler::ON_TASK_SCHEDULED, function (Job $task, DateTime $dateTime) { + Logger::info( + 'Scheduling %s of job %s to run at %s', + $task->getSchedule()->getName(), + $task->getName(), + $dateTime->format('Y-m-d H:i:s') + ); + }); + + $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Job $task, DateTime $dateTime) { + Logger::info( + 'Detaching expired schedule %s of job %s at %s', + $task->getSchedule()->getName(), + $task->getName(), + $dateTime->format('Y-m-d H:i:s') + ); + }); + } +} diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php new file mode 100644 index 0000000..cb4e389 --- /dev/null +++ b/application/clicommands/MigrateCommand.php @@ -0,0 +1,121 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Clicommands; + +use DateTime; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Job; +use Icinga\Repository\IniRepository; +use Icinga\User; +use Icinga\Util\Json; +use ipl\Scheduler\Cron; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use stdClass; + +use function ipl\Stdlib\get_php_type; + +class MigrateCommand extends Command +{ + /** + * Migrate the jobs config rom INI to the database + * + * USAGE + * + * icingacli x509 migrate jobs --author=<name> + * + * OPTIONS + * + * --author=<name> + * An Icinga Web 2 user used to mark as an author for all the migrated jobs. + */ + public function jobsAction(): void + { + /** @var string $author */ + $author = $this->params->getRequired('author'); + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + $user->setUsername($author); + + $this->migrateJobs(); + + Logger::info('Successfully applied all pending migrations'); + } + + protected function migrateJobs(): void + { + $repo = new class () extends IniRepository { + /** @var array<string, array<int, string>> */ + protected $queryColumns = [ + 'jobs' => ['name', 'cidrs', 'ports', 'exclude_targets', 'schedule', 'frequencyType'] + ]; + + /** @var array<string, array<string, string>> */ + protected $configs = [ + 'jobs' => [ + 'module' => 'x509', + 'name' => 'jobs', + 'keyColumn' => 'name' + ] + ]; + }; + + $conn = Database::get(); + $conn->transaction(function (Connection $conn) use ($repo) { + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + /** @var stdClass $data */ + foreach ($repo->select() as $data) { + $config = []; + if (! isset($data->frequencyType) && ! empty($data->schedule)) { + $frequency = new Cron($data->schedule); + $config = [ + 'type' => get_php_type($frequency), + 'frequency' => Json::encode($frequency) + ]; + } elseif (! empty($data->schedule)) { + $config = [ + 'type' => $data->frequencyType, + 'frequency' => $data->schedule // Is already json encoded + ]; + } + + $excludes = $data->exclude_targets; + if (empty($excludes)) { + $excludes = new Expression('NULL'); + } + + $conn->insert('x509_job', [ + 'name' => $data->name, + 'author' => $user->getUsername(), + 'cidrs' => $data->cidrs, + 'ports' => $data->ports, + 'exclude_targets' => $excludes, + 'ctime' => (new DateTime())->getTimestamp() * 1000, + 'mtime' => (new DateTime())->getTimestamp() * 1000 + ]); + + $jobId = (int) $conn->lastInsertId(); + if (! empty($config)) { + $config['rescan'] = 'n'; + $config['full_scan'] = 'n'; + $config['since_last_scan'] = Job::DEFAULT_SINCE_LAST_SCAN; + + $conn->insert('x509_schedule', [ + 'job_id' => $jobId, + 'name' => $data->name . ' Schedule', + 'author' => $user->getUsername(), + 'config' => Json::encode($config), + 'ctime' => (new DateTime())->getTimestamp() * 1000, + 'mtime' => (new DateTime())->getTimestamp() * 1000, + ]); + } + } + }); + } +} diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php new file mode 100644 index 0000000..3743adc --- /dev/null +++ b/application/clicommands/ScanCommand.php @@ -0,0 +1,163 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Exception; +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Common\JobUtils; +use Icinga\Module\X509\Hook\SniHook; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Model\X509Job; +use ipl\Stdlib\Filter; +use React\EventLoop\Loop; +use Throwable; + +class ScanCommand extends Command +{ + use JobUtils; + + /** + * Scan targets to find their X.509 certificates and track changes to them. + * + * A target is an IP-port combination that is generated from the job configuration, taking into account + * configured SNI maps, so that targets with multiple certificates are also properly scanned. + * + * By default, successive calls to the scan command perform partial scans, checking both targets not yet scanned + * and targets whose scan is older than 24 hours, to ensure that all targets are rescanned over time and new + * certificates are collected. This behavior can be customized through the command options. + * + * Note that when rescanning due targets, they will be rescanned regardless of whether the target previously + * provided a certificate or not, to collect new certificates, track changed certificates, and remove + * decommissioned certificates. + * + * USAGE + * + * icingacli x509 scan --job <name> [OPTIONS] + * + * OPTIONS + * + * --job=<name> + * Scan targets that belong to the specified job. + * + * --since-last-scan=<datetime> + * Scan targets whose last scan is older than the specified date/time, + * which can also be an English textual datetime description like "2 days". + * Defaults to "-24 hours". + * + * --parallel=<number> + * Allow parallel scanning of targets up to the specified number. Defaults to 256. + * May cause **too many open files** error if set to a number higher than the configured one (ulimit). + * + * --rescan + * Rescan only targets that have been scanned before. + * + * --full + * (Re)scan all known and unknown targets. + * This will override the "rescan" and "since-last-scan" options. + * + * EXAMPLES + * + * Scan all targets that have not yet been scanned, or whose last scan is older than a certain date/time: + * + * icingacli x509 scan --job <name> --since-last-scan="3 days" + * + * Scan only unknown targets + * + * icingacli x509 scan --job <name> --since-last-scan=null + * + * Scan only known targets + * + * icingacli x509 scan --job <name> --rescan + * + * Scan only known targets whose last scan is older than a certain date/time: + * + * icingacli x509 scan --job <name> --rescan --since-last-scan="5 days" + * + * Scan all known and unknown targets: + * + * icingacli x509 scan --job <name> --full + */ + public function indexAction(): void + { + /** @var string $name */ + $name = $this->params->shiftRequired('job'); + $fullScan = (bool) $this->params->get('full', false); + $rescan = (bool) $this->params->get('rescan', false); + + /** @var string $sinceLastScan */ + $sinceLastScan = $this->params->get('since-last-scan', Job::DEFAULT_SINCE_LAST_SCAN); + if ($sinceLastScan === 'null') { + $sinceLastScan = null; + } + + /** @var int $parallel */ + $parallel = $this->params->get('parallel', Job::DEFAULT_PARALLEL); + if ($parallel <= 0) { + throw new Exception('The \'parallel\' option must be set to at least 1'); + } + + /** @var X509Job $jobConfig */ + $jobConfig = X509Job::on(Database::get()) + ->filter(Filter::equal('name', $name)) + ->first(); + if ($jobConfig === null) { + throw new Exception(sprintf('Job %s not found', $name)); + } + + if (! strlen($jobConfig->cidrs)) { + throw new Exception(sprintf('The job %s does not specify any CIDRs', $name)); + } + + $cidrs = $this->parseCIDRs($jobConfig->cidrs); + $ports = $this->parsePorts($jobConfig->ports); + $job = (new Job($name, $cidrs, $ports, SniHook::getAll())) + ->setId($jobConfig->id) + ->setFullScan($fullScan) + ->setRescan($rescan) + ->setParallel($parallel) + ->setExcludes($this->parseExcludes($jobConfig->exclude_targets)) + ->setLastScan($sinceLastScan); + + $promise = $job->run(); + $signalHandler = function () use (&$promise, $job) { + $promise->cancel(); + + Logger::info('Job %s canceled', $job->getName()); + + Loop::futureTick(function () { + Loop::stop(); + }); + }; + Loop::addSignal(SIGINT, $signalHandler); + Loop::addSignal(SIGTERM, $signalHandler); + + $promise->then(function ($targets = 0) use ($job) { + if ($targets === 0) { + Logger::warning('The job %s does not have any targets', $job->getName()); + } else { + Logger::info('Scanned %d target(s) from job %s', $targets, $job->getName()); + + try { + $verified = CertificateUtils::verifyCertificates(Database::get()); + + Logger::info('Checked %d certificate chain(s)', $verified); + } catch (Exception $err) { + Logger::error($err->getMessage()); + Logger::debug($err->getTraceAsString()); + } + } + }, function (Throwable $err) use ($job) { + Logger::error('Failed to run job %s: %s', $job->getName(), $err->getMessage()); + Logger::debug($err->getTraceAsString()); + })->always(function () { + Loop::futureTick(function () { + Loop::stop(); + }); + }); + } +} diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php new file mode 100644 index 0000000..15976fc --- /dev/null +++ b/application/clicommands/VerifyCommand.php @@ -0,0 +1,27 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Command; +use Icinga\Module\X509\Common\Database; + +class VerifyCommand extends Command +{ + /** + * Verify all currently collected X.509 certificates + * + * USAGE: + * + * icingacli x509 verify + */ + public function indexAction() + { + $verified = CertificateUtils::verifyCertificates(Database::get()); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } +} diff --git a/application/controllers/CertificateController.php b/application/controllers/CertificateController.php new file mode 100644 index 0000000..016b312 --- /dev/null +++ b/application/controllers/CertificateController.php @@ -0,0 +1,43 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\CertificateDetails; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Model\X509Certificate; +use ipl\Stdlib\Filter; + +class CertificateController extends Controller +{ + public function indexAction() + { + $this->addTitleTab($this->translate('X.509 Certificate')); + $this->getTabs()->disableLegacyExtensions(); + + $certId = $this->params->getRequired('cert'); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + + return; + } + + /** @var ?X509Certificate $cert */ + $cert = X509Certificate::on($conn) + ->filter(Filter::equal('id', $certId)) + ->first(); + + if (! $cert) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->view->certificateDetails = (new CertificateDetails()) + ->setCert($cert); + } +} diff --git a/application/controllers/CertificatesController.php b/application/controllers/CertificatesController.php new file mode 100644 index 0000000..37434fa --- /dev/null +++ b/application/controllers/CertificatesController.php @@ -0,0 +1,117 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\CertificatesTable; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions; +use ipl\Orm\Query; +use ipl\Web\Control\LimitControl; +use ipl\Web\Control\SortControl; + +class CertificatesController extends Controller +{ + public function indexAction() + { + $this->addTitleTab($this->translate('Certificates')); + $this->getTabs()->enableDataExports(); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + + return; + } + + $certificates = X509Certificate::on($conn); + + $sortColumns = [ + 'subject' => $this->translate('Certificate'), + 'issuer' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'duration' => $this->translate('Duration') + ]; + + $limitControl = $this->createLimitControl(); + $paginator = $this->createPaginationControl($certificates); + $sortControl = $this->createSortControl($certificates, $sortColumns); + + $searchBar = $this->createSearchBar($certificates, [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ]); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $certificates->peekAhead($this->view->compact); + + $certificates->filter($filter); + + $this->addControl($paginator); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + + $this->handleFormatRequest($certificates, function (Query $certificates) { + /** @var X509Certificate $cert */ + foreach ($certificates as $cert) { + $cert->valid_from = $cert->valid_from->format('l F jS, Y H:i:s e'); + $cert->valid_to = $cert->valid_to->format('l F jS, Y H:i:s e'); + + yield array_intersect_key(iterator_to_array($cert), array_flip($cert->getExportableColumns())); + } + }); + + $this->addContent((new CertificatesTable())->setData($certificates)); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); // Updates the browser search bar + } + } + + public function completeAction() + { + $this->getDocument()->add( + (new ObjectSuggestions()) + ->setModel(X509Certificate::class) + ->forRequest($this->getServerRequest()) + ); + } + + public function searchEditorAction() + { + $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM + ]); + + $this->getDocument()->add($editor); + $this->setTitle(t('Adjust Filter')); + } +} diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php new file mode 100644 index 0000000..5408526 --- /dev/null +++ b/application/controllers/ChainController.php @@ -0,0 +1,77 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\ChainDetails; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509CertificateChain; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Stdlib\Filter; + +class ChainController extends Controller +{ + public function indexAction() + { + $this->addTitleTab($this->translate('X.509 Certificate Chain')); + $this->getTabs()->disableLegacyExtensions(); + + $id = $this->params->getRequired('id'); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + /** @var ?X509CertificateChain $chain */ + $chain = X509CertificateChain::on($conn) + ->with(['target']) + ->filter(Filter::equal('id', $id)) + ->first(); + + if (! $chain) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $chainInfo = Html::tag('div'); + $chainInfo->add(Html::tag('dl', [ + Html::tag('dt', $this->translate('Host')), + Html::tag('dd', $chain->target->hostname), + Html::tag('dt', $this->translate('IP')), + Html::tag('dd', $chain->target->ip), + Html::tag('dt', $this->translate('Port')), + Html::tag('dd', $chain->target->port) + ])); + + $valid = Html::tag('div', ['class' => 'cert-chain']); + + if ($chain['valid']) { + $valid->getAttributes()->add('class', '-valid'); + $valid->add(Html::tag('p', $this->translate('Certificate chain is valid.'))); + } else { + $valid->getAttributes()->add('class', '-invalid'); + $valid->add(Html::tag('p', sprintf( + $this->translate('Certificate chain is invalid: %s.'), + $chain['invalid_reason'] + ))); + } + + $certs = X509Certificate::on($conn)->with(['chain']); + $certs + ->filter(Filter::equal('chain.id', $id)) + ->getSelectBase() + ->orderBy('certificate_link.order'); + + $this->view->chain = (new HtmlDocument()) + ->add($chainInfo) + ->add($valid) + ->add((new ChainDetails())->setData($certs)); + } +} diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php new file mode 100644 index 0000000..b4300ef --- /dev/null +++ b/application/controllers/ConfigController.php @@ -0,0 +1,30 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Application\Config; +use Icinga\Module\X509\Forms\Config\BackendConfigForm; +use Icinga\Web\Controller; + +class ConfigController extends Controller +{ + public function init() + { + $this->assertPermission('config/modules'); + + parent::init(); + } + + public function backendAction() + { + $form = (new BackendConfigForm()) + ->setIniConfig(Config::module('x509')); + + $form->handleRequest(); + + $this->view->tabs = $this->Module()->getConfigTabs()->activate('backend'); + $this->view->form = $form; + } +} diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php new file mode 100644 index 0000000..8b43761 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,153 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\CertificateUtils; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Donut; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Web\Url; +use ipl\Html\Html; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; + +class DashboardController extends Controller +{ + public function indexAction() + { + $this->addTitleTab($this->translate('Certificate Dashboard')); + $this->getTabs()->disableLegacyExtensions(); + + try { + $db = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $byCa = X509Certificate::on($db) + ->columns([ + 'issuer_certificate.subject', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->orderBy('issuer_certificate.subject') + ->filter(Filter::equal('issuer_certificate.ca', true)) + ->limit(5); + + $byCa + ->getSelectBase() + ->groupBy('certificate_issuer_certificate.id'); + + $this->view->byCa = (new Donut()) + ->setHeading($this->translate('Certificates by CA'), 2) + ->setData($byCa) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath('x509/certificates', [ + 'issuer' => $data->issuer_certificate->subject + ])->getAbsoluteUrl() + ], + $data->issuer_certificate->subject + ); + }); + + $duration = X509Certificate::on($db) + ->columns([ + 'duration', + 'cnt' => new Expression('COUNT(*)') + ]) + ->filter(Filter::equal('ca', false)) + ->orderBy('cnt', SORT_DESC) + ->limit(5); + + $duration + ->getSelectBase() + ->groupBy('duration'); + + $this->view->duration = (new Donut()) + ->setHeading($this->translate('Certificates by Duration'), 2) + ->setData($duration) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + "x509/certificates?duration={$data->duration->getTimestamp()}&ca=n" + )->getAbsoluteUrl() + ], + CertificateUtils::duration($data->duration->getTimestamp()) + ); + }); + + $keyStrength = X509Certificate::on($db) + ->columns([ + 'pubkey_algo', + 'pubkey_bits', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->limit(5); + + $keyStrength + ->getSelectBase() + ->groupBy(['pubkey_algo', 'pubkey_bits']); + + $this->view->keyStrength = (new Donut()) + ->setHeading($this->translate('Key Strength'), 2) + ->setData($keyStrength) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'pubkey_algo' => $data->pubkey_algo, + 'pubkey_bits' => $data->pubkey_bits + ] + )->getAbsoluteUrl() + ], + "{$data->pubkey_algo} {$data->pubkey_bits} bits" + ); + }); + + $sigAlgos = X509Certificate::on($db) + ->columns([ + 'signature_algo', + 'signature_hash_algo', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->limit(5); + + $sigAlgos + ->getSelectBase() + ->groupBy(['signature_algo', 'signature_hash_algo']); + + $this->view->sigAlgos = (new Donut()) + ->setHeading($this->translate('Signature Algorithms'), 2) + ->setData($sigAlgos) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'signature_hash_algo' => $data->signature_hash_algo, + 'signature_algo' => $data->signature_algo + ] + )->getAbsoluteUrl() + ], + "{$data->signature_hash_algo} with {$data->signature_algo}" + ); + }); + } +} diff --git a/application/controllers/JobController.php b/application/controllers/JobController.php new file mode 100644 index 0000000..7655a74 --- /dev/null +++ b/application/controllers/JobController.php @@ -0,0 +1,226 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Common\Links; +use Icinga\Module\X509\Forms\Jobs\JobConfigForm; +use Icinga\Module\X509\Model\X509Job; +use Icinga\Module\X509\Model\X509Schedule; +use Icinga\Module\X509\Forms\Jobs\ScheduleForm; +use Icinga\Module\X509\Widget\JobDetails; +use Icinga\Module\X509\Widget\Schedules; +use Icinga\Util\Json; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\ValidHtml; +use ipl\Scheduler\Contract\Frequency; +use ipl\Stdlib\Filter; +use ipl\Web\Compat\CompatController; +use ipl\Web\Url; +use ipl\Web\Widget\ActionBar; +use ipl\Web\Widget\ActionLink; +use ipl\Web\Widget\ButtonLink; +use stdClass; + +class JobController extends CompatController +{ + /** @var X509Job */ + protected $job; + + public function init() + { + parent::init(); + + $this->getTabs()->disableLegacyExtensions(); + + /** @var int $jobId */ + $jobId = $this->params->getRequired('id'); + + /** @var X509Job $job */ + $job = X509Job::on(Database::get()) + ->filter(Filter::equal('id', $jobId)) + ->first(); + + if ($job === null) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->job = $job; + } + + public function indexAction(): void + { + $this->assertPermission('config/x509'); + + $this->initTabs(); + $this->getTabs()->activate('job-activities'); + + $jobRuns = $this->job->job_run->with(['job', 'schedule']); + + $limitControl = $this->createLimitControl(); + $sortControl = $this->createSortControl($jobRuns, [ + 'schedule.name' => $this->translate('Schedule Name'), + 'schedule.author' => $this->translate('Author'), + 'total_targets' => $this->translate('Total Targets'), + 'finished_targets' => $this->translate('Finished Targets'), + 'start_time desc' => $this->translate('Started At'), + 'end_time' => $this->translate('Ended At') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($this->createActionBar()); + + $this->addContent(new JobDetails($jobRuns)); + } + + public function updateAction(): void + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('Update Job')); + + $form = (new JobConfigForm($this->job)) + ->setAction((string) Url::fromRequest()) + ->populate([ + 'name' => $this->job->name, + 'cidrs' => $this->job->cidrs, + 'ports' => $this->job->ports, + 'exclude_targets' => $this->job->exclude_targets + ]) + ->on(JobConfigForm::ON_SUCCESS, function (JobConfigForm $form) { + /** @var FormSubmitElement $button */ + $button = $form->getPressedSubmitElement(); + if ($button->getName() === 'btn_remove') { + $this->switchToSingleColumnLayout(); + } else { + $this->closeModalAndRefreshRelatedView(Links::job($this->job)); + } + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + } + + public function schedulesAction(): void + { + $this->assertPermission('config/x509'); + + $this->initTabs(); + $this->getTabs()->activate('schedules'); + + $schedules = $this->job->schedule->with(['job']); + + $sortControl = $this->createSortControl($schedules, [ + 'name' => $this->translate('Name'), + 'author' => $this->translate('Author'), + 'ctime' => $this->translate('Date Created'), + 'mtime' => $this->translate('Date Modified') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->addControl( + (new ButtonLink($this->translate('New Schedule'), Links::scheduleJob($this->job), 'plus')) + ->openInModal() + ); + $this->addControl($sortControl); + + $this->addContent(new Schedules($schedules)); + } + + public function scheduleAction(): void + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('Schedule Job')); + + $form = (new ScheduleForm()) + ->setAction((string) Url::fromRequest()) + ->setJobId($this->job->id) + ->on(JobConfigForm::ON_SUCCESS, function () { + $this->redirectNow(Links::schedules($this->job)); + }) + ->handleRequest($this->getServerRequest()); + + $parts = $form->getPartUpdates(); + if (! empty($parts)) { + $this->sendMultipartUpdate(...$parts); + } + + $this->addContent($form); + } + + public function updateScheduleAction(): void + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('Update Schedule')); + + /** @var int $id */ + $id = $this->params->getRequired('scheduleId'); + /** @var X509Schedule $schedule */ + $schedule = X509Schedule::on(Database::get()) + ->filter(Filter::equal('id', $id)) + ->first(); + if ($schedule === null) { + $this->httpNotFound($this->translate('Schedule not found')); + } + + /** @var stdClass $config */ + $config = Json::decode($schedule->config); + /** @var Frequency $type */ + $type = $config->type; + $frequency = $type::fromJson($config->frequency); + + $form = (new ScheduleForm($schedule)) + ->setAction((string) Url::fromRequest()) + ->populate([ + 'name' => $schedule->name, + 'full_scan' => $config->full_scan ?? 'n', + 'rescan' => $config->rescan ?? 'n', + 'since_last_scan' => $config->since_last_scan ?? null, + 'schedule_element' => $frequency + ]) + ->on(JobConfigForm::ON_SUCCESS, function () { + $this->redirectNow('__BACK__'); + }) + ->handleRequest($this->getServerRequest()); + + $parts = $form->getPartUpdates(); + if (! empty($parts)) { + $this->sendMultipartUpdate(...$parts); + } + + $this->addContent($form); + } + + protected function createActionBar(): ValidHtml + { + $actions = new ActionBar(); + $actions->addHtml( + (new ActionLink($this->translate('Modify'), Links::updateJob($this->job), 'edit')) + ->openInModal(), + (new ActionLink($this->translate('Schedule'), Links::scheduleJob($this->job), 'calendar')) + ->openInModal() + ); + + return $actions; + } + + protected function initTabs(): void + { + $tabs = $this->getTabs(); + $tabs + ->add('job-activities', [ + 'label' => $this->translate('Job Activities'), + 'url' => Links::job($this->job) + ]) + ->add('schedules', [ + 'label' => $this->translate('Schedules'), + 'url' => Links::schedules($this->job) + ]); + } +} diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php new file mode 100644 index 0000000..48deede --- /dev/null +++ b/application/controllers/JobsController.php @@ -0,0 +1,66 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Forms\Jobs\JobConfigForm; +use Icinga\Module\X509\Model\X509Job; +use Icinga\Module\X509\Widget\Jobs; +use ipl\Web\Compat\CompatController; +use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; + +class JobsController extends CompatController +{ + /** + * List all jobs + */ + public function indexAction() + { + $this->addTitleTab($this->translate('Jobs')); + $this->getTabs()->add('sni', [ + 'title' => $this->translate('Configure SNI'), + 'label' => $this->translate('SNI'), + 'url' => 'x509/sni', + 'baseTarget' => '_main' + ]); + + $jobs = X509Job::on(Database::get()); + if ($this->hasPermission('config/x509')) { + $this->addControl( + (new ButtonLink($this->translate('New Job'), Url::fromPath('x509/jobs/new'), 'plus')) + ->openInModal() + ); + } + + $sortControl = $this->createSortControl($jobs, [ + 'name' => $this->translate('Name'), + 'author' => $this->translate('Author'), + 'ctime' => $this->translate('Date Created'), + 'mtime' => $this->translate('Date Modified') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->addControl($sortControl); + + $this->addContent(new Jobs($jobs)); + } + + public function newAction() + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('New Job')); + + $form = (new JobConfigForm()) + ->setAction((string) Url::fromRequest()) + ->on(JobConfigForm::ON_SUCCESS, function () { + $this->closeModalAndRefreshRelatedView(Url::fromPath('x509/jobs')); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + } +} diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php new file mode 100644 index 0000000..cde4807 --- /dev/null +++ b/application/controllers/SniController.php @@ -0,0 +1,103 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\X509\Forms\Config\SniConfigForm; +use Icinga\Module\X509\SniIniRepository; +use ipl\Html\HtmlString; +use ipl\Web\Compat\CompatController; +use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; + +class SniController extends CompatController +{ + /** + * List all maps + */ + public function indexAction() + { + $this->getTabs()->add('jobs', [ + 'title' => $this->translate('Configure Jobs'), + 'label' => $this->translate('Jobs'), + 'url' => 'x509/jobs', + 'baseTarget' => '_main' + + ]); + $this->addTitleTab($this->translate('SNI')); + + $this->addControl( + (new ButtonLink($this->translate('New SNI Map'), Url::fromPath('x509/sni/new'), 'plus')) + ->openInModal() + ); + $this->controls->getAttributes()->add('class', 'default-layout'); + + $this->view->controls = $this->controls; + + $repo = new SniIniRepository(); + + $this->view->sni = $repo->select(array('ip')); + } + + /** + * Create a map + */ + public function newAction() + { + $this->addTitleTab($this->translate('New SNI Map')); + + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->addContent(new HtmlString($form->render())); + } + + /** + * Update a map + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Update SNI Map')); + } + + /** + * Remove a map + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Remove SNI Map')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return SniConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new SniConfigForm()) + ->setRepository(new SniIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/sni')); + } +} diff --git a/application/controllers/UsageController.php b/application/controllers/UsageController.php new file mode 100644 index 0000000..079d24a --- /dev/null +++ b/application/controllers/UsageController.php @@ -0,0 +1,141 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Controllers; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Controller; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\UsageTable; +use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions; +use ipl\Orm\Query; +use ipl\Sql\Expression; +use ipl\Web\Control\LimitControl; +use ipl\Web\Control\SortControl; + +class UsageController extends Controller +{ + public function indexAction() + { + $this->addTitleTab($this->translate('Certificate Usage')); + $this->getTabs()->enableDataExports(); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $targets = X509Certificate::on($conn) + ->with(['chain', 'chain.target']) + ->withColumns([ + 'chain.id', + 'chain.valid', + 'chain.target.ip', + 'chain.target.port', + 'chain.target.hostname', + ]); + + $targets + ->getSelectBase() + ->where(new Expression('certificate_link.order = 0')); + + $sortColumns = [ + 'chain.target.hostname' => $this->translate('Hostname'), + 'chain.target.ip' => $this->translate('IP'), + 'chain.target.port' => $this->translate('Port'), + 'subject' => $this->translate('Certificate'), + 'issuer' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'chain.valid' => $this->translate('Chain Is Valid'), + 'duration' => $this->translate('Duration') + ]; + + $limitControl = $this->createLimitControl(); + $paginator = $this->createPaginationControl($targets); + $sortControl = $this->createSortControl($targets, $sortColumns); + + $searchBar = $this->createSearchBar($targets, [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ]); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $targets->peekAhead($this->view->compact); + + $targets->filter($filter); + + $this->addControl($paginator); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + + $this->handleFormatRequest($targets, function (Query $targets) { + /** @var X509Certificate $usage */ + foreach ($targets as $usage) { + $usage->valid_from = $usage->valid_from->format('l F jS, Y H:i:s e'); + $usage->valid_to = $usage->valid_to->format('l F jS, Y H:i:s e'); + + $usage->ip = $usage->chain->target->ip; + $usage->hostname = $usage->chain->target->hostname; + $usage->port = $usage->chain->target->port; + $usage->valid = $usage->chain->valid; + + yield array_intersect_key( + iterator_to_array($usage), + array_flip(array_merge(['valid', 'hostname', 'ip', 'port'], $usage->getExportableColumns())) + ); + } + }); + + $this->addContent((new UsageTable())->setData($targets)); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); // Updates the browser search bar + } + } + + public function completeAction() + { + $this->getDocument()->add( + (new ObjectSuggestions()) + ->setModel(X509Certificate::class) + ->forRequest($this->getServerRequest()) + ); + } + + public function searchEditorAction() + { + $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM + ]); + + $this->getDocument()->add($editor); + $this->setTitle(t('Adjust Filter')); + } +} diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php new file mode 100644 index 0000000..e806d26 --- /dev/null +++ b/application/forms/Config/BackendConfigForm.php @@ -0,0 +1,29 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Forms\Config; + +use Icinga\Data\ResourceFactory; +use Icinga\Forms\ConfigForm; + +class BackendConfigForm extends ConfigForm +{ + public function init() + { + $this->setName('x509_backend'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + public function createElements(array $formData) + { + $dbResources = ResourceFactory::getResourceConfigs('db')->keys(); + + $this->addElement('select', 'backend_resource', [ + 'label' => $this->translate('Database'), + 'description' => $this->translate('Database resource'), + 'multiOptions' => array_combine($dbResources, $dbResources), + 'required' => true + ]); + } +} diff --git a/application/forms/Config/SniConfigForm.php b/application/forms/Config/SniConfigForm.php new file mode 100644 index 0000000..27a4823 --- /dev/null +++ b/application/forms/Config/SniConfigForm.php @@ -0,0 +1,79 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Forms\Config; + +use Icinga\Data\Filter\Filter; +use Icinga\Forms\RepositoryForm; + +/** + * Create, update and delete jobs + */ +class SniConfigForm extends RepositoryForm +{ + protected function createInsertElements(array $formData) + { + $this->addElements([ + [ + 'text', + 'ip', + [ + 'description' => $this->translate('IP'), + 'label' => $this->translate('IP'), + 'required' => true + ] + ], + [ + 'textarea', + 'hostnames', + [ + 'description' => $this->translate('Comma-separated list of hostnames'), + 'label' => $this->translate('Hostnames'), + 'required' => true + ] + ] + ]); + + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit map for %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove map for %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('ip', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Map created') + : $this->translate('Failed to create map'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Map updated') + : $this->translate('Failed to update map'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Map removed') + : $this->translate('Failed to remove map'); + } +} diff --git a/application/forms/Jobs/JobConfigForm.php b/application/forms/Jobs/JobConfigForm.php new file mode 100644 index 0000000..539bc58 --- /dev/null +++ b/application/forms/Jobs/JobConfigForm.php @@ -0,0 +1,154 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Forms\Jobs; + +use DateTime; +use Exception; +use Icinga\Authentication\Auth; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Model\X509Job; +use Icinga\User; +use Icinga\Web\Notification; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\HtmlDocument; +use ipl\Stdlib\Str; +use ipl\Validator\CallbackValidator; +use ipl\Validator\CidrValidator; +use ipl\Web\Compat\CompatForm; + +/** + * Create, update and delete jobs + */ +class JobConfigForm extends CompatForm +{ + /** @var ?X509Job */ + protected $job; + + public function __construct(X509Job $job = null) + { + $this->job = $job; + } + + protected function isUpdating(): bool + { + return $this->job !== null; + } + + public function hasBeenSubmitted(): bool + { + if (! $this->hasBeenSent()) { + return false; + } + + $button = $this->getPressedSubmitElement(); + + return $button && ($button->getName() === 'btn_submit' || $button->getName() === 'btn_remove'); + } + + protected function assemble(): void + { + $this->addElement('text', 'name', [ + 'required' => true, + 'label' => $this->translate('Name'), + 'description' => $this->translate('Job name'), + ]); + + $this->addElement('textarea', 'cidrs', [ + 'required' => true, + 'label' => $this->translate('CIDRs'), + 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator): bool { + $cidrValidator = new CidrValidator(); + $cidrs = Str::trimSplit($value); + + foreach ($cidrs as $cidr) { + if (! $cidrValidator->isValid($cidr)) { + $validator->addMessage(...$cidrValidator->getMessages()); + + return false; + } + } + + return true; + }) + ] + ]); + + $this->addElement('textarea', 'ports', [ + 'required' => true, + 'label' => $this->translate('Ports'), + 'description' => $this->translate('Comma-separated list of ports to scan'), + ]); + + $this->addElement('textarea', 'exclude_targets', [ + 'required' => false, + 'label' => $this->translate('Exclude Targets'), + 'description' => $this->translate('Comma-separated list of addresses/hostnames to exclude'), + ]); + + $this->addElement('submit', 'btn_submit', [ + 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Create') + ]); + + if ($this->isUpdating()) { + $removeButton = $this->createElement('submit', 'btn_remove', [ + 'class' => 'btn-remove', + 'label' => $this->translate('Remove Job'), + ]); + $this->registerElement($removeButton); + + /** @var HtmlDocument $wrapper */ + $wrapper = $this->getElement('btn_submit')->getWrapper(); + $wrapper->prepend($removeButton); + } + } + + protected function onSuccess(): void + { + $conn = Database::get(); + /** @var FormSubmitElement $submitElement */ + $submitElement = $this->getPressedSubmitElement(); + if ($submitElement->getName() === 'btn_remove') { + try { + /** @var X509Job $job */ + $job = $this->job; + $conn->delete('x509_job', ['id = ?' => $job->id]); + + Notification::success($this->translate('Removed job successfully')); + } catch (Exception $err) { + Notification::error($this->translate('Failed to remove job') . ': ' . $err->getMessage()); + } + } else { + $values = $this->getValues(); + + try { + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + if ($this->job === null) { + $values['author'] = $user->getUsername(); + $values['ctime'] = (new DateTime())->getTimestamp() * 1000.0; + $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0; + + $conn->insert('x509_job', $values); + $message = $this->translate('Created job successfully'); + } else { + $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0; + + $conn->update('x509_job', $values, ['id = ?' => $this->job->id]); + $message = $this->translate('Updated job successfully'); + } + + Notification::success($message); + } catch (Exception $err) { + $message = $this->isUpdating() + ? $this->translate('Failed to update job') + : $this->translate('Failed to create job'); + + Notification::error($message . ': ' . $err->getMessage()); + } + } + } +} diff --git a/application/forms/Jobs/ScheduleForm.php b/application/forms/Jobs/ScheduleForm.php new file mode 100644 index 0000000..ae47e58 --- /dev/null +++ b/application/forms/Jobs/ScheduleForm.php @@ -0,0 +1,201 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Forms\Jobs; + +use DateTime; +use Exception; +use Icinga\Application\Icinga; +use Icinga\Application\Web; +use Icinga\Authentication\Auth; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Model\X509Schedule; +use Icinga\User; +use Icinga\Util\Json; +use Icinga\Web\Notification; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; +use ipl\Web\Compat\CompatForm; +use ipl\Web\FormElement\ScheduleElement; +use Psr\Http\Message\RequestInterface; + +use function ipl\Stdlib\get_php_type; + +class ScheduleForm extends CompatForm +{ + /** @var int */ + protected $jobId; + + /** @var ?X509Schedule */ + protected $schedule; + + /** @var ScheduleElement */ + protected $scheduleElement; + + public function __construct(X509Schedule $schedule = null) + { + $this->schedule = $schedule; + $this->scheduleElement = new ScheduleElement('schedule_element'); + + /** @var Web $app */ + $app = Icinga::app(); + $this->scheduleElement->setIdProtector([$app->getRequest(), 'protectId']); + } + + protected function isUpdating(): bool + { + return $this->schedule !== null; + } + + public function setJobId(int $jobId): self + { + $this->jobId = $jobId; + + return $this; + } + + /** + * Get multipart updates + * + * @return array<int, BaseHtmlElement> + */ + public function getPartUpdates(): array + { + /** @var RequestInterface $request */ + $request = $this->getRequest(); + + return $this->scheduleElement->prepareMultipartUpdate($request); + } + + public function hasBeenSubmitted(): bool + { + if (! $this->hasBeenSent()) { + return false; + } + + $button = $this->getPressedSubmitElement(); + + return $button && ($button->getName() === 'submit' || $button->getName() === 'btn_remove'); + } + + protected function assemble(): void + { + $this->addElement('text', 'name', [ + 'required' => true, + 'label' => $this->translate('Name'), + 'description' => $this->translate('Schedule name'), + ]); + + $this->addElement('checkbox', 'full_scan', [ + 'required' => false, + 'class' => 'autosubmit', + 'label' => $this->translate('Full Scan'), + 'description' => $this->translate( + 'Scan all known and unknown targets of this job. (Defaults to only scan unknown targets)' + ) + ]); + + if ($this->getPopulatedValue('full_scan', 'n') === 'n') { + $this->addElement('checkbox', 'rescan', [ + 'required' => false, + 'class' => 'autosubmit', + 'label' => $this->translate('Rescan'), + 'description' => $this->translate('Rescan only targets that have been scanned before') + ]); + + $this->addElement('text', 'since_last_scan', [ + 'required' => false, + 'label' => $this->translate('Since Last Scan'), + 'placeholder' => '-24 hours', + 'description' => $this->translate( + 'Scan targets whose last scan is older than the specified date/time, which can also be an' + . ' English textual datetime description like "2 days". If you want to scan only unknown targets' + . ' you can set this to "null".' + ), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if ($value !== null && $value !== 'null') { + try { + new DateTime($value); + } catch (Exception $_) { + $validator->addMessage($this->translate('Invalid textual date time')); + + return false; + } + } + + return true; + }) + ] + ]); + } + + $this->addHtml(HtmlElement::create('div', ['class' => 'schedule-element-separator'])); + $this->addElement($this->scheduleElement); + + $this->addElement('submit', 'submit', [ + 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Schedule') + ]); + + if ($this->isUpdating()) { + $removeButton = $this->createElement('submit', 'btn_remove', [ + 'class' => 'btn-remove', + 'label' => $this->translate('Remove Schedule'), + ]); + $this->registerElement($removeButton); + + /** @var HtmlDocument $wrapper */ + $wrapper = $this->getElement('submit')->getWrapper(); + $wrapper->prepend($removeButton); + } + } + + protected function onSuccess(): void + { + /** @var X509Schedule $schedule */ + $schedule = $this->schedule; + $conn = Database::get(); + /** @var FormSubmitElement $submitElement */ + $submitElement = $this->getPressedSubmitElement(); + if ($submitElement->getName() === 'btn_remove') { + $conn->delete('x509_schedule', ['id = ?' => $schedule->id]); + + Notification::success($this->translate('Deleted schedule successfully')); + } else { + $config = $this->getValues(); + unset($config['name']); + unset($config['schedule_element']); + + $frequency = $this->scheduleElement->getValue(); + $config['type'] = get_php_type($frequency); + $config['frequency'] = Json::encode($frequency); + + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + if (! $this->isUpdating()) { + $conn->insert('x509_schedule', [ + 'job_id' => $this->schedule ? $this->schedule->job_id : $this->jobId, + 'name' => $this->getValue('name'), + 'author' => $user->getUsername(), + 'config' => Json::encode($config), + 'ctime' => (new DateTime())->getTimestamp() * 1000.0, + 'mtime' => (new DateTime())->getTimestamp() * 1000.0 + ]); + $message = $this->translate('Created schedule successfully'); + } else { + $conn->update('x509_schedule', [ + 'name' => $this->getValue('name'), + 'config' => Json::encode($config), + 'mtime' => (new DateTime())->getTimestamp() * 1000.0 + ], ['id = ?' => $schedule->id]); + $message = $this->translate('Updated schedule successfully'); + } + + Notification::success($message); + } + } +} diff --git a/application/views/scripts/certificate/index.phtml b/application/views/scripts/certificate/index.phtml new file mode 100644 index 0000000..08cfadb --- /dev/null +++ b/application/views/scripts/certificate/index.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <?= /** @var \Icinga\Module\X509\CertificateDetails $certificateDetails */ $certificateDetails->render() ?> +</div> diff --git a/application/views/scripts/chain/index.phtml b/application/views/scripts/chain/index.phtml new file mode 100644 index 0000000..ffa3872 --- /dev/null +++ b/application/views/scripts/chain/index.phtml @@ -0,0 +1,8 @@ +<?php if (! $this->compact): ?> +<div class="controls"> + <?= $this->tabs ?> +</div> +<?php endif ?> +<div class="content"> + <?= /** @var \ipl\Html\ValidHtml $chain */ $chain->render() ?> +</div> diff --git a/application/views/scripts/config/backend.phtml b/application/views/scripts/config/backend.phtml new file mode 100644 index 0000000..78e312e --- /dev/null +++ b/application/views/scripts/config/backend.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?> +</div> +<div class="content"> + <?= /** @var \Icinga\Module\X509\Forms\Config\BackendConfigForm $form */ $form ?> +</div> diff --git a/application/views/scripts/dashboard/index.phtml b/application/views/scripts/dashboard/index.phtml new file mode 100644 index 0000000..3b6ec0f --- /dev/null +++ b/application/views/scripts/dashboard/index.phtml @@ -0,0 +1,13 @@ +<?php if (! $this->compact): ?> + <div class="controls"> + <?= $this->tabs ?> + </div> +<?php endif ?> +<div class="content"> + <div class="cert-dashboard"> + <?= $byCa->render() ?> + <?= $duration->render() ?> + <?= $keyStrength->render() ?> + <?= $sigAlgos->render() ?> + </div> +</div> diff --git a/application/views/scripts/missing-resource.phtml b/application/views/scripts/missing-resource.phtml new file mode 100644 index 0000000..fcfa255 --- /dev/null +++ b/application/views/scripts/missing-resource.phtml @@ -0,0 +1,12 @@ +<div class="controls"> + <?= $this->tabs ?> +</div> +<div class="content"> + <h2><?= $this->translate('Database not configured') ?></h2> + <p data-base-target="_next"><?= sprintf( + $this->translate('You seem to not have configured a database resource yet. Please create one %1$shere%3$s and then set it in this %2$smodule\'s configuration%3$s.'), + '<a class="action-link" href="' . $this->href('config/resource') . '">', + '<a class="action-link" href="' . $this->href('x509/config/backend') . '">', + '</a>' + ) ?></p> +</div> diff --git a/application/views/scripts/simple-form.phtml b/application/views/scripts/simple-form.phtml new file mode 100644 index 0000000..9bcba74 --- /dev/null +++ b/application/views/scripts/simple-form.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <?= $tabs ?> +</div> +<div class="content"> + <?= $form->create()->setTitle(null) // @TODO(el): create() has to be called because the UserForm is setting the title there ?> +</div> diff --git a/application/views/scripts/sni/index.phtml b/application/views/scripts/sni/index.phtml new file mode 100644 index 0000000..2be5280 --- /dev/null +++ b/application/views/scripts/sni/index.phtml @@ -0,0 +1,31 @@ +<?= $this->controls->render() ?> +<div class="content"> + <?php /** @var \Icinga\Repository\RepositoryQuery $sni */ if (! $sni->hasResult()): ?> + <p><?= $this->escape($this->translate('No SNI maps configured yet.')) ?></p> + <?php else: ?> + <table class="common-table table-row-selectable" data-base-target="_next"> + <thead> + <tr> + <th><?= $this->escape($this->translate('IP')) ?></th> + </tr> + </thead> + <tbody> + <?php foreach ($sni as $data): ?> + <tr> + <td><?= $this->qlink($data->ip, 'x509/sni/update', ['ip' => $data->ip]) ?></td> + <td class="icon-col"><?= $this->qlink( + null, + 'x509/sni/remove', + array('ip' => $data->ip), + array( + 'class' => 'action-link', + 'icon' => 'cancel', + 'title' => $this->translate('Remove this SNI map') + ) + ) ?></td> + </tr> + <?php endforeach ?> + </tbody> + </table> + <?php endif ?> +</div> 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..c7adad4 --- /dev/null +++ b/configuration.php @@ -0,0 +1,33 @@ +<?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 +)); + +$section->add(N_('Configuration'), [ + 'url' => 'x509/jobs', + 'priority' => 100, + 'description' => $this->translate('Configure the scan jobs and SNI map') +]); + +$this->provideConfigTab('backend', array( + 'title' => $this->translate('Configure the database backend'), + 'label' => $this->translate('Backend'), + 'url' => 'config/backend' +)); 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..af2eaf3 --- /dev/null +++ b/doc/02-Installation.md @@ -0,0 +1,73 @@ +<!-- {% if index %} --> +# Installing Icinga Certificate Monitoring + +The recommended way to install Icinga Certificate Monitoring +and its dependencies is to use prebuilt packages for +all supported platforms from our official release repository. +Please note that [Icinga Web](https://icinga.com/docs/icinga-web) is required +and if it is not already set up, it is best to do this first. + +The following steps will guide you through installing and setting up Icinga Certificate Monitoring. +<!-- {% else %} --> +<!-- {% if not icingaDocs %} --> + +## Installing the Package + +If the [repository](https://packages.icinga.com) is not configured yet, please add it first. +Then use your distribution's package manager to install the `icinga-x509` package +or install [from source](02-Installation.md.d/From-Source.md). +<!-- {% endif %} --> + +## Setting up the Database + +### Setting up a MySQL or MariaDB Database + +The module needs a MySQL/MariaDB database with the schema that's provided in the `/usr/share/icingaweb2/modules/x509/schema/mysql.schema.sql` file. +<!-- {% if not icingaDocs %} --> + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + +<!-- {% endif %} --> + +You can use the following sample command for creating the MySQL/MariaDB database. Please change the password: + +``` +CREATE DATABASE x509; +GRANT CREATE, SELECT, INSERT, UPDATE, DELETE, DROP, ALTER, 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 < /usr/share/icingaweb2/modules/x509/schema/mysql.schema.sql +``` + +### Setting up a PostgreSQL Database + +The module needs a PostgreSQL database with the schema that's provided in the `/usr/share/icingaweb2/modules/x509/schema/pgsql.schema.sql` file. +<!-- {% if not icingaDocs %} --> + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + +<!-- {% endif %} --> + +You can use the following sample command for creating the PostgreSQL database. Please change the password: + +```sql +CREATE USER x509 WITH PASSWORD 'secret'; +CREATE DATABASE x509 + WITH OWNER x509 + ENCODING 'UTF8' + LC_COLLATE = 'en_US.UTF-8' + LC_CTYPE = 'en_US.UTF-8'; +``` + +After, you can import the schema using the following command: + +``` +psql -U x509 x509 -a -f /usr/share/icingaweb2/modules/x509/schema/pgsql.schema.sql +``` + +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. +<!-- {% endif %} --><!-- {# end else if index #} --> diff --git a/doc/02-Installation.md.d/From-Source.md b/doc/02-Installation.md.d/From-Source.md new file mode 100644 index 0000000..31f3d2b --- /dev/null +++ b/doc/02-Installation.md.d/From-Source.md @@ -0,0 +1,16 @@ +# Installing Icinga Certificate Monitoring from Source + +Please see the Icinga Web documentation on +[how to install modules](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation) from source. +Make sure you use `x509` as the module name. The following requirements must also be met. + +## Requirements + +* PHP (≥7.2) +* MySQL or PostgreSQL PDO PHP libraries +* The following PHP modules must be installed: `gmp`, `pcntl`, `openssl` +* [Icinga Web](https://github.com/Icinga/icingaweb2) (≥2.9) +* [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (≥0.13.0) +* [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.12.0) + +<!-- {% include "02-Installation.md" %} --> diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md new file mode 100644 index 0000000..4ecde77 --- /dev/null +++ b/doc/03-Configuration.md @@ -0,0 +1,77 @@ +# <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 +``` + +## Configure 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` + +Additionally, each job may also exclude specific **hosts** and **IP** addresses from scan. These hosts won't be scanned +when you run the [scan](04-Scanning.md#scan-command) or [jobs](04-Scanning.md#scheduling-jobs) command. Excluding an entire network and specifying IP addresses in CIDR +format will not work. You must specify concrete **IP**s and **host CN**s separated with commas, e.g: + +`192.0.2.2,192.0.2.5,icinga.com` + +### Job Schedules + +Schedules are [`cron`](https://crontab.guru) and rule based configs used to run jobs periodically at the given interval. +Every job is allowed to have multiple schedules that can be run independently of each other. Each job schedule provides +different options that you can use to control the scheduling behavior of the [jobs command](04-Scanning.md#scheduling-jobs). + +#### Examples + +A schedule that runs weekly on **Friday** and scans all targets that have not yet been scanned, or +whose last scan is older than `1 week`. + +![Weekly Schedules](res/weekly-schedules.png "Weekly Schedules") + +## 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. + +## Icinga Certificate Monitoring Daemon + +The default `systemd` service of this module, shipped with package installations, uses the [jobs command](04-Scanning.md#scheduling-jobs) +and runs all your configured jobs and schedules. + +<!-- {% if not icingaDocs %} --> + +> **Note** +> +> If you haven't installed this module from packages, you have to configure this as a `systemd` service yourself by just +> copying the example service definition from `/usr/share/icingaweb2/modules/x509/config/systemd/icinga-x509.service` +> to `/etc/systemd/system/icinga-x509.service`. +<!-- {% endif %} --> + +You can run the following command to enable and start the daemon. +``` +systemctl enable --now icinga-x509.service +``` diff --git a/doc/04-Scanning.md b/doc/04-Scanning.md new file mode 100644 index 0000000..608d18a --- /dev/null +++ b/doc/04-Scanning.md @@ -0,0 +1,85 @@ +# <a id="Scanning"></a>Scanning + +The Icinga Certificate Monitoring provides CLI commands to scan **hosts** and **IPs** in various ways. +These commands are listed below and can be used individually. It is necessary for all commands to know which IP address +ranges and ports to scan. These can be configured as described [here](03-Configuration.md#configure-jobs). + +## Scan Command + +The scan command, scans targets to find their X.509 certificates and track changes to them. +A **target** is an **IP-port** combination that is generated from the job configuration, taking into account configured +[**SNI**](03-Configuration.md#server-name-indication) maps, so that targets with multiple certificates are also properly +scanned. + +By default, successive calls to the scan command perform partial scans, checking both targets not yet scanned and +targets whose scan is older than 24 hours, to ensure that all targets are rescanned over time and new certificates +are collected. This behavior can be customized through the command [options](#usage-1). + +> **Note** +> +> When rescanning due targets, they will be rescanned regardless of whether the target previously provided a certificate +> or not, to collect new certificates, track changed certificates, and remove decommissioned certificates. + +### Usage + +This scan command can be used like any other Icinga Web cli operations like this: `icingacli x509 scan [OPTIONS]` + +**Options:** + +``` +--job=<name> Scan targets that belong to the specified job. (Required) +--since-last-scan=<time> Scan targets whose last scan is older than the spcified date/time, which can also be an + English textual datetime description like "2 days". Defaults to "-24 hours". +--rescan Rescn only targets that have been scanned before. +--full (Re)scan all known and unknown targets. This will override the "rescan" and "since-last-scan" options. +--parallel=<number> Allow parallel scanning of targets up to the specified number. Defaults to 256. + May cause **too many open files** error if set to a number higher than the configured one (ulimit). +``` + +#### Example + +Scan all targets that have not yet been scanned, or whose last scan is older than a certain date/time: +``` +# icingacli x509 scan --job <name> --since-last-scan '3 days' +``` + +Scan only **unknown** targets: +``` +# icingacli x509 scan --job <name> --since-last-scan=null +``` + +Scan only known targets: +``` +# icingacli x509 scan --job <name> --rescan +``` + +Scan only known targets whose last scan is older than certain a given date/time: +``` +# icingacli x509 scan --job <name> --rescan --since-last-scan '5 days' +``` + +Scan all known and unknown targets: +``` +# icingacli x509 scan --job <name> --full +``` + +## Scheduling Jobs + +The jobs command is similar to the [scan command](#scan-command), but it additionally allows you to schedule your jobs +in a more convenient way. This is used by the default `systemd` service of this module as well. By default, this command +will run all your configured jobs based on their frequency. This behaviour can be customized through the command options +too. Since you can have multiple schedules for a single job, all job schedules can also be scheduled individually. + +### Usage + +This scan command can be used like any other Icinga Web cli operations like this: `icingacli x509 jobs run [OPTIONS` + +**Options:** + +``` +--job=<name> Run all configured schedules only of the specified job. +--schedule=<name> Run only the given schedule of the specified job. + Providing a schedule name without a job will fail immediately. +--parallel=<number> Allow parallel scanning of targets up to the specified number. Defaults to 256. + May cause **too many open files** error if set to a number higher than the configured one (ulimit). +``` diff --git a/doc/10-Monitoring.md b/doc/10-Monitoring.md new file mode 100644 index 0000000..d729bb9 --- /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 (`y` or `n`) +cert_trusted | Whether the certificate is trusted (`y` or `n`) +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/11-Housekeeping.md b/doc/11-Housekeeping.md new file mode 100644 index 0000000..174a9ef --- /dev/null +++ b/doc/11-Housekeeping.md @@ -0,0 +1,38 @@ +# <a id="Datbase Housekeeping"></a>Database Housekeeping + +Your database may grow over time and contain some outdated information. Icinga Certificate Monitoring provides you +the ability to clean up these outdated info in an easy way. + +## Certificates and Targets + +The default `cleanup` action removes targets whose last scan is older than a certain date/time and certificates that +are no longer used. + +By default, any targets whose last scan is older than `1 month` are removed. The last scan information is always updated +when scanning a target, regardless of whether a successful connection is made or not. Therefore, targets that have been +decommissioned or are no longer part of a job configuration are removed after the specified period. Any certificates +that are no longer used are also removed. This can either be because the associated target has been removed or because +it is presenting a new certificate chain. + +The `cleanup` command will also remove additionally all jobs activities created before the given date/time. +Jobs activities are usually just some stats about the job runs performed by the scheduler or/and manually +executed using the [scan](04-Scanning.md#scan-command) and/or [jobs](04-Scanning.md#scheduling-jobs) command. + +### Usage + +This command can be used like any other Icinga Web cli operations like this: `icingacli x509 cleanup [OPTIONS]` + +**Options:** + +``` +--since-last-scan=<datetime> Clean up targets whose last scan is older than the specified date/time, + which can also be an English textual datetime description like "2 days". + Defaults to "1 month". +``` + +#### Example + +Remove any targets that have not been scanned for at least two months and any certificates that are no longer used. +``` +icingacli x509 cleanup --since-last-scan="2 months" +``` diff --git a/doc/80-Upgrading.md b/doc/80-Upgrading.md new file mode 100644 index 0000000..a66ddc4 --- /dev/null +++ b/doc/80-Upgrading.md @@ -0,0 +1,91 @@ +# 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.3.0 + +Icinga Certificate Monitoring version `1.3.0` requires a schema update for the database. We have dropped the use of **INI** +files to store jobs and are using the database instead. So you need to migrate your job configs to the database. + +If you're already using Icinga Web 2 version `>= 2.12`, then you don't need to import the sql upgrade scripts manually. +Icinga Web provides you the ability to perform such migrations in a simple way. You may be familiar with such an automation +if you're an Icinga Director user. + +> **Note** +> +> Please note that it doesn't matter if you import the database upgrade script manually or via the new automation, +> you will have to migrate your [Jobs config](#migrate-jobs) from INI to the database manually afterwards. + +Before migrating your jobs from **INI** to the database, you need to first apply the migration script. This will create +the tables needed to store the jobs and schedules in the database. + +You may use the following command to apply the database schema upgrade file: +<!-- {% if not icingaDocs %} --> + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + +<!-- {% endif %} --> +```sql +# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.3.0.sql +``` + +### Migrate Jobs + +Afterwards, you can safely migrate your jobs with the following command. Keep in mind that you need to specify an +Icinga Web username that will be used as the author of these jobs in the database. + +``` +# icingacli x509 migrate jobs --author "icingaadmin" +``` + +## Upgrading to version 1.2.0 + +Icinga Certificate Monitoring version 1.2.0 requires a schema update for the database. We have changed all `timestamp` +columns in the database to biguint to store all timestamps in milliseconds. The sort column `expires` has been dropped +as well, but you can sort the certificates by `valid_to` instead. + +You may use the following command to apply the database schema upgrade file: +<!-- {% if not icingaDocs %} --> + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + +<!-- {% endif %} --> +```sql +# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.2.0.sql +``` + +## 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 **/usr/share/icingaweb2/modules/x509/schema/mysql-upgrades**. + +You may use the following command to apply the database schema upgrade file: +<!-- {% if not icingaDocs %} --> + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + +<!-- {% endif %} --> + +``` +# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.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 **/user/share/icingaweb2/modules/x509/schema/mysql-upgrades**. + +You may use the following command to apply the database schema upgrade file: +<!-- {% if not icingaDocs %} --> + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + +<!-- {% endif %} --> + +``` +# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.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/weekly-schedules.png b/doc/res/weekly-schedules.png Binary files differnew file mode 100644 index 0000000..a7b9508 --- /dev/null +++ b/doc/res/weekly-schedules.png diff --git a/doc/res/x509-certificates.png b/doc/res/x509-certificates.png Binary files differnew file mode 100644 index 0000000..2a67029 --- /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..4d5eaf1 --- /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..cb80267 --- /dev/null +++ b/doc/res/x509-usage.png diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php new file mode 100644 index 0000000..f28e423 --- /dev/null +++ b/library/X509/CertificateDetails.php @@ -0,0 +1,120 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use DateTime; +use Icinga\Module\X509\Model\X509Certificate; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\IcingaIcon; + +/** + * Widget to display X.509 certificate details + */ +class CertificateDetails extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'cert-details']; + + /** + * @var X509Certificate + */ + protected $cert; + + public function setCert(X509Certificate $cert) + { + $this->cert = $cert; + + return $this; + } + + protected function assemble() + { + $pem = $this->cert->certificate; + $cert = openssl_x509_parse($pem); +// $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem)); + + $subject = Html::tag('dl'); + $sans = CertificateUtils::splitSANs($cert['extensions']['subjectAltName'] ?? null); + if (! isset($cert['subject']['CN']) && ! empty($sans)) { + foreach ($sans as $type => $values) { + foreach ($values as $value) { + $subject->addHtml(Html::tag('dt', $type), Html::tag('dd', $value)); + } + } + } else { + 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', $this->cert->valid_from->format('l F jS, Y H:i:s e')), + Html::tag('dt', mt('x509', 'Not Valid After')), + Html::tag('dd', $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', [new IcingaIcon('certificate'), $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..e524024 --- /dev/null +++ b/library/X509/CertificateUtils.php @@ -0,0 +1,538 @@ +<?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 Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509CertificateSubjectAltName; +use Icinga\Module\X509\Model\X509Dn; +use Icinga\Module\X509\Model\X509Target; +use ipl\Orm\Model; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use ipl\Sql\Select; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Str; + +use function ipl\Stdlib\yield_groups; + +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): string + { + if (isset($dn['CN'])) { + return ((array) $dn['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 $sanStr + * + * @return array + */ + public static function splitSANs(?string $sanStr): array + { + $sans = []; + foreach (Str::trimSplit($sanStr) as $altName) { + if (strpos($altName, ':') === false) { + [$k, $v] = Str::trimSplit($altName, '=', 2); + } else { + [$k, $v] = Str::trimSplit($altName, ':', 2); + } + + $sans[$k][] = $v; + } + + $order = array_flip(['DNS', 'URI', 'IP Address', 'email', 'DirName']); + uksort($sans, function ($a, $b) use ($order) { + return ($order[$a] ?? PHP_INT_MAX) <=> ($order[$b] ?? PHP_INT_MAX); + }); + + return $sans; + } + + /** + * 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 array + */ + public static function findOrInsertCert(Connection $db, $cert) + { + $dbTool = new DbTool($db); + + $certInfo = openssl_x509_parse($cert); + + $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true); + + $row = X509Certificate::on($db); + $row + ->columns(['id', 'issuer_hash']) + ->filter(Filter::equal('fingerprint', $fingerprint)); + + $row = $row->first(); + if ($row) { + return [$row->id, $row->issuer_hash]; + } + + 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']); + + $sans = static::splitSANs($certInfo['extensions']['subjectAltName'] ?? null); + if (! isset($certInfo['subject']['CN']) && ! empty($sans)) { + $subject = current($sans)[0]; + } else { + $subject = self::shortNameFromDN($certInfo['subject']); + } + + // TODO: https://github.com/Icinga/ipl-orm/pull/78 + $db->insert( + 'x509_certificate', + [ + 'subject' => $subject, + 'subject_hash' => $dbTool->marshalBinary($subjectHash), + 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']), + 'issuer_hash' => $dbTool->marshalBinary($issuerHash), + 'version' => $certInfo['version'] + 1, + 'self_signed' => $subjectHash === $issuerHash ? 'y' : 'n', + 'ca' => $ca ? 'y' : 'n', + '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'] * 1000.0, + 'valid_to' => $certInfo['validTo_time_t'] * 1000.0, + 'fingerprint' => $dbTool->marshalBinary($fingerprint), + 'serial' => $dbTool->marshalBinary(gmp_export($certInfo['serialNumber'])), + 'certificate' => $dbTool->marshalBinary($der), + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + + $certId = $db->lastInsertId(); + + CertificateUtils::insertSANs($db, $certId, $sans); + + return [$certId, $issuerHash]; + } + + private static function insertSANs($db, $certId, iterable $sans): void + { + $dbTool = new DbTool($db); + foreach ($sans as $type => $values) { + foreach ($values as $value) { + $hash = hash('sha256', sprintf('%s=%s', $type, $value), true); + + $row = X509CertificateSubjectAltName::on($db); + $row->columns([new Expression('1')]); + + $filter = Filter::all( + Filter::equal('certificate_id', $certId), + Filter::equal('hash', $hash) + ); + + $row->filter($filter); + + // Ignore duplicate SANs + if ($row->execute()->hasResult()) { + continue; + } + + // TODO: https://github.com/Icinga/ipl-orm/pull/78 + $db->insert( + 'x509_certificate_subject_alt_name', + [ + 'certificate_id' => $certId, + 'hash' => $dbTool->marshalBinary($hash), + 'type' => $type, + 'value' => $value, + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + } + } + } + + private static function findOrInsertDn($db, $certInfo, $type) + { + $dbTool = new DbTool($db); + + $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 = X509Dn::on($db); + $row + ->columns(['hash']) + ->filter(Filter::all( + Filter::equal('hash', $hash), + Filter::equal('type', $type) + )) + ->limit(1); + + $row = $row->first(); + if ($row) { + return $row->hash; + } + + $index = 0; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + // TODO: https://github.com/Icinga/ipl-orm/pull/78 + $db->insert( + 'x509_dn', + [ + 'hash' => $dbTool->marshalBinary($hash), + $db->quoteIdentifier('key') => $key, + $db->quoteIdentifier('value') => $value, + $db->quoteIdentifier('order') => $index, + 'type' => $type, + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + $index++; + } + } + + return $hash; + } + + /** + * Remove certificates that are no longer in use + * + * Remove chains that aren't used by any target, certificates that aren't part of any chain, and DNs + * that aren't used anywhere. + * + * @param Connection $conn + */ + public static function cleanupNoLongerUsedCertificates(Connection $conn) + { + $chainQuery = $conn->delete( + 'x509_certificate_chain', + ['id NOT IN ?' => X509Target::on($conn)->columns('latest_certificate_chain_id')->assembleSelect()] + ); + + $rows = $chainQuery->rowCount(); + if ($rows > 0) { + Logger::info('Removed %d certificate chains that are not used by any targets', $rows); + } + + $certsQuery = $conn->delete('x509_certificate', [ + 'id NOT IN ?' => (new Select()) + ->from('x509_certificate_chain_link ccl') + ->columns(['ccl.certificate_id']) + ->distinct(), + 'trusted = ?' => 'n', + ]); + + $rows = $certsQuery->rowCount(); + if ($rows > 0) { + Logger::info('Removed %d certificates that are not part of any chains', $rows); + } + + $dnQuery = $conn->delete('x509_dn', [ + 'hash NOT IN ?' => X509Certificate::on($conn)->columns('subject_hash')->assembleSelect() + ]); + + $rows = $dnQuery->rowCount(); + if ($rows > 0) { + Logger::info('Removed %d DNs that are not used anywhere', $rows); + } + } + + /** + * 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 = X509Certificate::on($db); + $cas + ->columns(['certificate']) + ->filter(Filter::all( + Filter::equal('ca', true), + Filter::equal('trusted', true) + )); + + $contents = []; + /** @var Model $ca */ + foreach ($cas as $ca) { + $contents[] = $ca->certificate; + } + + if (empty($contents)) { + throw new \RuntimeException('Trust store is empty'); + } + + $files->create($caFile, implode("\n", $contents)); + + $count = 0; + $certs = X509Certificate::on($db) + ->with(['chain']) + ->utilize('chain.target') + ->columns(['chain.id', 'certificate']) + ->filter(Filter::equal('chain.valid', false)) + ->orderBy('chain.id') + ->orderBy(new Expression('certificate_link.order'), SORT_DESC); + + $db->beginTransaction(); + + try { + $caFile = escapeshellarg($files->resolvePath($caFile)); + $verifyCertsFunc = function (int $chainId, array $collection) use ($db, $caFile) { + $certFiles = new TemporaryLocalFileStorage(); + $certFile = uniqid('cert'); + $certFiles->create($certFile, array_pop($collection)); + + $untrusted = ''; + if (! empty($collection)) { + $intermediateFile = uniqid('intermediate'); + $certFiles->create($intermediateFile, implode("\n", $collection)); + + $untrusted = sprintf( + ' -untrusted %s', + escapeshellarg($certFiles->resolvePath($intermediateFile)) + ); + } + + $command = sprintf( + 'openssl verify -CAfile %s%s %s 2>&1', + $caFile, + $untrusted, + escapeshellarg($certFiles->resolvePath($certFile)) + ); + + $output = null; + + exec($command, $output, $exitcode); + + $output = implode("\n", $output); + + if ($exitcode !== 0) { + Logger::debug('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' => 'y', 'invalid_reason' => null]; + } + + // TODO: https://github.com/Icinga/ipl-orm/pull/78 + $db->update('x509_certificate_chain', $set, ['id = ?' => $chainId]); + }; + + $groupBy = function (X509Certificate $cert): array { + // Group all the certificates by their chain id. + return [$cert->chain->id, $cert->certificate]; + }; + + foreach (yield_groups($certs, $groupBy) as $chainId => $collection) { + ++$count; + $verifyCertsFunc($chainId, $collection); + } + + $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..1c1970e --- /dev/null +++ b/library/X509/CertificatesTable.php @@ -0,0 +1,104 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Web\Url; +use ipl\Html\Html; +use ipl\Web\Widget\IcingaIcon; +use ipl\Web\Widget\Icon; + +/** + * 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) { + return null; + } + + return new IcingaIcon('ca-check-circle', ['title' => mt('x509', 'Is Certificate Authority')]); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if (! $selfSigned) { + return null; + } + + return new IcingaIcon('refresh-cert', ['title' => mt('x509', 'Is Self-Signed')]); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if (! $trusted) { + return null; + } + + return new 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(X509Certificate $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..722b7b3 --- /dev/null +++ b/library/X509/ChainDetails.php @@ -0,0 +1,111 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Web\Url; +use ipl\Html\Html; +use ipl\Web\Widget\IcingaIcon; +use ipl\Web\Widget\Icon; + +/** + * 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 new IcingaIcon('certificate', ['title' => mt('x509', 'Is a x509 certificate')]); + } + ], + + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => [ + 'label' => mt('x509', 'Subject', 'x509.certificate') + ], + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if (! $ca) { + return null; + } + + return new IcingaIcon('ca-check-circle', ['title' => mt('x509', 'Is Certificate Authority')]); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if (! $selfSigned) { + return null; + } + + return new IcingaIcon('refresh-cert', ['title' => mt('x509', 'Is Self-Signed')]); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if (! $trusted) { + return null; + } + + return new 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(X509Certificate $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/ColorScheme.php b/library/X509/ColorScheme.php new file mode 100644 index 0000000..14a436e --- /dev/null +++ b/library/X509/ColorScheme.php @@ -0,0 +1,37 @@ +<?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..9f18727 --- /dev/null +++ b/library/X509/Command.php @@ -0,0 +1,18 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Application\Icinga; + +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(); + } +} diff --git a/library/X509/Common/Database.php b/library/X509/Common/Database.php new file mode 100644 index 0000000..d6eb3e1 --- /dev/null +++ b/library/X509/Common/Database.php @@ -0,0 +1,56 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Common; + +use Icinga\Application\Config; +use Icinga\Data\ResourceFactory; +use ipl\Sql; +use PDO; + +final class Database +{ + /** @var Sql\Connection Database connection */ + private static $instance; + + private function __construct() + { + } + + /** + * Get the database connection + * + * @return Sql\Connection + */ + public static function get(): Sql\Connection + { + if (self::$instance === null) { + self::$instance = self::getDb(); + } + + return self::$instance; + } + + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + private static function getDb(): Sql\Connection + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + Config::module('x509')->get('backend', 'resource', 'x509') + )); + + $options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ]; + if ($config->db === 'mysql') { + $options[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'"; + } + + $config->options = $options; + + return new Sql\Connection($config); + } +} diff --git a/library/X509/Common/JobOptions.php b/library/X509/Common/JobOptions.php new file mode 100644 index 0000000..5112272 --- /dev/null +++ b/library/X509/Common/JobOptions.php @@ -0,0 +1,162 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Common; + +use DateTime; +use Exception; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Schedule; +use InvalidArgumentException; +use LogicException; +use stdClass; + +trait JobOptions +{ + /** @var bool Whether this job should only perform a rescan */ + protected $rescan; + + /** @var bool Whether this job should perform a full scan */ + protected $fullScan; + + /** @var ?string Since last scan threshold used to filter out scan targets */ + protected $sinceLastScan; + + /** @var int Used to control how many targets can be scanned in parallel */ + protected $parallel = Job::DEFAULT_PARALLEL; + + /** @var Schedule The job schedule config */ + protected $schedule; + + /** + * Get whether this job is performing only a rescan + * + * @return bool + */ + public function isRescan(): bool + { + return $this->rescan; + } + + /** + * Set whether this job should do only a rescan or full scan + * + * @param bool $rescan + * + * @return $this + */ + public function setRescan(bool $rescan): self + { + $this->rescan = $rescan; + + return $this; + } + + public function getParallel(): int + { + return $this->parallel; + } + + public function setParallel(int $parallel): self + { + $this->parallel = $parallel; + + return $this; + } + + /** + * Set whether this job should scan all known and unknown targets + * + * @param bool $fullScan + * + * @return $this + */ + public function setFullScan(bool $fullScan): self + { + $this->fullScan = $fullScan; + + return $this; + } + + /** + * Set since last scan threshold for the targets to rescan + * + * @param ?string $time + * + * @return $this + */ + public function setLastScan(?string $time): self + { + if ($time && $time !== 'null') { + $sinceLastScan = $time; + if ($sinceLastScan[0] !== '-') { + // When the user specified "2 days" as a threshold strtotime() will compute the + // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days) + $sinceLastScan = "-$sinceLastScan"; + } + + try { + // Ensure it's a valid date time string representation. + new DateTime($sinceLastScan); + + $this->sinceLastScan = $sinceLastScan; + } catch (Exception $_) { + throw new InvalidArgumentException(sprintf( + 'The specified last scan time is in an unknown format: %s', + $time + )); + } + } + + return $this; + } + + /** + * Get the targets since last scan threshold + * + * @return ?DateTime + */ + public function getSinceLastScan(): ?DateTime + { + if (! $this->sinceLastScan) { + return null; + } + + return new DateTime($this->sinceLastScan); + } + + /** + * Get the schedule config of this job + * + * @return Schedule + */ + public function getSchedule(): Schedule + { + if (! $this->schedule) { + throw new LogicException('You are accessing an unset property. Please make sure to set it beforehand.'); + } + + return $this->schedule; + } + + /** + * Set the schedule config of this job + * + * @param Schedule $schedule + * + * @return $this + */ + public function setSchedule(Schedule $schedule): self + { + $this->schedule = $schedule; + + /** @var stdClass $config */ + $config = $schedule->getConfig(); + $this->setFullScan($config->full_scan ?? false); + $this->setRescan($config->rescan ?? false); + $this->setLastScan($config->since_last_scan ?? Job::DEFAULT_SINCE_LAST_SCAN); + + return $this; + } +} diff --git a/library/X509/Common/JobUtils.php b/library/X509/Common/JobUtils.php new file mode 100644 index 0000000..54398fe --- /dev/null +++ b/library/X509/Common/JobUtils.php @@ -0,0 +1,77 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\Common; + +use GMP; +use Icinga\Application\Logger; +use ipl\Stdlib\Str; + +trait JobUtils +{ + /** + * Parse the given comma separated CIDRs + * + * @param string $cidrs + * + * @return array<string, array<int, int|string>> + */ + public function parseCIDRs(string $cidrs): array + { + $result = []; + foreach (Str::trimSplit($cidrs) as $cidr) { + $pieces = Str::trimSplit($cidr, '/'); + if (count($pieces) !== 2) { + Logger::warning('CIDR %s is in the wrong format', $cidr); + continue; + } + + $result[$cidr] = $pieces; + } + + return $result; + } + + /** + * Parse the given comma separated ports + * + * @param string $ports + * + * @return array<int, array<string>> + */ + public function parsePorts(string $ports): array + { + $result = []; + foreach (Str::trimSplit($ports) as $portRange) { + $pieces = Str::trimSplit($portRange, '-'); + if (count($pieces) === 2) { + list($start, $end) = $pieces; + } else { + $start = $pieces[0]; + $end = $pieces[0]; + } + + $result[] = [$start, $end]; + } + + return $result; + } + + /** + * Parse the given comma separated excluded targets + * + * @param ?string $excludes + * + * @return array<string> + */ + public function parseExcludes(?string $excludes): array + { + $result = []; + if (! empty($excludes)) { + $result = array_flip(Str::trimSplit($excludes)); + } + + return $result; + } +} diff --git a/library/X509/Common/Links.php b/library/X509/Common/Links.php new file mode 100644 index 0000000..c1570dc --- /dev/null +++ b/library/X509/Common/Links.php @@ -0,0 +1,37 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Common; + +use Icinga\Module\X509\Model\X509Job; +use Icinga\Module\X509\Model\X509Schedule; +use ipl\Web\Url; + +class Links +{ + public static function job(X509Job $job): Url + { + return Url::fromPath('x509/job', ['id' => $job->id]); + } + + public static function updateJob(X509Job $job): Url + { + return Url::fromPath('x509/job/update', ['id' => $job->id]); + } + + public static function schedules(X509Job $job): Url + { + return Url::fromPath('x509/job/schedules', ['id' => $job->id]); + } + + public static function scheduleJob(X509Job $job): Url + { + return Url::fromPath('x509/job/schedule', ['id' => $job->id]); + } + + public static function updateSchedule(X509Schedule $schedule): Url + { + return Url::fromPath('x509/job/update-schedule', ['id' => $schedule->job->id, 'scheduleId' => $schedule->id]); + } +} diff --git a/library/X509/Controller.php b/library/X509/Controller.php new file mode 100644 index 0000000..f16787d --- /dev/null +++ b/library/X509/Controller.php @@ -0,0 +1,87 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\File\Csv; +use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions; +use Icinga\Util\Json; +use ipl\Html\Html; +use ipl\Orm\Query; +use ipl\Stdlib\Filter; +use ipl\Web\Compat\CompatController; +use ipl\Web\Compat\SearchControls; +use ipl\Web\Filter\QueryString; + +class Controller extends CompatController +{ + use SearchControls; + + /** @var Filter\Rule */ + protected $filter; + + protected $format; + + public function fetchFilterColumns(Query $query): array + { + return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver())); + } + + public function getFilter(): Filter\Rule + { + if ($this->filter === null) { + $this->filter = QueryString::parse((string) $this->params); + } + + return $this->filter; + } + + protected function handleFormatRequest(Query $query, callable $callback) + { + if ($this->format !== 'html' && ! $this->params->has('limit')) { + $query->limit(null); // Resets any default limit and offset + } + + if ($this->format === 'sql') { + $this->content->add(Html::tag('pre', $query->dump()[0])); + return true; + } + + switch ($this->format) { + 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(iterator_to_array($callback($query))) + ) + ->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($query))) + ->sendResponse(); + exit; + } + } + + public function preDispatch() + { + parent::preDispatch(); + + $this->format = $this->params->shift('format', 'html'); + } +} diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php new file mode 100644 index 0000000..bb82959 --- /dev/null +++ b/library/X509/DataTable.php @@ -0,0 +1,150 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Module\X509\Model\X509Certificate; +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(X509Certificate $row) + { + $cells = []; + + foreach ($this->columns as $key => $column) { + if (! is_int($key) && $row->hasProperty($key)) { + $data = $row->$key; + } else { + $data = null; + if (isset($column['column'])) { + if (is_callable($column['column'])) { + $data = call_user_func(($column['column']), $row); + } elseif (isset($row->{$column['column']})) { + $data = $row->{$column['column']}; + } + } + } + + if (isset($column['renderer'])) { + $content = call_user_func(($column['renderer']), $data, $row); + } else { + $content = $data; + } + + $cells[] = Html::tag('td', $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/DbTool.php b/library/X509/DbTool.php new file mode 100644 index 0000000..4049c5a --- /dev/null +++ b/library/X509/DbTool.php @@ -0,0 +1,45 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2020 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use ipl\Sql\Connection; + +class DbTool +{ + protected $pgsql = false; + + public function __construct(Connection $db) + { + $this->pgsql = $db->getConfig()->db === 'pgsql'; + } + + /** + * @param string $binary + * + * @return string + */ + public function marshalBinary($binary) + { + if ($this->pgsql) { + return sprintf('\\x%s', bin2hex(static::unmarshalBinary($binary))); + } + + return $binary; + } + + /** + * @param resource|string $binary + * + * @return string + */ + public static function unmarshalBinary($binary) + { + if (is_resource($binary)) { + return stream_get_contents($binary); + } + + return $binary; + } +} diff --git a/library/X509/Donut.php b/library/X509/Donut.php new file mode 100644 index 0000000..fe8b748 --- /dev/null +++ b/library/X509/Donut.php @@ -0,0 +1,92 @@ +<?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() + { + $donut = new \Icinga\Chart\Donut(); + $legend = new Table(); + + foreach ($this->data as $index => $data) { + $donut->addSlice((int) $data['cnt'], ['class' => 'segment-' . $index]); + $legend->addRow( + [ + Html::tag('span', ['class' => 'badge badge-' . $index]), + 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..dffc3a8 --- /dev/null +++ b/library/X509/ExpirationWidget.php @@ -0,0 +1,80 @@ +<?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; +use ipl\Web\Compat\StyleWithNonce; + +class ExpirationWidget extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'expiration-widget']; + + 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->getTimestamp() > $now) { + $ratio = 0; + $dateTip = $from->format('Y-m-d H:i:s'); + $message = sprintf(mt('x509', 'not until after %s'), DateFormatter::timeUntil($from->getTimestamp(), true)); + } else { + $to = $this->to; + + $secondsRemaining = $to->getTimestamp() - $now; + $daysRemaining = ($secondsRemaining - $secondsRemaining % 86400) / 86400; + if ($daysRemaining > 0) { + $secondsTotal = $to->getTimestamp() - $from->getTimestamp(); + $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 = $to->format('Y-m-d H:i:s'); + } + + if ($ratio >= 75) { + if ($ratio >= 90) { + $state = 'state-critical'; + } else { + $state = 'state-warning'; + } + } else { + $state = 'state-ok'; + } + + $progressBar = Html::tag('div', ['class' => "bg-stateful $state"], new HtmlString(' ')); + $progressBarStyle = (new StyleWithNonce()) + ->setModule('x509') + ->addFor($progressBar, ['width' => sprintf('%F%%', $ratio)]); + + $this->addHtml(Html::tag('span', ['class' => 'progress-bar-label', 'title' => $dateTip], $message)); + $this->addHtml($progressBarStyle, Html::tag('div', ['class' => 'progress-bar dont-print'], $progressBar)); + } +} diff --git a/library/X509/FilterAdapter.php b/library/X509/FilterAdapter.php new file mode 100644 index 0000000..5a43071 --- /dev/null +++ b/library/X509/FilterAdapter.php @@ -0,0 +1,56 @@ +<?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..0b707b6 --- /dev/null +++ b/library/X509/Hook/SniHook.php @@ -0,0 +1,54 @@ +<?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 = []; + + /** @var self $hook */ + foreach (Hook::all('X509\Sni') as $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..1e0b3f7 --- /dev/null +++ b/library/X509/Job.php @@ -0,0 +1,755 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use DateTime; +use Exception; +use Generator; +use GMP; +use Icinga\Application\Logger; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Common\JobOptions; +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Module\X509\Model\X509CertificateChain; +use Icinga\Module\X509\Model\X509JobRun; +use Icinga\Module\X509\Model\X509Target; +use Icinga\Module\X509\React\StreamOptsCaptureConnector; +use Icinga\Util\Json; +use ipl\Scheduler\Common\TaskProperties; +use ipl\Scheduler\Contract\Task; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; +use LogicException; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use React\EventLoop\Loop; +use React\Promise; +use React\Socket\ConnectionInterface; +use React\Socket\Connector; +use React\Socket\ConnectorInterface; +use React\Socket\SecureConnector; +use React\Socket\TimeoutConnector; +use Throwable; + +class Job implements Task +{ + use JobOptions; + use TaskProperties; + + /** @var int Number of targets to be scanned in parallel by default */ + public const DEFAULT_PARALLEL = 256; + + /** @var string Default since last scan threshold used to filter out scan targets */ + public const DEFAULT_SINCE_LAST_SCAN = '-24 hours'; + + /** @var int The database id of this job */ + protected $id; + + /** @var Connection x509 database connection */ + private $db; + + /** @var DbTool Database utils for marshalling and unmarshalling binary data */ + private $dbTool; + + /** @var int Number of pending targets to be scanned */ + private $pendingTargets = 0; + + /** @var int Total number of scan targets */ + private $totalTargets = 0; + + /** @var int Number of scanned targets */ + private $finishedTargets = 0; + + /** @var Generator Scan targets generator */ + private $targets; + + /** @var array<string, array<string>> The configured SNI maps */ + private $snimap; + + /** @var int The id of the last inserted job run entry */ + private $jobRunId; + + /** @var Promise\Deferred React promise deferred instance used to resolve the running promise */ + protected $deferred; + + /** @var DateTime The start time of this job */ + protected $jobRunStart; + + /** @var array<string> A list of excluded IP addresses and host names */ + private $excludedTargets = []; + + /** @var array<string, array<int, int|string>> The configured CIDRs of this job */ + private $cidrs; + + /** @var array<int, array<string>> The configured ports of this job */ + private $ports; + + /** + * Construct a new Job instance + * + * @param string $name The name of this job + * @param array<string, array<int, int|string>> $cidrs The configured CIDRs to be used by this job + * @param array<int, array<string>> $ports The configured ports to be used by this job + * @param array<string, array<string>> $snimap The configured SNI maps to be used by this job + * @param ?Schedule $schedule + */ + public function __construct(string $name, array $cidrs, array $ports, array $snimap, Schedule $schedule = null) + { + $this->name = $name; + $this->db = Database::get(); + $this->dbTool = new DbTool($this->db); + $this->snimap = $snimap; + $this->cidrs = $cidrs; + $this->ports = $ports; + + if ($schedule) { + $this->setSchedule($schedule); + } + + $this->setName($name); + } + + /** + * Transform the given human-readable IP address into a binary format + * + * @param string $addr + * + * @return string + */ + public static function binary(string $addr): string + { + return str_pad(inet_pton($addr), 16, "\0", STR_PAD_LEFT); + } + + /** + * Transform the given human-readable IP address into GMP number + * + * @param string $addr + * + * @return ?GMP + */ + public static function addrToNumber(string $addr): ?GMP + { + return gmp_import(static::binary($addr)); + } + + /** + * Transform the given number into human-readable IP address + * + * @param $num + * @param bool $ipv6 + * + * @return false|string + */ + public static function numberToAddr($num, bool $ipv6 = true) + { + if ($ipv6) { + return inet_ntop(str_pad(gmp_export($num), 16, "\0", STR_PAD_LEFT)); + } else { + return inet_ntop(gmp_export($num)); + } + } + + /** + * Check whether the given IP is inside the specified CIDR + * + * @param GMP $addr + * @param string $subnet + * @param int $mask + * + * @return bool + */ + public static function isAddrInside(GMP $addr, string $subnet, int $mask): bool + { + // `gmp_pow()` is like PHP's pow() function, but handles also very large numbers + // and `gmp_com()` is like the bitwise NOT (~) operator. + $mask = gmp_com(gmp_pow(2, (static::isIPV6($subnet) ? 128 : 32) - $mask) - 1); + return gmp_strval(gmp_and($addr, $mask)) === gmp_strval(gmp_and(static::addrToNumber($subnet), $mask)); + } + + /** + * Get whether the given IP address is IPV6 address + * + * @param $addr + * + * @return bool + */ + public static function isIPV6($addr): bool + { + return filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + + /** + * Get the database id of this job + * + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * Set the database id of this job + * + * @param int $id + * + * @return $this + */ + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getUuid(): UuidInterface + { + if (! $this->uuid) { + $this->setUuid(Uuid::fromBytes($this->getChecksum())); + } + + return $this->uuid; + } + + /** + * Get the configured job CIDRS + * + * @return array<string, array<int, int|string>> + */ + public function getCIDRs(): array + { + return $this->cidrs; + } + + /** + * Set the CIDRs of this job + * + * @param array<string, array<int, int|string>> $cidrs + * + * @return $this + */ + public function setCIDRs(array $cidrs): self + { + $this->cidrs = $cidrs; + + return $this; + } + + /** + * Get the configured ports of this job + * + * @return array<int, array<string>> + */ + public function getPorts(): array + { + return $this->ports; + } + + /** + * Set the ports of this job to be scanned + * + * @param array<int, array<string>> $ports + * + * @return $this + */ + public function setPorts(array $ports): self + { + $this->ports = $ports; + + return $this; + } + + /** + * Get excluded IPs and host names + * + * @return array<string> + */ + public function getExcludes(): array + { + return $this->excludedTargets; + } + + /** + * Set a set of IPs and host names to be excluded from scan + * + * @param array<string> $targets + * + * @return $this + */ + public function setExcludes(array $targets): self + { + $this->excludedTargets = $targets; + + return $this; + } + + private function getConnector($peerName): array + { + $simpleConnector = new Connector(); + $streamCaptureConnector = new StreamOptsCaptureConnector($simpleConnector); + $secureConnector = new SecureConnector($streamCaptureConnector, null, [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'capture_peer_cert_chain' => true, + 'SNI_enabled' => true, + 'peer_name' => $peerName + ]); + return [new TimeoutConnector($secureConnector, 5.0), $streamCaptureConnector]; + } + + /** + * Get whether this job has been completed scanning all targets + * + * @return bool + */ + public function isFinished(): bool + { + return ! $this->targets->valid() && $this->pendingTargets === 0; + } + + public function updateLastScan($target) + { + if (! $this->isRescan() && ! isset($target->id)) { + return; + } + + $this->db->update('x509_target', [ + 'last_scan' => new Expression('UNIX_TIMESTAMP() * 1000') + ], ['id = ?' => $target->id]); + } + + public function getChecksum(): string + { + $data = [ + 'name' => $this->getName(), + 'cidrs' => $this->getCIDRs(), + 'ports' => $this->getPorts(), + 'exclude_targets' => $this->getExcludes(), + ]; + + $schedule = null; + if ($this->schedule) { + $schedule = $this->getSchedule(); + } + + return md5(Json::encode($data) . ($schedule ? bin2hex($schedule->getChecksum()) : ''), true); + } + + protected function getScanTargets(): Generator + { + $generate = $this->fullScan || ! $this->isRescan(); + if (! $generate) { + $run = X509JobRun::on($this->db) + ->columns([new Expression('1')]) + ->filter(Filter::equal('schedule.job_id', $this->getId())) + ->filter(Filter::unequal('total_targets', 0)) + ->limit(1) + ->execute(); + + $generate = ! $run->hasResult(); + } + + if ($generate) { + yield from $this->generateTargets(); + } + + $sinceLastScan = $this->getSinceLastScan(); + if ((! $this->fullScan && $sinceLastScan !== null) || $this->isRescan()) { + $targets = X509Target::on($this->db)->columns(['id', 'ip', 'hostname', 'port']); + if (! $this->fullScan && $sinceLastScan) { + $targets->filter(Filter::lessThan('last_scan', $sinceLastScan)); + } + + foreach ($targets as $target) { + $addr = static::addrToNumber($target->ip); + $addrFound = false; + foreach ($this->getCIDRs() as $cidr) { + list($subnet, $mask) = $cidr; + if (static::isAddrInside($addr, (string) $subnet, (int) $mask)) { + $target->ip = static::numberToAddr($addr, static::isIPV6($subnet)); + $addrFound = true; + + break; + } + } + + if ($addrFound) { + yield $target; + } + } + } + } + + private function generateTargets(): Generator + { + $excludes = $this->getExcludes(); + foreach ($this->getCIDRs() as $cidr) { + list($startIp, $prefix) = $cidr; + $ipv6 = static::isIPV6($startIp); + $subnet = $ipv6 ? 128 : 32; + $numIps = pow(2, ($subnet - (int) $prefix)); + + Logger::info('Scanning %d IPs in the CIDR %s', $numIps, implode('/', $cidr)); + + $start = static::addrToNumber((string) $startIp); + for ($i = 0; $i < $numIps; $i++) { + $ip = static::numberToAddr(gmp_add($start, $i), $ipv6); + if (isset($excludes[$ip])) { + Logger::debug('Excluding IP %s from scan', $ip); + continue; + } + + foreach ($this->getPorts() as $portRange) { + list($startPort, $endPort) = $portRange; + foreach (range($startPort, $endPort) as $port) { + foreach ($this->snimap[$ip] ?? [null] as $hostname) { + if (array_key_exists((string) $hostname, $excludes)) { + Logger::debug('Excluding host %s from scan', $hostname); + continue; + } + + if (! $this->fullScan) { + $targets = X509Target::on($this->db) + ->columns([new Expression('1')]) + ->filter( + Filter::all( + Filter::equal('ip', $ip), + Filter::equal('port', $port), + $hostname !== null + ? Filter::equal('hostname', $hostname) + : Filter::unlike('hostname', '*') + ) + ); + + if ($targets->execute()->hasResult()) { + continue; + } + } + + yield (object) [ + 'ip' => $ip, + 'port' => $port, + 'hostname' => $hostname + ]; + } + } + } + } + } + } + + public function updateJobStats(bool $finished = false): void + { + $fields = ['finished_targets' => $this->finishedTargets]; + if ($finished) { + $fields['end_time'] = new Expression('UNIX_TIMESTAMP() * 1000'); + $fields['total_targets'] = $this->totalTargets; + } + + $this->db->update('x509_job_run', $fields, ['id = ?' => $this->jobRunId]); + } + + private static function formatTarget($target): string + { + $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->isFinished()) { + // No targets to process anymore, so we can now resolve the promise + $this->deferred->resolve($this->finishedTargets); + + return; + } + + if (! $this->targets->valid()) { + // When nothing is yielded, and it's still not finished yet, just get the next target + return; + } + + $target = $this->targets->current(); + $this->targets->next(); + + $this->totalTargets++; + $this->pendingTargets++; + + $url = "tls://[{$target->ip}]:{$target->port}"; + Logger::debug("Connecting to %s", self::formatTarget($target)); + + /** @var ConnectorInterface $connector */ + /** @var StreamOptsCaptureConnector $streamCapture */ + list($connector, $streamCapture) = $this->getConnector($target->hostname); + $connector->connect($url)->then( + function (ConnectionInterface $conn) use ($target, $streamCapture) { + Logger::info("Connected to %s", self::formatTarget($target)); + + // Close connection in order to capture stream context options + $conn->close(); + + $capturedStreamOptions = $streamCapture->getCapturedStreamOptions(); + + $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']); + + $this->finishTarget(); + }, + function (Exception $exception) use ($target, $streamCapture) { + Logger::debug("Cannot connect to server: %s", $exception->getMessage()); + + $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, + 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000') + ], + [ + 'hostname = ?' => $target->hostname, + 'ip = ?' => $this->dbTool->marshalBinary(static::binary($target->ip)), + 'port = ?' => $target->port + ] + ); + } + + $step = max($this->totalTargets / 100, 1); + + if ($this->finishedTargets % (int) $step == 0) { + $this->updateJobStats(); + } + + $this->finishTarget(); + } + )->always(function () use ($target) { + $this->updateLastScan($target); + })->otherwise(function (Throwable $e) { + Logger::error($e->getMessage()); + Logger::error($e->getTraceAsString()); + }); + } + + public function run(): Promise\ExtendedPromiseInterface + { + $this->jobRunStart = new DateTime(); + // Update the job statistics regardless of whether the job was successful, failed, or canceled. + // Otherwise, some database columns might remain null. + $updateJobStats = function () { + $this->updateJobStats(true); + }; + $this->deferred = new Promise\Deferred($updateJobStats); + $this->deferred->promise()->always($updateJobStats); + + Loop::futureTick(function () { + if (! $this->db->ping()) { + $this->deferred->reject(new LogicException('Lost connection to database and failed to reconnect')); + + return; + } + + // Reset those statistics for the next run! Is only necessary when + // running this job using the scheduler + $this->totalTargets = 0; + $this->finishedTargets = 0; + $this->pendingTargets = 0; + + if ($this->schedule) { + $scheduleId = $this->getSchedule()->getId(); + } else { + $scheduleId = new Expression('NULL'); + } + + $this->db->insert('x509_job_run', [ + 'job_id' => $this->getId(), + 'schedule_id' => $scheduleId, + 'start_time' => $this->jobRunStart->getTimestamp() * 1000.0, + 'total_targets' => 0, + 'finished_targets' => 0 + ]); + $this->jobRunId = (int) $this->db->lastInsertId(); + + $this->targets = $this->getScanTargets(); + + if ($this->isFinished()) { + // There are no targets to scan, so we can resolve the promise earlier + $this->deferred->resolve(0); + + return; + } + + // Start scanning the first couple of targets... + for ($i = 0; $i < $this->getParallel() && ! $this->isFinished(); $i++) { + $this->startNextTarget(); + } + }); + + /** @var Promise\ExtendedPromiseInterface $promise */ + $promise = $this->deferred->promise(); + return $promise; + } + + 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 = X509Target::on($this->db) + ->columns(['id']) + ->filter( + Filter::all( + Filter::equal('ip', $target->ip), + Filter::equal('port', $target->port), + Filter::equal('hostname', $target->hostname) + ) + )->first(); + + if (! $row) { + // TODO: https://github.com/Icinga/ipl-orm/pull/78 + $this->db->insert( + 'x509_target', + [ + 'ip' => $this->dbTool->marshalBinary(static::binary($target->ip)), + 'port' => $target->port, + 'hostname' => $target->hostname, + 'last_scan' => new Expression('UNIX_TIMESTAMP() * 1000'), + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + $targetId = $this->db->lastInsertId(); + } else { + $targetId = $row->id; + } + + $chainUptodate = false; + + $lastChain = X509CertificateChain::on($this->db) + ->columns(['id']) + ->filter(Filter::equal('target_id', $targetId)) + ->orderBy('id', SORT_DESC) + ->limit(1) + ->first(); + + if ($lastChain) { + $lastFingerprints = X509Certificate::on($this->db)->utilize('chain'); + $lastFingerprints + ->columns(['fingerprint']) + ->getSelectBase() + ->where(new Expression( + 'certificate_link.certificate_chain_id = %d', + [$lastChain->id] + )) + ->orderBy('certificate_link.order'); + + $lastFingerprintsArr = []; + foreach ($lastFingerprints as $lastFingerprint) { + $lastFingerprintsArr[] = $lastFingerprint->fingerprint; + } + + $currentFingerprints = []; + + foreach ($chain as $cert) { + $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true); + } + + $chainUptodate = $currentFingerprints === $lastFingerprintsArr; + } + + if ($lastChain && $chainUptodate) { + $chainId = $lastChain->id; + } else { + // TODO: https://github.com/Icinga/ipl-orm/pull/78 + $this->db->insert( + 'x509_certificate_chain', + [ + 'target_id' => $targetId, + 'length' => count($chain), + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + + $chainId = $this->db->lastInsertId(); + + $lastCertInfo = []; + foreach ($chain as $index => $cert) { + $lastCertInfo = CertificateUtils::findOrInsertCert($this->db, $cert); + list($certId, $_) = $lastCertInfo; + + $this->db->insert( + 'x509_certificate_chain_link', + [ + 'certificate_chain_id' => $chainId, + $this->db->quoteIdentifier('order') => $index, + 'certificate_id' => $certId, + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + + $lastCertInfo[] = $index; + } + + // There might be chains that do not include the self-signed top-level Ca, + // so we need to include it manually here, as we need to display the full + // chain in the UI. + $rootCa = X509Certificate::on($this->db) + ->columns(['id']) + ->filter(Filter::equal('subject_hash', $lastCertInfo[1])) + ->filter(Filter::equal('self_signed', true)) + ->first(); + + if ($rootCa && $rootCa->id !== $lastCertInfo[0]) { + $this->db->update( + 'x509_certificate_chain', + ['length' => count($chain) + 1], + ['id = ?' => $chainId] + ); + + $this->db->insert( + 'x509_certificate_chain_link', + [ + 'certificate_chain_id' => $chainId, + $this->db->quoteIdentifier('order') => $lastCertInfo[2] + 1, + 'certificate_id' => $rootCa->id, + 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000') + ] + ); + } + } + + $this->db->update( + 'x509_target', + [ + 'latest_certificate_chain_id' => $chainId, + 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000') + ], + ['id = ?' => $targetId] + ); + }); + } +} diff --git a/library/X509/Model/Behavior/DERBase64.php b/library/X509/Model/Behavior/DERBase64.php new file mode 100644 index 0000000..f7b7215 --- /dev/null +++ b/library/X509/Model/Behavior/DERBase64.php @@ -0,0 +1,44 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; + +/** + * Support automatically transformation of DER-encoded certificates to PEM and vice versa. + */ +class DERBase64 extends PropertyBehavior +{ + public function fromDb($value, $key, $_) + { + if (! $value) { + return null; + } + + $block = chunk_split(base64_encode($value), 64, "\n"); + + return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----"; + } + + public function toDb($value, $key, $_) + { + if (! $value) { + return null; + } + + $lines = explode("\n", $value); + $der = ''; + + foreach ($lines as $line) { + if (strpos($line, '-----') === 0) { + continue; + } + + $der .= base64_decode($line); + } + + return $der; + } +} diff --git a/library/X509/Model/Behavior/ExpressionInjector.php b/library/X509/Model/Behavior/ExpressionInjector.php new file mode 100644 index 0000000..c3fa2cb --- /dev/null +++ b/library/X509/Model/Behavior/ExpressionInjector.php @@ -0,0 +1,62 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model\Behavior; + +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Query; +use ipl\Sql\ExpressionInterface; +use ipl\Stdlib\Filter; + +/** + * Support expression columns (which don't really exist in the database, but rather + * resulted e.g. from a `case..when` expression), being used as filter columns + */ +class ExpressionInjector implements RewriteFilterBehavior, QueryAwareBehavior +{ + /** @var array */ + protected $columns; + + /** @var Query */ + protected $query; + + public function __construct(...$columns) + { + $this->columns = $columns; + } + + public function setQuery(Query $query) + { + $this->query = $query; + + return $this; + } + + public function rewriteCondition(Filter\Condition $condition, $relation = null) + { + $columnName = $condition->metaData()->get('columnName'); + if (in_array($columnName, $this->columns, true)) { + $relationPath = $condition->metaData()->get('relationPath'); + if ($relationPath && $relationPath !== $this->query->getModel()->getTableAlias()) { + $subject = $this->query->getResolver()->resolveRelation($relationPath)->getTarget(); + } else { + $subject = $this->query->getModel(); + } + + /** @var ExpressionInterface $column */ + $column = $subject->getColumns()[$columnName]; + $expression = clone $column; + $expression->setColumns($this->query->getResolver()->qualifyColumns( + $this->query->getResolver()->requireAndResolveColumns( + $expression->getColumns(), + $subject + ), + $subject + )); + + $condition->setColumn($this->query->getDb()->getQueryBuilder()->buildExpression($expression)); + } + } +} diff --git a/library/X509/Model/Behavior/Ip.php b/library/X509/Model/Behavior/Ip.php new file mode 100644 index 0000000..79c9e80 --- /dev/null +++ b/library/X509/Model/Behavior/Ip.php @@ -0,0 +1,39 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model\Behavior; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Contract\PropertyBehavior; + +/** + * Support automatically transformation of human-readable IP addresses into their respective packed + * binary representation and vice versa. + */ +class Ip extends Binary +{ + public function fromDb($value, $key, $_) + { + $value = parent::fromDb($value, $key, $_); + if ($value === null) { + return null; + } + + $ipv4 = ltrim($value, "\0"); + if (strlen($ipv4) === 4) { + $value = $ipv4; + } + + return inet_ntop($value); + } + + public function toDb($value, $key, $_) + { + if ($value === null || $value === '*' || ! ctype_print($value)) { + return $value; + } + + return parent::toDb(str_pad(inet_pton($value), 16, "\0", STR_PAD_LEFT), $key, $_); + } +} diff --git a/library/X509/Model/Schema.php b/library/X509/Model/Schema.php new file mode 100644 index 0000000..02ec0c0 --- /dev/null +++ b/library/X509/Model/Schema.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; + +/** + * A database model for x509 schema version table + * + * @property int $id Unique identifier of the database schema entries + * @property string $version The current schema version of Icinga Web + * @property DateTime $timestamp The insert/modify time of the schema entry + * @property bool $success Whether the database migration of the current version was successful + * @property ?string $reason The reason why the database migration has failed + */ +class Schema extends Model +{ + public function getTableName(): string + { + return 'x509_schema'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns(): array + { + return [ + 'version', + 'timestamp', + 'success', + 'reason' + ]; + } + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new BoolCast(['success'])); + $behaviors->add(new MillisecondTimestamp(['timestamp'])); + } +} diff --git a/library/X509/Model/X509Certificate.php b/library/X509/Model/X509Certificate.php new file mode 100644 index 0000000..63bdf95 --- /dev/null +++ b/library/X509/Model/X509Certificate.php @@ -0,0 +1,159 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use Icinga\Module\X509\Model\Behavior\DERBase64; +use Icinga\Module\X509\Model\Behavior\ExpressionInjector; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; +use ipl\Sql\Expression; + +class X509Certificate extends Model +{ + public function getTableName() + { + return 'x509_certificate'; + } + + public function getTableAlias(): string + { + return 'certificate'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'subject', + 'subject_hash', + 'issuer', + 'issuer_hash', + 'issuer_certificate_id', + 'version', + 'self_signed', + 'ca', + 'trusted', + 'pubkey_algo', + 'pubkey_bits', + 'signature_algo', + 'signature_hash_algo', + 'valid_from', + 'valid_to', + 'fingerprint', + 'serial', + 'certificate', + 'ctime', + 'mtime', + 'duration' => new Expression('%s - %s', ['valid_to', 'valid_from']) + ]; + } + + public function getColumnDefinitions() + { + return [ + 'subject' => t('Certificate'), + 'issuer' => t('Issuer'), + 'version' => t('Version'), + 'self_signed' => t('Is Self-Signed'), + 'ca' => t('Is Certificate Authority'), + 'trusted' => t('Is Trusted'), + 'pubkey_algo' => t('Public Key Algorithm'), + 'pubkey_bits' => t('Public Key Strength'), + 'signature_algo' => t('Signature Algorithm'), + 'signature_hash_algo' => t('Signature Hash Algorithm'), + 'valid_from' => t('Valid From'), + 'valid_to' => t('Valid To'), + 'duration' => t('Duration'), + 'subject_hash' => t('Subject Hash'), + 'issuer_hash' => t('Issuer Hash'), + ]; + } + + public function getSearchColumns() + { + return ['subject', 'issuer']; + } + + /** + * Get list of allowed columns to be exported + * + * @return string[] + */ + public function getExportableColumns(): array + { + return [ + 'id', + 'subject', + 'issuer', + 'version', + 'self_signed', + 'ca', + 'trusted', + 'pubkey_algo', + 'pubkey_bits', + 'signature_algo', + 'signature_hash_algo', + 'valid_from', + 'valid_to' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'subject_hash', + 'issuer_hash', + 'fingerprint', + 'serial', + 'certificate' + ])); + + $behaviors->add(new DERBase64(['certificate'])); + + $behaviors->add(new BoolCast([ + 'ca', + 'trusted', + 'self_signed' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'valid_from', + 'valid_to', + 'ctime', + 'mtime', + 'duration' + ])); + + $behaviors->add(new ExpressionInjector('duration')); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('issuer_certificate', static::class) + ->setForeignKey('subject_hash') + ->setCandidateKey('issuer_hash'); + $relations->belongsToMany('chain', X509CertificateChain::class) + ->through(X509CertificateChainLink::class) + ->setForeignKey('certificate_id'); + + $relations->hasMany('certificate', static::class) + ->setForeignKey('issuer_hash') + ->setCandidateKey('subject_hash'); + $relations->hasMany('alt_name', X509CertificateSubjectAltName::class) + ->setJoinType('LEFT'); + $relations->hasMany('dn', X509Dn::class) + ->setForeignKey('hash') + ->setCandidateKey('subject_hash') + ->setJoinType('LEFT'); + } +} diff --git a/library/X509/Model/X509CertificateChain.php b/library/X509/Model/X509CertificateChain.php new file mode 100644 index 0000000..189c38d --- /dev/null +++ b/library/X509/Model/X509CertificateChain.php @@ -0,0 +1,58 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class X509CertificateChain extends Model +{ + public function getTableName() + { + return 'x509_certificate_chain'; + } + + public function getTableAlias(): string + { + return 'chain'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'target_id', + 'length', + 'valid', + 'invalid_reason', + 'ctime' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast(['valid'])); + + $behaviors->add(new MillisecondTimestamp(['ctime'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('target', X509Target::class) + ->setCandidateKey('id') + ->setForeignKey('latest_certificate_chain_id'); + + $relations->belongsToMany('certificate', X509Certificate::class) + ->through(X509CertificateChainLink::class) + ->setForeignKey('certificate_chain_id'); + } +} diff --git a/library/X509/Model/X509CertificateChainLink.php b/library/X509/Model/X509CertificateChainLink.php new file mode 100644 index 0000000..d093793 --- /dev/null +++ b/library/X509/Model/X509CertificateChainLink.php @@ -0,0 +1,46 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class X509CertificateChainLink extends Model +{ + public function getTableName() + { + return 'x509_certificate_chain_link'; + } + + public function getTableAlias(): string + { + return 'link'; + } + + public function getKeyName() + { + return ['certificate_chain_id', 'certificate_id', 'order']; + } + + public function getColumns() + { + return ['ctime']; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new MillisecondTimestamp(['ctime'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('certificate', X509Certificate::class) + ->setCandidateKey('certificate_id'); + $relations->belongsTo('chain', X509CertificateChain::class) + ->setCandidateKey('certificate_chain_id'); + } +} diff --git a/library/X509/Model/X509CertificateSubjectAltName.php b/library/X509/Model/X509CertificateSubjectAltName.php new file mode 100644 index 0000000..62aac5c --- /dev/null +++ b/library/X509/Model/X509CertificateSubjectAltName.php @@ -0,0 +1,50 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class X509CertificateSubjectAltName extends Model +{ + public function getTableName() + { + return 'x509_certificate_subject_alt_name'; + } + + public function getTableAlias(): string + { + return 'alt_name'; + } + + public function getKeyName() + { + return ['certificate_id', 'hash']; + } + + public function getColumns() + { + return [ + 'type', + 'value', + 'ctime' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary(['hash'])); + + $behaviors->add(new MillisecondTimestamp(['ctime'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('certificate', X509Certificate::class); + } +} diff --git a/library/X509/Model/X509Dn.php b/library/X509/Model/X509Dn.php new file mode 100644 index 0000000..fa0406f --- /dev/null +++ b/library/X509/Model/X509Dn.php @@ -0,0 +1,51 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class X509Dn extends Model +{ + public function getTableName() + { + return 'x509_dn'; + } + + public function getTableAlias(): string + { + return 'dn'; + } + + public function getKeyName() + { + return ['hash', 'type', 'order']; + } + + public function getColumns() + { + return [ + 'key', + 'value', + 'ctime' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary(['hash'])); + + $behaviors->add(new MillisecondTimestamp(['ctime'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('certificate', X509Certificate::class) + ->setForeignKey('subject_hash'); + } +} diff --git a/library/X509/Model/X509Job.php b/library/X509/Model/X509Job.php new file mode 100644 index 0000000..1b3a855 --- /dev/null +++ b/library/X509/Model/X509Job.php @@ -0,0 +1,73 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use DateTime; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Query; +use ipl\Orm\Relations; + +/** + * A database model for all x509 jobs + * + * @property int $id Unique identifier of this job + * @property string $name The name of this job + * @property string $author The author of this job + * @property string $cidrs The configured cidrs of this job + * @property string $ports The configured ports of this job + * @property ?string $exclude_targets The configured excluded targets of this job + * @property DateTime $ctime The creation time of this job + * @property DateTime $mtime The modification time of this job + * @property Query|X509Schedule $schedule The configured schedules of this job + * @property Query|X509JobRun $job_run Job activities + */ +class X509Job extends Model +{ + public function getTableName(): string + { + return 'x509_job'; + } + + public function getTableAlias(): string + { + return 'job'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns(): array + { + return [ + 'name', + 'author', + 'cidrs', + 'ports', + 'exclude_targets', + 'ctime', + 'mtime' + ]; + } + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp([ + 'ctime', + 'mtime' + ])); + } + + public function createRelations(Relations $relations): void + { + $relations->hasMany('schedule', X509Schedule::class) + ->setForeignKey('job_id'); + $relations->hasMany('job_run', X509JobRun::class) + ->setForeignKey('job_id'); + } +} diff --git a/library/X509/Model/X509JobRun.php b/library/X509/Model/X509JobRun.php new file mode 100644 index 0000000..d776622 --- /dev/null +++ b/library/X509/Model/X509JobRun.php @@ -0,0 +1,77 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use DateTime; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Query; +use ipl\Orm\Relations; + +/** + * A database model for all x509 job schedules + * + * @property int $id Unique identifier of this job + * @property ?int $job_id The id of the x509 job this job run belongs to + * @property ?int $schedule_id The id of the x509 job schedule this run belongs to + * @property int $total_targets All the x509 targets found by this job run + * @property int $finished_targets All the x509 targets scanned by this job run + * @property DateTime $start_time The start time of this job run + * @property DateTime $end_time The end time of this job run + * @property Query|X509Job $job The x509 job this job run belongs to + * @property Query|X509Schedule $schedule The x509 job schedule this job run belongs to + */ +class X509JobRun extends Model +{ + public function getTableName(): string + { + return 'x509_job_run'; + } + + public function getTableAlias(): string + { + return 'job_run'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns(): array + { + return [ + 'job_id', + 'schedule_id', + 'total_targets', + 'finished_targets', + 'start_time', + 'end_time' + ]; + } + + public function getDefaultSort(): string + { + return 'start_time desc'; + } + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp([ + 'start_time', + 'end_time', + ])); + } + + public function createRelations(Relations $relations): void + { + $relations->belongsTo('job', X509Job::class) + ->setCandidateKey('job_id'); + $relations->belongsTo('schedule', X509Schedule::class) + ->setJoinType('LEFT') + ->setCandidateKey('schedule_id'); + } +} diff --git a/library/X509/Model/X509Schedule.php b/library/X509/Model/X509Schedule.php new file mode 100644 index 0000000..476641a --- /dev/null +++ b/library/X509/Model/X509Schedule.php @@ -0,0 +1,70 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use DateTime; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * A database model for all x509 job schedules + * + * @property int $id Unique identifier of this job + * @property int $job_id The id of the x509 job this schedule belongs to + * @property string $name The name of this job schedule + * @property string $author The author of this job schedule + * @property string $config The config of this job schedule + * @property DateTime $ctime The creation time of this job + * @property DateTime $mtime The modification time of this job + * @property X509Job $job The x509 job this schedule belongs to + * @property X509JobRun $job_run Schedule activities + */ +class X509Schedule extends Model +{ + public function getTableName(): string + { + return 'x509_schedule'; + } + + public function getTableAlias(): string + { + return 'schedule'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns(): array + { + return [ + 'job_id', + 'name', + 'author', + 'config', + 'ctime', + 'mtime' + ]; + } + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp([ + 'ctime', + 'mtime' + ])); + } + + public function createRelations(Relations $relations): void + { + $relations->belongsTo('job', X509Job::class) + ->setCandidateKey('job_id'); + $relations->hasMany('job_run', X509JobRun::class) + ->setForeignKey('schedule_id'); + } +} diff --git a/library/X509/Model/X509Target.php b/library/X509/Model/X509Target.php new file mode 100644 index 0000000..7705d57 --- /dev/null +++ b/library/X509/Model/X509Target.php @@ -0,0 +1,74 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Model; + +use Icinga\Module\X509\Model\Behavior\Ip; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class X509Target extends Model +{ + public function getTableName() + { + return 'x509_target'; + } + + public function getTableAlias(): string + { + return 'target'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'ip', + 'port', + 'hostname', + 'latest_certificate_chain_id', + 'last_scan', + 'ctime', + 'mtime' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'hostname' => t('Host Name'), + 'ip' => t('IP'), + 'port' => t('Port') + ]; + } + + public function getSearchColumns() + { + return ['hostname']; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Ip(['ip'])); + + $behaviors->add(new MillisecondTimestamp([ + 'ctime', + 'mtime', + 'last_scan' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('chain', X509CertificateChain::class) + ->setCandidateKey('latest_certificate_chain_id'); + } +} diff --git a/library/X509/ProvidedHook/DbMigration.php b/library/X509/ProvidedHook/DbMigration.php new file mode 100644 index 0000000..8314e3c --- /dev/null +++ b/library/X509/ProvidedHook/DbMigration.php @@ -0,0 +1,95 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\ProvidedHook; + +use Icinga\Application\Hook\DbMigrationHook; +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Model\Schema; +use ipl\Orm\Query; +use ipl\Sql; +use ipl\Sql\Adapter\Pgsql; + +class DbMigration extends DbMigrationHook +{ + public function getName(): string + { + return $this->translate('Icinga Certificate Monitoring'); + } + + public function providedDescriptions(): array + { + return [ + '1.0.0' => $this->translate( + 'Adjusts the database type of several columns and changes some composed primary keys.' + ), + '1.1.0' => $this->translate( + 'Changes the composed x509_target index and x509_certificate valid from/to types to bigint.' + ), + '1.2.0' => $this->translate( + 'Changes all timestamp columns to bigint and adjusts enum types of "yes/no" to "n/y".' + ), + '1.3.0' => $this->translate( + 'Introduces the required tables to store jobs and job schedules in the database.' + ) + ]; + } + + public function getVersion(): string + { + if ($this->version === null) { + $conn = $this->getDb(); + $schema = $this->getSchemaQuery() + ->columns(['version', 'success']) + ->orderBy('id', SORT_DESC) + ->limit(2); + + if (static::tableExists($conn, $schema->getModel()->getTableName())) { + /** @var Schema $version */ + foreach ($schema as $version) { + if ($version->success) { + $this->version = $version->version; + + break; + } + } + + if (! $this->version) { + // Schema version table exist, but the user has probably deleted the entry! + $this->version = '1.3.0'; + } + } elseif ( + $this->getDb()->getAdapter() instanceof Pgsql + || static::getColumnType($conn, 'x509_certificate', 'ctime') === 'bigint(20) unsigned' + ) { + // We modified a bunch of timestamp columns to bigint in x509 version 1.2.0. + // We have also added Postgres support with x509 version 1.2 and never had an upgrade scripts until now. + $this->version = '1.2.0'; + } elseif (static::getColumnType($conn, 'x509_certificate_subject_alt_name', 'hash') !== null) { + if (static::getColumnType($conn, 'x509_certificate', 'valid_from') === 'bigint(20) unsigned') { + $this->version = '1.0.0'; + } else { + $this->version = '1.1.0'; + } + } else { + // X509 version 1.0 was the first release of this module, but due to some reason it also contains + // an upgrade script and adds `hash` column. However, if this column doesn't exist yet, we need + // to use the lowest possible release value as the initial (last migrated) version. + $this->version = '0.0.0'; + } + } + + return $this->version; + } + + public function getDb(): Sql\Connection + { + return Database::get(); + } + + protected function getSchemaQuery(): Query + { + return Schema::on($this->getDb()); + } +} diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php new file mode 100644 index 0000000..70d584c --- /dev/null +++ b/library/X509/ProvidedHook/HostsImportSource.php @@ -0,0 +1,91 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Model\X509Target; +use ipl\Sql; + +class HostsImportSource extends X509ImportSource +{ + public function fetchData() + { + $conn = Database::get(); + $targets = X509Target::on($conn) + ->utilize('chain') + ->utilize('chain.certificate') + ->columns([ + 'ip', + 'host_name' => 'hostname' + ]); + + $targets + ->getSelectBase() + ->where(new Sql\Expression('target_chain_link.order = 0')) + ->groupBy(['ip', 'hostname']); + + if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) { + $targets->withColumns([ + 'host_ports' => new Sql\Expression("ARRAY_TO_STRING(ARRAY_AGG(DISTINCT port), ',')") + ]); + } else { + $targets->withColumns([ + 'host_ports' => new Sql\Expression("GROUP_CONCAT(DISTINCT port SEPARATOR ',')") + ]); + } + + $results = []; + $foundDupes = []; + /** @var X509Target $target */ + foreach ($targets as $target) { + $isV6 = Job::isIPV6($target->ip); + $target->host_ip = $target->ip; + $target->host_address = $isV6 ? null : $target->ip; + $target->host_address6 = $isV6 ? $target->ip : null; + + 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; + } + + // Target ip is now obsolete and must not be included in the results. + // The relation is only used to utilize the query and must not be in the result set as well. + unset($target->ip); + unset($target->chain); + + $results[$target->host_name_or_ip] = (object) iterator_to_array($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..7b87cd8 --- /dev/null +++ b/library/X509/ProvidedHook/ServicesImportSource.php @@ -0,0 +1,143 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use Icinga\Module\X509\Common\Database; +use Icinga\Module\X509\Job; +use Icinga\Module\X509\Model\X509CertificateSubjectAltName; +use Icinga\Module\X509\Model\X509Target; +use ipl\Sql; + +class ServicesImportSource extends X509ImportSource +{ + public function fetchData() + { + $conn = Database::get(); + $targets = X509Target::on($conn) + ->with([ + 'chain', + 'chain.certificate', + 'chain.certificate.dn', + 'chain.certificate.issuer_certificate' + ]) + ->columns([ + 'ip', + 'host_name' => 'hostname', + 'host_port' => 'port', + 'cert_subject' => 'chain.certificate.subject', + 'cert_issuer' => 'chain.certificate.issuer', + 'cert_trusted' => 'chain.certificate.trusted', + 'cert_valid_from' => 'chain.certificate.valid_from', + 'cert_valid_to' => 'chain.certificate.valid_to', + 'cert_self_signed' => new Sql\Expression('COALESCE(%s, %s)', [ + 'chain.certificate.issuer_certificate.self_signed', + 'chain.certificate.self_signed' + ]) + ]); + + $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT'); + $targets + ->getSelectBase() + ->where(new Sql\Expression('target_chain_link.order = 0')) + ->groupBy(['ip, hostname, port']); + + $certAltName = X509CertificateSubjectAltName::on($conn); + $certAltName + ->getSelectBase() + ->where(new Sql\Expression('certificate_id = target_chain_certificate.id')) + ->groupBy(['alt_name.certificate_id']); + + if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) { + $targets + ->withColumns([ + 'cert_fingerprint' => new Sql\Expression("ENCODE(%s, 'hex')", [ + 'chain.certificate.fingerprint' + ]), + 'cert_dn' => new Sql\Expression( + "ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, '=', %s)), ',')", + [ + 'chain.certificate.dn.key', + 'chain.certificate.dn.value' + ] + ) + ]) + ->getSelectBase() + ->groupBy(['target_chain_certificate.id', 'target_chain_certificate_issuer_certificate.id']); + + $certAltName->columns([ + new Sql\Expression("ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, ':', %s)), ',')", ['type', 'value']) + ]); + } else { + $targets->withColumns([ + 'cert_fingerprint' => new Sql\Expression('HEX(%s)', ['chain.certificate.fingerprint']), + 'cert_dn' => new Sql\Expression( + "GROUP_CONCAT(CONCAT(%s, '=', %s) SEPARATOR ',')", + [ + 'chain.certificate.dn.key', + 'chain.certificate.dn.value' + ] + ) + ]); + + $certAltName->columns([ + new Sql\Expression("GROUP_CONCAT(CONCAT(%s, ':', %s) SEPARATOR ',')", ['type', 'value']) + ]); + } + + list($select, $values) = $certAltName->dump(); + $targets->withColumns(['cert_subject_alt_name' => new Sql\Expression("$select", null, ...$values)]); + + $results = []; + /** @var X509Target $target */ + foreach ($targets as $target) { + $isV6 = Job::isIPV6($target->ip); + $target->host_ip = $target->ip; + $target->host_address = $isV6 ? null : $target->ip; + $target->host_address6 = $isV6 ? $target->ip : null; + + $target->host_name_ip_and_port = sprintf( + '%s/%s:%d', + $target->host_name, + $target->host_ip, + $target->host_port + ); + + // Target ip is now obsolete and must not be included in the results. + // The relation is only used to utilize the query and must not be in the result set as well. + unset($target->ip); + unset($target->chain); + + $results[$target->host_name_ip_and_port] = (object) iterator_to_array($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..dc280c0 --- /dev/null +++ b/library/X509/ProvidedHook/X509ImportSource.php @@ -0,0 +1,11 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509\ProvidedHook; + +use Icinga\Module\Director\Hook\ImportSourceHook; + +abstract class X509ImportSource extends ImportSourceHook +{ +} diff --git a/library/X509/React/StreamOptsCaptureConnector.php b/library/X509/React/StreamOptsCaptureConnector.php new file mode 100644 index 0000000..56a44e4 --- /dev/null +++ b/library/X509/React/StreamOptsCaptureConnector.php @@ -0,0 +1,60 @@ +<?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/Schedule.php b/library/X509/Schedule.php new file mode 100644 index 0000000..3f80932 --- /dev/null +++ b/library/X509/Schedule.php @@ -0,0 +1,125 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509; + +use Icinga\Module\X509\Model\X509Schedule; +use Icinga\Util\Json; +use stdClass; + +class Schedule +{ + /** @var int The database id of this schedule */ + protected $id; + + /** @var string The name of this job schedule */ + protected $name; + + /** @var object The config of this schedule */ + protected $config; + + public function __construct(string $name, int $id, object $config) + { + $this->id = $id; + $this->name = $name; + $this->config = $config; + } + + public static function fromModel(X509Schedule $schedule): self + { + /** @var stdClass $config */ + $config = Json::decode($schedule->config); + if (isset($config->rescan)) { + $config->rescan = $config->rescan === 'y'; + } + + if (isset($config->full_scan)) { + $config->full_scan = $config->full_scan === 'y'; + } + + return new static($schedule->name, $schedule->id, $config); + } + + /** + * Get the name of this schedule + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Set the name of this schedule + * + * @param string $name + * + * @return $this + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * Get the database id of this job + * + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * Set the database id of this job + * + * @param int $id + * + * @return $this + */ + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + /** + * Get the config of this schedule + * + * @return object + */ + public function getConfig(): object + { + return $this->config; + } + + /** + * Set the config of this schedule + * + * @param object $config + * + * @return $this + */ + public function setConfig(object $config): self + { + $this->config = $config; + + return $this; + } + + /** + * Get the checksum of this schedule + * + * @return string + */ + public function getChecksum(): string + { + return md5($this->getName() . Json::encode($this->getConfig()), true); + } +} diff --git a/library/X509/SniIniRepository.php b/library/X509/SniIniRepository.php new file mode 100644 index 0000000..432494b --- /dev/null +++ b/library/X509/SniIniRepository.php @@ -0,0 +1,21 @@ +<?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/Table.php b/library/X509/Table.php new file mode 100644 index 0000000..00fe6cf --- /dev/null +++ b/library/X509/Table.php @@ -0,0 +1,39 @@ +<?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..109e5ee --- /dev/null +++ b/library/X509/UsageTable.php @@ -0,0 +1,91 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Icinga\Module\X509\Model\X509Certificate; +use Icinga\Web\Url; +use ipl\Web\Widget\Icon; + +/** + * 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'], + 'column' => function ($data) { + return $data->chain->valid; + }, + 'renderer' => function ($valid) { + return new Icon($valid ? 'circle-check' : 'ban', ['class' => $valid ? '-ok' : '-critical']); + } + ], + + 'hostname' => [ + 'label' => mt('x509', 'Hostname'), + 'column' => function ($data) { + return $data->chain->target->hostname; + } + ], + + 'ip' => [ + 'label' => mt('x509', 'IP'), + 'column' => function ($data) { + return $data->chain->target->ip; + }, + ], + + 'port' => [ + 'label' => mt('x509', 'Port'), + 'column' => function ($data) { + return $data->chain->target->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(X509Certificate $row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/chain', ['id' => $row->chain->id]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/Web/Control/SearchBar/ObjectSuggestions.php b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php new file mode 100644 index 0000000..ca9630f --- /dev/null +++ b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php @@ -0,0 +1,203 @@ +<?php + +namespace Icinga\Module\X509\Web\Control\SearchBar; + +use Exception; +use Icinga\Module\X509\Common\Database; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Model; +use ipl\Orm\Relation; +use ipl\Orm\Relation\HasOne; +use ipl\Orm\Resolver; +use ipl\Orm\UnionModel; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Seq; +use ipl\Stdlib\Str; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Control\SearchBar\Suggestions; + +class ObjectSuggestions extends Suggestions +{ + /** @var Model */ + protected $model; + + /** + * Set the model to show suggestions for + * + * @param string|Model $model + * + * @return $this + */ + public function setModel($model): self + { + if (is_string($model)) { + /** @var Model $model */ + $model = new $model(); + } + + $this->model = $model; + + return $this; + } + + protected function shouldShowRelationFor(string $column): bool + { + $columns = Str::trimSplit($column, '.'); + + switch (count($columns)) { + case 2: + return $columns[0] !== $this->model->getTableAlias(); + default: + return true; + } + } + + protected function createQuickSearchFilter($searchTerm) + { + $model = $this->model; + $resolver = $model::on(Database::get())->getResolver(); + + $quickFilter = Filter::any(); + foreach ($model->getSearchColumns() as $column) { + $where = Filter::like($resolver->qualifyColumn($column, $model->getTableAlias()), $searchTerm); + $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel()); + $quickFilter->add($where); + } + + return $quickFilter; + } + + protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter) + { + $model = $this->model; + $query = $model::on(Database::get()); + $query->limit(static::DEFAULT_LIMIT); + + if (strpos($column, ' ') !== false) { + // Searching for `Host Name` and continue typing without accepting/clicking the suggested + // column name will cause the search bar to use a label as a filter column + list($path, $_) = Seq::find( + self::collectFilterColumns($query->getModel(), $query->getResolver()), + $column, + false + ); + if ($path !== null) { + $column = $path; + } + } + + $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableAlias()); + $inputFilter = Filter::like($columnPath, $searchTerm); + + $query->columns($columnPath); + $query->orderBy($columnPath); + + if ($searchFilter instanceof Filter\None) { + $query->filter($inputFilter); + } elseif ($searchFilter instanceof Filter\All) { + $searchFilter->add($inputFilter); + + // When 10 hosts are sharing the same certificate, filtering in the search bar by + // `Host Name=foo&Host Name=` will suggest only `foo` for the second filter. So, we have + // to force the filter processor to optimize search bar filter + $searchFilter->metaData()->set('forceOptimization', true); + $inputFilter->metaData()->set('forceOptimization', false); + } else { + $searchFilter = $inputFilter; + } + + $query->filter($searchFilter); + // Not to suggest something like Port=443,443,443.... + $query->getSelectBase()->distinct(); + + try { + $steps = Str::trimSplit($column, '.'); + $columnName = array_pop($steps); + if ($steps[0] === $model->getTableAlias()) { + array_shift($steps); + } + + foreach ($query as $row) { + $model = $row; + foreach ($steps as $step) { + try { + $model = $model->$step; + } catch (Exception $_) { + // pass + break; + } + } + + $value = $model->$columnName; + if ($value && is_string($value) && ! ctype_print($value)) { // Is binary + $value = bin2hex($value); + } elseif ($value === false || $value === true) { + // TODO: The search bar is never going to suggest boolean types, so this + // is a hack to workaround this limitation!! + $value = $value ? 'y' : 'n'; + } + + yield $value; + } + } catch (InvalidColumnException $e) { + throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn())); + } + } + + protected function fetchColumnSuggestions($searchTerm) + { + $model = $this->model; + $query = $model::on(Database::get()); + + yield from self::collectFilterColumns($model, $query->getResolver()); + } + + public static function collectFilterColumns(Model $model, Resolver $resolver) + { + if ($model instanceof UnionModel) { + $models = []; + foreach ($model->getUnions() as $union) { + /** @var Model $unionModel */ + $unionModel = new $union[0](); + $models[$unionModel->getTableAlias()] = $unionModel; + self::collectRelations($resolver, $unionModel, $models, []); + } + } else { + $models = [$model->getTableAlias() => $model]; + self::collectRelations($resolver, $model, $models, []); + } + + /** @var Model $targetModel */ + foreach ($models as $path => $targetModel) { + foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) { + yield "$path.$columnName" => $definition->getLabel(); + } + } + } + + protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path) + { + foreach ($resolver->getRelations($subject) as $name => $relation) { + /** @var Relation $relation */ + $isHasOne = $relation instanceof HasOne; + $relationPath = [$name]; + + if (! isset($models[$name]) && ! in_array($name, $path, true)) { + if ($isHasOne || empty($path)) { + array_unshift($relationPath, $subject->getTableAlias()); + } + + $relationPath = array_merge($path, $relationPath); + $targetPath = implode('.', $relationPath); + + if (! isset($models[$targetPath])) { + $models[$targetPath] = $relation->getTarget(); + self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath); + return; + } + } else { + $path = []; + } + } + } +} diff --git a/library/X509/Widget/JobDetails.php b/library/X509/Widget/JobDetails.php new file mode 100644 index 0000000..c1e3843 --- /dev/null +++ b/library/X509/Widget/JobDetails.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Widget; + +use Icinga\Module\X509\Model\X509JobRun; +use ipl\Html\Table; +use ipl\I18n\Translation; +use ipl\Orm\Query; +use ipl\Web\Widget\EmptyStateBar; + +class JobDetails extends Table +{ + use Translation; + + protected $defaultAttributes = ['class' => 'common-table']; + + /** @var Query */ + protected $runs; + + public function __construct(Query $runs) + { + $this->runs = $runs; + } + + protected function assemble(): void + { + /** @var X509JobRun $run */ + foreach ($this->runs as $run) { + $row = static::tr(); + $row->addHtml( + static::td($run->job->name), + static::td($run->schedule->name ?: $this->translate('N/A')), + static::td((string) $run->total_targets), + static::td((string) $run->finished_targets), + static::td($run->start_time->format('Y-m-d H:i')), + static::td($run->end_time ? $run->end_time->format('Y-m-d H:i') : 'N/A') + ); + + $this->addHtml($row); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar($this->translate('Job never run.'))); + } else { + $row = static::tr(); + $row->addHtml( + static::th($this->translate('Name')), + static::th($this->translate('Schedule Name')), + static::th($this->translate('Total')), + static::th($this->translate('Scanned')), + static::th($this->translate('Started')), + static::th($this->translate('Finished')) + ); + + $this->getHeader()->addHtml($row); + } + } +} diff --git a/library/X509/Widget/Jobs.php b/library/X509/Widget/Jobs.php new file mode 100644 index 0000000..997e7ef --- /dev/null +++ b/library/X509/Widget/Jobs.php @@ -0,0 +1,64 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Widget; + +use Icinga\Module\X509\Common\Links; +use Icinga\Module\X509\Model\X509Job; +use ipl\Html\Table; +use ipl\I18n\Translation; +use ipl\Orm\Query; +use ipl\Web\Widget\EmptyStateBar; +use ipl\Web\Widget\Link; + +class Jobs extends Table +{ + use Translation; + + /** @var Query */ + protected $jobs; + + protected $defaultAttributes = [ + 'class' => 'common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function __construct(Query $jobs) + { + $this->jobs = $jobs; + } + + protected function assemble(): void + { + $jobs = $this->jobs->execute(); + if (! $jobs->hasResult()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar($this->translate('No jobs configured yet.'))); + + return; + } + + $headers = static::tr(); + $headers->addHtml( + static::th($this->translate('Name')), + static::th($this->translate('Author')), + static::th($this->translate('Date Created')), + static::th($this->translate('Date Modified')) + ); + $this->getHeader()->addHtml($headers); + + /** @var X509Job $job */ + foreach ($jobs as $job) { + $row = static::tr(); + $row->addHtml( + static::td(new Link($job->name, Links::job($job))), + static::td($job->author), + static::td($job->ctime->format('Y-m-d H:i')), + static::td($job->mtime->format('Y-m-d H:i')) + ); + + $this->addHtml($row); + } + } +} diff --git a/library/X509/Widget/Schedules.php b/library/X509/Widget/Schedules.php new file mode 100644 index 0000000..9f37986 --- /dev/null +++ b/library/X509/Widget/Schedules.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\X509\Widget; + +use Icinga\Module\X509\Common\Links; +use Icinga\Module\X509\Model\X509Schedule; +use ipl\Html\Table; +use ipl\I18n\Translation; +use ipl\Orm\Query; +use ipl\Web\Widget\EmptyStateBar; +use ipl\Web\Widget\Link; + +class Schedules extends Table +{ + use Translation; + + protected $defaultAttributes = [ + 'class' => 'common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + /** @var Query */ + protected $schedules; + + public function __construct(Query $schedules) + { + $this->schedules = $schedules; + } + + protected function assemble(): void + { + /** @var X509Schedule $schedule */ + foreach ($this->schedules as $schedule) { + $row = static::tr(); + $row->addHtml( + static::td(new Link($schedule->name, Links::updateSchedule($schedule))), + static::td($schedule->author), + static::td($schedule->ctime->format('Y-m-d H:i')), + static::td($schedule->mtime->format('Y-m-d H:i')) + ); + + $this->addHtml($row); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar($this->translate('No job schedules.'))); + } else { + $row = static::tr(); + $row->addHtml( + static::th($this->translate('Name')), + static::th($this->translate('Author')), + static::th($this->translate('Date Created')), + static::th($this->translate('Date Modified')) + ); + $this->getHeader()->addHtml($row); + } + } +} diff --git a/module.info b/module.info new file mode 100644 index 0000000..aec9dfb --- /dev/null +++ b/module.info @@ -0,0 +1,6 @@ +Module: Certificate Monitoring +Version: 1.3.2 +Requires: + Libraries: icinga-php-library (>=0.13.0), icinga-php-thirdparty (>=0.12.0) + Modules: monitoring (>=2.9.0), icingadb (>=1.0.0) +Description: Scan and view X.509 certificate usage diff --git a/phpstan-baseline-7x.neon b/phpstan-baseline-7x.neon new file mode 100644 index 0000000..62ec10c --- /dev/null +++ b/phpstan-baseline-7x.neon @@ -0,0 +1,96 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#1 \\$x509certdata of function openssl_x509_read expects resource\\|string, mixed given\\.$#" + count: 1 + path: application/clicommands/ImportCommand.php + + - + message: "#^Parameter \\#1 \\$data of function bin2hex expects string, mixed given\\.$#" + count: 2 + path: library/X509/CertificateDetails.php + + - + message: "#^Parameter \\#1 \\$x509cert of function openssl_x509_parse expects resource\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Parameter \\#2 \\$pieces of function implode expects array, array\\<int, string\\>\\|false given\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Parameter \\#1 \\$certificate of function openssl_pkey_get_public expects resource\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$key of function openssl_pkey_get_details expects resource, resource\\|false given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$x509 of function openssl_x509_export expects resource\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$x509 of function openssl_x509_fingerprint expects resource\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$x509cert of function openssl_x509_parse expects resource\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#2 \\$str of function explode expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$a of function gmp_add expects GMP\\|int\\|string, GMP\\|null given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$a of function gmp_and expects GMP\\|int\\|string, GMP\\|null given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$input of function str_pad expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$str of function base64_encode expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/DERBase64.php + + - + message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/DERBase64.php + + - + message: "#^Parameter \\#1 \\$in_addr of function inet_ntop expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php + + - + message: "#^Parameter \\#1 \\$input of function str_pad expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php + + - + message: "#^Parameter \\#1 \\$ip_address of function inet_pton expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php + + - + message: "#^Parameter \\#1 \\$str of function ltrim expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php diff --git a/phpstan-baseline-8x.neon b/phpstan-baseline-8x.neon new file mode 100644 index 0000000..7ffc0fe --- /dev/null +++ b/phpstan-baseline-8x.neon @@ -0,0 +1,96 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#1 \\$certificate of function openssl_x509_read expects OpenSSLCertificate\\|string, mixed given\\.$#" + count: 1 + path: application/clicommands/ImportCommand.php + + - + message: "#^Parameter \\#1 \\$certificate of function openssl_x509_parse expects OpenSSLCertificate\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#" + count: 2 + path: library/X509/CertificateDetails.php + + - + message: "#^Parameter \\#2 \\$array of function implode expects array\\|null, array\\<int, string\\>\\|false given\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Parameter \\#1 \\$certificate of function openssl_x509_export expects OpenSSLCertificate\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$certificate of function openssl_x509_fingerprint expects OpenSSLCertificate\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$certificate of function openssl_x509_parse expects OpenSSLCertificate\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$key of function openssl_pkey_get_details expects OpenSSLAsymmetricKey, OpenSSLAsymmetricKey\\|false given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$public_key of function openssl_pkey_get_public expects array\\|OpenSSLAsymmetricKey\\|OpenSSLCertificate\\|string, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$num1 of function gmp_add expects GMP\\|int\\|string, GMP\\|null given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$num1 of function gmp_and expects GMP\\|int\\|string, GMP\\|null given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$string of function str_pad expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$string of function base64_encode expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/DERBase64.php + + - + message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/DERBase64.php + + - + message: "#^Parameter \\#1 \\$ip of function inet_ntop expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php + + - + message: "#^Parameter \\#1 \\$ip of function inet_pton expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php + + - + message: "#^Parameter \\#1 \\$string of function ltrim expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php + + - + message: "#^Parameter \\#1 \\$string of function str_pad expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/Model/Behavior/Ip.php diff --git a/phpstan-baseline-by-php-version.php b/phpstan-baseline-by-php-version.php new file mode 100644 index 0000000..2099535 --- /dev/null +++ b/phpstan-baseline-by-php-version.php @@ -0,0 +1,10 @@ +<?php + +$includes = []; +if (PHP_VERSION_ID >= 80000) { + $includes[] = __DIR__ . '/phpstan-baseline-8x.neon'; +} else { + $includes[] = __DIR__ . '/phpstan-baseline-7x.neon'; +} + +return ['includes' => $includes]; diff --git a/phpstan-baseline-common.neon b/phpstan-baseline-common.neon new file mode 100644 index 0000000..651eb56 --- /dev/null +++ b/phpstan-baseline-common.neon @@ -0,0 +1,1111 @@ +parameters: + ignoreErrors: + - + message: "#^Cannot access offset 'self_signed' on mixed\\.$#" + count: 1 + path: application/clicommands/CheckCommand.php + + - + message: "#^Cannot access offset 'subject' on mixed\\.$#" + count: 1 + path: application/clicommands/CheckCommand.php + + - + message: "#^Cannot access property \\$chain on mixed\\.$#" + count: 2 + path: application/clicommands/CheckCommand.php + + - + message: "#^Cannot access property \\$subject on mixed\\.$#" + count: 6 + path: application/clicommands/CheckCommand.php + + - + message: "#^Cannot access property \\$valid_from on mixed\\.$#" + count: 1 + path: application/clicommands/CheckCommand.php + + - + message: "#^Cannot access property \\$valid_to on mixed\\.$#" + count: 1 + path: application/clicommands/CheckCommand.php + + - + message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|false\\.$#" + count: 5 + path: application/clicommands/CheckCommand.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:hostAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/clicommands/CheckCommand.php + + - + message: "#^Parameter \\#1 \\$from of method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:thresholdToDateTime\\(\\) expects DateTime, DateTime\\|false given\\.$#" + count: 2 + path: application/clicommands/CheckCommand.php + + - + message: "#^Parameter \\#1 \\$targetObject of method DateTime\\:\\:diff\\(\\) expects DateTimeInterface, DateTime\\|false given\\.$#" + count: 1 + path: application/clicommands/CheckCommand.php + + - + message: "#^Parameter \\#1 \\$threshold of method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:splitThreshold\\(\\) expects string, mixed given\\.$#" + count: 2 + path: application/clicommands/CheckCommand.php + + - + message: "#^Parameter \\#2 \\$to of method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:thresholdToDateTime\\(\\) expects DateTime, DateTime\\|false given\\.$#" + count: 2 + path: application/clicommands/CheckCommand.php + + - + message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#" + count: 3 + path: application/clicommands/CheckCommand.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CleanupCommand\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/clicommands/CleanupCommand.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\ImportCommand\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/clicommands/ImportCommand.php + + - + message: "#^Parameter \\#1 \\$file of static method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:parseBundle\\(\\) expects string, mixed given\\.$#" + count: 1 + path: application/clicommands/ImportCommand.php + + - + message: "#^Parameter \\#1 \\$filename of function file_exists expects string, mixed given\\.$#" + count: 1 + path: application/clicommands/ImportCommand.php + + - + message: "#^Cannot cast mixed to int\\.$#" + count: 1 + path: application/clicommands/JobsCommand.php + + - + message: "#^Cannot cast mixed to string\\.$#" + count: 2 + path: application/clicommands/JobsCommand.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\VerifyCommand\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/clicommands/VerifyCommand.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificateController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/CertificateController.php + + - + message: "#^Parameter \\#1 \\$cert of method Icinga\\\\Module\\\\X509\\\\CertificateDetails\\:\\:setCert\\(\\) expects Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate, Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\|null given\\.$#" + count: 1 + path: application/controllers/CertificateController.php + + - + message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#" + count: 1 + path: application/controllers/CertificateController.php + + - + message: "#^Cannot call method format\\(\\) on mixed\\.$#" + count: 2 + path: application/controllers/CertificatesController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificatesController\\:\\:completeAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/CertificatesController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificatesController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/CertificatesController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificatesController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/CertificatesController.php + + - + message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#" + count: 1 + path: application/controllers/CertificatesController.php + + - + message: "#^Cannot access property \\$hostname on mixed\\.$#" + count: 1 + path: application/controllers/ChainController.php + + - + message: "#^Cannot access property \\$ip on mixed\\.$#" + count: 1 + path: application/controllers/ChainController.php + + - + message: "#^Cannot access property \\$port on mixed\\.$#" + count: 1 + path: application/controllers/ChainController.php + + - + message: "#^Cannot access property \\$target on Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\|null\\.$#" + count: 3 + path: application/controllers/ChainController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\ChainController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/ChainController.php + + - + message: "#^Offset 'invalid_reason' does not exist on Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\|null\\.$#" + count: 1 + path: application/controllers/ChainController.php + + - + message: "#^Offset 'valid' does not exist on Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\|null\\.$#" + count: 1 + path: application/controllers/ChainController.php + + - + message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#" + count: 2 + path: application/controllers/ChainController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\ConfigController\\:\\:backendAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/ConfigController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\DashboardController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/DashboardController.php + + - + message: "#^Call to an undefined method Icinga\\\\Module\\\\X509\\\\Model\\\\X509JobRun\\|ipl\\\\Orm\\\\Query\\:\\:with\\(\\)\\.$#" + count: 1 + path: application/controllers/JobController.php + + - + message: "#^Call to an undefined method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Schedule\\|ipl\\\\Orm\\\\Query\\:\\:with\\(\\)\\.$#" + count: 1 + path: application/controllers/JobController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\JobsController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/JobsController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\JobsController\\:\\:newAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/JobsController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/SniController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:newAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/SniController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:removeAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/SniController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:updateAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/SniController.php + + - + message: "#^Parameter \\#1 \\$name of method Icinga\\\\Forms\\\\RepositoryForm\\:\\:edit\\(\\) expects string, mixed given\\.$#" + count: 1 + path: application/controllers/SniController.php + + - + message: "#^Parameter \\#1 \\$name of method Icinga\\\\Forms\\\\RepositoryForm\\:\\:remove\\(\\) expects string, mixed given\\.$#" + count: 1 + path: application/controllers/SniController.php + + - + message: "#^Cannot access property \\$target on mixed\\.$#" + count: 3 + path: application/controllers/UsageController.php + + - + message: "#^Cannot access property \\$valid on mixed\\.$#" + count: 1 + path: application/controllers/UsageController.php + + - + message: "#^Cannot call method format\\(\\) on mixed\\.$#" + count: 2 + path: application/controllers/UsageController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\UsageController\\:\\:completeAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/UsageController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\UsageController\\:\\:indexAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/UsageController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\UsageController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#" + count: 1 + path: application/controllers/UsageController.php + + - + message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#" + count: 1 + path: application/controllers/UsageController.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\BackendConfigForm\\:\\:createElements\\(\\) has no return type specified\\.$#" + count: 1 + path: application/forms/Config/BackendConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\BackendConfigForm\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#" + count: 1 + path: application/forms/Config/BackendConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createDeleteElements\\(\\) has no return type specified\\.$#" + count: 1 + path: application/forms/Config/SniConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createDeleteElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#" + count: 1 + path: application/forms/Config/SniConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createInsertElements\\(\\) has no return type specified\\.$#" + count: 1 + path: application/forms/Config/SniConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createInsertElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#" + count: 1 + path: application/forms/Config/SniConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createUpdateElements\\(\\) has no return type specified\\.$#" + count: 1 + path: application/forms/Config/SniConfigForm.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createUpdateElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#" + count: 1 + path: application/forms/Config/SniConfigForm.php + + - + message: "#^Cannot access offset 'issuer' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Cannot access offset 'subject' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Cannot call method format\\(\\) on mixed\\.$#" + count: 2 + path: library/X509/CertificateDetails.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateDetails\\:\\:assemble\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateDetails\\:\\:setCert\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/CertificateDetails.php + + - + message: "#^Cannot access offset 'bits' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'issuer' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'name' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'serialNumber' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'signatureTypeSN' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'subject' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'type' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'validFrom_time_t' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'validTo_time_t' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access offset 'version' on array\\|false\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Cannot access property \\$id on mixed\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:cleanupNoLongerUsedCertificates\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertCert\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has parameter \\$certInfo with no type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has parameter \\$db with no type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has parameter \\$type with no type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:insertSANs\\(\\) has parameter \\$certId with no type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:insertSANs\\(\\) has parameter \\$db with no type specified\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:insertSANs\\(\\) has parameter \\$sans with no value type specified in iterable type iterable\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:shortNameFromDN\\(\\) has parameter \\$dn with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:splitSANs\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$binary of method Icinga\\\\Module\\\\X509\\\\DbTool\\:\\:marshalBinary\\(\\) expects string, string\\|false given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$chainId of closure expects int, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#1 \\$orderBy of method ipl\\\\Orm\\\\Query\\:\\:orderBy\\(\\) expects array\\|int\\|string, ipl\\\\Sql\\\\Expression given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#2 \\$collection of closure expects array, mixed given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Parameter \\#2 \\$groupBy of function ipl\\\\Stdlib\\\\yield_groups expects callable\\(mixed, mixed\\)\\: array\\{0\\: mixed, 1\\?\\: mixed, 2\\?\\: mixed\\}, Closure\\(Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\)\\: array\\{mixed, mixed\\} given\\.$#" + count: 1 + path: library/X509/CertificateUtils.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificatesTable\\:\\:createColumns\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/CertificatesTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificatesTable\\:\\:renderRow\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/CertificatesTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ChainDetails\\:\\:createColumns\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/ChainDetails.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ChainDetails\\:\\:renderRow\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/ChainDetails.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ColorScheme\\:\\:__construct\\(\\) has parameter \\$colors with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/ColorScheme.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ColorScheme\\:\\:scheme\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/ColorScheme.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\ColorScheme\\:\\:\\$colors type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/ColorScheme.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Command\\:\\:init\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Command.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Command\\:\\:\\$configs has no type specified\\.$#" + count: 1 + path: library/X509/Command.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controller\\:\\:fetchFilterColumns\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Controller.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Controller\\:\\:handleFormatRequest\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Controller.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Controller\\:\\:\\$format has no type specified\\.$#" + count: 1 + path: library/X509/Controller.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:assemble\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:createColumns\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:getData\\(\\) return type has no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\|Traversable\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderBody\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderBody\\(\\) has parameter \\$data with no type specified\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderHeader\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderRow\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\|Traversable\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$columns type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$data type has no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$data type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$data type has no value type specified in iterable type array\\|Traversable\\.$#" + count: 1 + path: library/X509/DataTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\DbTool\\:\\:unmarshalBinary\\(\\) should return string but returns string\\|false\\.$#" + count: 1 + path: library/X509/DbTool.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\DbTool\\:\\:\\$pgsql has no type specified\\.$#" + count: 1 + path: library/X509/DbTool.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:assemble\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:getData\\(\\) return type has no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\|Traversable\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\|Traversable\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setHeading\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setHeading\\(\\) has parameter \\$heading with no type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setHeading\\(\\) has parameter \\$level with no type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setLabelCallback\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$data type has no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$data type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$data type has no value type specified in iterable type array\\|Traversable\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$heading has no type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$headingLevel has no type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$labelCallback has no type specified\\.$#" + count: 1 + path: library/X509/Donut.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:__construct\\(\\) has parameter \\$from with no type specified\\.$#" + count: 1 + path: library/X509/ExpirationWidget.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:__construct\\(\\) has parameter \\$to with no type specified\\.$#" + count: 1 + path: library/X509/ExpirationWidget.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:assemble\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/ExpirationWidget.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:\\$from has no type specified\\.$#" + count: 1 + path: library/X509/ExpirationWidget.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:\\$to has no type specified\\.$#" + count: 1 + path: library/X509/ExpirationWidget.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:addFilter\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:applyFilter\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:getFilter\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:setFilter\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:where\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:where\\(\\) has parameter \\$condition with no type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:where\\(\\) has parameter \\$value with no type specified\\.$#" + count: 1 + path: library/X509/FilterAdapter.php + + - + message: "#^Parameter \\#1 \\$value of static method Icinga\\\\Util\\\\StringHelper\\:\\:trimSplit\\(\\) expects string, mixed given\\.$#" + count: 1 + path: library/X509/Hook/SniHook.php + + - + message: "#^Cannot access property \\$fingerprint on mixed\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Cannot access property \\$hostname on mixed\\.$#" + count: 2 + path: library/X509/Job.php + + - + message: "#^Cannot access property \\$ip on mixed\\.$#" + count: 4 + path: library/X509/Job.php + + - + message: "#^Cannot access property \\$port on mixed\\.$#" + count: 2 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:finishTarget\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:formatTarget\\(\\) has parameter \\$target with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:getConnector\\(\\) has parameter \\$peerName with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:getConnector\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:isIPV6\\(\\) has parameter \\$addr with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:numberToAddr\\(\\) has parameter \\$num with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:processChain\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:processChain\\(\\) has parameter \\$chain with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:processChain\\(\\) has parameter \\$target with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:startNextTarget\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:updateLastScan\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:updateLastScan\\(\\) has parameter \\$target with no type specified\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Negated boolean expression is always true\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#1 \\$addr of static method Icinga\\\\Module\\\\X509\\\\Job\\:\\:isAddrInside\\(\\) expects GMP, GMP\\|null given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:lessThan\\(\\) expects float\\|int\\|string, DateTime given\\.$#" + count: 1 + path: library/X509/Job.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\Behavior\\\\ExpressionInjector\\:\\:__construct\\(\\) has parameter \\$columns with no type specified\\.$#" + count: 1 + path: library/X509/Model/Behavior/ExpressionInjector.php + + - + message: "#^Parameter \\#1 \\$path of method ipl\\\\Orm\\\\Resolver\\:\\:resolveRelation\\(\\) expects string, mixed given\\.$#" + count: 1 + path: library/X509/Model/Behavior/ExpressionInjector.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Model\\\\Behavior\\\\ExpressionInjector\\:\\:\\$columns type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Model/Behavior/ExpressionInjector.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509Certificate.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:createRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509Certificate.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Model/X509Certificate.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Model/X509Certificate.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509CertificateChain.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\:\\:createRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509CertificateChain.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChainLink\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509CertificateChainLink.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChainLink\\:\\:createRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509CertificateChainLink.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateSubjectAltName\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509CertificateSubjectAltName.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateSubjectAltName\\:\\:createRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509CertificateSubjectAltName.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Dn\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509Dn.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Dn\\:\\:createRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509Dn.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509Target.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:createRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Model/X509Target.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Model/X509Target.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Model/X509Target.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\HostsImportSource\\:\\:fetchData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/ProvidedHook/HostsImportSource.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\HostsImportSource\\:\\:listColumns\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/ProvidedHook/HostsImportSource.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\ServicesImportSource\\:\\:fetchData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/ProvidedHook/ServicesImportSource.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\ServicesImportSource\\:\\:listColumns\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/ProvidedHook/ServicesImportSource.php + + - + message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" + count: 1 + path: library/X509/ProvidedHook/ServicesImportSource.php + + - + message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" + count: 1 + path: library/X509/ProvidedHook/ServicesImportSource.php + + - + message: "#^Parameter \\#4 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" + count: 1 + path: library/X509/ProvidedHook/ServicesImportSource.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\React\\\\StreamOptsCaptureConnector\\:\\:getCapturedStreamOptions\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/React/StreamOptsCaptureConnector.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\React\\\\StreamOptsCaptureConnector\\:\\:setCapturedStreamOptions\\(\\) has parameter \\$capturedStreamOptions with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/React/StreamOptsCaptureConnector.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\React\\\\StreamOptsCaptureConnector\\:\\:\\$capturedStreamOptions type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/React/StreamOptsCaptureConnector.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\SniIniRepository\\:\\:\\$configs type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/SniIniRepository.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\SniIniRepository\\:\\:\\$queryColumns type has no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/SniIniRepository.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Table\\:\\:addRow\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Table.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Table\\:\\:addRow\\(\\) has parameter \\$attributes with no type specified\\.$#" + count: 1 + path: library/X509/Table.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Table\\:\\:addRow\\(\\) has parameter \\$cells with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Table.php + + - + message: "#^Property Icinga\\\\Module\\\\X509\\\\Table\\:\\:\\$rows has no type specified\\.$#" + count: 1 + path: library/X509/Table.php + + - + message: "#^Cannot access property \\$id on mixed\\.$#" + count: 1 + path: library/X509/UsageTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\UsageTable\\:\\:createColumns\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/UsageTable.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\UsageTable\\:\\:renderRow\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/UsageTable.php + + - + message: "#^Dead catch \\- Exception is never thrown in the try block\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectFilterColumns\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has no return type specified\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has parameter \\$models with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has parameter \\$path with no value type specified in iterable type array\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:fetchColumnSuggestions\\(\\) return type has no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:fetchValueSuggestions\\(\\) return type has no value type specified in iterable type Traversable\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Parameter \\#1 \\$path of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyPath\\(\\) expects string, mixed given\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php + + - + message: "#^Parameter \\#1 \\$subject of static method ipl\\\\Stdlib\\\\Str\\:\\:trimSplit\\(\\) expects string\\|null, mixed given\\.$#" + count: 1 + path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..efeb32e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,42 @@ +includes: + - phpstan-baseline-common.neon + - phpstan-baseline-by-php-version.php + +parameters: + level: max + + checkFunctionNameCase: true + checkInternalClassCaseSensitivity: true + treatPhpDocTypesAsCertain: false + + paths: + - application + - library + + scanDirectories: + - /icingaweb2 + - /usr/share/icingaweb2-modules/director + - /usr/share/icinga-php/ipl + - /usr/share/icinga-php/vendor + + ignoreErrors: + - + messages: + - '#Unsafe usage of new static\(\)#' + - '#. but return statement is missing#' + reportUnmatched: false + + - '#Call to an undefined method React\\Promise#' + + - '#Call to an undefined method ipl\\Sql\\Connection::lastInsertId\(\)#' + + - '#Access to an undefined property React\\Socket\\ConnectionInterface::\$stream#' + + - '#Binary operation .* between GMP and .* results in an error#' + + - '#Access to an undefined property (Icinga\\Module\\X509\\Model\\.*|ipl\\Orm\\Query::.*)#' + + universalObjectCratesClasses: + - ipl\Orm\Model + - Icinga\Web\View + - Icinga\Data\ConfigObject diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..09e75ee --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit backupGlobals="false" + backupStaticAttributes="false" + colors="true" + convertErrorsToExceptions="true" + convertNoticesToExceptions="true" + convertWarningsToExceptions="true" + processIsolation="false" + stopOnFailure="false" +> + <testsuites> + <testsuite name="Icinga Certificate Monitoring PHP Unit Tests"> + <directory suffix="Test.php">test/php</directory> + </testsuite> + </testsuites> +</phpunit> diff --git a/public/css/module.less b/public/css/module.less new file mode 100644 index 0000000..2a59ae7 --- /dev/null +++ b/public/css/module.less @@ -0,0 +1,171 @@ +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +@cert-segment-color-0: #FF595E; +@cert-segment-color-1: #FFCA3A; +@cert-segment-color-2: #8AC926; +@cert-segment-color-3: #1982C4; +@cert-segment-color-4: #6A4C93; + +.action-bar { + line-height: 2.5em; +} + +.cert-details { + .iicon-certificate { + 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; + } + } +} + +.expiration-col .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; + + span.progress-bar-label { + font-size: 0.9em; + } +} + +.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; + + .donut-graph { + .segment-0 { + stroke: @cert-segment-color-0; + } + + .segment-1 { + stroke: @cert-segment-color-1; + } + + .segment-2 { + stroke: @cert-segment-color-2; + } + + .segment-3 { + stroke: @cert-segment-color-3; + } + + .segment-4 { + stroke: @cert-segment-color-4; + } + } + + .badge { + height: 1.75em; + + &.badge-0 { + background: @cert-segment-color-0; + } + + &.badge-1 { + background: @cert-segment-color-1; + } + + &.badge-2 { + background: @cert-segment-color-2; + } + + &.badge-3 { + background: @cert-segment-color-3; + } + + &.badge-4 { + background: @cert-segment-color-4; + } + } +} + +.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; + } +} + +.schedule-element-separator { + border-top: 1px solid @gray-lighter; +} @@ -0,0 +1,10 @@ +<?php + +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +/** @var \Icinga\Application\Modules\Module $this */ + +$this->provideHook('DbMigration', '\\Icinga\\Module\\X509\\ProvidedHook\\DbMigration'); + +$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\HostsImportSource'); +$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\ServicesImportSource'); diff --git a/schema/mysql-upgrades/1.0.0.sql b/schema/mysql-upgrades/1.0.0.sql new file mode 100644 index 0000000..28b3e7d --- /dev/null +++ b/schema/mysql-upgrades/1.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/schema/mysql-upgrades/1.1.0.sql b/schema/mysql-upgrades/1.1.0.sql new file mode 100644 index 0000000..055d783 --- /dev/null +++ b/schema/mysql-upgrades/1.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/schema/mysql-upgrades/1.2.0.sql b/schema/mysql-upgrades/1.2.0.sql new file mode 100644 index 0000000..1fdd74f --- /dev/null +++ b/schema/mysql-upgrades/1.2.0.sql @@ -0,0 +1,103 @@ +ALTER TABLE x509_certificate + MODIFY self_signed enum('n', 'y', 'yes', 'no') NOT NULL DEFAULT 'n', + MODIFY ca enum('n', 'y', 'yes', 'no') NOT NULL, + MODIFY trusted enum('n', 'y', 'yes', 'no') NOT NULL DEFAULT 'n', + ADD COLUMN ctime_tmp bigint unsigned DEFAULT NULL, + ADD COLUMN mtime_tmp bigint unsigned DEFAULT NULL; + +UPDATE x509_certificate SET self_signed = 'y' WHERE self_signed = 'yes'; +UPDATE x509_certificate SET self_signed = 'n' WHERE self_signed = 'no'; + +UPDATE x509_certificate SET ca = 'y' WHERE ca = 'yes'; +UPDATE x509_certificate SET ca = 'n' WHERE ca = 'no'; + +UPDATE x509_certificate SET trusted = 'y' WHERE trusted = 'yes'; +UPDATE x509_certificate SET trusted = 'n' WHERE trusted = 'no'; + +UPDATE x509_certificate SET mtime_tmp = UNIX_TIMESTAMP(mtime) * 1000.0, ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0; +UPDATE x509_certificate SET valid_from = valid_from * 1000.0, valid_to = valid_to * 1000.0; + +ALTER TABLE x509_certificate + MODIFY self_signed enum('n', 'y') NOT NULL DEFAULT 'n', + MODIFY ca enum('n', 'y') NOT NULL, + MODIFY trusted enum('n', 'y') NOT NULL DEFAULT 'n', + DROP COLUMN mtime, + DROP COLUMN ctime, + CHANGE COLUMN ctime_tmp ctime bigint unsigned DEFAULT NULL, + CHANGE COLUMN mtime_tmp mtime bigint unsigned DEFAULT NULL; + +ALTER TABLE x509_certificate_chain + MODIFY valid enum('n', 'y', 'yes', 'no') NOT NULL DEFAULT 'n', + ADD COLUMN ctime_tmp bigint unsigned NOT NULL; + +UPDATE x509_certificate_chain SET valid = 'y' WHERE valid = 'yes'; +UPDATE x509_certificate_chain SET valid = 'n' WHERE valid = 'no'; + +UPDATE x509_certificate_chain SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0; + +ALTER TABLE x509_certificate_chain + MODIFY valid enum('n', 'y') NOT NULL DEFAULT 'n', + DROP ctime, + CHANGE ctime_tmp ctime bigint unsigned NOT NULL; + +ALTER TABLE x509_certificate_chain_link ADD COLUMN ctime_tmp bigint unsigned NOT NULL; + +UPDATE x509_certificate_chain_link SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0; + +ALTER TABLE x509_certificate_chain_link + DROP COLUMN ctime, + CHANGE ctime_tmp ctime bigint unsigned NOT NULL; + +ALTER TABLE x509_certificate_subject_alt_name ADD COLUMN ctime_tmp bigint unsigned NOT NULL; + +UPDATE x509_certificate_subject_alt_name SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0; + +ALTER TABLE x509_certificate_subject_alt_name + DROP COLUMN ctime, + CHANGE ctime_tmp ctime bigint unsigned NOT NULL; + +ALTER TABLE x509_dn ADD COLUMN ctime_tmp bigint unsigned NOT NULL; + +UPDATE x509_dn SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0; + +ALTER TABLE x509_dn + DROP COLUMN ctime, + CHANGE ctime_tmp ctime bigint unsigned NOT NULL; + +ALTER TABLE x509_job_run + ADD COLUMN starttime_tmp bigint unsigned DEFAULT NULL, + ADD COLUMN endtime_tmp bigint unsigned DEFAULT NULL, + ADD COLUMN ctime_tmp bigint unsigned DEFAULT NULL, + ADD COLUMN mtime_tmp bigint unsigned DEFAULT NULL; + +UPDATE x509_job_run SET + starttime_tmp = UNIX_TIMESTAMP(start_time) * 1000.0, + endtime_tmp = UNIX_TIMESTAMP(end_time) * 1000.0, + ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0, + mtime_tmp = UNIX_TIMESTAMP(mtime) * 1000.0; + +ALTER TABLE x509_job_run + DROP COLUMN start_time, + DROP COLUMN end_time, + DROP COLUMN mtime, + DROP COLUMN ctime, + CHANGE starttime_tmp start_time bigint unsigned DEFAULT NULL, + CHANGE endtime_tmp end_time bigint unsigned DEFAULT NULL, + CHANGE ctime_tmp ctime bigint unsigned DEFAULT NULL, + CHANGE mtime_tmp mtime bigint unsigned DEFAULT NULL; + +ALTER TABLE x509_target ADD COLUMN last_scan bigint unsigned DEFAULT NULL AFTER latest_certificate_chain_id; +UPDATE x509_target SET last_scan = UNIX_TIMESTAMP() * 1000.0; +ALTER TABLE x509_target MODIFY COLUMN last_scan bigint unsigned NOT NULL; + +ALTER TABLE x509_target + ADD COLUMN ctime_tmp bigint unsigned DEFAULT NULL, + ADD COLUMN mtime_tmp bigint unsigned DEFAULT NULL; + +UPDATE x509_target SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0, mtime_tmp = UNIX_TIMESTAMP(mtime) * 1000.0; + +ALTER TABLE x509_target + DROP COLUMN ctime, + DROP COLUMN mtime, + CHANGE ctime_tmp ctime bigint unsigned DEFAULT NULL, + CHANGE mtime_tmp mtime bigint unsigned DEFAULT NULL; diff --git a/schema/mysql-upgrades/1.3.0.sql b/schema/mysql-upgrades/1.3.0.sql new file mode 100644 index 0000000..f31e8bd --- /dev/null +++ b/schema/mysql-upgrades/1.3.0.sql @@ -0,0 +1,51 @@ +CREATE TABLE x509_job ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + cidrs text NOT NULL, + ports text NOT NULL, + exclude_targets text DEFAULT NULL, + ctime bigint unsigned NOT NULL, + mtime bigint unsigned NOT NULL, + + PRIMARY KEY (id), + UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_schedule ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + job_id int(10) unsigned NOT NULL, + name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + config text NOT NULL, -- json + ctime bigint unsigned NOT NULL, + mtime bigint unsigned NOT NULL, + + PRIMARY KEY (id), + CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +DELETE FROM x509_job_run; +ALTER TABLE x509_job_run + ADD COLUMN job_id int(10) unsigned NOT NULL AFTER id, + ADD COLUMN schedule_id int(10) unsigned DEFAULT NULL AFTER job_id, + DROP COLUMN `name`, + DROP COLUMN ctime, + DROP COLUMN mtime; +ALTER TABLE x509_job_run + ADD CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE, + ADD CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE; + +CREATE TABLE x509_schema ( + id int unsigned NOT NULL AUTO_INCREMENT, + version varchar(64) NOT NULL, + timestamp bigint unsigned NOT NULL, + success enum ('n', 'y') DEFAULT NULL, + reason text DEFAULT NULL, + + PRIMARY KEY (id), + CONSTRAINT idx_x509_schema_version UNIQUE (version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC; + +INSERT INTO x509_schema (version, timestamp, success, reason) + VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y', NULL); diff --git a/schema/mysql.schema.sql b/schema/mysql.schema.sql new file mode 100644 index 0000000..7e56746 --- /dev/null +++ b/schema/mysql.schema.sql @@ -0,0 +1,136 @@ +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('n', 'y') NOT NULL DEFAULT 'n', + ca enum('n', 'y') NOT NULL, + trusted enum('n', 'y') NOT NULL DEFAULT 'n', + 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 unsigned NOT NULL, + valid_to bigint unsigned NOT NULL, + fingerprint binary(32) NOT NULL COMMENT 'sha256 hash', + `serial` blob NOT NULL, + certificate blob NOT NULL COMMENT 'DER encoded certificate', + ctime bigint unsigned DEFAULT NULL, + mtime bigint unsigned DEFAULT NULL, + 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('n', 'y') NOT NULL DEFAULT 'n', + invalid_reason varchar(255) NULL DEFAULT NULL, + ctime bigint unsigned NOT NULL, + 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 bigint unsigned NOT NULL, + 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 bigint unsigned NOT NULL, + 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 bigint unsigned NOT NULL, + PRIMARY KEY (`hash`,`type`,`order`) +) 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, + last_scan bigint unsigned NOT NULL, + ctime bigint unsigned DEFAULT NULL, + mtime bigint unsigned DEFAULT NULL, + PRIMARY KEY (id), + INDEX x509_idx_target_ip_port (ip, port) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_job ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + cidrs text NOT NULL, + ports text NOT NULL, + exclude_targets text DEFAULT NULL, + ctime bigint unsigned NOT NULL, + mtime bigint unsigned NOT NULL, + + PRIMARY KEY (id), + UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_schedule ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + job_id int(10) unsigned NOT NULL, + name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci, + config text NOT NULL, -- json + ctime bigint unsigned NOT NULL, + mtime bigint unsigned NOT NULL, + + PRIMARY KEY (id), + CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_job_run ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + job_id int(10) unsigned NOT NULL, + schedule_id int(10) unsigned DEFAULT NULL, + total_targets int(10) NOT NULL, + finished_targets int(10) NOT NULL, + start_time bigint unsigned DEFAULT NULL, + end_time bigint unsigned DEFAULT NULL, + + PRIMARY KEY (id), + CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE, + CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_schema ( + id int unsigned NOT NULL AUTO_INCREMENT, + version varchar(64) NOT NULL, + timestamp bigint unsigned NOT NULL, + success enum ('n', 'y') DEFAULT NULL, + reason text DEFAULT NULL, + + PRIMARY KEY (id), + CONSTRAINT idx_x509_schema_version UNIQUE (version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC; + +INSERT INTO x509_schema (version, timestamp, success) + VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y'); diff --git a/schema/pgsql-upgrades/1.3.0.sql b/schema/pgsql-upgrades/1.3.0.sql new file mode 100644 index 0000000..7e1f43a --- /dev/null +++ b/schema/pgsql-upgrades/1.3.0.sql @@ -0,0 +1,49 @@ +CREATE TABLE x509_job ( + id serial PRIMARY KEY, + name varchar(255) NOT NULL, + author varchar(255) NOT NULL, + cidrs text NOT NULL, + ports text NOT NULL, + exclude_targets text DEFAULT NULL, + ctime bigint NOT NULL, + mtime bigint NOT NULL, + + UNIQUE (name) +); + +CREATE TABLE x509_schedule ( + id serial PRIMARY KEY, + job_id int NOT NULL, + name varchar(255) NOT NULL, + author varchar(255) NOT NULL, + config text NOT NULL, -- json + ctime bigint NOT NULL, + mtime bigint NOT NULL, + + CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE +); + +DELETE FROM x509_job_run; +ALTER TABLE x509_job_run + ADD COLUMN job_id int NOT NULL, + ADD COLUMN schedule_id int DEFAULT NULL, + DROP COLUMN name, + DROP COLUMN ctime, + DROP COLUMN mtime; +ALTER TABLE x509_job_run + ADD CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE, + ADD CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE; + +CREATE TABLE x509_schema ( + id serial, + version varchar(64) NOT NULL, + timestamp bigint NOT NULL, + success boolenum DEFAULT NULL, + reason text DEFAULT NULL, + + CONSTRAINT pk_x509_schema PRIMARY KEY (id), + CONSTRAINT idx_x509_schema_version UNIQUE (version) +); + +INSERT INTO x509_schema (version, timestamp, success, reason) + VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y', NULL); diff --git a/schema/pgsql.schema.sql b/schema/pgsql.schema.sql new file mode 100644 index 0000000..1d93ef3 --- /dev/null +++ b/schema/pgsql.schema.sql @@ -0,0 +1,162 @@ +CREATE DOMAIN uint2 AS int4 + CHECK(VALUE >= 0 AND VALUE < 65536); +CREATE DOMAIN biguint AS bigint CONSTRAINT positive CHECK ( VALUE IS NULL OR 0 <= VALUE ); +CREATE TYPE boolenum AS ENUM ('n', 'y'); +CREATE TYPE certificate_version AS ENUM('1','2','3'); +CREATE TYPE dn_type AS ENUM('issuer','subject'); +CREATE TYPE pubkey_algo AS ENUM('unknown','RSA','DSA','DH','EC'); + +-- Used when sorting certificates by expiration date. +CREATE OR REPLACE FUNCTION UNIX_TIMESTAMP(datetime timestamptz DEFAULT NOW()) + RETURNS biguint + LANGUAGE plpgsql + PARALLEL SAFE + AS $$ +BEGIN + RETURN EXTRACT(EPOCH FROM datetime); +END; +$$; + +-- IPL ORM renders SQL queries with LIKE operators for all suggestions in the search bar, +-- which fails for numeric and enum types on PostgreSQL. Just like in Icinga DB Web. +CREATE OR REPLACE FUNCTION anynonarrayliketext(anynonarray, text) + RETURNS bool + LANGUAGE plpgsql + IMMUTABLE + PARALLEL SAFE + AS $$ +BEGIN + RETURN $1::TEXT LIKE $2; +END; +$$; +CREATE OPERATOR ~~ (LEFTARG=anynonarray, RIGHTARG=text, PROCEDURE=anynonarrayliketext); + +CREATE TABLE x509_certificate ( + id serial PRIMARY KEY, + subject varchar(255) NOT NULL, + subject_hash bytea NOT NULL, + issuer varchar(255) NOT NULL, + issuer_hash bytea NOT NULL, + issuer_certificate_id int DEFAULT NULL, + version certificate_version NOT NULL, + self_signed boolenum NOT NULL DEFAULT 'n', + ca boolenum NOT NULL, + trusted boolenum NOT NULL DEFAULT 'n', + pubkey_algo pubkey_algo NOT NULL, + pubkey_bits uint2 NOT NULL, + signature_algo varchar(255) NOT NULL, + signature_hash_algo varchar(255) NOT NULL, + valid_from biguint NOT NULL, + valid_to biguint NOT NULL, + fingerprint bytea NOT NULL, + serial bytea NOT NULL, + certificate bytea NOT NULL, + ctime biguint NOT NULL, + mtime biguint DEFAULT NULL, + CONSTRAINT x509_idx_certificate_fingerprint UNIQUE(fingerprint), + CONSTRAINT x509_fk_certificate_issuer_certificate_id FOREIGN KEY (issuer_certificate_id) REFERENCES x509_certificate (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE TABLE x509_certificate_chain ( + id serial PRIMARY KEY, + target_id int NOT NULL, + length uint2 NOT NULL, + valid boolenum NOT NULL DEFAULT 'n', + invalid_reason varchar(255) NULL DEFAULT NULL, + ctime biguint NOT NULL +); + +CREATE TABLE x509_certificate_chain_link ( + certificate_chain_id int NOT NULL, + certificate_id int NOT NULL, + "order" uint2 NOT NULL, + ctime biguint NOT NULL, + PRIMARY KEY(certificate_chain_id,certificate_id,"order"), + 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 +); + +CREATE TABLE x509_certificate_subject_alt_name ( + certificate_id int NOT NULL, + hash bytea NOT NULL, + type varchar(255) NOT NULL, + value varchar(255) NOT NULL, + ctime biguint NOT NULL, + 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 +); + +CREATE TABLE x509_dn ( + hash bytea NOT NULL, + type dn_type NOT NULL, + "order" uint2 NOT NULL, + key varchar(255) NOT NULL, + value varchar(255) NOT NULL, + ctime biguint NOT NULL, + PRIMARY KEY (hash,type,"order") +); + +CREATE TABLE x509_target ( + id serial PRIMARY KEY, + ip bytea NOT NULL, + port uint2 NOT NULL, + hostname varchar(255) NULL DEFAULT NULL, + latest_certificate_chain_id int NULL DEFAULT NULL, + last_scan biguint NOT NULL, + ctime biguint NOT NULL, + mtime biguint DEFAULT NULL +); + +CREATE INDEX x509_idx_target ON x509_target (ip,port,hostname); + +CREATE TABLE x509_job ( + id serial PRIMARY KEY, + name varchar(255) NOT NULL, + author varchar(255) NOT NULL, + cidrs text NOT NULL, + ports text NOT NULL, + exclude_targets text DEFAULT NULL, + ctime bigint NOT NULL, + mtime bigint NOT NULL, + + UNIQUE (name) +); + +CREATE TABLE x509_schedule ( + id serial PRIMARY KEY, + job_id int NOT NULL, + name varchar(255) NOT NULL, + author varchar(255) NOT NULL, + config text NOT NULL, -- json + ctime bigint NOT NULL, + mtime bigint NOT NULL, + + CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE +); + +CREATE TABLE x509_job_run ( + id serial PRIMARY KEY, + job_id int NOT NULL, + schedule_id int DEFAULT NULL, + total_targets int NOT NULL, + finished_targets int NOT NULL, + start_time biguint NULL DEFAULT NULL, + end_time biguint NULL DEFAULT NULL, + + CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE, + CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE +); + +CREATE TABLE x509_schema ( + id serial, + version varchar(64) NOT NULL, + timestamp bigint NOT NULL, + success boolenum DEFAULT NULL, + reason text DEFAULT NULL, + + CONSTRAINT pk_x509_schema PRIMARY KEY (id), + CONSTRAINT idx_x509_schema_version UNIQUE (version) +); + +INSERT INTO x509_schema (version, timestamp, success) + VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y'); diff --git a/test/php/Lib/TestModel.php b/test/php/Lib/TestModel.php new file mode 100644 index 0000000..050ca48 --- /dev/null +++ b/test/php/Lib/TestModel.php @@ -0,0 +1,30 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Tests\Icinga\Module\X509\Lib; + +use ipl\Orm\Model; +use ipl\Sql\Expression; + +class TestModel extends Model +{ + public const EXPRESSION = 'CASE WHEN 1 THEN YES ELSE NO'; + + public function getTableName() + { + return 'test'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'duration' => new Expression(static::EXPRESSION) + ]; + } +} diff --git a/test/php/library/X509/Common/JobUtilsTest.php b/test/php/library/X509/Common/JobUtilsTest.php new file mode 100644 index 0000000..9283344 --- /dev/null +++ b/test/php/library/X509/Common/JobUtilsTest.php @@ -0,0 +1,44 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Tests\Icinga\Modules\X509\Common; + +use Icinga\Module\X509\Common\JobUtils; +use PHPUnit\Framework\TestCase; + +class JobUtilsTest extends TestCase +{ + use JobUtils; + + public function testGetCidrs() + { + $cidrs = $this->parseCIDRs('10.211.55.30/24,127.0.0.1/8,192.168.178.1/28'); + + $this->assertCount(3, $cidrs); + $this->assertCount(2, $cidrs['10.211.55.30/24']); + + $this->assertSame('10.211.55.30', $cidrs['10.211.55.30/24'][0]); + $this->assertSame('24', $cidrs['10.211.55.30/24'][1]); + } + + public function testGetPorts() + { + $ports = $this->parsePorts('5665,3306,6379,8000-9000'); + + $this->assertCount(4, $ports); + $this->assertCount(2, $ports[3]); + + $this->assertSame('8000', $ports[3][0]); + $this->assertSame('9000', $ports[3][1]); + } + + public function testGetExcludes() + { + $excludes = $this->parseExcludes('icinga.com,netways.de'); + + $this->assertCount(2, $excludes); + $this->assertArrayHasKey('icinga.com', $excludes); + $this->assertArrayHasKey('netways.de', $excludes); + } +} diff --git a/test/php/library/X509/JobTest.php b/test/php/library/X509/JobTest.php new file mode 100644 index 0000000..8b5ba53 --- /dev/null +++ b/test/php/library/X509/JobTest.php @@ -0,0 +1,53 @@ +<?php + +namespace Tests\Icinga\Modules\X509; + +use Icinga\Module\X509\Job; +use PHPUnit\Framework\TestCase; + +class JobTest extends TestCase +{ + public function testBinaryTransformsHumanReadableIPToItsPaddedVersionCorrectly() + { + $this->assertSame('0000000000000000000000000ad33720', bin2hex(Job::binary('10.211.55.32'))); + $this->assertSame( + '2a0104a00004210208a951031cba4915', + bin2hex(Job::binary('2a01:4a0:4:2102:8a9:5103:1cba:4915')) + ); + } + + public function testIsIPV6() + { + $this->assertTrue(Job::isIPV6('::1'), 'Job::isIPV6() could not determine valid IPv6 as an IPv6'); + $this->assertFalse(Job::isIPV6('10.211.54.35'), 'Job::isIPV6() could determines IPv4 as an IPv6'); + } + + public function testAddrToNumberUndViceVersa() + { + $this->assertSame('10.211.55.32', Job::numberToAddr(Job::addrToNumber('10.211.55.32'), false)); + + $this->assertSame('::1', Job::numberToAddr(Job::addrToNumber('::1'))); + $this->assertSame( + '2a01:4a0:4:2102:8a9:5103:1cba:4915', + Job::numberToAddr(Job::addrToNumber('2a01:4a0:4:2102:8a9:5103:1cba:4915')) + ); + } + + public function testIsAddrInsideCidr() + { + $this->assertTrue(Job::isAddrInside(Job::addrToNumber('10.211.55.31'), '10.211.55.30', 24)); + $this->assertFalse(Job::isAddrInside(Job::addrToNumber('10.211.54.35'), '10.211.55.30', 24)); + + $this->assertTrue( + Job::isAddrInside(Job::addrToNumber('2001:db8:abcd:0012::1'), '2001:db8:abcd:0012::', 64) + ); + $this->assertTrue( + Job::isAddrInside(Job::addrToNumber('2001:db8:abcd:0012:ffff::1'), '2001:db8:abcd:0012::', 64) + ); + + $this->assertFalse(Job::isAddrInside(Job::addrToNumber('2001:db8:abcd::1'), '2001:db8:abcd:0012::', 64)); + $this->assertFalse( + Job::isAddrInside(Job::addrToNumber('2001:db8:abcd:0011::'), '2001:db8:abcd:0012::', 64) + ); + } +} diff --git a/test/php/library/X509/Model/Behavior/DERBase64Test.php b/test/php/library/X509/Model/Behavior/DERBase64Test.php new file mode 100644 index 0000000..d451ac2 --- /dev/null +++ b/test/php/library/X509/Model/Behavior/DERBase64Test.php @@ -0,0 +1,69 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Tests\Icinga\Modules\X509\Model\Behavior; + +use Icinga\Module\X509\Model\Behavior\DERBase64; +use PHPUnit\Framework\TestCase; + +class DERBase64Test extends TestCase +{ + protected const COLUMN = 'cert'; + + protected const CERT = <<<'EOD' +-----BEGIN CERTIFICATE----- +MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAUAwggE8AgEAAkEA6QnU5eXu9ugwYsR3 +LcHVZwpag+GlLzASRmQXoaWFTVVTsdnxYqFTs4+/raVtN0/GUtXX8YTN95VE1y/H +pwyTgQIDAQABAkEA3EtX/9BB+xR5kRSKWS4QTyzhbiRj49y8meBK2ps/DV8bP4nE +E6VadMSpWFIjuUKZ+D8rdI/7BNUPmgS7Gtk4BQIhAPd3u0fiFje2PWNye9mZX3f+ +zbeAKXXrpWEGpNvi72wPAiEA8RK9fNLOBFUXsPtcGsQZD4DhthLfgTMbA/iGLC8i +t28CIDpKRJ3o/ky/K3SaSdv2iYtNRI2draZuDDVviDOXH8g3AiEAlFvAGW1yM+Ba +MCTAzggYlB3wyihbPBvDaHItwEtRxikCIQC2GzXRVDW6rbDJyX1Zhd/l7EC6heib +LErnxieVVzJglw== +-----END CERTIFICATE----- +EOD; + + protected const CERT_BASE64_HEX = <<<'EOD' +30820156020100300d06092a864886f70d0101010500048201403082013c020100024100e909d +4e5e5eef6e83062c4772dc1d5670a5a83e1a52f3012466417a1a5854d5553b1d9f162a153b38fb +fada56d374fc652d5d7f184cdf79544d72fc7a70c93810203010001024100dc4b57ffd041fb1479 +91148a592e104f2ce16e2463e3dcbc99e04ada9b3f0d5f1b3f89c413a55a74c4a9585223b94299f8 +3f2b748ffb04d50f9a04bb1ad93805022100f777bb47e21637b63d63727bd9995f77fecdb7802975e +ba56106a4dbe2ef6c0f022100f112bd7cd2ce045517b0fb5c1ac4190f80e1b612df81331b03f8862c2 +f22b76f02203a4a449de8fe4cbf2b749a49dbf6898b4d448d9dada66e0c356f8833971fc83702210094 +5bc0196d7233e05a3024c0ce0818941df0ca285b3c1bc368722dc04b51c629022100b61b35d15435baa +db0c9c97d5985dfe5ec40ba85e89b2c4ae7c6279557326097 +EOD; + + public function testFromDbReturnsNullWhenNullIsPassed() + { + $this->assertNull($this->behavior()->retrieveProperty(null, static::COLUMN)); + } + + public function testFromDBTransformsPemToDer() + { + $this->assertSame( + static::CERT, + $this->behavior()->retrieveProperty(hex2bin(str_replace("\n", '', static::CERT_BASE64_HEX)), static::COLUMN) + ); + } + + public function testToDbReturnsNullWhenNullIsPassed() + { + $this->assertNull($this->behavior()->persistProperty(null, static::COLUMN)); + } + + public function testToDbTransformsDerToPem() + { + $this->assertSame( + hex2bin(str_replace("\n", '', static::CERT_BASE64_HEX)), + $this->behavior()->persistProperty(static::CERT, static::COLUMN) + ); + } + + protected function behavior(): DERBase64 + { + return new DERBase64(['cert']); + } +} diff --git a/test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php b/test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php new file mode 100644 index 0000000..5481ce9 --- /dev/null +++ b/test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php @@ -0,0 +1,38 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Tests\Icinga\Modules\X509\Model\Behavior; + +use Icinga\Module\X509\Model\Behavior\ExpressionInjector; +use ipl\Orm\Query; +use ipl\Sql\Connection; +use ipl\Stdlib\Filter\Equal; +use PHPUnit\Framework\TestCase; +use Tests\Icinga\Module\X509\Lib\TestModel; + +class ExpressionInjectorTest extends TestCase +{ + public function testRewriteConditionReplacesExpressionColumnByItsExpression() + { + $cond = new Equal('duration', 'FOOO'); + $cond->metaData()->set('columnName', 'duration'); + $this->assertSame('duration', $cond->getColumn()); + $this->assertSame('FOOO', $cond->getValue()); + + $this->behavior()->rewriteCondition($cond); + + $this->assertSame('FOOO', $cond->getValue()); + $this->assertSame(TestModel::EXPRESSION, $cond->getColumn()); + } + + protected function behavior(): ExpressionInjector + { + return (new ExpressionInjector('duration')) + ->setQuery( + (new Query()) + ->setDb(new Connection(['db' => 'mysql'])) + ->setModel(new TestModel()) + ); + } +} diff --git a/test/php/library/X509/Model/Behavior/IpTest.php b/test/php/library/X509/Model/Behavior/IpTest.php new file mode 100644 index 0000000..87c4c68 --- /dev/null +++ b/test/php/library/X509/Model/Behavior/IpTest.php @@ -0,0 +1,92 @@ +<?php + +/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Tests\Icinga\Modules\X509\Model\Behavior; + +use Icinga\Module\X509\Model\Behavior\Ip; +use ipl\Orm\Query; +use ipl\Sql\Connection; +use PHPUnit\Framework\TestCase; + +class IpTest extends TestCase +{ + protected const IPV4 = '10.211.55.32'; + + protected const IPV6 = '2a01:4a0:4:2102:8a9:5103:1cba:4915'; + + protected const IPV4_HEX = '0000000000000000000000000ad33720'; + + protected const IPV6_HEX = '2a0104a00004210208a951031cba4915'; + + protected const COLUMN = 'ip'; + + public function testFromDbReturnsNullWhenNullIsPassed() + { + $this->assertNull($this->behavior()->retrieveProperty(null, static::COLUMN)); + $this->assertNull($this->behavior(true)->retrieveProperty(null, static::COLUMN)); + } + + public function testFromDBTransformsBinaryIpToHumanReadable() + { + $this->assertSame( + static::IPV4, + $this->behavior()->retrieveProperty(hex2bin(static::IPV4_HEX), static::COLUMN) + ); + $this->assertSame( + static::IPV6, + $this->behavior()->retrieveProperty(hex2bin(static::IPV6_HEX), static::COLUMN) + ); + + $this->assertSame( + static::IPV4, + $this->behavior(true)->retrieveProperty(hex2bin(static::IPV4_HEX), static::COLUMN) + ); + $this->assertSame( + static::IPV6, + $this->behavior(true)->retrieveProperty(hex2bin(static::IPV6_HEX), static::COLUMN) + ); + } + + public function testToDbReturnsInvalidValueAsIs() + { + $this->assertNull($this->behavior()->persistProperty(null, static::COLUMN)); + $this->assertSame('*', $this->behavior()->persistProperty('*', static::COLUMN)); + + $this->assertNull($this->behavior(true)->persistProperty(null, static::COLUMN)); + $this->assertSame('*', $this->behavior(true)->persistProperty('*', static::COLUMN)); + + $ipv4Bin = hex2bin(static::IPV4_HEX); + $ipv6Bin = hex2bin(static::IPV6_HEX); + + $this->assertSame($ipv4Bin, $this->behavior()->persistProperty($ipv4Bin, static::COLUMN)); + $this->assertSame($ipv6Bin, $this->behavior()->persistProperty($ipv6Bin, static::COLUMN)); + + $this->assertSame($ipv4Bin, $this->behavior(true)->persistProperty($ipv4Bin, static::COLUMN)); + $this->assertSame($ipv6Bin, $this->behavior(true)->persistProperty($ipv6Bin, static::COLUMN)); + } + + public function testToDbTransformsIpToBinaryCorrectly() + { + $this->assertSame(hex2bin(static::IPV4_HEX), $this->behavior()->persistProperty(static::IPV4, static::COLUMN)); + $this->assertSame(hex2bin(static::IPV6_HEX), $this->behavior()->persistProperty(static::IPV6, static::COLUMN)); + + $this->assertSame( + sprintf('\\x%s', static::IPV4_HEX), + $this->behavior(true)->persistProperty(static::IPV4, static::COLUMN) + ); + $this->assertSame( + sprintf('\\x%s', static::IPV6_HEX), + $this->behavior(true)->persistProperty(static::IPV6, static::COLUMN) + ); + } + + protected function behavior(bool $postgres = false): Ip + { + return (new Ip(['ip'])) + ->setQuery( + (new Query()) + ->setDb(new Connection(['db' => $postgres ? 'pgsql' : 'mysql'])) + ); + } +} |