From 5f112e7d0464d98282443b78870cdccabe42aae9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 14:47:35 +0200 Subject: Adding upstream version 1:1.1.2. Signed-off-by: Daniel Baumann --- .phpcs.xml | 33 ++ LICENSE | 339 +++++++++++++++ README.md | 28 ++ application/clicommands/CheckCommand.php | 236 +++++++++++ application/clicommands/ImportCommand.php | 57 +++ application/clicommands/JobsCommand.php | 73 ++++ application/clicommands/ScanCommand.php | 67 +++ application/clicommands/VerifyCommand.php | 27 ++ application/controllers/CertificateController.php | 40 ++ application/controllers/CertificatesController.php | 127 ++++++ application/controllers/ChainController.php | 83 ++++ application/controllers/ConfigController.php | 29 ++ application/controllers/DashboardController.php | 134 ++++++ application/controllers/IconsController.php | 31 ++ application/controllers/JobsController.php | 83 ++++ application/controllers/SniController.php | 83 ++++ application/controllers/UsageController.php | 155 +++++++ application/forms/Config/BackendConfigForm.php | 28 ++ application/forms/Config/JobConfigForm.php | 96 +++++ application/forms/Config/SniConfigForm.php | 79 ++++ application/views/scripts/certificate/index.phtml | 6 + application/views/scripts/certificates/index.phtml | 14 + application/views/scripts/chain/index.phtml | 8 + application/views/scripts/config/backend.phtml | 6 + application/views/scripts/dashboard/index.phtml | 13 + application/views/scripts/jobs/index.phtml | 46 +++ application/views/scripts/missing-resource.phtml | 12 + application/views/scripts/simple-form.phtml | 6 + application/views/scripts/sni/index.phtml | 46 +++ application/views/scripts/usage/index.phtml | 14 + composer.json | 8 + composer.lock | 79 ++++ config/systemd/icinga-x509.service | 10 + configuration.php | 40 ++ doc/01-About.md | 22 + doc/02-Installation.md | 43 ++ doc/03-Configuration.md | 104 +++++ doc/10-Monitoring.md | 212 ++++++++++ doc/80-Upgrading.md | 29 ++ doc/res/check-host-perf-data.png | Bin 0 -> 7466 bytes doc/res/host-check-multiple-services.png | Bin 0 -> 49022 bytes doc/res/host-check-single-service.png | Bin 0 -> 32771 bytes doc/res/host-template-fields.png | Bin 0 -> 15012 bytes doc/res/hosts-import-result.png | Bin 0 -> 7182 bytes doc/res/hosts-import-source.png | Bin 0 -> 8344 bytes doc/res/multiple-services-result.png | Bin 0 -> 13992 bytes doc/res/new-host-template.png | Bin 0 -> 11630 bytes doc/res/new-service-template.png | Bin 0 -> 14294 bytes doc/res/ports-property-modifier.png | Bin 0 -> 12071 bytes doc/res/service-template-fields.png | Bin 0 -> 27772 bytes doc/res/single-service-result.png | Bin 0 -> 82322 bytes doc/res/sync-rule-properties.png | Bin 0 -> 19091 bytes doc/res/x509-certificates.png | Bin 0 -> 146080 bytes doc/res/x509-dashboard.png | Bin 0 -> 89320 bytes doc/res/x509-usage.png | Bin 0 -> 114389 bytes etc/schema/mysql-upgrade/v1.0.0.sql | 27 ++ etc/schema/mysql-upgrade/v1.1.0.sql | 4 + etc/schema/mysql.schema.sql | 92 +++++ library/X509/CertificateDetails.php | 105 +++++ library/X509/CertificateUtils.php | 460 +++++++++++++++++++++ library/X509/CertificatesTable.php | 109 +++++ library/X509/ChainDetails.php | 116 ++++++ library/X509/ColorScheme.php | 36 ++ library/X509/Command.php | 35 ++ library/X509/Controller.php | 121 ++++++ library/X509/DataTable.php | 145 +++++++ library/X509/Donut.php | 93 +++++ library/X509/ExpirationWidget.php | 85 ++++ library/X509/FilterAdapter.php | 55 +++ library/X509/Hook/SniHook.php | 53 +++ library/X509/Job.php | 381 +++++++++++++++++ library/X509/JobsIniRepository.php | 20 + library/X509/ProvidedHook/HostsImportSource.php | 70 ++++ library/X509/ProvidedHook/ServicesImportSource.php | 85 ++++ library/X509/ProvidedHook/x509ImportSource.php | 49 +++ library/X509/React/StreamOptsCaptureConnector.php | 59 +++ library/X509/Scheduler.php | 59 +++ library/X509/SniIniRepository.php | 20 + library/X509/SortAdapter.php | 46 +++ library/X509/SqlFilter.php | 84 ++++ library/X509/Table.php | 38 ++ library/X509/UsageTable.php | 83 ++++ module.info | 6 + public/css/icons.less | 52 +++ public/css/module.less | 107 +++++ public/font/icons.eot | Bin 0 -> 2140 bytes public/font/icons.svg | 44 ++ public/font/icons.ttf | Bin 0 -> 1972 bytes public/font/icons.woff | Bin 0 -> 1424 bytes run.php | 7 + vendor/autoload.php | 7 + vendor/composer/ClassLoader.php | 445 ++++++++++++++++++++ vendor/composer/LICENSE | 21 + vendor/composer/autoload_classmap.php | 9 + vendor/composer/autoload_namespaces.php | 9 + vendor/composer/autoload_psr4.php | 10 + vendor/composer/autoload_real.php | 55 +++ vendor/composer/autoload_static.php | 31 ++ vendor/composer/installed.json | 64 +++ vendor/dragonmantank/cron-expression/CHANGELOG.md | 84 ++++ vendor/dragonmantank/cron-expression/LICENSE | 19 + vendor/dragonmantank/cron-expression/README.md | 78 ++++ vendor/dragonmantank/cron-expression/composer.json | 40 ++ .../cron-expression/src/Cron/AbstractField.php | 286 +++++++++++++ .../cron-expression/src/Cron/CronExpression.php | 413 ++++++++++++++++++ .../cron-expression/src/Cron/DayOfMonthField.php | 145 +++++++ .../cron-expression/src/Cron/DayOfWeekField.php | 196 +++++++++ .../cron-expression/src/Cron/FieldFactory.php | 54 +++ .../cron-expression/src/Cron/FieldInterface.php | 41 ++ .../cron-expression/src/Cron/HoursField.php | 85 ++++ .../cron-expression/src/Cron/MinutesField.php | 75 ++++ .../cron-expression/src/Cron/MonthField.php | 59 +++ 112 files changed, 7518 insertions(+) create mode 100644 .phpcs.xml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 application/clicommands/CheckCommand.php create mode 100644 application/clicommands/ImportCommand.php create mode 100644 application/clicommands/JobsCommand.php create mode 100644 application/clicommands/ScanCommand.php create mode 100644 application/clicommands/VerifyCommand.php create mode 100644 application/controllers/CertificateController.php create mode 100644 application/controllers/CertificatesController.php create mode 100644 application/controllers/ChainController.php create mode 100644 application/controllers/ConfigController.php create mode 100644 application/controllers/DashboardController.php create mode 100644 application/controllers/IconsController.php create mode 100644 application/controllers/JobsController.php create mode 100644 application/controllers/SniController.php create mode 100644 application/controllers/UsageController.php create mode 100644 application/forms/Config/BackendConfigForm.php create mode 100644 application/forms/Config/JobConfigForm.php create mode 100644 application/forms/Config/SniConfigForm.php create mode 100644 application/views/scripts/certificate/index.phtml create mode 100644 application/views/scripts/certificates/index.phtml create mode 100644 application/views/scripts/chain/index.phtml create mode 100644 application/views/scripts/config/backend.phtml create mode 100644 application/views/scripts/dashboard/index.phtml create mode 100644 application/views/scripts/jobs/index.phtml create mode 100644 application/views/scripts/missing-resource.phtml create mode 100644 application/views/scripts/simple-form.phtml create mode 100644 application/views/scripts/sni/index.phtml create mode 100644 application/views/scripts/usage/index.phtml create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/systemd/icinga-x509.service create mode 100644 configuration.php create mode 100644 doc/01-About.md create mode 100644 doc/02-Installation.md create mode 100644 doc/03-Configuration.md create mode 100644 doc/10-Monitoring.md create mode 100644 doc/80-Upgrading.md create mode 100644 doc/res/check-host-perf-data.png create mode 100644 doc/res/host-check-multiple-services.png create mode 100644 doc/res/host-check-single-service.png create mode 100644 doc/res/host-template-fields.png create mode 100644 doc/res/hosts-import-result.png create mode 100644 doc/res/hosts-import-source.png create mode 100644 doc/res/multiple-services-result.png create mode 100644 doc/res/new-host-template.png create mode 100644 doc/res/new-service-template.png create mode 100644 doc/res/ports-property-modifier.png create mode 100644 doc/res/service-template-fields.png create mode 100644 doc/res/single-service-result.png create mode 100644 doc/res/sync-rule-properties.png create mode 100644 doc/res/x509-certificates.png create mode 100644 doc/res/x509-dashboard.png create mode 100644 doc/res/x509-usage.png create mode 100644 etc/schema/mysql-upgrade/v1.0.0.sql create mode 100644 etc/schema/mysql-upgrade/v1.1.0.sql create mode 100644 etc/schema/mysql.schema.sql create mode 100644 library/X509/CertificateDetails.php create mode 100644 library/X509/CertificateUtils.php create mode 100644 library/X509/CertificatesTable.php create mode 100644 library/X509/ChainDetails.php create mode 100644 library/X509/ColorScheme.php create mode 100644 library/X509/Command.php create mode 100644 library/X509/Controller.php create mode 100644 library/X509/DataTable.php create mode 100644 library/X509/Donut.php create mode 100644 library/X509/ExpirationWidget.php create mode 100644 library/X509/FilterAdapter.php create mode 100644 library/X509/Hook/SniHook.php create mode 100644 library/X509/Job.php create mode 100644 library/X509/JobsIniRepository.php create mode 100644 library/X509/ProvidedHook/HostsImportSource.php create mode 100644 library/X509/ProvidedHook/ServicesImportSource.php create mode 100644 library/X509/ProvidedHook/x509ImportSource.php create mode 100644 library/X509/React/StreamOptsCaptureConnector.php create mode 100644 library/X509/Scheduler.php create mode 100644 library/X509/SniIniRepository.php create mode 100644 library/X509/SortAdapter.php create mode 100644 library/X509/SqlFilter.php create mode 100644 library/X509/Table.php create mode 100644 library/X509/UsageTable.php create mode 100644 module.info create mode 100644 public/css/icons.less create mode 100644 public/css/module.less create mode 100644 public/font/icons.eot create mode 100644 public/font/icons.svg create mode 100644 public/font/icons.ttf create mode 100644 public/font/icons.woff create mode 100644 run.php create mode 100644 vendor/autoload.php create mode 100644 vendor/composer/ClassLoader.php create mode 100644 vendor/composer/LICENSE create mode 100644 vendor/composer/autoload_classmap.php create mode 100644 vendor/composer/autoload_namespaces.php create mode 100644 vendor/composer/autoload_psr4.php create mode 100644 vendor/composer/autoload_real.php create mode 100644 vendor/composer/autoload_static.php create mode 100644 vendor/composer/installed.json create mode 100644 vendor/dragonmantank/cron-expression/CHANGELOG.md create mode 100644 vendor/dragonmantank/cron-expression/LICENSE create mode 100644 vendor/dragonmantank/cron-expression/README.md create mode 100644 vendor/dragonmantank/cron-expression/composer.json create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/HoursField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/MonthField.php diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..726def3 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,33 @@ + + + Sniff our code a while + + ./ + + vendor/* + + + + + + + + + + + + + + + + + + + + library/X509/SqlFilter\.php + + + + library/X509/ProvidedHook/x509ImportSource\.php + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ecbc059 --- /dev/null +++ b/LICENSE @@ -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. + + + Copyright (C) + + 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. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c27a4e --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Icinga Certificate Monitoring + +[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.0-777BB4?logo=PHP)](https://php.net/) +![Build Status](https://github.com/icinga/icingaweb2-module-x509/workflows/PHP%20Tests/badge.svg?branch=master) +[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-x509.svg)](https://github.com/Icinga/icingaweb2-module-x509) + +![Icinga Logo](https://icinga.com/wp-content/uploads/2014/06/icinga_logo.png) + +The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment. +It does this by scanning networks for TLS services and collects whatever certificates it finds along the way. +The certificates are verified using its own trust store. + +The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information +about any discovered certificate of your landscape: + +![X.509 Usage](doc/res/x509-usage.png "X.509 Usage") + +![X.509 Certificates](doc/res/x509-certificates.png "X.509 Certificates") + +At a glance you see which CAs have issued your certificates and key counters of your environment: + +![X.509 Dashboard](doc/res/x509-dashboard.png "X.509 Dashboard") + +## Documentation + +* [Installation](doc/02-Installation.md) +* [Configuration](doc/03-Configuration.md) +* [Monitoring](doc/10-Monitoring.md) diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php new file mode 100644 index 0000000..ae7f641 --- /dev/null +++ b/application/clicommands/CheckCommand.php @@ -0,0 +1,236 @@ +params->get('ip'); + $hostname = $this->params->get('host'); + if ($ip === null && $hostname === null) { + $this->showUsage('host'); + exit(3); + } + + $targets = (new Select()) + ->from('x509_target t') + ->columns([ + 't.port', + 'cc.valid', + 'cc.invalid_reason', + 'c.subject', + 'self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', + 'c.valid_from', + 'c.valid_to' + ]) + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash') + ->where(['ccl.order = ?' => 0]); + + if ($ip !== null) { + $targets->where(['t.ip = ?' => Job::binary($ip)]); + } + if ($hostname !== null) { + $targets->where(['t.hostname = ?' => $hostname]); + } + if ($this->params->has('port')) { + $targets->where(['t.port = ?' => $this->params->get('port')]); + } + + $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); + list($warningThreshold, $warningUnit) = $this->splitThreshold($this->params->get('warning', '25%')); + list($criticalThreshold, $criticalUnit) = $this->splitThreshold($this->params->get('critical', '10%')); + + $output = []; + $perfData = []; + + $state = 3; + foreach ($this->getDb()->select($targets) as $target) { + if ($target['valid'] === 'no' && ($target['self_signed'] === 'no' || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target['invalid_reason']; + $output[$invalidMessage] = $invalidMessage; + $state = 2; + } + + $now = new \DateTime(); + $validFrom = (new \DateTime())->setTimestamp($target['valid_from']); + $validTo = (new \DateTime())->setTimestamp($target['valid_to']); + $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold, $criticalUnit); + $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold, $warningUnit); + + if ($now > $criticalAfter) { + $state = 2; + } elseif ($state !== 2 && $now > $warningAfter) { + $state = 1; + } elseif ($state === 3) { + $state = 0; + } + + $remainingTime = $now->diff($validTo); + if (! $remainingTime->invert) { + // The certificate has not expired yet + $output[$target['subject']] = sprintf( + '%s expires in %d days', + $target['subject'], + $remainingTime->days + ); + } else { + $output[$target['subject']] = sprintf( + '%s has expired since %d days', + $target['subject'], + $remainingTime->days + ); + } + + $perfData[$target['subject']] = sprintf( + "'%s'=%ds;%d;%d;0;%d", + $target['subject'], + $remainingTime->invert + ? 0 + : $target['valid_to'] - time(), + $target['valid_to'] - $warningAfter->getTimestamp(), + $target['valid_to'] - $criticalAfter->getTimestamp(), + $target['valid_to'] - $target['valid_from'] + ); + } + + echo ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN'][$state]; + echo ' - '; + + if (! empty($output)) { + echo join('; ', $output); + } elseif ($state === 3) { + echo 'Host not found'; + } + + if (! empty($perfData)) { + echo '|' . join(' ', $perfData); + } + + echo PHP_EOL; + exit($state); + } + + /** + * Parse the given threshold definition + * + * @param string $threshold + * + * @return array + */ + protected function splitThreshold($threshold) + { + $match = preg_match('/(\d+)([%\w]{1})/', $threshold, $matches); + if (! $match) { + Logger::error('Invalid threshold definition: %s', $threshold); + exit(3); + } + + switch ($matches[2]) { + case '%': + return [(int) $matches[1], self::UNIT_PERCENT]; + case 'y': + case 'Y': + $intervalSpec = 'P' . $matches[1] . 'Y'; + break; + case 'M': + $intervalSpec = 'P' . $matches[1] . 'M'; + break; + case 'd': + case 'D': + $intervalSpec = 'P' . $matches[1] . 'D'; + break; + case 'h': + case 'H': + $intervalSpec = 'PT' . $matches[1] . 'H'; + break; + case 'm': + $intervalSpec = 'PT' . $matches[1] . 'M'; + break; + case 's': + case 'S': + $intervalSpec = 'PT' . $matches[1] . 'S'; + break; + default: + Logger::error('Unknown threshold unit given: %s', $threshold); + exit(3); + } + + return [new \DateInterval($intervalSpec), self::UNIT_INTERVAL]; + } + + /** + * Convert the given threshold information to a DateTime object + * + * @param \DateTime $from + * @param \DateTime $to + * @param int|\DateInterval $thresholdValue + * @param string $thresholdUnit + * + * @return \DateTime + */ + protected function thresholdToDateTime(\DateTime $from, \DateTime $to, $thresholdValue, $thresholdUnit) + { + $to = clone $to; + if ($thresholdUnit === self::UNIT_INTERVAL) { + return $to->sub($thresholdValue); + } + + $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100)); + return $to->sub(new \DateInterval('P' . $coveredDays . 'D')); + } +} diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php new file mode 100644 index 0000000..1f9d1ef --- /dev/null +++ b/application/clicommands/ImportCommand.php @@ -0,0 +1,57 @@ + + * + * EXAMPLES: + * + * icingacli x509 import --file /etc/ssl/certs/ca-bundle.crt + */ + public function indexAction() + { + $file = $this->params->getRequired('file'); + + if (! file_exists($file)) { + Logger::warning('The specified certificate file does not exist.'); + exit(1); + } + + $db = $this->getDb(); + + $bundle = CertificateUtils::parseBundle($file); + + $count = 0; + + $db->transaction(function (Connection $db) use ($bundle, &$count) { + foreach ($bundle as $data) { + $cert = openssl_x509_read($data); + + $id = CertificateUtils::findOrInsertCert($db, $cert); + + $db->update( + 'x509_certificate', + ['trusted' => 'yes'], + ['id = ?' => $id] + ); + + $count++; + } + }); + + printf("Processed %d X.509 certificate%s.\n", $count, $count !== 1 ? 's' : ''); + } +} diff --git a/application/clicommands/JobsCommand.php b/application/clicommands/JobsCommand.php new file mode 100644 index 0000000..0e1d599 --- /dev/null +++ b/application/clicommands/JobsCommand.php @@ -0,0 +1,73 @@ +Config()->get('scan', 'parallel', 256); + + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1."); + } + + $scheduler = new Scheduler(); + + $defaultSchedule = $this->Config()->get('jobs', 'default_schedule'); + + $db = $this->getDb(); + + foreach ($this->Config('jobs') as $name => $jobDescription) { + $schedule = $jobDescription->get('schedule', $defaultSchedule); + + if (! $schedule) { + Logger::debug("The job '%s' is not scheduled.", $name); + continue; + } + + $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel); + + $scheduler->add($name, $schedule, function () use ($job, $name, $db) { + if (! $db->ping()) { + Logger::error('Lost connection to database and failed to re-connect. Skipping this job run.'); + return; + } + + $finishedTargets = $job->run(); + + if ($finishedTargets === null) { + Logger::warning("The job '%s' does not have any targets.", $name); + } else { + Logger::info( + "Scanned %s target%s in job '%s'.\n", + $finishedTargets, + $finishedTargets != 1 ? 's' : '', + $name + ); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } + }); + } + + $scheduler->run(); + } +} diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php new file mode 100644 index 0000000..fd92c7a --- /dev/null +++ b/application/clicommands/ScanCommand.php @@ -0,0 +1,67 @@ + + */ + public function indexAction() + { + $name = $this->params->shiftRequired('job'); + + $parallel = (int) $this->Config()->get('scan', 'parallel', 256); + + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1."); + } + + $jobs = $this->Config('jobs'); + + if (! $jobs->hasSection($name)) { + $this->fail('Job not found.'); + } + + $jobDescription = $this->Config('jobs')->getSection($name); + + if (! strlen($jobDescription->get('cidrs'))) { + $this->fail('The job does not specify any CIDRs.'); + } + + $db = $this->getDb(); + + $job = new Job($name, $db, $jobDescription, SniHook::getAll(), $parallel); + + $finishedTargets = $job->run(); + + if ($finishedTargets === null) { + Logger::warning("The job '%s' does not have any targets.", $name); + } else { + Logger::info( + "Scanned %s target%s in job '%s'.\n", + $finishedTargets, + $finishedTargets != 1 ? 's' : '', + $name + ); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } + } +} diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php new file mode 100644 index 0000000..a76c100 --- /dev/null +++ b/application/clicommands/VerifyCommand.php @@ -0,0 +1,27 @@ +getDb(); + + $verified = CertificateUtils::verifyCertificates($db); + + Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : ''); + } +} diff --git a/application/controllers/CertificateController.php b/application/controllers/CertificateController.php new file mode 100644 index 0000000..414d1f3 --- /dev/null +++ b/application/controllers/CertificateController.php @@ -0,0 +1,40 @@ +params->getRequired('cert'); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $cert = $conn->select( + (new Sql\Select()) + ->from('x509_certificate') + ->columns('*') + ->where(['id = ?' => $certId]) + )->fetch(); + + if ($cert === false) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->setTitle($this->translate('X.509 Certificate')); + + $this->view->certificateDetails = (new CertificateDetails()) + ->setCert($cert); + } +} diff --git a/application/controllers/CertificatesController.php b/application/controllers/CertificatesController.php new file mode 100644 index 0000000..c145b03 --- /dev/null +++ b/application/controllers/CertificatesController.php @@ -0,0 +1,127 @@ +initTabs() + ->setTitle($this->translate('Certificates')); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $select = (new Sql\Select()) + ->from('x509_certificate c') + ->columns([ + 'c.id', 'c.subject', 'c.issuer', 'c.version', 'c.self_signed', 'c.ca', 'c.trusted', + 'c.pubkey_algo', 'c.pubkey_bits', 'c.signature_algo', 'c.signature_hash_algo', + 'c.valid_from', 'c.valid_to', + ]); + + $this->view->paginator = new PaginationControl(new Sql\Cursor($conn, $select), Url::fromRequest()); + $this->view->paginator->apply(); + + $sortAndFilterColumns = [ + 'subject' => $this->translate('Certificate'), + 'issuer' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'duration' => $this->translate('Duration'), + 'expires' => $this->translate('Expiration') + ]; + + $this->setupSortControl( + $sortAndFilterColumns, + new SortAdapter($select, function ($field) { + if ($field === 'duration') { + return '(valid_to - valid_from)'; + } elseif ($field === 'expires') { + return 'CASE WHEN UNIX_TIMESTAMP() > valid_to' + . ' THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END'; + } + }) + ); + + $this->setupLimitControl(); + + $filterAdapter = new FilterAdapter(); + $this->setupFilterControl( + $filterAdapter, + $sortAndFilterColumns, + ['subject', 'issuer'], + ['format'] + ); + SqlFilter::apply($select, $filterAdapter->getFilter(), function (FilterExpression $filter) { + switch ($filter->getColumn()) { + case 'issuer_hash': + $value = $filter->getExpression(); + + if (is_array($value)) { + $value = array_map('hex2bin', $value); + } else { + $value = hex2bin($value); + } + + return $filter->setExpression($value); + case 'duration': + return $filter->setColumn('(valid_to - valid_from)'); + case 'expires': + return $filter->setColumn( + 'CASE WHEN UNIX_TIMESTAMP() > valid_to THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END' + ); + case 'valid_from': + case 'valid_to': + $expr = $filter->getExpression(); + if (! is_numeric($expr)) { + return $filter->setExpression(strtotime($expr)); + } + + // expression doesn't need changing + default: + return false; + } + }); + + $this->handleFormatRequest($conn, $select, function (\PDOStatement $stmt) { + foreach ($stmt as $cert) { + $cert['valid_from'] = (new \DateTime()) + ->setTimestamp($cert['valid_from']) + ->format('l F jS, Y H:i:s e'); + $cert['valid_to'] = (new \DateTime()) + ->setTimestamp($cert['valid_to']) + ->format('l F jS, Y H:i:s e'); + + yield $cert; + } + }); + + $this->view->certificatesTable = (new CertificatesTable())->setData($conn->select($select)); + } +} diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php new file mode 100644 index 0000000..870fa81 --- /dev/null +++ b/application/controllers/ChainController.php @@ -0,0 +1,83 @@ +params->getRequired('id'); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $chainSelect = (new Sql\Select()) + ->from('x509_certificate_chain ch') + ->columns('*') + ->join('x509_target t', 't.id = ch.target_id') + ->where(['ch.id = ?' => $id]); + + $chain = $conn->select($chainSelect)->fetch(); + + if ($chain === false) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->setTitle($this->translate('X.509 Certificate Chain')); + + $ip = $chain['ip']; + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + + $chainInfo = Html::tag('div'); + $chainInfo->add(Html::tag('dl', [ + Html::tag('dt', $this->translate('Host')), + Html::tag('dd', $chain['hostname']), + Html::tag('dt', $this->translate('IP')), + Html::tag('dd', inet_ntop($ip)), + Html::tag('dt', $this->translate('Port')), + Html::tag('dd', $chain['port']) + ])); + + $valid = Html::tag('div', ['class' => 'cert-chain']); + + if ($chain['valid'] === 'yes') { + $valid->getAttributes()->add('class', '-valid'); + $valid->add(Html::tag('p', $this->translate('Certificate chain is valid.'))); + } else { + $valid->getAttributes()->add('class', '-invalid'); + $valid->add(Html::tag('p', sprintf( + $this->translate('Certificate chain is invalid: %s.'), + $chain['invalid_reason'] + ))); + } + + $certsSelect = (new Sql\Select()) + ->from('x509_certificate c') + ->columns('*') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') + ->join('x509_certificate_chain cc', 'cc.id = ccl.certificate_chain_id') + ->where(['cc.id = ?' => $id]) + ->orderBy('ccl.order'); + + $this->view->chain = (new HtmlDocument()) + ->add($chainInfo) + ->add($valid) + ->add((new ChainDetails())->setData($conn->select($certsSelect))); + } +} diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php new file mode 100644 index 0000000..c64cd5c --- /dev/null +++ b/application/controllers/ConfigController.php @@ -0,0 +1,29 @@ +assertPermission('config/modules'); + + parent::init(); + } + + public function backendAction() + { + $form = (new BackendConfigForm()) + ->setIniConfig(Config::module('x509')); + + $form->handleRequest(); + + $this->view->tabs = $this->Module()->getConfigTabs()->activate('backend'); + $this->view->form = $form; + } +} diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php new file mode 100644 index 0000000..a48bb98 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,134 @@ +setTitle($this->translate('Certificate Dashboard')); + + try { + $db = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $byCa = $db->select( + (new Select()) + ->from('x509_certificate i') + ->columns(['i.subject', 'cnt' => 'COUNT(*)']) + ->join('x509_certificate c', ['c.issuer_hash = i.subject_hash', 'i.ca = ?' => 'yes']) + ->groupBy(['i.id']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $this->view->byCa = (new Donut()) + ->setHeading($this->translate('Certificates by CA'), 2) + ->setData($byCa) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath('x509/certificates', ['issuer' => $data['subject']])->getAbsoluteUrl() + ], + $data['subject'] + ); + }); + + $duration = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns([ + 'duration' => 'valid_to - valid_from', + 'cnt' => 'COUNT(*)' + ]) + ->where(['ca = ?' => 'no']) + ->groupBy(['duration']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $this->view->duration = (new Donut()) + ->setHeading($this->translate('Certificates by Duration'), 2) + ->setData($duration) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + "x509/certificates?duration={$data['duration']}&ca=no" + )->getAbsoluteUrl() + ], + CertificateUtils::duration($data['duration']) + ); + }); + + $keyStrength = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns(['pubkey_algo', 'pubkey_bits', 'cnt' => 'COUNT(*)']) + ->groupBy(['pubkey_algo', 'pubkey_bits']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $this->view->keyStrength = (new Donut()) + ->setHeading($this->translate('Key Strength'), 2) + ->setData($keyStrength) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'pubkey_algo' => $data['pubkey_algo'], + 'pubkey_bits' => $data['pubkey_bits'] + ] + )->getAbsoluteUrl() + ], + "{$data['pubkey_algo']} {$data['pubkey_bits']} bits" + ); + }); + + $sigAlgos = $db->select( + (new Select()) + ->from('x509_certificate') + ->columns(['signature_algo', 'signature_hash_algo', 'cnt' => 'COUNT(*)']) + ->groupBy(['signature_algo', 'signature_hash_algo']) + ->orderBy('cnt', SORT_DESC) + ->limit(5) + ); + + $this->view->sigAlgos = (new Donut()) + ->setHeading($this->translate('Signature Algorithms'), 2) + ->setData($sigAlgos) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'signature_hash_algo' => $data['signature_hash_algo'], + 'signature_algo' => $data['signature_algo'] + ] + )->getAbsoluteUrl() + ], + "{$data['signature_hash_algo']} with {$data['signature_algo']}" + ); + }); + } +} diff --git a/application/controllers/IconsController.php b/application/controllers/IconsController.php new file mode 100644 index 0000000..422dcd5 --- /dev/null +++ b/application/controllers/IconsController.php @@ -0,0 +1,31 @@ +_helper->viewRenderer->setNoRender(true); + $this->_helper->layout()->disableLayout(); + } + + public function indexAction() + { + $file = realpath( + $this->Module()->getBaseDir() . '/public/font/icons.' . $this->params->get('q', 'svg') + ); + + if ($file === false) { + $this->httpNotFound('File does not exist'); + } + + readfile($file); + } +} diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php new file mode 100644 index 0000000..0df196b --- /dev/null +++ b/application/controllers/JobsController.php @@ -0,0 +1,83 @@ +view->tabs = $this->Module()->getConfigTabs()->activate('jobs'); + + $repo = new JobsIniRepository(); + + $this->view->jobs = $repo->select(array('name')); + } + + /** + * Create a job + */ + public function newAction() + { + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->renderForm($form, $this->translate('New Job')); + } + + /** + * Update a job + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('name')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->renderForm($form, $this->translate('Update Job')); + } + + /** + * Remove a job + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('name')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->renderForm($form, $this->translate('Remove Job')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return JobConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new JobConfigForm()) + ->setRepository(new JobsIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/jobs')); + } +} diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php new file mode 100644 index 0000000..21da41f --- /dev/null +++ b/application/controllers/SniController.php @@ -0,0 +1,83 @@ +view->tabs = $this->Module()->getConfigTabs()->activate('sni'); + + $repo = new SniIniRepository(); + + $this->view->sni = $repo->select(array('ip')); + } + + /** + * Create a map + */ + public function newAction() + { + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->renderForm($form, $this->translate('New SNI Map')); + } + + /** + * Update a map + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Update SNI Map')); + } + + /** + * Remove a map + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Remove SNI Map')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return SniConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new SniConfigForm()) + ->setRepository(new SniIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/sni')); + } +} diff --git a/application/controllers/UsageController.php b/application/controllers/UsageController.php new file mode 100644 index 0000000..287b979 --- /dev/null +++ b/application/controllers/UsageController.php @@ -0,0 +1,155 @@ +initTabs() + ->setTitle($this->translate('Certificate Usage')); + + try { + $conn = $this->getDb(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $select = (new Sql\Select()) + ->from('x509_target t') + ->columns('*') + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->where(['ccl.order = ?' => 0]); + + $sortAndFilterColumns = [ + 'hostname' => $this->translate('Hostname'), + 'ip' => $this->translate('IP'), + 'subject' => $this->translate('Certificate'), + 'issuer' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'valid' => $this->translate('Chain Is Valid'), + 'duration' => $this->translate('Duration'), + 'expires' => $this->translate('Expiration') + ]; + + $this->view->paginator = new PaginationControl(new Sql\Cursor($conn, $select), Url::fromRequest()); + $this->view->paginator->apply(); + + $this->setupSortControl( + $sortAndFilterColumns, + new SortAdapter($select, function ($field) { + if ($field === 'duration') { + return '(valid_to - valid_from)'; + } elseif ($field === 'expires') { + return 'CASE WHEN UNIX_TIMESTAMP() > valid_to' + . ' THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END'; + } + }) + ); + + $this->setupLimitControl(); + + $filterAdapter = new FilterAdapter(); + $this->setupFilterControl( + $filterAdapter, + $sortAndFilterColumns, + ['hostname', 'subject'], + ['format'] + ); + SqlFilter::apply($select, $filterAdapter->getFilter(), function (FilterExpression $filter) { + switch ($filter->getColumn()) { + case 'ip': + $value = $filter->getExpression(); + + if (is_array($value)) { + $value = array_map('Job::binary', $value); + } else { + $value = Job::binary($value); + } + + return $filter->setExpression($value); + case 'issuer_hash': + $value = $filter->getExpression(); + + if (is_array($value)) { + $value = array_map('hex2bin', $value); + } else { + $value = hex2bin($value); + } + + return $filter->setExpression($value); + case 'duration': + return $filter->setColumn('(valid_to - valid_from)'); + case 'expires': + return $filter->setColumn( + 'CASE WHEN UNIX_TIMESTAMP() > valid_to THEN 0 ELSE (valid_to - UNIX_TIMESTAMP()) / 86400 END' + ); + case 'valid_from': + case 'valid_to': + $expr = $filter->getExpression(); + if (! is_numeric($expr)) { + return $filter->setExpression(strtotime($expr)); + } + + // expression doesn't need changing + default: + return false; + } + }); + + $formatQuery = clone $select; + $formatQuery->resetColumns()->columns([ + 'valid', 'hostname', 'ip', 'port', 'subject', 'issuer', 'version', + 'self_signed', 'ca', 'trusted', 'pubkey_algo', 'pubkey_bits', + 'signature_algo', 'signature_hash_algo', 'valid_from', 'valid_to' + ]); + + $this->handleFormatRequest($conn, $formatQuery, function (\PDOStatement $stmt) { + foreach ($stmt as $usage) { + $usage['valid_from'] = (new \DateTime()) + ->setTimestamp($usage['valid_from']) + ->format('l F jS, Y H:i:s e'); + $usage['valid_to'] = (new \DateTime()) + ->setTimestamp($usage['valid_to']) + ->format('l F jS, Y H:i:s e'); + + $ip = $usage['ip']; + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + $usage['ip'] = inet_ntop($ip); + + yield $usage; + } + }); + + $this->view->usageTable = (new UsageTable())->setData($conn->select($select)); + } +} diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php new file mode 100644 index 0000000..28b2c79 --- /dev/null +++ b/application/forms/Config/BackendConfigForm.php @@ -0,0 +1,28 @@ +setName('x509_backend'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + public function createElements(array $formData) + { + $dbResources = ResourceFactory::getResourceConfigs('db')->keys(); + + $this->addElement('select', 'backend_resource', [ + 'label' => $this->translate('Database'), + 'description' => $this->translate('Database resource'), + 'multiOptions' => array_combine($dbResources, $dbResources), + 'required' => true + ]); + } +} diff --git a/application/forms/Config/JobConfigForm.php b/application/forms/Config/JobConfigForm.php new file mode 100644 index 0000000..2f0c018 --- /dev/null +++ b/application/forms/Config/JobConfigForm.php @@ -0,0 +1,96 @@ +addElements([ + [ + 'text', + 'name', + [ + 'description' => $this->translate('Job name'), + 'label' => $this->translate('Name'), + 'required' => true + ] + ], + [ + 'textarea', + 'cidrs', + [ + 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'), + 'label' => $this->translate('CIDRs'), + 'required' => true + ] + ], + [ + 'textarea', + 'ports', + [ + 'description' => $this->translate('Comma-separated list of ports to scan'), + 'label' => $this->translate('Ports'), + 'required' => true + ] + ], + [ + 'text', + 'schedule', + [ + 'description' => $this->translate('Job cron Schedule'), + 'label' => $this->translate('Schedule') + ] + ], + ]); + + $this->setTitle($this->translate('Create a New Job')); + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit job %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove job %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('name', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Job created') + : $this->translate('Failed to create job'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Job updated') + : $this->translate('Failed to update job'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Job removed') + : $this->translate('Failed to remove job'); + } +} diff --git a/application/forms/Config/SniConfigForm.php b/application/forms/Config/SniConfigForm.php new file mode 100644 index 0000000..6e36110 --- /dev/null +++ b/application/forms/Config/SniConfigForm.php @@ -0,0 +1,79 @@ +addElements([ + [ + 'text', + 'ip', + [ + 'description' => $this->translate('IP'), + 'label' => $this->translate('IP'), + 'required' => true + ] + ], + [ + 'textarea', + 'hostnames', + [ + 'description' => $this->translate('Comma-separated list of hostnames'), + 'label' => $this->translate('Hostnames'), + 'required' => true + ] + ] + ]); + + $this->setTitle($this->translate('Create a New SNI Map')); + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit map for %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove map for %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('ip', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Map created') + : $this->translate('Failed to create map'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Map updated') + : $this->translate('Failed to update map'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Map removed') + : $this->translate('Failed to remove map'); + } +} diff --git a/application/views/scripts/certificate/index.phtml b/application/views/scripts/certificate/index.phtml new file mode 100644 index 0000000..08cfadb --- /dev/null +++ b/application/views/scripts/certificate/index.phtml @@ -0,0 +1,6 @@ +
+ +
+
+ render() ?> +
diff --git a/application/views/scripts/certificates/index.phtml b/application/views/scripts/certificates/index.phtml new file mode 100644 index 0000000..47eb2b5 --- /dev/null +++ b/application/views/scripts/certificates/index.phtml @@ -0,0 +1,14 @@ +compact): ?> +
+ tabs ?> + paginator ?> +
+ limiter ?> + sortBox ?> +
+ filterEditor ?> +
+ +
+ render() ?> +
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 @@ +compact): ?> +
+ tabs ?> +
+ +
+ render() ?> +
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 @@ +
+ +
+
+ +
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 @@ +compact): ?> +
+ tabs ?> +
+ +
+
+ render() ?> + render() ?> + render() ?> + render() ?> +
+
diff --git a/application/views/scripts/jobs/index.phtml b/application/views/scripts/jobs/index.phtml new file mode 100644 index 0000000..e86c3a6 --- /dev/null +++ b/application/views/scripts/jobs/index.phtml @@ -0,0 +1,46 @@ +
+ +
+
+
+ qlink( + $this->translate('Create a New Job') , + 'x509/jobs/new', + null, + [ + 'class' => 'button-link', + 'data-base-target' => '_next', + 'icon' => 'plus', + 'title' => $this->translate('Create a New Job') + ] + ) ?> +
+hasResult()): ?> +

escape($this->translate('No jobs configured yet.')) ?>

+ + + + + + + + + + + + + + + +
escape($this->translate('Name')) ?>
qlink($job->name, 'x509/jobs/update', ['name' => $job->name]) ?>qlink( + null, + 'x509/jobs/remove', + array('name' => $job->name), + array( + 'class' => 'action-link', + 'icon' => 'cancel', + 'title' => $this->translate('Remove this job') + ) + ) ?>
+ +
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 @@ +
+ tabs ?> +
+
+

translate('Database not configured') ?>

+

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.'), + '', + '', + '' + ) ?>

+
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 @@ +
+ +
+
+ create()->setTitle(null) // @TODO(el): create() has to be called because the UserForm is setting the title there ?> +
diff --git a/application/views/scripts/sni/index.phtml b/application/views/scripts/sni/index.phtml new file mode 100644 index 0000000..09c4de8 --- /dev/null +++ b/application/views/scripts/sni/index.phtml @@ -0,0 +1,46 @@ +
+ +
+
+
+ qlink( + $this->translate('Create a New SNI Map') , + 'x509/sni/new', + null, + [ + 'class' => 'button-link', + 'data-base-target' => '_next', + 'icon' => 'plus', + 'title' => $this->translate('Create a New SNI Map') + ] + ) ?> +
+ hasResult()): ?> +

escape($this->translate('No SNI maps configured yet.')) ?>

+ + + + + + + + + + + + + + + +
escape($this->translate('IP')) ?>
qlink($data->ip, 'x509/sni/update', ['ip' => $data->ip]) ?>qlink( + null, + 'x509/sni/remove', + array('ip' => $data->ip), + array( + 'class' => 'action-link', + 'icon' => 'cancel', + 'title' => $this->translate('Remove this SNI map') + ) + ) ?>
+ +
diff --git a/application/views/scripts/usage/index.phtml b/application/views/scripts/usage/index.phtml new file mode 100644 index 0000000..a0eed09 --- /dev/null +++ b/application/views/scripts/usage/index.phtml @@ -0,0 +1,14 @@ +compact): ?> +
+ tabs ?> + paginator ?> +
+ limiter ?> + sortBox ?> +
+ filterEditor ?> +
+ +
+ render() ?> +
diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f69d281 --- /dev/null +++ b/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "dragonmantank/cron-expression": "^2.3.1" + }, + "scripts": { + "git-archive": "git archive --format=tar --prefix=x509/ HEAD >icingaweb2-module-x509.tar && tar --transform 's,^,x509/,' --exclude-vcs --exclude '.*' -rf icingaweb2-module-x509.tar vendor composer.lock && gzip -f icingaweb2-module-x509.tar" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..88f915e --- /dev/null +++ b/composer.lock @@ -0,0 +1,79 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "37bc3620fcf5e5bf942cda3048e34017", + "packages": [ + { + "name": "dragonmantank/cron-expression", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2020-10-13T00:52:37+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "1.1.0" +} diff --git a/config/systemd/icinga-x509.service b/config/systemd/icinga-x509.service new file mode 100644 index 0000000..01cda66 --- /dev/null +++ b/config/systemd/icinga-x509.service @@ -0,0 +1,10 @@ +[Unit] +Description=Icinga Certificate Monitoring Module Jobs Runner + +[Service] +Type=simple +ExecStart=/usr/bin/icingacli x509 jobs run +Restart=on-success + +[Install] +WantedBy=multi-user.target diff --git a/configuration.php b/configuration.php new file mode 100644 index 0000000..4182884 --- /dev/null +++ b/configuration.php @@ -0,0 +1,40 @@ +menuSection(N_('Certificate Monitoring'), array( + 'icon' => 'check', + 'url' => 'x509/dashboard', + 'priority' => 40 +)); + +$section->add(N_('Certificate Overview'), array( + 'url' => 'x509/certificates', + 'priority' => 10 +)); + +$section->add(N_('Certificate Usage'), array( + 'url' => 'x509/usage', + 'priority' => 20 +)); + +$this->provideConfigTab('backend', array( + 'title' => $this->translate('Configure the database backend'), + 'label' => $this->translate('Backend'), + 'url' => 'config/backend' +)); + +$this->provideConfigTab('jobs', array( + 'title' => $this->translate('Configure the scan jobs'), + 'label' => $this->translate('Jobs'), + 'url' => 'jobs' +)); + +$this->provideConfigTab('sni', array( + 'title' => $this->translate('Configure SNI'), + 'label' => $this->translate('SNI'), + 'url' => 'sni' +)); + +$this->provideCssFile('icons.less'); diff --git a/doc/01-About.md b/doc/01-About.md new file mode 100644 index 0000000..38fa06a --- /dev/null +++ b/doc/01-About.md @@ -0,0 +1,22 @@ +# Icinga Certificate Monitoring + +The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment. +It does this by scanning networks for TLS services and collects whatever certificates it finds along the way. +The certificates are verified using its own trust store. + +The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information +about any discovered certificate of your landscape: + +![X.509 Usage](res/x509-usage.png "X.509 Usage") + +![X.509 Certificates](res/x509-certificates.png "X.509 Certificates") + +At a glance you see which CAs have issued your certificates and key counters of your environment: + +![X.509 Dashboard](res/x509-dashboard.png "X.509 Dashboard") + +## Documentation + +* [Installation](02-Installation.md) +* [Configuration](03-Configuration.md) +* [Monitoring](10-Monitoring.md) diff --git a/doc/02-Installation.md b/doc/02-Installation.md new file mode 100644 index 0000000..ab06e5d --- /dev/null +++ b/doc/02-Installation.md @@ -0,0 +1,43 @@ +# Installation + +## Requirements + +* PHP (>= 7.0) +* Icinga Web 2 (>= 2.9) +* Icinga Web 2 libraries: + * [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (>= 0.8.1) + * [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (>= 0.10) +* php-gmp +* OpenSSL +* MySQL or MariaDB + +## Database Setup + +The module needs a MySQL/MariaDB database with the schema that's provided in the `etc/schema/mysql.schema.sql` file. + +You may use the following example command for creating the MySQL/MariaDB database. Please change the password: + +``` +CREATE DATABASE x509; +GRANT SELECT, INSERT, UPDATE, DELETE, DROP, CREATE VIEW, INDEX, EXECUTE ON x509.* TO x509@localhost IDENTIFIED BY 'secret'; +``` + +After, you can import the schema using the following command: + +``` +mysql -p -u root x509 < etc/schema/mysql.schema.sql +``` + +## Installation + +1. Install it [like any other module](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation). +Use `x509` as name. + +2. Once you've set up the database, create a new Icinga Web 2 resource for it using the +`Configuration -> Application -> Resources` menu. + +3. The next step involves telling the module which database resource to use. This can be done in +`Configuration -> Modules -> x509 -> Backend`. + +This concludes the installation. You should now be able to import CA certificates and set up scan jobs. +Please read the [Configuration](03-Configuration.md) section for details. diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md new file mode 100644 index 0000000..a70bf0b --- /dev/null +++ b/doc/03-Configuration.md @@ -0,0 +1,104 @@ +# Configuration + +## Importing CA certificates + +The module tries to verify certificates using its own trust store. By default this trust store is empty and it +is up to the Icinga Web 2 admin to import CA certificates into it. + +Using the `icingacli x509 import` command CA certificates can be imported. The certificate chain file that is specified +with the `--file` option should contain a PEM-encoded list of X.509 certificates which should be added to the trust +store: + +``` +icingacli x509 import --file /etc/ssl/certs/ca-certificates.crt +``` + +## Scan Jobs + +The module needs to know which IP address ranges and ports to scan. These can be configured in +`Configuration -> Modules -> x509 -> Jobs`. + +Scan jobs have a name which uniquely identifies them, e.g. `lan`. These names are used by the CLI command to start +scanning for specific jobs. + +Each scan job can have one or more IP address ranges and one or more port ranges. The module scans each port in +a job's port ranges for all the individual IP addresses in the IP ranges. + +IP address ranges have to be specified using the CIDR format. Multiple IP address ranges can be separated with commas, +e.g.: + +`192.0.2.0/24,10.0.10.0/24` + +Port ranges are separated with dashes (`-`). If you only want to scan a single port you don't need to specify the second +port: + +`443,5665-5669` + +Scan jobs can be executed using the `icingacli x509 scan` CLI command. The `--job` option is used to specify the scan +job which should be run: + +``` +icingacli x509 scan --job lan +``` + +## Scheduling Jobs + +Each job may specify a `cron` compatible `schedule` to run periodically at the given interval. The `cron` format is as +follows: + +``` +* * * * * +- - - - - +| | | | | +| | | | | +| | | | +----- day of week (0 - 6) (Sunday to Saturday) +| | | +---------- month (1 - 12) +| | +--------------- day of month (1 - 31) +| +-------------------- hour (0 - 23) ++------------------------- minute (0 - 59) +``` + +Example definitions: + +Description | Definition +------------------------------------------------------------| ---------- +Run once a year at midnight of 1 January | 0 0 1 1 * +Run once a month at midnight of the first day of the month | 0 0 1 * * +Run once a week at midnight on Sunday morning | 0 0 * * 0 +Run once a day at midnight | 0 0 * * * +Run once an hour at the beginning of the hour | 0 * * * * + +Jobs are executed on CLI with the `jobs` command: + +``` +icingacli x509 jobs run +``` + +This command runs all jobs which are currently due and schedules the next execution of all jobs. + +You may configure this command as `systemd` service. Just copy the example service definition from +`config/systemd/icinga-x509.service` to `/etc/systemd/system/icinga-x509.service` and enable it afterwards: + +``` +systemctl enable icinga-x509.service +``` + +As an alternative if you want scan jobs to be run periodically, you can use the `cron(8)` daemon to run them on a +schedule: + +``` +vi /etc/crontab +[...] + +# Runs job 'lan' daily at 2:30 AM +30 2 * * * www-data icingacli x509 scan --job lan +``` + +## Server Name Indication + +In case you are serving multiple virtual hosts under a single IP you can configure those in +`Configuration -> Modules -> x509 -> SNI`. + +Each entry defines an IP with multiple hostnames associated with it. These are then utilized when jobs run. + +Modules may also provide sources for SNI. At this time the module monitoring is the only one with known support. diff --git a/doc/10-Monitoring.md b/doc/10-Monitoring.md new file mode 100644 index 0000000..7ef3abd --- /dev/null +++ b/doc/10-Monitoring.md @@ -0,0 +1,212 @@ +# Monitoring + +## Host Check Command + +The module provides a CLI command to check a host's certificate. It does so by +fetching all the necessary information from this module's own database. + +### Usage + +General: `icingacli x509 check host [options]` + +Options: + +``` +--ip A hosts IP address +--host A hosts name +--port The port to check in particular +--warning Less remaining time results in state WARNING [25%] +--critical Less remaining time results in state CRITICAL [10%] +--allow-self-signed Ignore if a certificate or its issuer has been self-signed +``` + +### Threshold Definition + +Thresholds can either be defined relative (in percent) or absolute (time interval). +Time intervals consist of a digit and an accompanying unit (e.g. "3M" are three +months). Supported units are: + + Identifier | Description +------------|------------ +y, Y | Year +M | Month +d, D | Day +h, H | Hour +m | Minute +s, S | Second + +**Example:** + +``` +$ icingacli x509 check host --host example.org --warning 1y +WARNING - *.example.org expires in 219 days|'*.example.org'=18985010s;25574400;10281600;0;102470399 +``` + +### Performance Data + +The command outputs a performance data value for each certificate that is +served by the host. The value measured is the amount of seconds remaining +until the certificate expires. + +![check host perf data](res/check-host-perf-data.png) + +The value of `max` is the total amount of seconds the certificate is valid. +`warning` and `critical` are the seconds remaining after which the respective +state is reported. + +## Icinga 2 Integration + +First off, this chapter relies on the fact that you're using the Director +already and that you're familiar with some of the terms and functionalities +used there. + +If you don't want to use the Director, know that Icinga 2 already provides +an appropriate template for the host check command in its template library: +https://icinga.com/docs/icinga2/latest/doc/10-icinga-template-library/#x509 + +### Director Import Sources + +The module provides two different import sources: + +#### Hosts (X509) + +Focuses on the hosts the module found when scanning the networks. Use this +for the most straightforward way of integrating the results into your +environment. It's also the utilized source in the example further below. + +Columns provided by this source: + +Name | Description +----------------|-------------------------------------------------------------- +host_name_or_ip | Default key column. This is primarily `host_name`, though if this is not unique it falls back to `host_ip` for individual results +host_ip | A host's IP address by which it is known to this module. May be IPv4 or IPv6 +host_name | A host's name as detected by SNI or a reverse DNS lookup during the scan process +host_ports | Separated by comma. All ports where certificates were found +host_address | Set to `host_ip` if it is IPv4 else `null` +host_address6 | Set to `host_ip` if it is IPv6 else `null` + +#### Services (X509) + +While the hosts import source does not provide any details about the found +certificates this one does. This also means that this source may generate +multiple results for a single host since it focuses on the found certificates. + +Use this source if you want to import service objects directly and relate them +to already existing hosts by their utilized certificates. The Director's many +utilities provided in this regard will again come in handy here. + +Columns provided by this source: + +Name | Description +----------------------|-------------------------------------------------------- +host_name_ip_and_port | Default key column. This is a combination of `host_name`, `host_ip` and `host_port` in the format `name/ip:port` +host_ip | A host's IP address by which it is known to this module. May be IPv4 or IPv6 +host_name | A host's name as detected by SNI or a reverse DNS lookup during the scan process +host_port | A host's port where a certificate has been found +host_address | Set to `host_ip` if it is IPv4 else `null` +host_address6 | Set to `host_ip` if it is IPv6 else `null` +cert_subject | A certificate's common name +cert_issuer | The issuer's common name +cert_self_signed | Whether the certificate is self-signed (`yes` or `no`) +cert_trusted | Whether the certificate is trusted (`yes` or `no`) +cert_valid_from | The certificate's start time of validity (UNIX timestamp) +cert_valid_to | The certificate's end time of validity (UNIX timestamp) +cert_fingerprint | The certificate's fingerprint (hex-encoded) +cert_dn | The certificate's distinguished name +cert_subject_alt_name | The certificate's alternative subject names (Comma separated pairs of `type:name`) + +### Service Checks With the Hosts Import Source + +This example covers the setup of service checks by using a particular host +template and suggests then two options utilizing service apply rules. + +#### Preparations + +Assuming the check command definition `icingacli-x509` has already been imported +you need to define a few data fields now: + +Field name | Data type +---------------------------------|---------- +certified_ports | Array +icingacli_x509_ip | String +icingacli_x509_host | String +icingacli_x509_port | String +icingacli_x509_warning | String +icingacli_x509_critical | String +icingacli_x509_allow_self_signed | Boolean + +Then please create a new host template with a name of your choosing. We've chosen +`x509-host`. We're also importing our base template `base-host` here which defines +all the default properties of our hosts. + +![new host template](res/new-host-template.png) + +This host template also requires three data fields which are shown below. + +![host template fields](res/host-template-fields.png) + +A service template is also needed. We chose the name `x509-host-check` and +`icingacli-x509` as check command. + +![new service template](res/new-service-template.png) + +The service template now requires all data fields which correspond to the +check command's parameters. + +![service template fields](res/service-template-fields.png) + +#### Import Source Setup + +Create a new import source of type `Hosts (X509)`. +![hosts import source](res/hosts-import-source.png) + +Configure a property modifier for column `host_ports` of type `Split` and use +the comma `,` as delimiter. +![ports property modifier](res/ports-property-modifier.png) + +The preview should now produce a similar result to this: +![hosts import result](res/hosts-import-result.png) + +#### Sync Rule Setup + +Create a new sync rule for objects of type `Host`. Depending on your environment +you may choose either `Merge` or `Replace` as update policy. Choose `Merge` to +continue with this example. + +Which properties this rule defines is also very dependent on what you want to +achieve. We now assume that you already have host objects whose object names +match exactly those the import source provides. (Hence you should choose +`Merge` as update policy) + +![sync rule properties](res/sync-rule-properties.png) + +#### Service Check Setup + +There are two choices now. The first checks a host's certificates as a single +service. The second creates for each individual certificate (port) a service. + +##### Single Service + +This is done by defining a new service as part of the host template created +earlier. There add a service and choose the service template also created +previously. + +![host check single service](res/host-check-single-service.png) + +Once you've triggered the import and synchronisation as well as deployed +the resulting changes you should see this in Icinga Web 2: + +![single service result](res/single-service-result.png) + +##### Multiple Services + +This utilizes a service apply rule. Trigger the import and synchronisation +first, otherwise you can't choose a custom variable for the *apply for* rule. + +Once the synchronisation is finished, set up the service apply rule like this: + +![host check multiple services](res/host-check-multiple-services.png) + +After deploying the resulting changes you should see this in Icinga Web 2: + +![multiple services result](res/multiple-services-result.png) diff --git a/doc/80-Upgrading.md b/doc/80-Upgrading.md new file mode 100644 index 0000000..85c5fa8 --- /dev/null +++ b/doc/80-Upgrading.md @@ -0,0 +1,29 @@ +# Upgrading Icinga Certificate Monitoring + +Upgrading Icinga Certificate Monitoring is straightforward. +Usually the only manual steps involved are schema updates for the database. + +## Upgrading to version 1.1.0 + +Icinga Certificate Monitoring version 1.1.0 fixes issues that affect the database schema. +To have these issues really fixed in your environment, the schema must be upgraded. +Please find the upgrade script in **etc/schema/mysql-upgrade**. + +You may use the following command to apply the database schema upgrade file: + +``` +# mysql -u root -p x509 < etc/schema/mysql-upgrade/v1.1.0.sql +``` + +## Upgrading to version 1.0.0 + +Icinga Certificate Monitoring version 1.0.0 requires a schema update for the database. +The schema has been adjusted so that it is no longer necessary to adjust server settings +if you're using a version of MySQL < 5.7 or MariaDB < 10.2. +Please find the upgrade script in **etc/schema/mysql-upgrade**. + +You may use the following command to apply the database schema upgrade file: + +``` +# mysql -u root -p x509 < etc/schema/mysql-upgrade/v1.0.0.sql +``` diff --git a/doc/res/check-host-perf-data.png b/doc/res/check-host-perf-data.png new file mode 100644 index 0000000..958a226 Binary files /dev/null and b/doc/res/check-host-perf-data.png differ diff --git a/doc/res/host-check-multiple-services.png b/doc/res/host-check-multiple-services.png new file mode 100644 index 0000000..a153e2e Binary files /dev/null and b/doc/res/host-check-multiple-services.png differ diff --git a/doc/res/host-check-single-service.png b/doc/res/host-check-single-service.png new file mode 100644 index 0000000..1f4bdec Binary files /dev/null and b/doc/res/host-check-single-service.png differ diff --git a/doc/res/host-template-fields.png b/doc/res/host-template-fields.png new file mode 100644 index 0000000..a6aa438 Binary files /dev/null and b/doc/res/host-template-fields.png differ diff --git a/doc/res/hosts-import-result.png b/doc/res/hosts-import-result.png new file mode 100644 index 0000000..c19b1f2 Binary files /dev/null and b/doc/res/hosts-import-result.png differ diff --git a/doc/res/hosts-import-source.png b/doc/res/hosts-import-source.png new file mode 100644 index 0000000..fe525c7 Binary files /dev/null and b/doc/res/hosts-import-source.png differ diff --git a/doc/res/multiple-services-result.png b/doc/res/multiple-services-result.png new file mode 100644 index 0000000..8bec4b9 Binary files /dev/null and b/doc/res/multiple-services-result.png differ diff --git a/doc/res/new-host-template.png b/doc/res/new-host-template.png new file mode 100644 index 0000000..2ff9074 Binary files /dev/null and b/doc/res/new-host-template.png differ diff --git a/doc/res/new-service-template.png b/doc/res/new-service-template.png new file mode 100644 index 0000000..fec3d22 Binary files /dev/null and b/doc/res/new-service-template.png differ diff --git a/doc/res/ports-property-modifier.png b/doc/res/ports-property-modifier.png new file mode 100644 index 0000000..7bb3ecc Binary files /dev/null and b/doc/res/ports-property-modifier.png differ diff --git a/doc/res/service-template-fields.png b/doc/res/service-template-fields.png new file mode 100644 index 0000000..17c1cbd Binary files /dev/null and b/doc/res/service-template-fields.png differ diff --git a/doc/res/single-service-result.png b/doc/res/single-service-result.png new file mode 100644 index 0000000..418f495 Binary files /dev/null and b/doc/res/single-service-result.png differ diff --git a/doc/res/sync-rule-properties.png b/doc/res/sync-rule-properties.png new file mode 100644 index 0000000..1b54553 Binary files /dev/null and b/doc/res/sync-rule-properties.png differ diff --git a/doc/res/x509-certificates.png b/doc/res/x509-certificates.png new file mode 100644 index 0000000..c89e6fb Binary files /dev/null and b/doc/res/x509-certificates.png differ diff --git a/doc/res/x509-dashboard.png b/doc/res/x509-dashboard.png new file mode 100644 index 0000000..c4a2f5c Binary files /dev/null and b/doc/res/x509-dashboard.png differ diff --git a/doc/res/x509-usage.png b/doc/res/x509-usage.png new file mode 100644 index 0000000..a4c81b6 Binary files /dev/null and b/doc/res/x509-usage.png differ diff --git a/etc/schema/mysql-upgrade/v1.0.0.sql b/etc/schema/mysql-upgrade/v1.0.0.sql new file mode 100644 index 0000000..28b3e7d --- /dev/null +++ b/etc/schema/mysql-upgrade/v1.0.0.sql @@ -0,0 +1,27 @@ +ALTER TABLE x509_target MODIFY COLUMN `port` smallint unsigned NOT NULL; + +ALTER TABLE x509_certificate_subject_alt_name DROP FOREIGN KEY x509_fk_certificate_subject_alt_name_certificate_id; + +ALTER TABLE x509_certificate_subject_alt_name DROP PRIMARY KEY; + +ALTER TABLE x509_certificate_subject_alt_name ADD COLUMN hash binary(32) NOT NULL + COMMENT 'sha256 hash of type=value' + AFTER certificate_id; + +UPDATE x509_certificate_subject_alt_name SET hash = UNHEX(SHA2(CONCAT(type, '=', value), 256)); + +ALTER TABLE x509_certificate_subject_alt_name ADD PRIMARY KEY(certificate_id, hash); + +ALTER TABLE x509_certificate_subject_alt_name ADD + CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id + FOREIGN KEY (certificate_id) + REFERENCES x509_certificate (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE x509_certificate_subject_alt_name ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=default; + +ALTER TABLE x509_target DROP INDEX x509_idx_target_ip_port_hostname; + +ALTER TABLE x509_target ADD INDEX x509_idx_target_ip_port_hostname(ip,port,hostname(191)); + +ALTER TABLE x509_target ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=default; diff --git a/etc/schema/mysql-upgrade/v1.1.0.sql b/etc/schema/mysql-upgrade/v1.1.0.sql new file mode 100644 index 0000000..055d783 --- /dev/null +++ b/etc/schema/mysql-upgrade/v1.1.0.sql @@ -0,0 +1,4 @@ +ALTER TABLE x509_target DROP INDEX x509_idx_target_ip_port_hostname; +ALTER TABLE x509_target ADD INDEX x509_idx_target_ip_port (ip, port); +ALTER TABLE x509_certificate MODIFY COLUMN valid_from bigint(20) NOT NULL; +ALTER TABLE x509_certificate MODIFY COLUMN valid_to bigint(20) NOT NULL; diff --git a/etc/schema/mysql.schema.sql b/etc/schema/mysql.schema.sql new file mode 100644 index 0000000..03c1cc1 --- /dev/null +++ b/etc/schema/mysql.schema.sql @@ -0,0 +1,92 @@ +CREATE TABLE x509_certificate ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + `subject` varchar(255) NOT NULL COMMENT 'CN of the subject DN if present else full subject DN', + subject_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full subject DN', + `issuer` varchar(255) NOT NULL COMMENT 'CN of the issuer DN if present else full issuer DN', + issuer_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full issuer DN', + issuer_certificate_id int(10) unsigned DEFAULT NULL, + version enum('1','2','3') NOT NULL, + self_signed enum('yes','no') NOT NULL DEFAULT 'no', + ca enum('yes','no') NOT NULL, + trusted enum('yes','no') NOT NULL DEFAULT 'no', + pubkey_algo enum('unknown','RSA','DSA','DH','EC') NOT NULL, + pubkey_bits smallint(6) unsigned NOT NULL, + signature_algo varchar(255) NOT NULL, + signature_hash_algo varchar(255) NOT NULL, + valid_from bigint(20) NOT NULL, + valid_to bigint(20) NOT NULL, + fingerprint binary(32) NOT NULL COMMENT 'sha256 hash', + `serial` blob NOT NULL, + certificate blob NOT NULL COMMENT 'DER encoded certificate', + ctime timestamp NULL DEFAULT NULL, + mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY x509_idx_certificate_fingerprint (fingerprint), + KEY x509_fk_certificate_issuer_certificate_id (issuer_certificate_id), + CONSTRAINT x509_fk_certificate_issuer_certificate_id FOREIGN KEY (issuer_certificate_id) REFERENCES x509_certificate (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_certificate_chain ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + target_id int(10) unsigned NOT NULL, + length smallint(6) NOT NULL, + valid enum('yes','no') NOT NULL DEFAULT 'no', + invalid_reason varchar(255) NULL DEFAULT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_certificate_chain_link ( + certificate_chain_id int(10) unsigned NOT NULL, + certificate_id int(10) unsigned NOT NULL, + `order` tinyint(4) NOT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (certificate_chain_id,certificate_id,`order`), + KEY x509_fk_certificate_chain_link_certificate_id (certificate_id), + CONSTRAINT x509_fk_certificate_chain_link_certificate_chain_id FOREIGN KEY (certificate_chain_id) REFERENCES x509_certificate_chain (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT x509_fk_certificate_chain_link_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_certificate_subject_alt_name ( + certificate_id int(10) unsigned NOT NULL, + hash binary(32) NOT NULL COMMENT 'sha256 hash of type=value', + `type` varchar(255) NOT NULL, + `value` varchar(255) NOT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (certificate_id,hash), + CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_dn ( + `hash` binary(32) NOT NULL, + `type` enum('issuer','subject') NOT NULL, + `order` tinyint(4) unsigned NOT NULL, + `key` varchar(255) NOT NULL, + `value` varchar(255) NOT NULL, + ctime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`hash`,`type`,`order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_job_run ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + total_targets int(10) NOT NULL, + finished_targets int(10) NOT NULL, + start_time timestamp NULL DEFAULT NULL, + end_time timestamp NULL DEFAULT NULL, + ctime timestamp NULL DEFAULT NULL, + mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE x509_target ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + ip binary(16) NOT NULL, + `port` smallint unsigned NOT NULL, + hostname varchar(255) NULL DEFAULT NULL, + latest_certificate_chain_id int(10) unsigned NULL DEFAULT NULL, + ctime timestamp NULL DEFAULT NULL, + mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX x509_idx_target_ip_port (ip, port) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php new file mode 100644 index 0000000..3ec2d54 --- /dev/null +++ b/library/X509/CertificateDetails.php @@ -0,0 +1,105 @@ + 'cert-details']; + + /** + * @var array + */ + protected $cert; + + public function setCert(array $cert) + { + $this->cert = $cert; + + return $this; + } + + protected function assemble() + { + $pem = CertificateUtils::der2pem($this->cert['certificate']); + $cert = openssl_x509_parse($pem); +// $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem)); + + $subject = Html::tag('dl'); + foreach ($cert['subject'] as $key => $value) { + $subject->add([ + Html::tag('dt', $key), + Html::tag('dd', $value) + ]); + } + + $issuer = Html::tag('dl'); + foreach ($cert['issuer'] as $key => $value) { + $issuer->add([ + Html::tag('dt', $key), + Html::tag('dd', $value) + ]); + } + + $certInfo = Html::tag('dl'); + $certInfo->add([ + Html::tag('dt', mt('x509', 'Serial Number')), + Html::tag('dd', bin2hex($this->cert['serial'])), + Html::tag('dt', mt('x509', 'Version')), + Html::tag('dd', $this->cert['version']), + Html::tag('dt', mt('x509', 'Signature Algorithm')), + Html::tag('dd', $this->cert['signature_algo'] . ' with ' . $this->cert['signature_hash_algo']), + Html::tag('dt', mt('x509', 'Not Valid Before')), + Html::tag('dd', (new DateTime())->setTimestamp($this->cert['valid_from'])->format('l F jS, Y H:i:s e')), + Html::tag('dt', mt('x509', 'Not Valid After')), + Html::tag('dd', (new DateTime())->setTimestamp($this->cert['valid_to'])->format('l F jS, Y H:i:s e')), + ]); + + $pubkeyInfo = Html::tag('dl'); + $pubkeyInfo->add([ + Html::tag('dt', mt('x509', 'Algorithm')), + Html::tag('dd', $this->cert['pubkey_algo']), + Html::tag('dt', mt('x509', 'Key Size')), + Html::tag('dd', $this->cert['pubkey_bits']) + ]); + + $extensions = Html::tag('dl'); + foreach ($cert['extensions'] as $key => $value) { + $extensions->add([ + Html::tag('dt', ucwords(implode(' ', preg_split('/(?=[A-Z])/', $key)))), + Html::tag('dd', $value) + ]); + } + + $fingerprints = Html::tag('dl'); + $fingerprints->add([ + Html::tag('dt', 'SHA-256'), + Html::tag('dd', wordwrap(strtoupper(bin2hex($this->cert['fingerprint'])), 2, ' ', true)) + ]); + + $this->add([ + Html::tag('h2', [Html::tag('i', ['class' => 'x509-icon-cert']), $this->cert['subject']]), + Html::tag('h3', mt('x509', 'Subject Name')), + $subject, + Html::tag('h3', mt('x509', 'Issuer Name')), + $issuer, + Html::tag('h3', mt('x509', 'Certificate Info')), + $certInfo, + Html::tag('h3', mt('x509', 'Public Key Info')), + $pubkeyInfo, + Html::tag('h3', mt('x509', 'Extensions')), + $extensions, + Html::tag('h3', mt('x509', 'Fingerprints')), + $fingerprints + ]); + } +} diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php new file mode 100644 index 0000000..c538444 --- /dev/null +++ b/library/X509/CertificateUtils.php @@ -0,0 +1,460 @@ + 'unknown', + OPENSSL_KEYTYPE_RSA => 'RSA', + OPENSSL_KEYTYPE_DSA => 'DSA', + OPENSSL_KEYTYPE_DH => 'DH', + OPENSSL_KEYTYPE_EC => 'EC' + ]; + + /** + * Convert the given chunk from PEM to DER + * + * @param string $pem + * + * @return string + */ + public static function pem2der($pem) + { + $lines = explode("\n", $pem); + + $der = ''; + + foreach ($lines as $line) { + if (strpos($line, '-----') === 0) { + continue; + } + + $der .= base64_decode($line); + } + + return $der; + } + + /** + * Convert the given chunk from DER to PEM + * + * @param string $der + * + * @return string + */ + public static function der2pem($der) + { + $block = chunk_split(base64_encode($der), 64, "\n"); + + return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----"; + } + + /** + * Format seconds to human-readable duration + * + * @param int $seconds + * + * @return string + */ + public static function duration($seconds) + { + if ($seconds < 60) { + return "$seconds Seconds"; + } + + if ($seconds < 3600) { + $minutes = round($seconds / 60); + + return "$minutes Minutes"; + } + + if ($seconds < 86400) { + $hours = round($seconds / 3600); + + return "$hours Hours"; + } + + if ($seconds < 604800) { + $days = round($seconds / 86400); + + return "$days Days"; + } + + if ($seconds < 2592000) { + $weeks = round($seconds / 604800); + + return "$weeks Weeks"; + } + + if ($seconds < 31536000) { + $months = round($seconds / 2592000); + + return "$months Months"; + } + + $years = round($seconds / 31536000); + + return "$years Years"; + } + + /** + * Get the short name from the given DN + * + * If the given DN contains a CN, the CN is returned. Else, the DN is returned as string. + * + * @param array $dn + * + * @return string The CN if it exists or the full DN as string + */ + private static function shortNameFromDN(array $dn) + { + if (isset($dn['CN'])) { + $cn = (array) $dn['CN']; + return $cn[0]; + } else { + $result = []; + foreach ($dn as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $result[] = "{$key}={$item}"; + } + } else { + $result[] = "{$key}={$value}"; + } + } + + return implode(', ', $result); + } + } + + /** + * Split the given Subject Alternative Names into key-value pairs + * + * @param string $sans + * + * @return \Generator + */ + private static function splitSANs($sans) + { + preg_match_all('/(?:^|, )([^:]+):/', $sans, $keys); + $values = preg_split('/(^|, )[^:]+:\s*/', $sans); + for ($i = 0; $i < count($keys[1]); $i++) { + yield [$keys[1][$i], $values[$i + 1]]; + } + } + + /** + * Yield certificates in the given bundle + * + * @param string $file Path to the bundle + * + * @return \Generator + */ + public static function parseBundle($file) + { + $content = file_get_contents($file); + + $blocks = explode('-----BEGIN CERTIFICATE-----', $content); + + foreach ($blocks as $block) { + $end = strrpos($block, '-----END CERTIFICATE-----'); + + if ($end !== false) { + yield '-----BEGIN CERTIFICATE-----' . substr($block, 0, $end) . '-----END CERTIFICATE-----'; + } + } + } + + /** + * Find or insert the given certificate and return its ID + * + * @param Connection $db + * @param mixed $cert + * + * @return int + */ + public static function findOrInsertCert(Connection $db, $cert) + { + $certInfo = openssl_x509_parse($cert); + + $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true); + + $row = $db->select( + (new Select()) + ->columns(['id']) + ->from('x509_certificate') + ->where(['fingerprint = ?' => $fingerprint]) + )->fetch(); + + if ($row !== false) { + return (int) $row['id']; + } + + Logger::debug("Importing certificate: %s", $certInfo['name']); + + $pem = null; + if (! openssl_x509_export($cert, $pem)) { + die('Failed to encode X.509 certificate.'); + } + $der = CertificateUtils::pem2der($pem); + + $ca = false; + if (isset($certInfo['extensions']['basicConstraints'])) { + if (strpos($certInfo['extensions']['basicConstraints'], 'CA:TRUE') !== false) { + $ca = true; + } + } + + $subjectHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'subject'); + $issuerHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'issuer'); + $pubkey = openssl_pkey_get_details(openssl_pkey_get_public($cert)); + $signature = explode('-', $certInfo['signatureTypeSN']); + + $db->insert( + 'x509_certificate', + [ + 'subject' => CertificateUtils::shortNameFromDN($certInfo['subject']), + 'subject_hash' => $subjectHash, + 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']), + 'issuer_hash' => $issuerHash, + 'version' => $certInfo['version'] + 1, + 'self_signed' => $subjectHash === $issuerHash ? 'yes' : 'no', + 'ca' => $ca ? 'yes' : 'no', + 'pubkey_algo' => CertificateUtils::$pubkeyTypes[$pubkey['type']], + 'pubkey_bits' => $pubkey['bits'], + 'signature_algo' => array_shift($signature), // Support formats like RSA-SHA1 and + 'signature_hash_algo' => array_pop($signature), // ecdsa-with-SHA384 + 'valid_from' => $certInfo['validFrom_time_t'], + 'valid_to' => $certInfo['validTo_time_t'], + 'fingerprint' => $fingerprint, + 'serial' => gmp_export($certInfo['serialNumber']), + 'certificate' => $der + ] + ); + + $certId = (int) $db->lastInsertId(); + + CertificateUtils::insertSANs($db, $certId, $certInfo); + + return $certId; + } + + private static function insertSANs($db, $certId, array $certInfo) + { + if (isset($certInfo['extensions']['subjectAltName'])) { + foreach (CertificateUtils::splitSANs($certInfo['extensions']['subjectAltName']) as $san) { + list($type, $value) = $san; + + $hash = hash('sha256', sprintf('%s=%s', $type, $value), true); + + $row = $db->select( + (new Select()) + ->from('x509_certificate_subject_alt_name') + ->columns('certificate_id') + ->where([ + 'certificate_id = ?' => $certId, + 'hash = ?' => $hash + ]) + )->fetch(); + + // Ignore duplicate SANs + if ($row !== false) { + continue; + } + + $db->insert( + 'x509_certificate_subject_alt_name', + [ + 'certificate_id' => $certId, + 'hash' => $hash, + 'type' => $type, + 'value' => $value + ] + ); + } + } + } + + private static function findOrInsertDn($db, $certInfo, $type) + { + $dn = $certInfo[$type]; + + $data = ''; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $data .= "{$key}=${value}, "; + } + } + $hash = hash('sha256', $data, true); + + $row = $db->select( + (new Select()) + ->from('x509_dn') + ->columns('hash') + ->where([ 'hash = ?' => $hash, 'type = ?' => $type ]) + ->limit(1) + )->fetch(); + + if ($row !== false) { + return $row['hash']; + } + + $index = 0; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $db->insert( + 'x509_dn', + [ + 'hash' => $hash, + '`key`' => $key, + '`value`' => $value, + '`order`' => $index, + 'type' => $type + ] + ); + $index++; + } + } + + return $hash; + } + + /** + * Verify certificates + * + * @param Connection $db Connection to the X.509 database + * + * @return int + */ + public static function verifyCertificates(Connection $db) + { + $files = new TemporaryLocalFileStorage(); + + $caFile = uniqid('ca'); + + $cas = $db->select( + (new Select) + ->from('x509_certificate') + ->columns(['certificate']) + ->where(['ca = ?' => 'yes', 'trusted = ?' => 'yes']) + ); + + $contents = []; + + foreach ($cas as $ca) { + $contents[] = static::der2pem($ca['certificate']); + } + + if (empty($contents)) { + throw new \RuntimeException('Trust store is empty'); + } + + $files->create($caFile, implode("\n", $contents)); + + $count = 0; + + $db->beginTransaction(); + + try { + $chains = $db->select( + (new Select) + ->from('x509_certificate_chain c') + ->join('x509_target t', ['t.latest_certificate_chain_id = c.id', 'c.valid = ?' => 'no']) + ->columns('c.id') + ); + + foreach ($chains as $chain) { + ++$count; + + $certs = $db->select( + (new Select) + ->from('x509_certificate c') + ->columns('c.certificate') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') + ->where(['ccl.certificate_chain_id = ?' => $chain['id']]) + ->orderBy(['ccl.order' => 'DESC']) + ); + + $collection = []; + + foreach ($certs as $cert) { + $collection[] = CertificateUtils::der2pem($cert['certificate']); + } + + $certFile = uniqid('cert'); + + $files->create($certFile, array_pop($collection)); + + $untrusted = ''; + foreach ($collection as $intermediate) { + $intermediateFile = uniqid('intermediate'); + $files->create($intermediateFile, $intermediate); + $untrusted .= ' -untrusted ' . escapeshellarg($files->resolvePath($intermediateFile)); + } + + $command = sprintf( + 'openssl verify -CAfile %s%s %s 2>&1', + escapeshellarg($files->resolvePath($caFile)), + $untrusted, + escapeshellarg($files->resolvePath($certFile)) + ); + + $output = null; + + exec($command, $output, $exitcode); + + $output = implode("\n", $output); + + if ($exitcode !== 0) { + Logger::warning('openssl verify failed for command %s: %s', $command, $output); + } + + preg_match('/^error \d+ at \d+ depth lookup:(.+)$/m', $output, $match); + + if (!empty($match)) { + $set = ['invalid_reason' => trim($match[1])]; + } else { + $set = ['valid' => 'yes', 'invalid_reason' => null]; + } + + $db->update( + 'x509_certificate_chain', + $set, + ['id = ?' => $chain['id']] + ); + } + + $db->commitTransaction(); + } catch (Exception $e) { + Logger::error($e); + $db->rollBackTransaction(); + } + + return $count; + } +} diff --git a/library/X509/CertificatesTable.php b/library/X509/CertificatesTable.php new file mode 100644 index 0000000..3900221 --- /dev/null +++ b/library/X509/CertificatesTable.php @@ -0,0 +1,109 @@ + 'cert-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + protected function createColumns() + { + return [ + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => mt('x509', 'Certificate'), + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if ($ca === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-ca', 'title' => mt('x509', 'Is Certificate Authority')] + ); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if ($selfSigned === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-self-signed', 'title' => mt('x509', 'Is Self-Signed')] + ); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if ($trusted === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'icon icon-thumbs-up', 'title' => mt('x509', 'Is Trusted')] + ); + } + ], + + 'issuer' => mt('x509', 'Issuer'), + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/certificate', ['cert' => $row['id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/ChainDetails.php b/library/X509/ChainDetails.php new file mode 100644 index 0000000..527e06c --- /dev/null +++ b/library/X509/ChainDetails.php @@ -0,0 +1,116 @@ + 'cert-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function createColumns() + { + return [ + [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function () { + return Html::tag('i', ['class' => 'x509-icon-cert']); + } + ], + + 'version' => [ + 'attributes' => ['class' => 'version-col'], + 'renderer' => function ($version) { + return Html::tag('div', ['class' => 'badge'], $version); + } + ], + + 'subject' => [ + 'label' => mt('x509', 'Subject') + ], + + 'ca' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($ca) { + if ($ca === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-ca', 'title' => mt('x509', 'Is Certificate Authority')] + ); + } + ], + + 'self_signed' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($selfSigned) { + if ($selfSigned === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'x509-icon-self-signed', 'title' => mt('x509', 'Is Self-Signed')] + ); + } + ], + + 'trusted' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($trusted) { + if ($trusted === 'no') { + return null; + } + + return Html::tag( + 'i', + ['class' => 'icon icon-thumbs-up', 'title' => mt('x509', 'Is Trusted')] + ); + } + ], + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/certificate', ['cert' => $row['certificate_id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/library/X509/ColorScheme.php b/library/X509/ColorScheme.php new file mode 100644 index 0000000..94a3bf7 --- /dev/null +++ b/library/X509/ColorScheme.php @@ -0,0 +1,36 @@ +colors = $colors; + } + + public function scheme() + { + $iter = new InfiniteIterator(new ArrayIterator($this->colors)); + $iter->rewind(); + + return function () use ($iter) { + $color = $iter->current(); + + $iter->next(); + + return $color; + }; + } +} diff --git a/library/X509/Command.php b/library/X509/Command.php new file mode 100644 index 0000000..7737119 --- /dev/null +++ b/library/X509/Command.php @@ -0,0 +1,35 @@ +getModuleManager()->loadEnabledModules(); + } + + /** + * Get the connection to the X.509 database + * + * @return Sql\Connection + */ + public function getDb() + { + $config = new Sql\Config(ResourceFactory::getResourceConfig( + $this->Config()->get('backend', 'resource') + )); + + $conn = new Sql\Connection($config); + + return $conn; + } +} diff --git a/library/X509/Controller.php b/library/X509/Controller.php new file mode 100644 index 0000000..bb798a0 --- /dev/null +++ b/library/X509/Controller.php @@ -0,0 +1,121 @@ +Config()->get('backend', 'resource') + )); + + $config->options = [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION SQL_MODE='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE" + . ",ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'" + ]; + + $conn = new Sql\Connection($config); + + return $conn; + } + + /** + * Set the title tab of this view + * + * @param string $label + * + * @return $this + */ + protected function setTitle($label) + { + $this->getTabs()->add(uniqid(), [ + 'active' => true, + 'label' => (string) $label, + 'url' => $this->getRequest()->getUrl() + ]); + + return $this; + } + + protected function handleFormatRequest(Sql\Connection $db, Sql\Select $select, callable $callback = null) + { + $desiredContentType = $this->getRequest()->getHeader('Accept'); + if ($desiredContentType === 'application/json') { + $desiredFormat = 'json'; + } elseif ($desiredContentType === 'text/csv') { + $desiredFormat = 'csv'; + } else { + $desiredFormat = strtolower($this->params->get('format', 'html')); + } + + if ($desiredFormat !== 'html' && ! $this->params->has('limit')) { + $select->limit(null); // Resets any default limit and offset + } + + switch ($desiredFormat) { + case 'sql': + echo '
'
+                    . var_export((new Sql\QueryBuilder())->assembleSelect($select), true)
+                    . '
'; + exit; + case 'json': + $response = $this->getResponse(); + $response + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'inline; filename=' . $this->getRequest()->getActionName() . '.json' + ) + ->appendBody( + Json::encode( + $callback !== null + ? iterator_to_array($callback($db->select($select))) + : $db->select($select)->fetchAll() + ) + ) + ->sendResponse(); + exit; + case 'csv': + $response = $this->getResponse(); + $response + ->setHeader('Content-Type', 'text/csv') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv' + ) + ->appendBody( + (string) Csv::fromQuery( + $callback !== null ? $callback($db->select($select)) : $db->select($select) + ) + ) + ->sendResponse(); + exit; + } + } + + protected function initTabs() + { + $this->getTabs()->extend(new OutputFormat())->extend(new DashboardAction())->extend(new MenuAction()); + + return $this; + } +} diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php new file mode 100644 index 0000000..edcbf88 --- /dev/null +++ b/library/X509/DataTable.php @@ -0,0 +1,145 @@ +data; + } + + /** + * Set the data to display + * + * @param array|\Traversable $data + * + * @return $this + */ + public function setData($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + return $this; + } + + protected function createColumns() + { + } + + public function renderHeader() + { + $cells = []; + + foreach ($this->columns as $column) { + if (is_array($column)) { + if (isset($column['label'])) { + $label = $column['label']; + } else { + $label = new HtmlString(' '); + } + } else { + $label = $column; + } + + $cells[] = Html::tag('th', $label); + } + + return Html::tag('thead', Html::tag('tr', $cells)); + } + + protected function renderRow($row) + { + $cells = []; + + foreach ($this->columns as $key => $column) { + if (! is_int($key) && array_key_exists($key, $row)) { + $data = $row[$key]; + } else { + if (isset($column['column']) && array_key_exists($column['column'], $row)) { + $data = $row[$column['column']]; + } else { + $data = null; + } + } + + if (isset($column['renderer'])) { + $content = call_user_func(($column['renderer']), $data, $row); + } else { + $content = $data; + } + + $cells[] = Html::tag('td', isset($column['attributes']) ? $column['attributes'] : null, $content); + } + + return Html::tag('tr', $cells); + } + + protected function renderBody($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $rows = []; + + foreach ($data as $row) { + $rows[] = $this->renderRow($row); + } + + if (empty($rows)) { + $colspan = count($this->columns); + + $rows = Html::tag( + 'tr', + Html::tag( + 'td', + ['colspan' => $colspan], + mt('x509', 'No results found.') + ) + ); + } + + return Html::tag('tbody', $rows); + } + + protected function assemble() + { + $this->columns = $this->createColumns(); + + $this->add(array_filter([ + $this->renderHeader(), + $this->renderBody($this->getData()) + ])); + } +} diff --git a/library/X509/Donut.php b/library/X509/Donut.php new file mode 100644 index 0000000..f3e199f --- /dev/null +++ b/library/X509/Donut.php @@ -0,0 +1,93 @@ + 'cert-donut']; + + /** + * The donut data + * + * @var array|\Traversable + */ + protected $data = []; + + protected $heading; + + protected $headingLevel; + + protected $labelCallback; + + /** + * Get data to display + * + * @return array|\Traversable + */ + public function getData() + { + return $this->data; + } + + /** + * Set the data to display + * + * @param array|\Traversable $data + * + * @return $this + */ + public function setData($data) + { + if (! is_array($data) && ! $data instanceof \Traversable) { + throw new \InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + return $this; + } + + public function setHeading($heading, $level) + { + $this->heading = $heading; + $this->headingLevel = (int) $level; + + return $this; + } + + public function setLabelCallback(callable $callback) + { + $this->labelCallback = $callback; + + return $this; + } + + public function assemble() + { + $colorScheme = (new ColorScheme(['#014573', '#3588A5', '#BBD9B0', '#F5CC0A', '#F04B0D']))->scheme(); + $donut = new \Icinga\Chart\Donut(); + $legend = new Table(); + + foreach ($this->data as $data) { + $color = $colorScheme(); + $donut->addSlice((int) $data['cnt'], ['stroke' => $color]); + $legend->addRow( + [ + Html::tag('span', ['class' => 'badge', 'style' => "background-color: $color; height: 1.75em;"]), + call_user_func($this->labelCallback, $data), + $data['cnt'] + ] + ); + } + + $this->add([Html::tag("h{$this->headingLevel}", $this->heading), new HtmlString($donut->render()), $legend]); + } +} diff --git a/library/X509/ExpirationWidget.php b/library/X509/ExpirationWidget.php new file mode 100644 index 0000000..b3c3081 --- /dev/null +++ b/library/X509/ExpirationWidget.php @@ -0,0 +1,85 @@ +from = $from; + $this->to = $to; + } + + protected function assemble() + { + $now = time(); + + $from = $this->from; + + if ($from > $now) { + $ratio = 0; + $dateTip = DateFormatter::formatDateTime($from); + $message = sprintf(mt('x509', 'not until after %s'), DateFormatter::timeUntil($from, true)); + } else { + $to = $this->to; + + $secondsRemaining = $to - $now; + $daysRemaining = ($secondsRemaining - $secondsRemaining % 86400) / 86400; + if ($daysRemaining > 0) { + $secondsTotal = $to - $from; + $daysTotal = ($secondsTotal - $secondsTotal % 86400) / 86400; + + $ratio = min(100, 100 - round(($daysRemaining * 100) / $daysTotal, 2)); + $message = sprintf(mt('x509', 'in %d days'), $daysRemaining); + } else { + $ratio = 100; + if ($daysRemaining < 0) { + $message = sprintf(mt('x509', '%d days ago'), $daysRemaining * -1); + } else { + $message = mt('x509', 'today'); + } + } + + $dateTip = DateFormatter::formatDateTime($to); + } + + if ($ratio >= 75) { + if ($ratio >= 90) { + $state = 'state-critical'; + } else { + $state = 'state-warning'; + } + } else { + $state = 'state-ok'; + } + + $this->add([ + Html::tag( + 'span', + ['class' => '', 'style' => 'font-size: 0.9em;', 'title' => $dateTip], + $message + ), + Html::tag( + 'div', + ['class' => 'progress-bar dont-print'], + Html::tag( + 'div', + ['style' => sprintf('width: %.2F%%;', $ratio), 'class' => "bg-stateful {$state}"], + new HtmlString(' ') + ) + ) + ]); + } +} diff --git a/library/X509/FilterAdapter.php b/library/X509/FilterAdapter.php new file mode 100644 index 0000000..1c7dcb0 --- /dev/null +++ b/library/X509/FilterAdapter.php @@ -0,0 +1,55 @@ +addFilter($filter); + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + + return $this; + } + + public function getFilter() + { + return $this->filter; + } + + public function addFilter(Filter $filter) + { + if (! $filter->isEmpty()) { + if ($this->filter === null) { + $this->filter = $filter; + } else { + $this->filter->andFilter($filter); + } + } + + return $this; + } + + public function where($condition, $value = null) + { + $this->addFilter(Filter::expression($condition, '=', $value)); + + return $this; + } +} diff --git a/library/X509/Hook/SniHook.php b/library/X509/Hook/SniHook.php new file mode 100644 index 0000000..417200f --- /dev/null +++ b/library/X509/Hook/SniHook.php @@ -0,0 +1,53 @@ + ['example.com', 'mail.example.com']] + * + * @return string[][] + */ + public static function getAll() + { + // This is implemented as map of maps to avoid duplicates, + // the caller is expected to handle it as map of sequences though + $sni = []; + + foreach (Hook::all('X509\Sni') as $hook) { + /** @var self $hook */ + foreach ($hook->getHosts() as $ip => $hostname) { + $sni[$ip][$hostname] = $hostname; + } + } + + foreach (Config::module('x509', 'sni') as $ip => $config) { + foreach (array_filter(StringHelper::trimSplit($config->get('hostnames', []))) as $hostname) { + $sni[$ip][$hostname] = $hostname; + } + } + + return $sni; + } + + /** + * Aggregate pairs of ip => hostname + * + * @param Filter $filter + * + * @return \Generator + */ + abstract public function getHosts(Filter $filter = null); +} diff --git a/library/X509/Job.php b/library/X509/Job.php new file mode 100644 index 0000000..4c6cb65 --- /dev/null +++ b/library/X509/Job.php @@ -0,0 +1,381 @@ +db = $db; + $this->jobDescription = $jobDescription; + $this->snimap = $snimap; + $this->parallel = $parallel; + $this->name = $name; + } + + private function getConnector($peerName) + { + $simpleConnector = new Connector($this->loop); + $streamCaptureConnector = new StreamOptsCaptureConnector($simpleConnector); + $secureConnector = new SecureConnector($streamCaptureConnector, $this->loop, array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'capture_peer_cert_chain' => true, + 'SNI_enabled' => true, + 'peer_name' => $peerName + )); + return [new TimeoutConnector($secureConnector, 5.0, $this->loop), $streamCaptureConnector]; + } + + public static function binary($addr) + { + return str_pad(inet_pton($addr), 16, "\0", STR_PAD_LEFT); + } + + private static function addrToNumber($addr) + { + return gmp_import(static::binary($addr)); + } + + private static function numberToAddr($num, $ipv6 = true) + { + if ((bool) $ipv6) { + return inet_ntop(str_pad(gmp_export($num), 16, "\0", STR_PAD_LEFT)); + } else { + return inet_ntop(gmp_export($num)); + } + } + + private static function generateTargets(ConfigObject $jobDescription, array $hostnamesConfig) + { + foreach (StringHelper::trimSplit($jobDescription->get('cidrs')) as $cidr) { + $pieces = explode('/', $cidr); + if (count($pieces) !== 2) { + Logger::warning("CIDR '%s' is in the wrong format.", $cidr); + continue; + } + $start_ip = $pieces[0]; + $prefix = $pieces[1]; +// $subnet = 128; +// if (substr($start_ip, 0, 2) === '::') { +// if (strtoupper(substr($start_ip, 0, 7)) !== '::FFFF:') { +// $subnet = 32; +// } +// } elseif (strpos($start_ip, ':') === false) { +// $subnet = 32; +// } + $ipv6 = filter_var($start_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $subnet = $ipv6 ? 128 : 32; + $ip_count = 1 << ($subnet - $prefix); + $start = static::addrToNumber($start_ip); + for ($i = 0; $i < $ip_count; $i++) { + $ip = static::numberToAddr(gmp_add($start, $i), $ipv6); + foreach (StringHelper::trimSplit($jobDescription->get('ports')) as $portRange) { + $pieces = StringHelper::trimSplit($portRange, '-'); + if (count($pieces) === 2) { + list($start_port, $end_port) = $pieces; + } else { + $start_port = $pieces[0]; + $end_port = $pieces[0]; + } + + foreach (range($start_port, $end_port) as $port) { + $hostnames = isset($hostnamesConfig[$ip]) ? $hostnamesConfig[$ip] : []; + + if (empty($hostnames)) { + $hostnames[] = null; + } + + foreach ($hostnames as $hostname) { + $target = (object)[]; + $target->ip = $ip; + $target->port = $port; + $target->hostname = $hostname; + yield $target; + } + } + } + } + } + } + + private function updateJobStats($finished = false) + { + $fields = ['finished_targets' => $this->finishedTargets]; + + if ($finished) { + $fields['end_time'] = new Expression('NOW()'); + } + + $this->db->update( + 'x509_job_run', + $fields, + ['id = ?' => $this->jobId] + ); + } + + private static function formatTarget($target) + { + $result = "tls://[{$target->ip}]:{$target->port}"; + + if ($target->hostname !== null) { + $result .= " [SNI hostname: {$target->hostname}]"; + } + + return $result; + } + + private function finishTarget() + { + $this->pendingTargets--; + $this->finishedTargets++; + $this->startNextTarget(); + } + + private function startNextTarget() + { + if (!$this->targets->valid()) { + if ($this->pendingTargets == 0) { + $this->updateJobStats(true); + $this->loop->stop(); + } + + return; + } + + $target = $this->targets->current(); + $this->targets->next(); + + $url = "tls://[{$target->ip}]:{$target->port}"; + Logger::debug("Connecting to %s", static::formatTarget($target)); + $this->pendingTargets++; + /** @var ConnectorInterface $connector */ + /** @var StreamOptsCaptureConnector $streamCapture */ + list($connector, $streamCapture) = $this->getConnector($target->hostname); + $connector->connect($url)->then( + function (ConnectionInterface $conn) use ($target, $streamCapture) { + $this->finishTarget(); + + Logger::info("Connected to %s", static::formatTarget($target)); + + // Close connection in order to capture stream context options + $conn->close(); + + $capturedStreamOptions = $streamCapture->getCapturedStreamOptions(); + + $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']); + }, + function (\Exception $exception) use ($target, $streamCapture) { + Logger::debug("Cannot connect to server: %s", $exception->getMessage()); + + $this->finishTarget(); + + $capturedStreamOptions = $streamCapture->getCapturedStreamOptions(); + + if (isset($capturedStreamOptions['ssl']['peer_certificate_chain'])) { + // The scanned target presented its certificate chain despite throwing an error + // This is the case for targets which require client certificates for example + $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']); + } else { + $this->db->update( + 'x509_target', + ['latest_certificate_chain_id' => null], + [ + 'hostname = ?' => $target->hostname, + 'ip = ?' => static::binary($target->ip), + 'port = ?' => $target->port + ] + ); + } + + $step = max($this->totalTargets / 100, 1); + + if ($this->finishedTargets % (int) $step == 0) { + $this->updateJobStats(); + } + //$loop->stop(); + } + )->otherwise(function (\Exception $e) { + echo $e->getMessage() . PHP_EOL; + echo $e->getTraceAsString() . PHP_EOL; + }); + } + + public function getJobId() + { + return $this->jobId; + } + + public function run() + { + $this->loop = Factory::create(); + + $this->totalTargets = iterator_count(static::generateTargets($this->jobDescription, $this->snimap)); + + if ($this->totalTargets == 0) { + return null; + } + + $this->targets = static::generateTargets($this->jobDescription, $this->snimap); + + $this->db->insert( + 'x509_job_run', + [ + 'name' => $this->name, + 'total_targets' => $this->totalTargets, + 'finished_targets' => 0 + ] + ); + + $this->jobId = $this->db->lastInsertId(); + + // Start scanning the first couple of targets... + for ($i = 0; $i < $this->parallel; $i++) { + $this->startNextTarget(); + } + + $this->loop->run(); + + return $this->totalTargets; + } + + protected function processChain($target, $chain) + { + if ($target->hostname === null) { + $hostname = gethostbyaddr($target->ip); + + if ($hostname !== false) { + $target->hostname = $hostname; + } + } + + $this->db->transaction(function () use ($target, $chain) { + $row = $this->db->select( + (new Select()) + ->columns(['id']) + ->from('x509_target') + ->where([ + 'ip = ?' => static::binary($target->ip), + 'port = ?' => $target->port, + 'hostname = ?' => $target->hostname + ]) + )->fetch(); + + if ($row === false) { + $this->db->insert( + 'x509_target', + [ + 'ip' => static::binary($target->ip), + 'port' => $target->port, + 'hostname' => $target->hostname + ] + ); + $targetId = $this->db->lastInsertId(); + } else { + $targetId = $row['id']; + } + + $chainUptodate = false; + + $lastChain = $this->db->select( + (new Select()) + ->columns(['id']) + ->from('x509_certificate_chain') + ->where(['target_id = ?' => $targetId]) + ->orderBy('id', SORT_DESC) + ->limit(1) + )->fetch(); + + if ($lastChain !== false) { + $lastFingerprints = $this->db->select( + (new Select()) + ->columns(['c.fingerprint']) + ->from('x509_certificate_chain_link l') + ->join('x509_certificate c', 'l.certificate_id = c.id') + ->where(['l.certificate_chain_id = ?' => $lastChain[0]]) + ->orderBy('l.`order`') + )->fetchAll(); + + foreach ($lastFingerprints as &$lastFingerprint) { + $lastFingerprint = $lastFingerprint[0]; + } + + $currentFingerprints = []; + + foreach ($chain as $cert) { + $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true); + } + + $chainUptodate = $currentFingerprints === $lastFingerprints; + } + + if ($chainUptodate) { + $chainId = $lastChain[0]; + } else { + $this->db->insert( + 'x509_certificate_chain', + [ + 'target_id' => $targetId, + 'length' => count($chain) + ] + ); + + $chainId = $this->db->lastInsertId(); + + foreach ($chain as $index => $cert) { + $certInfo = openssl_x509_parse($cert); + + $certId = CertificateUtils::findOrInsertCert($this->db, $cert, $certInfo); + + $this->db->insert( + 'x509_certificate_chain_link', + [ + 'certificate_chain_id' => $chainId, + '`order`' => $index, + 'certificate_id' => $certId + ] + ); + } + } + + $this->db->update( + 'x509_target', + ['latest_certificate_chain_id' => $chainId], + ['id = ?' => $targetId] + ); + }); + } +} diff --git a/library/X509/JobsIniRepository.php b/library/X509/JobsIniRepository.php new file mode 100644 index 0000000..9a261ee --- /dev/null +++ b/library/X509/JobsIniRepository.php @@ -0,0 +1,20 @@ + array('name', 'cidrs', 'ports', 'schedule')); + + protected $configs = array('jobs' => array( + 'module' => 'x509', + 'name' => 'jobs', + 'keyColumn' => 'name' + )); +} diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php new file mode 100644 index 0000000..6f7cfb3 --- /dev/null +++ b/library/X509/ProvidedHook/HostsImportSource.php @@ -0,0 +1,70 @@ +from('x509_target t') + ->columns([ + 'host_ip' => 't.ip', + 'host_name' => 't.hostname', + 'host_ports' => 'GROUP_CONCAT(DISTINCT t.port SEPARATOR ",")' + ]) + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->where(['ccl.order = ?' => 0]) + ->groupBy(['t.ip', 't.hostname']); + + $results = []; + $foundDupes = []; + foreach ($this->getDb()->select($targets) as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + $target->host_ip = $ipv4 ?: $ipv6; + $target->host_address = $ipv4; + $target->host_address6 = $ipv6; + + if (isset($foundDupes[$target->host_name])) { + // For load balanced systems the IP address is the better choice + $target->host_name_or_ip = $target->host_ip; + } elseif (! isset($results[$target->host_name])) { + // Hostnames are usually preferred, especially in the case of SNI + $target->host_name_or_ip = $target->host_name; + } else { + $dupe = $results[$target->host_name]; + unset($results[$target->host_name]); + $foundDupes[$dupe->host_name] = true; + $dupe->host_name_or_ip = $dupe->host_ip; + $results[$dupe->host_name_or_ip] = $dupe; + $target->host_name_or_ip = $target->host_ip; + } + + $results[$target->host_name_or_ip] = $target; + } + + return $results; + } + + public function listColumns() + { + return [ + 'host_name_or_ip', + 'host_ip', + 'host_name', + 'host_ports', + 'host_address', + 'host_address6' + ]; + } + + public static function getDefaultKeyColumnName() + { + return 'host_name_or_ip'; + } +} diff --git a/library/X509/ProvidedHook/ServicesImportSource.php b/library/X509/ProvidedHook/ServicesImportSource.php new file mode 100644 index 0000000..19f9de9 --- /dev/null +++ b/library/X509/ProvidedHook/ServicesImportSource.php @@ -0,0 +1,85 @@ +from('x509_target t') + ->columns([ + 'host_ip' => 't.ip', + 'host_name' => 't.hostname', + 'host_port' => 't.port', + 'cert_subject' => 'c.subject', + 'cert_issuer' => 'c.issuer', + 'cert_self_signed' => 'COALESCE(ci.self_signed, c.self_signed)', + 'cert_trusted' => 'c.trusted', + 'cert_valid_from' => 'c.valid_from', + 'cert_valid_to' => 'c.valid_to', + 'cert_fingerprint' => 'HEX(c.fingerprint)', + 'cert_dn' => 'GROUP_CONCAT(CONCAT(dn.key, \'=\', dn.value) SEPARATOR \',\')', + 'cert_subject_alt_name' => (new Sql\Select()) + ->from('x509_certificate_subject_alt_name can') + ->columns('GROUP_CONCAT(CONCAT(can.type, \':\', can.value) SEPARATOR \',\')') + ->where(['can.certificate_id = c.id']) + ->groupBy(['can.certificate_id']) + ]) + ->join('x509_certificate_chain cc', 'cc.id = t.latest_certificate_chain_id') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_chain_id = cc.id') + ->join('x509_certificate c', 'c.id = ccl.certificate_id') + ->joinLeft('x509_certificate ci', 'ci.subject_hash = c.issuer_hash') + ->joinLeft('x509_dn dn', 'dn.hash = c.subject_hash') + ->where(['ccl.order = ?' => 0]) + ->groupBy(['t.ip', 't.hostname', 't.port']); + + $results = []; + foreach ($this->getDb()->select($targets) as $target) { + list($ipv4, $ipv6) = $this->transformIpAddress($target->host_ip); + $target->host_ip = $ipv4 ?: $ipv6; + $target->host_address = $ipv4; + $target->host_address6 = $ipv6; + + $target->host_name_ip_and_port = sprintf( + '%s/%s:%d', + $target->host_name, + $target->host_ip, + $target->host_port + ); + + $results[$target->host_name_ip_and_port] = $target; + } + + return $results; + } + + public function listColumns() + { + return [ + 'host_name_ip_and_port', + 'host_ip', + 'host_name', + 'host_port', + 'host_address', + 'host_address6', + 'cert_subject', + 'cert_issuer', + 'cert_self_signed', + 'cert_trusted', + 'cert_valid_from', + 'cert_valid_to', + 'cert_fingerprint', + 'cert_dn', + 'cert_subject_alt_name' + ]; + } + + public static function getDefaultKeyColumnName() + { + return 'host_name_ip_and_port'; + } +} diff --git a/library/X509/ProvidedHook/x509ImportSource.php b/library/X509/ProvidedHook/x509ImportSource.php new file mode 100644 index 0000000..184744b --- /dev/null +++ b/library/X509/ProvidedHook/x509ImportSource.php @@ -0,0 +1,49 @@ +get('backend', 'resource') + )); + $config->options = [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ + ]; + + $conn = new Sql\Connection($config); + + return $conn; + } + + /** + * Transform the given binary IP address in a human readable format + * + * @param string $ip + * + * @return array The first element is IPv4, the second IPv6 + */ + protected function transformIpAddress($ip) + { + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + return [inet_ntop($ipv4), null]; + } else { + return [null, inet_ntop($ip)]; + } + } +} diff --git a/library/X509/React/StreamOptsCaptureConnector.php b/library/X509/React/StreamOptsCaptureConnector.php new file mode 100644 index 0000000..81cd8aa --- /dev/null +++ b/library/X509/React/StreamOptsCaptureConnector.php @@ -0,0 +1,59 @@ +connector = $connector; + } + + /** + * @return array + */ + public function getCapturedStreamOptions() + { + return (array) $this->capturedStreamOptions; + } + + /** + * @param array $capturedStreamOptions + * + * @return $this + */ + public function setCapturedStreamOptions($capturedStreamOptions) + { + $this->capturedStreamOptions = $capturedStreamOptions; + + return $this; + } + + public function connect($uri) + { + return $this->connector->connect($uri)->then(function (ConnectionInterface $conn) { + $conn->on('close', function () use ($conn) { + if (is_resource($conn->stream)) { + $this->setCapturedStreamOptions(stream_context_get_options($conn->stream)); + } + }); + + return resolve($conn); + }); + } +} diff --git a/library/X509/Scheduler.php b/library/X509/Scheduler.php new file mode 100644 index 0000000..0963016 --- /dev/null +++ b/library/X509/Scheduler.php @@ -0,0 +1,59 @@ +loop = Loop::create(); + } + + public function add($name, $cronSchedule, callable $callback) + { + if (! CronExpression::isValidExpression($cronSchedule)) { + throw new \RuntimeException('Invalid cron expression'); + } + + $now = new \DateTime(); + + $expression = new CronExpression($cronSchedule); + + if ($expression->isDue($now)) { + $this->loop->futureTick($callback); + } + + $nextRuns = $expression->getMultipleRunDates(2, $now); + + $interval = $nextRuns[0]->getTimestamp() - $now->getTimestamp(); + + $period = $nextRuns[1]->getTimestamp() - $nextRuns[0]->getTimestamp(); + + Logger::info('Scheduling job %s to run at %s.', $name, $nextRuns[0]->format('Y-m-d H:i:s')); + + $loop = function () use (&$loop, $name, $callback, $period) { + $callback(); + + $nextRun = (new \DateTime()) + ->add(new \DateInterval("PT{$period}S")); + + Logger::info('Scheduling job %s to run at %s.', $name, $nextRun->format('Y-m-d H:i:s')); + + $this->loop->addTimer($period, $loop); + }; + + $this->loop->addTimer($interval, $loop); + } + + public function run() + { + $this->loop->run(); + } +} diff --git a/library/X509/SniIniRepository.php b/library/X509/SniIniRepository.php new file mode 100644 index 0000000..ba4c4ba --- /dev/null +++ b/library/X509/SniIniRepository.php @@ -0,0 +1,20 @@ + array('ip', 'hostnames')); + + protected $configs = array('sni' => array( + 'module' => 'x509', + 'name' => 'sni', + 'keyColumn' => 'ip' + )); +} diff --git a/library/X509/SortAdapter.php b/library/X509/SortAdapter.php new file mode 100644 index 0000000..5adb86b --- /dev/null +++ b/library/X509/SortAdapter.php @@ -0,0 +1,46 @@ +select = $select; + $this->callback = $callback; + } + + public function order($field, $direction = null) + { + if ($this->callback !== null) { + $field = call_user_func($this->callback, $field) ?: $field; + } + + if ($direction === null) { + $this->select->orderBy($field); + } else { + $this->select->orderBy($field, $direction); + } + } + + public function hasOrder() + { + return $this->select->hasOrderBy(); + } + + public function getOrder() + { + return $this->select->getOrderBy(); + } +} diff --git a/library/X509/SqlFilter.php b/library/X509/SqlFilter.php new file mode 100644 index 0000000..40da0d9 --- /dev/null +++ b/library/X509/SqlFilter.php @@ -0,0 +1,84 @@ +renderFilterCallback = $callback; + + return $this; + } + + protected function renderFilterExpression(Filter $filter) + { + $hit = false; + + if (isset($this->renderFilterCallback)) { + $hit = call_user_func($this->renderFilterCallback, clone $filter); + } + + if ($hit !== false) { + $filter = $hit; + } + + return parent::renderFilterExpression($filter); + } +} + +/** + * @internal + */ +class SqlFilter +{ + public static function apply(Select $select, Filter $filter = null, callable $renderFilterCallback = null) + { + if ($filter === null || $filter->isEmpty()) { + return; + } + + if (! $filter->isEmpty()) { + $conn = (new NoImplicitConnectDbConnection())->setRenderFilterCallback($renderFilterCallback); + + $reflection = new ReflectionClass('\Icinga\Data\Db\DbConnection'); + + $dbAdapter = $reflection->getProperty('dbAdapter'); + $dbAdapter->setAccessible(true); + $dbAdapter->setValue($conn, new Quoter()); + + $select->where($conn->renderFilter($filter)); + } + } +} diff --git a/library/X509/Table.php b/library/X509/Table.php new file mode 100644 index 0000000..1281668 --- /dev/null +++ b/library/X509/Table.php @@ -0,0 +1,38 @@ +add(Html::tag('td', $cell)); + } + + $this->rows[] = $row; + } + + public function renderContent() + { + $tbody = Html::tag('tbody'); + + foreach ($this->rows as $row) { + $tbody->add($row); + } + + $this->add($tbody); + + return parent::renderContent(); // TODO: Change the autogenerated stub + } +} diff --git a/library/X509/UsageTable.php b/library/X509/UsageTable.php new file mode 100644 index 0000000..20fd147 --- /dev/null +++ b/library/X509/UsageTable.php @@ -0,0 +1,83 @@ + 'usage-table common-table table-row-selectable', + 'data-base-target' => '_next' + ]; + + public function createColumns() + { + return [ + 'valid' => [ + 'attributes' => ['class' => 'icon-col'], + 'renderer' => function ($valid) { + $icon = $valid === 'yes' ? 'check -ok' : 'block -critical'; + + return Html::tag('i', ['class' => "icon icon-{$icon}"]); + } + ], + + 'hostname' => mt('x509', 'Hostname'), + + 'ip' => [ + 'label' => mt('x509', 'IP'), + 'renderer' => function ($ip) { + $ipv4 = ltrim($ip, "\0"); + if (strlen($ipv4) === 4) { + $ip = $ipv4; + } + + return inet_ntop($ip); + } + ], + + 'port' => mt('x509', 'Port'), + + 'subject' => mt('x509', 'Certificate'), + + 'signature_algo' => [ + 'label' => mt('x509', 'Signature Algorithm'), + 'renderer' => function ($algo, $data) { + return "{$data['signature_hash_algo']} with $algo"; + } + ], + + 'pubkey_algo' => [ + 'label' => mt('x509', 'Public Key'), + 'renderer' => function ($algo, $data) { + return "$algo {$data['pubkey_bits']} bits"; + } + ], + + 'valid_to' => [ + 'attributes' => ['class' => 'expiration-col'], + 'label' => mt('x509', 'Expiration'), + 'renderer' => function ($to, $data) { + return new ExpirationWidget($data['valid_from'], $to); + } + ] + ]; + } + + protected function renderRow($row) + { + $tr = parent::renderRow($row); + + $url = Url::fromPath('x509/chain', ['id' => $row['certificate_chain_id']]); + + $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]); + + return $tr; + } +} diff --git a/module.info b/module.info new file mode 100644 index 0000000..389383a --- /dev/null +++ b/module.info @@ -0,0 +1,6 @@ +Module: Certificate Monitoring +Version: 1.1.2 +Requires: + Libraries: icinga-php-library (>=0.8.1), icinga-php-thirdparty (>=0.10.0) + Modules: monitoring (>=2.9.0), icingadb (>=1.0.0) +Description: Scan and view X.509 certificate usage diff --git a/public/css/icons.less b/public/css/icons.less new file mode 100644 index 0000000..73701b5 --- /dev/null +++ b/public/css/icons.less @@ -0,0 +1,52 @@ +/* Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 */ + +[class^='x509-icon-'], [class*=' x509-icon-'] { + &:before { + font-style: normal; + font-weight: normal; + speak: none; + + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ + } +} + +@font-face { + font-family: 'Icinga Web 2 Module X.509'; + src: url('../x509/icons?q=eot#iefix') format('embedded-opentype'), + url('../x509/icons?q=woff') format('woff'), + url('../x509/icons?q=ttf') format('truetype'), + url('../x509/icons?q=svg#icinga-icons') format('svg'); + font-weight: normal; + font-style: normal; +} + +[class^='x509-icon-'], [class*=' x509-icon-'] { + font-family: 'Icinga Web 2 Module X.509'; +} + +.x509-icon-ca:before { content: '\e000'; } +.x509-icon-self-signed:before { content: '\e001'; } +.x509-icon-cert:before { content: '\e002'; } diff --git a/public/css/module.less b/public/css/module.less new file mode 100644 index 0000000..adad589 --- /dev/null +++ b/public/css/module.less @@ -0,0 +1,107 @@ +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +.cert-details { + .x509-icon-cert { + font-size: 5em; + } + + h3 { + text-align: right; + //text-decoration: underline; + width: 10.25em; + border-bottom: 1px solid @gray-lighter; + } + + dl { + > dd { + // Reset default margin + margin: 0; + } + + > dt { + margin-right: 1em; + text-align: right; + width: 12em; + + float: left; + clear: left; + } + } +} + +.progress-bar { + background-color: @gray-lighter; + border-radius: 2px; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.25) inset; + height: 0.5em; + + > div { + border-radius: 2px; + height: 100%; + } +} + +.certificate-days-remaining { + font-size: @font-size-small; + margin-left: 2em; +} + +.expiration-col { + width: 18em; +} + +.icon-col > i { + font-size: 120%; +} + +.version-col { + width: 1em; +} + +.cert-table, .usage-table { + width: 98%; +} + +.cert-dashboard { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; +} + +.cert-donut { + align-self: flex-start; + padding: 1em; +} + +.cert-chain { + .rounded-corners(); + + color: @text-color-inverted; + font-size: 120%; + font-weight: @font-weight-bold; + padding: 0.75em; + text-align: center; + + > p { + margin: 0; + } + + &.-valid { + background-color: @color-ok; + } + + &.-invalid { + background-color: @color-critical; + } +} + +.icon { + &.-ok { + color: @color-ok; + } + + &.-critical { + color: @color-critical; + } +} diff --git a/public/font/icons.eot b/public/font/icons.eot new file mode 100644 index 0000000..96af737 Binary files /dev/null and b/public/font/icons.eot differ diff --git a/public/font/icons.svg b/public/font/icons.svg new file mode 100644 index 0000000..195eeb8 --- /dev/null +++ b/public/font/icons.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/font/icons.ttf b/public/font/icons.ttf new file mode 100644 index 0000000..7301cb5 Binary files /dev/null and b/public/font/icons.ttf differ diff --git a/public/font/icons.woff b/public/font/icons.woff new file mode 100644 index 0000000..6c74bc3 Binary files /dev/null and b/public/font/icons.woff differ diff --git a/run.php b/run.php new file mode 100644 index 0000000..e68e4d4 --- /dev/null +++ b/run.php @@ -0,0 +1,7 @@ +provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\HostsImportSource'); +$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\ServicesImportSource'); diff --git a/vendor/autoload.php b/vendor/autoload.php new file mode 100644 index 0000000..72995ae --- /dev/null +++ b/vendor/autoload.php @@ -0,0 +1,7 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see http://www.php-fig.org/psr/psr-0/ + * @see http://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + // PSR-4 + private $prefixLengthsPsr4 = array(); + private $prefixDirsPsr4 = array(); + private $fallbackDirsPsr4 = array(); + + // PSR-0 + private $prefixesPsr0 = array(); + private $fallbackDirsPsr0 = array(); + + private $useIncludePath = false; + private $classMap = array(); + private $classMapAuthoritative = false; + private $missingClasses = array(); + private $apcuPrefix; + + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param array|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + */ + public function add($prefix, $paths, $prepend = false) + { + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + (array) $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + (array) $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = (array) $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + (array) $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param array|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + (array) $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + (array) $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + (array) $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param array|string $paths The PSR-0 base directories + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param array|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + } + + /** + * Unregisters this instance as an autoloader. + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return bool|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + includeFile($file); + + return true; + } + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } +} + +/** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + */ +function includeFile($file) +{ + include $file; +} diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE new file mode 100644 index 0000000..f27399a --- /dev/null +++ b/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..7a91153 --- /dev/null +++ b/vendor/composer/autoload_classmap.php @@ -0,0 +1,9 @@ + array($vendorDir . '/dragonmantank/cron-expression/src/Cron'), +); diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php new file mode 100644 index 0000000..02bd074 --- /dev/null +++ b/vendor/composer/autoload_real.php @@ -0,0 +1,55 @@ += 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded()); + if ($useStaticLoader) { + require_once __DIR__ . '/autoload_static.php'; + + call_user_func(\Composer\Autoload\ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e::getInitializer($loader)); + } else { + $map = require __DIR__ . '/autoload_namespaces.php'; + foreach ($map as $namespace => $path) { + $loader->set($namespace, $path); + } + + $map = require __DIR__ . '/autoload_psr4.php'; + foreach ($map as $namespace => $path) { + $loader->setPsr4($namespace, $path); + } + + $classMap = require __DIR__ . '/autoload_classmap.php'; + if ($classMap) { + $loader->addClassMap($classMap); + } + } + + $loader->register(true); + + return $loader; + } +} diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php new file mode 100644 index 0000000..755f718 --- /dev/null +++ b/vendor/composer/autoload_static.php @@ -0,0 +1,31 @@ + + array ( + 'Cron\\' => 5, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'Cron\\' => + array ( + 0 => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron', + ), + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit52105223ed18fb0487ba02f5d0fcfd9e::$prefixDirsPsr4; + + }, null, ClassLoader::class); + } +} diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json new file mode 100644 index 0000000..0b82b8e --- /dev/null +++ b/vendor/composer/installed.json @@ -0,0 +1,64 @@ +[ + { + "name": "dragonmantank/cron-expression", + "version": "v2.3.1", + "version_normalized": "2.3.1.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2", + "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" + }, + "time": "2020-10-13T00:52:37+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ] + } +] diff --git a/vendor/dragonmantank/cron-expression/CHANGELOG.md b/vendor/dragonmantank/cron-expression/CHANGELOG.md new file mode 100644 index 0000000..4e207aa --- /dev/null +++ b/vendor/dragonmantank/cron-expression/CHANGELOG.md @@ -0,0 +1,84 @@ +# Change Log + +## [2.3.1] - 2020-10-12 +### Added +- Added support for PHP 8 (#92) +### Changed +- N/A +### Fixed +- N/A + +## [2.3.0] - 2019-03-30 +### Added +- Added support for DateTimeImmutable via DateTimeInterface +- Added support for PHP 7.3 +- Started listing projects that use the library +### Changed +- Errors should now report a human readable position in the cron expression, instead of starting at 0 +### Fixed +- N/A + +## [2.2.0] - 2018-06-05 +### Added +- Added support for steps larger than field ranges (#6) +## Changed +- N/A +### Fixed +- Fixed validation for numbers with leading 0s (#12) + +## [2.1.0] - 2018-04-06 +### Added +- N/A +### Changed +- Upgraded to PHPUnit 6 (#2) +### Fixed +- Refactored timezones to deal with some inconsistent behavior (#3) +- Allow ranges and lists in same expression (#5) +- Fixed regression where literals were not converted to their numerical counterpart (#) + +## [2.0.0] - 2017-10-12 +### Added +- N/A + +### Changed +- Dropped support for PHP 5.x +- Dropped support for the YEAR field, as it was not part of the cron standard + +### Fixed +- Reworked validation for all the field types +- Stepping should now work for 1-indexed fields like Month (#153) + +## [1.2.0] - 2017-01-22 +### Added +- Added IDE, CodeSniffer, and StyleCI.IO support + +### Changed +- Switched to PSR-4 Autoloading + +### Fixed +- 0 step expressions are handled better +- Fixed `DayOfMonth` validation to be more strict +- Typos + +## [1.1.0] - 2016-01-26 +### Added +- Support for non-hourly offset timezones +- Checks for valid expressions + +### Changed +- Max Iterations no longer hardcoded for `getRunDate()` +- Supports DateTimeImmutable for newer PHP verions + +### Fixed +- Fixed looping bug for PHP 7 when determining the last specified weekday of a month + +## [1.0.3] - 2013-11-23 +### Added +- Now supports expressions with any number of extra spaces, tabs, or newlines + +### Changed +- Using static instead of self in `CronExpression::factory` + +### Fixed +- Fixes issue [#28](https://github.com/mtdowling/cron-expression/issues/28) where PHP increments of ranges were failing due to PHP casting hyphens to 0 +- Only set default timezone if the given $currentTime is not a DateTime instance ([#34](https://github.com/mtdowling/cron-expression/issues/34)) diff --git a/vendor/dragonmantank/cron-expression/LICENSE b/vendor/dragonmantank/cron-expression/LICENSE new file mode 100644 index 0000000..3e38bbc --- /dev/null +++ b/vendor/dragonmantank/cron-expression/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Michael Dowling , 2016 Chris Tankersley , and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/dragonmantank/cron-expression/README.md b/vendor/dragonmantank/cron-expression/README.md new file mode 100644 index 0000000..8e8021b --- /dev/null +++ b/vendor/dragonmantank/cron-expression/README.md @@ -0,0 +1,78 @@ +PHP Cron Expression Parser +========================== + +[![Latest Stable Version](https://poser.pugx.org/dragonmantank/cron-expression/v/stable.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Total Downloads](https://poser.pugx.org/dragonmantank/cron-expression/downloads.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Build Status](https://secure.travis-ci.org/dragonmantank/cron-expression.png)](http://travis-ci.org/dragonmantank/cron-expression) + +The PHP cron expression parser can parse a CRON expression, determine if it is +due to run, calculate the next run date of the expression, and calculate the previous +run date of the expression. You can calculate dates far into the future or past by +skipping **n** number of matching dates. + +The parser can handle increments of ranges (e.g. */12, 2-59/3), intervals (e.g. 0-9), +lists (e.g. 1,2,3), **W** to find the nearest weekday for a given day of the month, **L** to +find the last day of the month, **L** to find the last given weekday of a month, and hash +(#) to find the nth weekday of a given month. + +More information about this fork can be found in the blog post [here](http://ctankersley.com/2017/10/12/cron-expression-update/). tl;dr - v2.0.0 is a major breaking change, and @dragonmantank can better take care of the project in a separate fork. + +Installing +========== + +Add the dependency to your project: + +```bash +composer require dragonmantank/cron-expression +``` + +Usage +===== +```php +isDue(); +echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); +echo $cron->getPreviousRunDate()->format('Y-m-d H:i:s'); + +// Works with complex expressions +$cron = Cron\CronExpression::factory('3-59/15 6-12 */15 1 2-5'); +echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); + +// Calculate a run date two iterations into the future +$cron = Cron\CronExpression::factory('@daily'); +echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s'); + +// Calculate a run date relative to a specific time +$cron = Cron\CronExpression::factory('@monthly'); +echo $cron->getNextRunDate('2010-01-12 00:00:00')->format('Y-m-d H:i:s'); +``` + +CRON Expressions +================ + +A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows: + + * * * * * + - - - - - + | | | | | + | | | | | + | | | | +----- day of week (0 - 7) (Sunday=0 or 7) + | | | +---------- month (1 - 12) + | | +--------------- day of month (1 - 31) + | +-------------------- hour (0 - 23) + +------------------------- min (0 - 59) + +Requirements +============ + +- PHP 7.0+ +- PHPUnit is required to run the unit tests +- Composer is required to run the unit tests + +Projects that Use cron-expression +================================= +* Part of the [Laravel Framework](https://github.com/laravel/framework/) +* Available as a [Symfony Bundle - setono/cron-expression-bundle](https://github.com/Setono/CronExpressionBundle) \ No newline at end of file diff --git a/vendor/dragonmantank/cron-expression/composer.json b/vendor/dragonmantank/cron-expression/composer.json new file mode 100644 index 0000000..6fcf818 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/composer.json @@ -0,0 +1,40 @@ +{ + "name": "dragonmantank/cron-expression", + "type": "library", + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": ["cron", "schedule"], + "license": "MIT", + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/Cron/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php b/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php new file mode 100644 index 0000000..8b1072a --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php @@ -0,0 +1,286 @@ +fullRange = range($this->rangeStart, $this->rangeEnd); + } + + /** + * Check to see if a field is satisfied by a value + * + * @param string $dateValue Date value to check + * @param string $value Value to test + * + * @return bool + */ + public function isSatisfied($dateValue, $value) + { + if ($this->isIncrementsOfRanges($value)) { + return $this->isInIncrementsOfRanges($dateValue, $value); + } elseif ($this->isRange($value)) { + return $this->isInRange($dateValue, $value); + } + + return $value == '*' || $dateValue == $value; + } + + /** + * Check if a value is a range + * + * @param string $value Value to test + * + * @return bool + */ + public function isRange($value) + { + return strpos($value, '-') !== false; + } + + /** + * Check if a value is an increments of ranges + * + * @param string $value Value to test + * + * @return bool + */ + public function isIncrementsOfRanges($value) + { + return strpos($value, '/') !== false; + } + + /** + * Test if a value is within a range + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInRange($dateValue, $value) + { + $parts = array_map(function($value) { + $value = trim($value); + $value = $this->convertLiterals($value); + return $value; + }, + explode('-', $value, 2) + ); + + + return $dateValue >= $parts[0] && $dateValue <= $parts[1]; + } + + /** + * Test if a value is within an increments of ranges (offset[-to]/step size) + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInIncrementsOfRanges($dateValue, $value) + { + $chunks = array_map('trim', explode('/', $value, 2)); + $range = $chunks[0]; + $step = isset($chunks[1]) ? $chunks[1] : 0; + + // No step or 0 steps aren't cool + if (is_null($step) || '0' === $step || 0 === $step) { + return false; + } + + // Expand the * to a full range + if ('*' == $range) { + $range = $this->rangeStart . '-' . $this->rangeEnd; + } + + // Generate the requested small range + $rangeChunks = explode('-', $range, 2); + $rangeStart = $rangeChunks[0]; + $rangeEnd = isset($rangeChunks[1]) ? $rangeChunks[1] : $rangeStart; + + if ($rangeStart < $this->rangeStart || $rangeStart > $this->rangeEnd || $rangeStart > $rangeEnd) { + throw new \OutOfRangeException('Invalid range start requested'); + } + + if ($rangeEnd < $this->rangeStart || $rangeEnd > $this->rangeEnd || $rangeEnd < $rangeStart) { + throw new \OutOfRangeException('Invalid range end requested'); + } + + // Steps larger than the range need to wrap around and be handled slightly differently than smaller steps + if ($step >= $this->rangeEnd) { + $thisRange = [$this->fullRange[$step % count($this->fullRange)]]; + } else { + $thisRange = range($rangeStart, $rangeEnd, $step); + } + + return in_array($dateValue, $thisRange); + } + + /** + * Returns a range of values for the given cron expression + * + * @param string $expression The expression to evaluate + * @param int $max Maximum offset for range + * + * @return array + */ + public function getRangeForExpression($expression, $max) + { + $values = array(); + $expression = $this->convertLiterals($expression); + + if (strpos($expression, ',') !== false) { + $ranges = explode(',', $expression); + $values = []; + foreach ($ranges as $range) { + $expanded = $this->getRangeForExpression($range, $this->rangeEnd); + $values = array_merge($values, $expanded); + } + return $values; + } + + if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) { + if (!$this->isIncrementsOfRanges($expression)) { + list ($offset, $to) = explode('-', $expression); + $offset = $this->convertLiterals($offset); + $to = $this->convertLiterals($to); + $stepSize = 1; + } + else { + $range = array_map('trim', explode('/', $expression, 2)); + $stepSize = isset($range[1]) ? $range[1] : 0; + $range = $range[0]; + $range = explode('-', $range, 2); + $offset = $range[0]; + $to = isset($range[1]) ? $range[1] : $max; + } + $offset = $offset == '*' ? $this->rangeStart : $offset; + if ($stepSize >= $this->rangeEnd) { + $values = [$this->fullRange[$stepSize % count($this->fullRange)]]; + } else { + for ($i = $offset; $i <= $to; $i += $stepSize) { + $values[] = (int)$i; + } + } + sort($values); + } + else { + $values = array($expression); + } + + return $values; + } + + /** + * Convert literal + * + * @param string $value + * @return string + */ + protected function convertLiterals($value) + { + if (count($this->literals)) { + $key = array_search($value, $this->literals); + if ($key !== false) { + return (string) $key; + } + } + + return $value; + } + + /** + * Checks to see if a value is valid for the field + * + * @param string $value + * @return bool + */ + public function validate($value) + { + $value = $this->convertLiterals($value); + + // All fields allow * as a valid value + if ('*' === $value) { + return true; + } + + if (strpos($value, '/') !== false) { + list($range, $step) = explode('/', $value); + return $this->validate($range) && filter_var($step, FILTER_VALIDATE_INT); + } + + // Validate each chunk of a list individually + if (strpos($value, ',') !== false) { + foreach (explode(',', $value) as $listItem) { + if (!$this->validate($listItem)) { + return false; + } + } + return true; + } + + if (strpos($value, '-') !== false) { + if (substr_count($value, '-') > 1) { + return false; + } + + $chunks = explode('-', $value); + $chunks[0] = $this->convertLiterals($chunks[0]); + $chunks[1] = $this->convertLiterals($chunks[1]); + + if ('*' == $chunks[0] || '*' == $chunks[1]) { + return false; + } + + return $this->validate($chunks[0]) && $this->validate($chunks[1]); + } + + if (!is_numeric($value)) { + return false; + } + + if (is_float($value) || strpos($value, '.') !== false) { + return false; + } + + // We should have a numeric by now, so coerce this into an integer + $value = (int) $value; + + return in_array($value, $this->fullRange, true); + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php b/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php new file mode 100644 index 0000000..dbb93ce --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php @@ -0,0 +1,413 @@ + '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@hourly' => '0 * * * *' + ); + + if (isset($mappings[$expression])) { + $expression = $mappings[$expression]; + } + + return new static($expression, $fieldFactory ?: new FieldFactory()); + } + + /** + * Validate a CronExpression. + * + * @param string $expression The CRON expression to validate. + * + * @return bool True if a valid CRON expression was passed. False if not. + * @see \Cron\CronExpression::factory + */ + public static function isValidExpression($expression) + { + try { + self::factory($expression); + } catch (InvalidArgumentException $e) { + return false; + } + + return true; + } + + /** + * Parse a CRON expression + * + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param FieldFactory|null $fieldFactory Factory to create cron fields + */ + public function __construct($expression, FieldFactory $fieldFactory = null) + { + $this->fieldFactory = $fieldFactory ?: new FieldFactory(); + $this->setExpression($expression); + } + + /** + * Set or change the CRON expression + * + * @param string $value CRON expression (e.g. 8 * * * *) + * + * @return CronExpression + * @throws \InvalidArgumentException if not a valid CRON expression + */ + public function setExpression($value) + { + $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + if (count($this->cronParts) < 5) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + foreach ($this->cronParts as $position => $part) { + $this->setPart($position, $part); + } + + return $this; + } + + /** + * Set part of the CRON expression + * + * @param int $position The position of the CRON expression to set + * @param string $value The value to set + * + * @return CronExpression + * @throws \InvalidArgumentException if the value is not valid for the part + */ + public function setPart($position, $value) + { + if (!$this->fieldFactory->getField($position)->validate($value)) { + throw new InvalidArgumentException( + 'Invalid CRON field value ' . $value . ' at position ' . $position + ); + } + + $this->cronParts[$position] = $value; + + return $this; + } + + /** + * Set max iteration count for searching next run dates + * + * @param int $maxIterationCount Max iteration count when searching for next run date + * + * @return CronExpression + */ + public function setMaxIterationCount($maxIterationCount) + { + $this->maxIterationCount = $maxIterationCount; + + return $this; + } + + /** + * Get a next run date relative to the current date or a specific date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning a + * matching next run date. 0, the default, will return the + * current date and time if the next run date falls on the + * current date and time. Setting this value to 1 will + * skip the first match and go to the second match. + * Setting this value to 2 will skip the first 2 + * matches and so on. + * @param bool $allowCurrentDate Set to TRUE to return the current date if + * it matches the cron expression. + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + */ + public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null) + { + return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone); + } + + /** + * Get a previous run date relative to the current date or a specific date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + * @see \Cron\CronExpression::getNextRunDate + */ + public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null) + { + return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone); + } + + /** + * Get multiple run dates starting at the current date or a specific date + * + * @param int $total Set the total number of dates to calculate + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param bool $invert Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime[] Returns an array of run dates + */ + public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false, $timeZone = null) + { + $matches = array(); + for ($i = 0; $i < max(0, $total); $i++) { + try { + $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate, $timeZone); + } catch (RuntimeException $e) { + break; + } + } + + return $matches; + } + + /** + * Get all or part of the CRON expression + * + * @param string $part Specify the part to retrieve or NULL to get the full + * cron schedule string. + * + * @return string|null Returns the CRON expression, a part of the + * CRON expression, or NULL if the part was specified but not found + */ + public function getExpression($part = null) + { + if (null === $part) { + return implode(' ', $this->cronParts); + } elseif (array_key_exists($part, $this->cronParts)) { + return $this->cronParts[$part]; + } + + return null; + } + + /** + * Helper method to output the full expression. + * + * @return string Full CRON expression + */ + public function __toString() + { + return $this->getExpression(); + } + + /** + * Determine if the cron is due to run based on the current date or a + * specific date. This method assumes that the current number of + * seconds are irrelevant, and should be called once per minute. + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return bool Returns TRUE if the cron is due to run or FALSE if not + */ + public function isDue($currentTime = 'now', $timeZone = null) + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ('now' === $currentTime) { + $currentTime = new DateTime(); + } elseif ($currentTime instanceof DateTime) { + // + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); + } else { + $currentTime = new DateTime($currentTime); + } + $currentTime->setTimeZone(new DateTimeZone($timeZone)); + + // drop the seconds to 0 + $currentTime = DateTime::createFromFormat('Y-m-d H:i', $currentTime->format('Y-m-d H:i')); + + try { + return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp(); + } catch (Exception $e) { + return false; + } + } + + /** + * Get the next or previous run date of the expression relative to a date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $invert Set to TRUE to go backwards in time + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + */ + protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false, $timeZone = null) + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ($currentTime instanceof DateTime) { + $currentDate = clone $currentTime; + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); + } else { + $currentDate = new DateTime($currentTime ?: 'now'); + } + + $currentDate->setTimeZone(new DateTimeZone($timeZone)); + $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); + $nextRun = clone $currentDate; + $nth = (int) $nth; + + // We don't have to satisfy * or null fields + $parts = array(); + $fields = array(); + foreach (self::$order as $position) { + $part = $this->getExpression($position); + if (null === $part || '*' === $part) { + continue; + } + $parts[$position] = $part; + $fields[$position] = $this->fieldFactory->getField($position); + } + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < $this->maxIterationCount; $i++) { + + foreach ($parts as $position => $part) { + $satisfied = false; + // Get the field object used to validate this part + $field = $fields[$position]; + // Check if this is singular or a list + if (strpos($part, ',') === false) { + $satisfied = $field->isSatisfiedBy($nextRun, $part); + } else { + foreach (array_map('trim', explode(',', $part)) as $listPart) { + if ($field->isSatisfiedBy($nextRun, $listPart)) { + $satisfied = true; + break; + } + } + } + + // If the field is not satisfied, then start over + if (!$satisfied) { + $field->increment($nextRun, $invert, $part); + continue 2; + } + } + + // Skip this match if needed + if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { + $this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null); + continue; + } + + return $nextRun; + } + + // @codeCoverageIgnoreStart + throw new RuntimeException('Impossible CRON expression'); + // @codeCoverageIgnoreEnd + } + + /** + * Workout what timeZone should be used. + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @return string + */ + protected function determineTimeZone($currentTime, $timeZone) + { + if (! is_null($timeZone)) { + return $timeZone; + } + + if ($currentTime instanceOf DateTimeInterface) { + return $currentTime->getTimeZone()->getName(); + } + + return date_default_timezone_get(); + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php b/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php new file mode 100644 index 0000000..d4552e0 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php @@ -0,0 +1,145 @@ + + */ +class DayOfMonthField extends AbstractField +{ + /** + * @inheritDoc + */ + protected $rangeStart = 1; + + /** + * @inheritDoc + */ + protected $rangeEnd = 31; + + /** + * Get the nearest day of the week for a given day in a month + * + * @param int $currentYear Current year + * @param int $currentMonth Current month + * @param int $targetDay Target day of the month + * + * @return \DateTime Returns the nearest date + */ + private static function getNearestWeekday($currentYear, $currentMonth, $targetDay) + { + $tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT); + $target = DateTime::createFromFormat('Y-m-d', "$currentYear-$currentMonth-$tday"); + $currentWeekday = (int) $target->format('N'); + + if ($currentWeekday < 6) { + return $target; + } + + $lastDayOfMonth = $target->format('t'); + + foreach (array(-1, 1, -2, 2) as $i) { + $adjusted = $targetDay + $i; + if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { + $target->setDate($currentYear, $currentMonth, $adjusted); + if ($target->format('N') < 6 && $target->format('m') == $currentMonth) { + return $target; + } + } + } + } + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + // ? states that the field value is to be skipped + if ($value == '?') { + return true; + } + + $fieldValue = $date->format('d'); + + // Check to see if this is the last day of the month + if ($value == 'L') { + return $fieldValue == $date->format('t'); + } + + // Check to see if this is the nearest weekday to a particular value + if (strpos($value, 'W')) { + // Parse the target day + $targetDay = substr($value, 0, strpos($value, 'W')); + // Find out if the current day is the nearest day of the week + return $date->format('j') == self::getNearestWeekday( + $date->format('Y'), + $date->format('m'), + $targetDay + )->format('j'); + } + + return $this->isSatisfied($date->format('d'), $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable &$date + */ + public function increment(DateTimeInterface &$date, $invert = false) + { + if ($invert) { + $date = $date->modify('previous day')->setTime(23, 59); + } else { + $date = $date->modify('next day')->setTime(0, 0); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function validate($value) + { + $basicChecks = parent::validate($value); + + // Validate that a list don't have W or L + if (strpos($value, ',') !== false && (strpos($value, 'W') !== false || strpos($value, 'L') !== false)) { + return false; + } + + if (!$basicChecks) { + + if ($value === 'L') { + return true; + } + + if (preg_match('/^(.*)W$/', $value, $matches)) { + return $this->validate($matches[1]); + } + + return false; + } + + return $basicChecks; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php b/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php new file mode 100644 index 0000000..d4ba315 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php @@ -0,0 +1,196 @@ + 'MON', 2 => 'TUE', 3 => 'WED', 4 => 'THU', 5 => 'FRI', 6 => 'SAT', 7 => 'SUN']; + + /** + * Constructor + */ + public function __construct() + { + $this->nthRange = range(1, 5); + parent::__construct(); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable $date + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + if ($value == '?') { + return true; + } + + // Convert text day of the week values to integers + $value = $this->convertLiterals($value); + + $currentYear = $date->format('Y'); + $currentMonth = $date->format('m'); + $lastDayOfMonth = $date->format('t'); + + // Find out if this is the last specific weekday of the month + if (strpos($value, 'L')) { + $weekday = (int) $this->convertLiterals(substr($value, 0, strpos($value, 'L'))); + $weekday %= 7; + + $tdate = clone $date; + $tdate = $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); + while ($tdate->format('w') != $weekday) { + $tdateClone = new DateTime(); + $tdate = $tdateClone + ->setTimezone($tdate->getTimezone()) + ->setDate($currentYear, $currentMonth, --$lastDayOfMonth); + } + + return $date->format('j') == $lastDayOfMonth; + } + + // Handle # hash tokens + if (strpos($value, '#')) { + list($weekday, $nth) = explode('#', $value); + + if (!is_numeric($nth)) { + throw new InvalidArgumentException("Hashed weekdays must be numeric, {$nth} given"); + } else { + $nth = (int) $nth; + } + + // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601 + if ($weekday === '0') { + $weekday = 7; + } + + $weekday = $this->convertLiterals($weekday); + + // Validate the hash fields + if ($weekday < 0 || $weekday > 7) { + throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given"); + } + + if (!in_array($nth, $this->nthRange)) { + throw new InvalidArgumentException("There are never more than 5 or less than 1 of a given weekday in a month, {$nth} given"); + } + + // The current weekday must match the targeted weekday to proceed + if ($date->format('N') != $weekday) { + return false; + } + + $tdate = clone $date; + $tdate = $tdate->setDate($currentYear, $currentMonth, 1); + $dayCount = 0; + $currentDay = 1; + while ($currentDay < $lastDayOfMonth + 1) { + if ($tdate->format('N') == $weekday) { + if (++$dayCount >= $nth) { + break; + } + } + $tdate = $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + } + + return $date->format('j') == $currentDay; + } + + // Handle day of the week values + if (strpos($value, '-')) { + $parts = explode('-', $value); + if ($parts[0] == '7') { + $parts[0] = '0'; + } elseif ($parts[1] == '0') { + $parts[1] = '7'; + } + $value = implode('-', $parts); + } + + // Test to see which Sunday to use -- 0 == 7 == Sunday + $format = in_array(7, str_split($value)) ? 'N' : 'w'; + $fieldValue = $date->format($format); + + return $this->isSatisfied($fieldValue, $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable &$date + */ + public function increment(DateTimeInterface &$date, $invert = false) + { + if ($invert) { + $date = $date->modify('-1 day')->setTime(23, 59, 0); + } else { + $date = $date->modify('+1 day')->setTime(0, 0, 0); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function validate($value) + { + $basicChecks = parent::validate($value); + + if (!$basicChecks) { + // Handle the # value + if (strpos($value, '#') !== false) { + $chunks = explode('#', $value); + $chunks[0] = $this->convertLiterals($chunks[0]); + + if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && in_array($chunks[1], $this->nthRange)) { + return true; + } + } + + if (preg_match('/^(.*)L$/', $value, $matches)) { + return $this->validate($matches[1]); + } + + return false; + } + + return $basicChecks; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php new file mode 100644 index 0000000..545e4b8 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php @@ -0,0 +1,54 @@ +fields[$position])) { + switch ($position) { + case 0: + $this->fields[$position] = new MinutesField(); + break; + case 1: + $this->fields[$position] = new HoursField(); + break; + case 2: + $this->fields[$position] = new DayOfMonthField(); + break; + case 3: + $this->fields[$position] = new MonthField(); + break; + case 4: + $this->fields[$position] = new DayOfWeekField(); + break; + default: + throw new InvalidArgumentException( + ($position + 1) . ' is not a valid position' + ); + } + } + + return $this->fields[$position]; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php b/vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php new file mode 100644 index 0000000..f8366ea --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php @@ -0,0 +1,41 @@ +isSatisfied($date->format('H'), $value); + } + + /** + * {@inheritDoc} + * + * @param \DateTime|\DateTimeImmutable &$date + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null) + { + // Change timezone to UTC temporarily. This will + // allow us to go back or forwards and hour even + // if DST will be changed between the hours. + if (is_null($parts) || $parts == '*') { + $timezone = $date->getTimezone(); + $date = $date->setTimezone(new DateTimeZone('UTC')); + $date = $date->modify(($invert ? '-' : '+') . '1 hour'); + $date = $date->setTimezone($timezone); + + $date = $date->setTime($date->format('H'), $invert ? 59 : 0); + return $this; + } + + $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts); + $hours = array(); + foreach ($parts as $part) { + $hours = array_merge($hours, $this->getRangeForExpression($part, 23)); + } + + $current_hour = $date->format('H'); + $position = $invert ? count($hours) - 1 : 0; + if (count($hours) > 1) { + for ($i = 0; $i < count($hours) - 1; $i++) { + if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) || + ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) { + $position = $invert ? $i : $i + 1; + break; + } + } + } + + $hour = $hours[$position]; + if ((!$invert && $date->format('H') >= $hour) || ($invert && $date->format('H') <= $hour)) { + $date = $date->modify(($invert ? '-' : '+') . '1 day'); + $date = $date->setTime($invert ? 23 : 0, $invert ? 59 : 0); + } + else { + $date = $date->setTime($hour, $invert ? 59 : 0); + } + + return $this; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php b/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php new file mode 100644 index 0000000..fecc9b6 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php @@ -0,0 +1,75 @@ +isSatisfied($date->format('i'), $value); + } + + /** + * {@inheritDoc} + * + * @param \DateTime|\DateTimeImmutable &$date + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null) + { + if (is_null($parts)) { + $date = $date->modify(($invert ? '-' : '+') . '1 minute'); + return $this; + } + + $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts); + $minutes = array(); + foreach ($parts as $part) { + $minutes = array_merge($minutes, $this->getRangeForExpression($part, 59)); + } + + $current_minute = $date->format('i'); + $position = $invert ? count($minutes) - 1 : 0; + if (count($minutes) > 1) { + for ($i = 0; $i < count($minutes) - 1; $i++) { + if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) || + ($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) { + $position = $invert ? $i : $i + 1; + break; + } + } + } + + if ((!$invert && $current_minute >= $minutes[$position]) || ($invert && $current_minute <= $minutes[$position])) { + $date = $date->modify(($invert ? '-' : '+') . '1 hour'); + $date = $date->setTime($date->format('H'), $invert ? 59 : 0); + } + else { + $date = $date->setTime($date->format('H'), $minutes[$position]); + } + + return $this; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php b/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php new file mode 100644 index 0000000..afc9caf --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php @@ -0,0 +1,59 @@ + 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAY', 6 => 'JUN', 7 => 'JUL', + 8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC']; + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value) + { + if ($value == '?') { + return true; + } + + $value = $this->convertLiterals($value); + + return $this->isSatisfied($date->format('m'), $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable &$date + */ + public function increment(DateTimeInterface &$date, $invert = false) + { + if ($invert) { + $date = $date->modify('last day of previous month')->setTime(23, 59); + } else { + $date = $date->modify('first day of next month')->setTime(0, 0); + } + + return $this; + } + + +} -- cgit v1.2.3