From 067008c5f094ba9606daacbe540f6b929dc124ea Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 14 Apr 2024 15:31:28 +0200 Subject: Adding upstream version 1:1.3.2. Signed-off-by: Daniel Baumann --- .mailmap | 17 + AUTHORS | 17 + LICENSE | 339 ++++++ README.md | 28 + application/clicommands/CheckCommand.php | 268 +++++ application/clicommands/CleanupCommand.php | 95 ++ application/clicommands/ImportCommand.php | 61 ++ application/clicommands/JobsCommand.php | 279 +++++ application/clicommands/MigrateCommand.php | 121 +++ application/clicommands/ScanCommand.php | 163 +++ application/clicommands/VerifyCommand.php | 27 + application/controllers/CertificateController.php | 43 + application/controllers/CertificatesController.php | 117 +++ application/controllers/ChainController.php | 77 ++ application/controllers/ConfigController.php | 30 + application/controllers/DashboardController.php | 153 +++ application/controllers/JobController.php | 226 ++++ application/controllers/JobsController.php | 66 ++ application/controllers/SniController.php | 103 ++ application/controllers/UsageController.php | 141 +++ application/forms/Config/BackendConfigForm.php | 29 + application/forms/Config/SniConfigForm.php | 79 ++ application/forms/Jobs/JobConfigForm.php | 154 +++ application/forms/Jobs/ScheduleForm.php | 201 ++++ application/views/scripts/certificate/index.phtml | 6 + application/views/scripts/chain/index.phtml | 8 + application/views/scripts/config/backend.phtml | 6 + application/views/scripts/dashboard/index.phtml | 13 + application/views/scripts/missing-resource.phtml | 12 + application/views/scripts/simple-form.phtml | 6 + application/views/scripts/sni/index.phtml | 31 + config/systemd/icinga-x509.service | 10 + configuration.php | 33 + doc/01-About.md | 22 + doc/02-Installation.md | 73 ++ doc/02-Installation.md.d/From-Source.md | 16 + doc/03-Configuration.md | 77 ++ doc/04-Scanning.md | 85 ++ doc/10-Monitoring.md | 212 ++++ doc/11-Housekeeping.md | 38 + doc/80-Upgrading.md | 91 ++ 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/weekly-schedules.png | Bin 0 -> 140091 bytes doc/res/x509-certificates.png | Bin 0 -> 475307 bytes doc/res/x509-dashboard.png | Bin 0 -> 208525 bytes doc/res/x509-usage.png | Bin 0 -> 286297 bytes library/X509/CertificateDetails.php | 120 +++ library/X509/CertificateUtils.php | 538 ++++++++++ library/X509/CertificatesTable.php | 104 ++ library/X509/ChainDetails.php | 111 ++ library/X509/ColorScheme.php | 37 + library/X509/Command.php | 18 + library/X509/Common/Database.php | 56 + library/X509/Common/JobOptions.php | 162 +++ library/X509/Common/JobUtils.php | 77 ++ library/X509/Common/Links.php | 37 + library/X509/Controller.php | 87 ++ library/X509/DataTable.php | 150 +++ library/X509/DbTool.php | 45 + library/X509/Donut.php | 92 ++ library/X509/ExpirationWidget.php | 80 ++ library/X509/FilterAdapter.php | 56 + library/X509/Hook/SniHook.php | 54 + library/X509/Job.php | 755 +++++++++++++ library/X509/Model/Behavior/DERBase64.php | 44 + library/X509/Model/Behavior/ExpressionInjector.php | 62 ++ library/X509/Model/Behavior/Ip.php | 39 + library/X509/Model/Schema.php | 49 + library/X509/Model/X509Certificate.php | 159 +++ library/X509/Model/X509CertificateChain.php | 58 + library/X509/Model/X509CertificateChainLink.php | 46 + .../X509/Model/X509CertificateSubjectAltName.php | 50 + library/X509/Model/X509Dn.php | 51 + library/X509/Model/X509Job.php | 73 ++ library/X509/Model/X509JobRun.php | 77 ++ library/X509/Model/X509Schedule.php | 70 ++ library/X509/Model/X509Target.php | 74 ++ library/X509/ProvidedHook/DbMigration.php | 95 ++ library/X509/ProvidedHook/HostsImportSource.php | 91 ++ library/X509/ProvidedHook/ServicesImportSource.php | 143 +++ library/X509/ProvidedHook/X509ImportSource.php | 11 + library/X509/React/StreamOptsCaptureConnector.php | 60 ++ library/X509/Schedule.php | 125 +++ library/X509/SniIniRepository.php | 21 + library/X509/Table.php | 39 + library/X509/UsageTable.php | 91 ++ .../Web/Control/SearchBar/ObjectSuggestions.php | 203 ++++ library/X509/Widget/JobDetails.php | 61 ++ library/X509/Widget/Jobs.php | 64 ++ library/X509/Widget/Schedules.php | 61 ++ module.info | 6 + phpstan-baseline-7x.neon | 96 ++ phpstan-baseline-8x.neon | 96 ++ phpstan-baseline-by-php-version.php | 10 + phpstan-baseline-common.neon | 1111 ++++++++++++++++++++ phpstan.neon | 42 + phpunit.xml | 16 + public/css/module.less | 171 +++ run.php | 10 + schema/mysql-upgrades/1.0.0.sql | 27 + schema/mysql-upgrades/1.1.0.sql | 4 + schema/mysql-upgrades/1.2.0.sql | 103 ++ schema/mysql-upgrades/1.3.0.sql | 51 + schema/mysql.schema.sql | 136 +++ schema/pgsql-upgrades/1.3.0.sql | 49 + schema/pgsql.schema.sql | 162 +++ test/php/Lib/TestModel.php | 30 + test/php/library/X509/Common/JobUtilsTest.php | 44 + test/php/library/X509/JobTest.php | 53 + .../library/X509/Model/Behavior/DERBase64Test.php | 69 ++ .../X509/Model/Behavior/ExpressionInjectorTest.php | 38 + test/php/library/X509/Model/Behavior/IpTest.php | 92 ++ 124 files changed, 10485 insertions(+) create mode 100644 .mailmap create mode 100644 AUTHORS create mode 100644 LICENSE create mode 100644 README.md create mode 100644 application/clicommands/CheckCommand.php create mode 100644 application/clicommands/CleanupCommand.php create mode 100644 application/clicommands/ImportCommand.php create mode 100644 application/clicommands/JobsCommand.php create mode 100644 application/clicommands/MigrateCommand.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/JobController.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/SniConfigForm.php create mode 100644 application/forms/Jobs/JobConfigForm.php create mode 100644 application/forms/Jobs/ScheduleForm.php create mode 100644 application/views/scripts/certificate/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/missing-resource.phtml create mode 100644 application/views/scripts/simple-form.phtml create mode 100644 application/views/scripts/sni/index.phtml 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/02-Installation.md.d/From-Source.md create mode 100644 doc/03-Configuration.md create mode 100644 doc/04-Scanning.md create mode 100644 doc/10-Monitoring.md create mode 100644 doc/11-Housekeeping.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/weekly-schedules.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 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/Common/Database.php create mode 100644 library/X509/Common/JobOptions.php create mode 100644 library/X509/Common/JobUtils.php create mode 100644 library/X509/Common/Links.php create mode 100644 library/X509/Controller.php create mode 100644 library/X509/DataTable.php create mode 100644 library/X509/DbTool.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/Model/Behavior/DERBase64.php create mode 100644 library/X509/Model/Behavior/ExpressionInjector.php create mode 100644 library/X509/Model/Behavior/Ip.php create mode 100644 library/X509/Model/Schema.php create mode 100644 library/X509/Model/X509Certificate.php create mode 100644 library/X509/Model/X509CertificateChain.php create mode 100644 library/X509/Model/X509CertificateChainLink.php create mode 100644 library/X509/Model/X509CertificateSubjectAltName.php create mode 100644 library/X509/Model/X509Dn.php create mode 100644 library/X509/Model/X509Job.php create mode 100644 library/X509/Model/X509JobRun.php create mode 100644 library/X509/Model/X509Schedule.php create mode 100644 library/X509/Model/X509Target.php create mode 100644 library/X509/ProvidedHook/DbMigration.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/Schedule.php create mode 100644 library/X509/SniIniRepository.php create mode 100644 library/X509/Table.php create mode 100644 library/X509/UsageTable.php create mode 100644 library/X509/Web/Control/SearchBar/ObjectSuggestions.php create mode 100644 library/X509/Widget/JobDetails.php create mode 100644 library/X509/Widget/Jobs.php create mode 100644 library/X509/Widget/Schedules.php create mode 100644 module.info create mode 100644 phpstan-baseline-7x.neon create mode 100644 phpstan-baseline-8x.neon create mode 100644 phpstan-baseline-by-php-version.php create mode 100644 phpstan-baseline-common.neon create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 public/css/module.less create mode 100644 run.php create mode 100644 schema/mysql-upgrades/1.0.0.sql create mode 100644 schema/mysql-upgrades/1.1.0.sql create mode 100644 schema/mysql-upgrades/1.2.0.sql create mode 100644 schema/mysql-upgrades/1.3.0.sql create mode 100644 schema/mysql.schema.sql create mode 100644 schema/pgsql-upgrades/1.3.0.sql create mode 100644 schema/pgsql.schema.sql create mode 100644 test/php/Lib/TestModel.php create mode 100644 test/php/library/X509/Common/JobUtilsTest.php create mode 100644 test/php/library/X509/JobTest.php create mode 100644 test/php/library/X509/Model/Behavior/DERBase64Test.php create mode 100644 test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php create mode 100644 test/php/library/X509/Model/Behavior/IpTest.php diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..b83b0a3 --- /dev/null +++ b/.mailmap @@ -0,0 +1,17 @@ +Alexander A. Klimov +Eric Lippmann +Florian Strohmaier +Gunnar Beutner +Jan Wagner +Jens Meißner +Johannes Meyer +Michael Friedrich +Ravi Kumar Kempapura Srinivasa <33730024+raviks789@users.noreply.github.com> +Robert Rettig +Sukhwinder Dhillon +Timm Ortloff +Yonas Habteab +lx +moreamazingnick +nmartinii <51709615+nmartinii@users.noreply.github.com> +pgress diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..8efaa3d --- /dev/null +++ b/AUTHORS @@ -0,0 +1,17 @@ +Alexander A. Klimov +Eric Lippmann +Florian Strohmaier +Gunnar Beutner +Jan Wagner +Jens Meißner +Johannes Meyer +Michael Friedrich +Ravi Kumar Kempapura Srinivasa +Robert Rettig +Sukhwinder Dhillon +Timm Ortloff +Yonas Habteab +lx +moreamazingnick +nmartinii <51709615+nmartinii@users.noreply.github.com> +pgress 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..35f1d6f --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Icinga Certificate Monitoring + +[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.2-777BB4?logo=PHP)](https://php.net/) +![Build Status](https://github.com/icinga/icingaweb2-module-x509/workflows/PHP%20Tests/badge.svg?branch=master) +[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-x509.svg)](https://github.com/Icinga/icingaweb2-module-x509) + +![Icinga Logo](https://icinga.com/wp-content/uploads/2014/06/icinga_logo.png) + +The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment. +It does this by scanning networks for TLS services and collects whatever certificates it finds along the way. +The certificates are verified using its own trust store. + +The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information +about any discovered certificate of your landscape: + +![X.509 Usage](doc/res/x509-usage.png "X.509 Usage") + +![X.509 Certificates](doc/res/x509-certificates.png "X.509 Certificates") + +At a glance you see which CAs have issued your certificates and key counters of your environment: + +![X.509 Dashboard](doc/res/x509-dashboard.png "X.509 Dashboard") + +## Documentation + +* [Installation](doc/02-Installation.md) +* [Configuration](doc/03-Configuration.md) +* [Monitoring](doc/10-Monitoring.md) diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php new file mode 100644 index 0000000..0c369d9 --- /dev/null +++ b/application/clicommands/CheckCommand.php @@ -0,0 +1,268 @@ +params->get('ip'); + $hostname = $this->params->get('host'); + if ($ip === null && $hostname === null) { + $this->showUsage('host'); + exit(3); + } + + $targets = X509Target::on(Database::get())->with([ + 'chain', + 'chain.certificate', + 'chain.certificate.issuer_certificate' + ]); + + $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT'); + + $targets->columns([ + 'port', + 'chain.valid', + 'chain.invalid_reason', + 'subject' => 'chain.certificate.subject', + 'self_signed' => new Expression('COALESCE(%s, %s)', [ + 'chain.certificate.issuer_certificate.self_signed', + 'chain.certificate.self_signed' + ]) + ]); + + // Sub query for `valid_from` column + $validFrom = $targets->createSubQuery(new X509Certificate(), 'chain.certificate'); + $validFrom + ->columns([new Expression('MAX(GREATEST(%s, %s))', ['valid_from', 'issuer_certificate.valid_from'])]) + ->getSelectBase() + ->resetWhere() + ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id')); + + // Sub query for `valid_to` column + $validTo = $targets->createSubQuery(new X509Certificate(), 'chain.certificate'); + $validTo + ->columns([new Expression('MIN(LEAST(%s, %s))', ['valid_to', 'issuer_certificate.valid_to'])]) + ->getSelectBase() + // Reset the where clause generated within the createSubQuery() method. + ->resetWhere() + ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id')); + + list($validFromSelect, $_) = $validFrom->dump(); + list($validToSelect, $_) = $validTo->dump(); + $targets + ->withColumns([ + 'valid_from' => new Expression($validFromSelect), + 'valid_to' => new Expression($validToSelect) + ]) + ->getSelectBase() + ->where(new Expression('target_chain_link.order = 0')); + + if ($ip !== null) { + $targets->filter(Filter::equal('ip', $ip)); + } + if ($hostname !== null) { + $targets->filter(Filter::equal('hostname', $hostname)); + } + if ($this->params->has('port')) { + $targets->filter(Filter::equal('port', $this->params->get('port'))); + } + + $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false); + $warningThreshold = $this->splitThreshold($this->params->get('warning', '25%')); + $criticalThreshold = $this->splitThreshold($this->params->get('critical', '10%')); + + $output = []; + $perfData = []; + + $state = 3; + foreach ($targets as $target) { + if (! $target->chain->valid && (! $target['self_signed'] || ! $allowSelfSigned)) { + $invalidMessage = $target['subject'] . ': ' . $target->chain->invalid_reason; + $output[$invalidMessage] = $invalidMessage; + $state = 2; + } + + $now = new DateTime(); + $validFrom = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_from / 1000.0)); + $validTo = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_to / 1000.0)); + $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold); + $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold); + + if ($now > $criticalAfter) { + $state = 2; + } elseif ($state !== 2 && $now > $warningAfter) { + $state = 1; + } elseif ($state === 3) { + $state = 0; + } + + $remainingTime = $now->diff($validTo); + if (! $remainingTime->invert) { + // The certificate has not expired yet + $output[$target->subject] = sprintf( + '%s expires in %d days', + $target->subject, + $remainingTime->days + ); + } else { + $output[$target->subject] = sprintf( + '%s has expired since %d days', + $target->subject, + $remainingTime->days + ); + } + + $perfData[$target->subject] = sprintf( + "'%s'=%ds;%d:;%d:;0;%d", + $target->subject, + $remainingTime->invert + ? 0 + : $validTo->getTimestamp() - time(), + $validTo->getTimestamp() - $warningAfter->getTimestamp(), + $validTo->getTimestamp() - $criticalAfter->getTimestamp(), + $validTo->getTimestamp() - $validFrom->getTimestamp() + ); + } + + echo ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN'][$state]; + echo ' - '; + + if (! empty($output)) { + echo join('; ', $output); + } elseif ($state === 3) { + echo 'Host not found'; + } + + if (! empty($perfData)) { + echo '|' . join(' ', $perfData); + } + + echo PHP_EOL; + exit($state); + } + + /** + * Parse the given threshold definition + * + * @param string $threshold + * + * @return int|DateInterval + */ + protected function splitThreshold(string $threshold) + { + $match = preg_match('/(\d+)([%\w]{1})/', $threshold, $matches); + if (! $match) { + Logger::error('Invalid threshold definition: %s', $threshold); + exit(3); + } + + switch ($matches[2]) { + case '%': + return (int) $matches[1]; + case 'y': + case 'Y': + $intervalSpec = 'P' . $matches[1] . 'Y'; + break; + case 'M': + $intervalSpec = 'P' . $matches[1] . 'M'; + break; + case 'd': + case 'D': + $intervalSpec = 'P' . $matches[1] . 'D'; + break; + case 'h': + case 'H': + $intervalSpec = 'PT' . $matches[1] . 'H'; + break; + case 'm': + $intervalSpec = 'PT' . $matches[1] . 'M'; + break; + case 's': + case 'S': + $intervalSpec = 'PT' . $matches[1] . 'S'; + break; + default: + Logger::error('Unknown threshold unit given: %s', $threshold); + exit(3); + } + + return new DateInterval($intervalSpec); + } + + /** + * Convert the given threshold information to a DateTime object + * + * @param DateTime $from + * @param DateTime $to + * @param int|DateInterval $thresholdValue + * + * @return DateTimeInterface + */ + protected function thresholdToDateTime(DateTime $from, DateTime $to, $thresholdValue): DateTimeInterface + { + $to = clone $to; + if ($thresholdValue instanceof DateInterval) { + return $to->sub($thresholdValue); + } + + $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100)); + return $to->sub(new DateInterval('P' . $coveredDays . 'D')); + } +} diff --git a/application/clicommands/CleanupCommand.php b/application/clicommands/CleanupCommand.php new file mode 100644 index 0000000..61c43d4 --- /dev/null +++ b/application/clicommands/CleanupCommand.php @@ -0,0 +1,95 @@ + + * Clean up targets whose last scan is older than the specified date/time, + * which can also be an English textual datetime description like "2 days". + * Defaults to "1 month". + * + * EXAMPLES + * + * Remove any targets that have not been scanned for at least two months and any certificates that are no longer + * used. + * + * icingacli x509 cleanup --since-last-scan="2 months" + * + */ + public function indexAction() + { + /** @var string $sinceLastScan */ + $sinceLastScan = $this->params->get('since-last-scan', '-1 month'); + $lastScan = $sinceLastScan; + if ($lastScan[0] !== '-') { + // When the user specified "2 days" as a threshold strtotime() will compute the + // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days) + $lastScan = "-$lastScan"; + } + + try { + $sinceLastScan = new DateTime($lastScan); + } catch (Exception $_) { + throw new InvalidArgumentException(sprintf( + 'The specified last scan time is in an unknown format: %s', + $sinceLastScan + )); + } + + try { + $conn = Database::get(); + $query = $conn->delete( + 'x509_target', + ['last_scan < ?' => $sinceLastScan->format('Uv')] + ); + + if ($query->rowCount() > 0) { + Logger::info( + 'Removed %d targets matching since last scan filter: %s', + $query->rowCount(), + $sinceLastScan->format('Y-m-d H:i:s') + ); + } + + $query = $conn->delete('x509_job_run', ['start_time < ?' => $sinceLastScan->getTimestamp() * 1000]); + if ($query->rowCount() > 0) { + Logger::info('Removed %d jobs activities', $query->rowCount()); + } + + CertificateUtils::cleanupNoLongerUsedCertificates($conn); + } catch (Throwable $err) { + Logger::error($err); + } + } +} diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php new file mode 100644 index 0000000..2e7b157 --- /dev/null +++ b/application/clicommands/ImportCommand.php @@ -0,0 +1,61 @@ + + * + * EXAMPLES: + * + * icingacli x509 import --file /etc/ssl/certs/ca-bundle.crt + */ + public function indexAction() + { + $file = $this->params->getRequired('file'); + + if (! file_exists($file)) { + Logger::warning('The specified certificate file does not exist.'); + exit(1); + } + + $bundle = CertificateUtils::parseBundle($file); + + $count = 0; + + Database::get()->transaction(function (Connection $db) use ($bundle, &$count) { + foreach ($bundle as $data) { + $cert = openssl_x509_read($data); + + list($id, $_) = CertificateUtils::findOrInsertCert($db, $cert); + + $db->update( + 'x509_certificate', + [ + 'trusted' => 'y', + 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000') + ], + ['id = ?' => $id] + ); + + $count++; + } + }); + + printf("Processed %d X.509 certificate%s.\n", $count, $count !== 1 ? 's' : ''); + } +} diff --git a/application/clicommands/JobsCommand.php b/application/clicommands/JobsCommand.php new file mode 100644 index 0000000..27f7202 --- /dev/null +++ b/application/clicommands/JobsCommand.php @@ -0,0 +1,279 @@ + + * Run all configured schedules only of the specified job. + * + * --schedule= + * Run only the given schedule of the specified job. Providing a schedule name + * without a job will fail immediately. + * + * --parallel= + * Allow parallel scanning of targets up to the specified number. Defaults to 256. + * May cause **too many open files** error if set to a number higher than the configured one (ulimit). + */ + public function runAction(): void + { + $parallel = (int) $this->params->get('parallel', Job::DEFAULT_PARALLEL); + if ($parallel <= 0) { + $this->fail("The 'parallel' option must be set to at least 1"); + } + + $jobName = (string) $this->params->get('job'); + $scheduleName = (string) $this->params->get('schedule'); + if (! $jobName && $scheduleName) { + throw new InvalidArgumentException('You cannot provide a schedule without a job'); + } + + $scheduler = new Scheduler(); + $this->attachJobsLogging($scheduler); + + $signalHandler = function () use ($scheduler) { + $scheduler->removeTasks(); + + Loop::futureTick(function () { + Loop::stop(); + }); + }; + Loop::addSignal(SIGINT, $signalHandler); + Loop::addSignal(SIGTERM, $signalHandler); + + /** @var Job[] $scheduled Caches scheduled jobs */ + $scheduled = []; + // Periodically check configuration changes to ensure that new jobs are scheduled, jobs are updated, + // and deleted jobs are canceled. + $watchdog = function () use (&$watchdog, &$scheduled, $scheduler, $parallel, $jobName, $scheduleName) { + $jobs = []; + try { + // Since this is a long-running daemon, the resources or module config may change meanwhile. + // Therefore, reload the resources and module config from disk each time (at 5m intervals) + // before reconnecting to the database. + ResourceFactory::setConfig(Config::app('resources', true)); + Config::module('x509', 'config', true); + + $jobs = $this->fetchSchedules($jobName, $scheduleName); + } catch (Throwable $err) { + Logger::error('Failed to fetch job schedules from the database: %s', $err); + Logger::debug($err->getTraceAsString()); + } + + $outdatedJobs = array_diff_key($scheduled, $jobs); + foreach ($outdatedJobs as $job) { + Logger::info( + 'Removing schedule %s of job %s, as it either no longer exists in the configuration or its' + . ' config has been changed', + $job->getSchedule()->getName(), + $job->getName() + ); + + $scheduler->remove($job); + + unset($scheduled[$job->getUuid()->toString()]); + } + + $newJobs = array_diff_key($jobs, $scheduled); + foreach ($newJobs as $key => $job) { + $job->setParallel($parallel); + + /** @var stdClass $config */ + $config = $job->getSchedule()->getConfig(); + try { + /** @var Frequency $type */ + $type = $config->type; + $frequency = $type::fromJson($config->frequency); + } catch (Throwable $err) { + Logger::error( + 'Cannot create schedule %s of job %s: %s', + $job->getSchedule()->getName(), + $job->getName(), + $err->getMessage() + ); + + continue; + } + + $scheduler->schedule($job, $frequency); + + $scheduled[$key] = $job; + } + + Loop::addTimer(5 * 60, $watchdog); + }; + // Check configuration and add jobs directly after starting the scheduler. + Loop::futureTick($watchdog); + } + + /** + * Fetch job schedules from database + * + * @param ?string $jobName + * @param ?string $scheduleName + * + * @return Job[] + */ + protected function fetchSchedules(?string $jobName, ?string $scheduleName): array + { + $conn = Database::get(); + // Even if the Job class regularly pings the same connection whenever its frequency becomes due and is run by + // the scheduler, we need to explicitly ping that same connection here, as the interval of the schedule jobs + // could be larger than the daemon configuration reload interval (5m). + $conn->ping(); + + $jobs = X509Job::on($conn); + if ($jobName) { + $jobs->filter(Filter::equal('name', $jobName)); + } + + $jobSchedules = []; + $snimap = SniHook::getAll(); + /** @var X509Job $jobConfig */ + foreach ($jobs as $jobConfig) { + $cidrs = $this->parseCIDRs($jobConfig->cidrs); + $ports = $this->parsePorts($jobConfig->ports); + + /** @var Query $schedules */ + $schedules = $jobConfig->schedule; + if ($scheduleName) { + $schedules->filter(Filter::equal('name', $scheduleName)); + } + + $schedules = $schedules->execute(); + $hasSchedules = $schedules->hasResult(); + + /** @var X509Schedule $scheduleModel */ + foreach ($schedules as $scheduleModel) { + $job = (new Job($jobConfig->name, $cidrs, $ports, $snimap, Schedule::fromModel($scheduleModel))) + ->setId($jobConfig->id) + ->setExcludes($this->parseExcludes($jobConfig->exclude_targets)); + + $jobSchedules[$job->getUuid()->toString()] = $job; + } + + if (! $hasSchedules) { + Logger::info('Skipping job %s because no schedules are configured', $jobConfig->name); + } + } + + return $jobSchedules; + } + + /** + * Set up logging of jobs states based on scheduler events + * + * @param Scheduler $scheduler + */ + protected function attachJobsLogging(Scheduler $scheduler): void + { + $scheduler->on(Scheduler::ON_TASK_CANCEL, function (Job $task, array $_) { + Logger::info('Schedule %s of job %s canceled', $task->getSchedule()->getName(), $task->getName()); + }); + + $scheduler->on(Scheduler::ON_TASK_DONE, function (Job $task, $targets = 0) { + if ($targets === 0) { + $sinceLastScan = $task->getSinceLastScan(); + if ($sinceLastScan) { + Logger::info( + 'Schedule %s of job %s does not have any targets to be rescanned matching since last scan: %s', + $task->getSchedule()->getName(), + $task->getName(), + $sinceLastScan->format('Y-m-d H:i:s') + ); + } else { + Logger::warning( + 'Schedule %s of job %s does not have any targets', + $task->getSchedule()->getName(), + $task->getName() + ); + } + } else { + Logger::info( + 'Scanned %d target(s) by schedule %s of job %s', + $targets, + $task->getSchedule()->getName(), + $task->getName() + ); + + try { + $verified = CertificateUtils::verifyCertificates(Database::get()); + + Logger::info('Checked %d certificate chain(s)', $verified); + } catch (Exception $err) { + Logger::error($err->getMessage()); + Logger::debug($err->getTraceAsString()); + } + } + }); + + $scheduler->on(Scheduler::ON_TASK_FAILED, function (Job $task, Throwable $e) { + Logger::error( + 'Failed to run schedule %s of job %s: %s', + $task->getSchedule()->getName(), + $task->getName(), + $e->getMessage() + ); + Logger::debug($e->getTraceAsString()); + }); + + $scheduler->on(Scheduler::ON_TASK_RUN, function (Job $task, ExtendedPromiseInterface $_) { + Logger::info('Running schedule %s of job %s', $task->getSchedule()->getName(), $task->getName()); + }); + + $scheduler->on(Scheduler::ON_TASK_SCHEDULED, function (Job $task, DateTime $dateTime) { + Logger::info( + 'Scheduling %s of job %s to run at %s', + $task->getSchedule()->getName(), + $task->getName(), + $dateTime->format('Y-m-d H:i:s') + ); + }); + + $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Job $task, DateTime $dateTime) { + Logger::info( + 'Detaching expired schedule %s of job %s at %s', + $task->getSchedule()->getName(), + $task->getName(), + $dateTime->format('Y-m-d H:i:s') + ); + }); + } +} diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php new file mode 100644 index 0000000..cb4e389 --- /dev/null +++ b/application/clicommands/MigrateCommand.php @@ -0,0 +1,121 @@ + + * + * OPTIONS + * + * --author= + * An Icinga Web 2 user used to mark as an author for all the migrated jobs. + */ + public function jobsAction(): void + { + /** @var string $author */ + $author = $this->params->getRequired('author'); + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + $user->setUsername($author); + + $this->migrateJobs(); + + Logger::info('Successfully applied all pending migrations'); + } + + protected function migrateJobs(): void + { + $repo = new class () extends IniRepository { + /** @var array> */ + protected $queryColumns = [ + 'jobs' => ['name', 'cidrs', 'ports', 'exclude_targets', 'schedule', 'frequencyType'] + ]; + + /** @var array> */ + protected $configs = [ + 'jobs' => [ + 'module' => 'x509', + 'name' => 'jobs', + 'keyColumn' => 'name' + ] + ]; + }; + + $conn = Database::get(); + $conn->transaction(function (Connection $conn) use ($repo) { + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + /** @var stdClass $data */ + foreach ($repo->select() as $data) { + $config = []; + if (! isset($data->frequencyType) && ! empty($data->schedule)) { + $frequency = new Cron($data->schedule); + $config = [ + 'type' => get_php_type($frequency), + 'frequency' => Json::encode($frequency) + ]; + } elseif (! empty($data->schedule)) { + $config = [ + 'type' => $data->frequencyType, + 'frequency' => $data->schedule // Is already json encoded + ]; + } + + $excludes = $data->exclude_targets; + if (empty($excludes)) { + $excludes = new Expression('NULL'); + } + + $conn->insert('x509_job', [ + 'name' => $data->name, + 'author' => $user->getUsername(), + 'cidrs' => $data->cidrs, + 'ports' => $data->ports, + 'exclude_targets' => $excludes, + 'ctime' => (new DateTime())->getTimestamp() * 1000, + 'mtime' => (new DateTime())->getTimestamp() * 1000 + ]); + + $jobId = (int) $conn->lastInsertId(); + if (! empty($config)) { + $config['rescan'] = 'n'; + $config['full_scan'] = 'n'; + $config['since_last_scan'] = Job::DEFAULT_SINCE_LAST_SCAN; + + $conn->insert('x509_schedule', [ + 'job_id' => $jobId, + 'name' => $data->name . ' Schedule', + 'author' => $user->getUsername(), + 'config' => Json::encode($config), + 'ctime' => (new DateTime())->getTimestamp() * 1000, + 'mtime' => (new DateTime())->getTimestamp() * 1000, + ]); + } + } + }); + } +} diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php new file mode 100644 index 0000000..3743adc --- /dev/null +++ b/application/clicommands/ScanCommand.php @@ -0,0 +1,163 @@ + [OPTIONS] + * + * OPTIONS + * + * --job= + * Scan targets that belong to the specified job. + * + * --since-last-scan= + * Scan targets whose last scan is older than the specified date/time, + * which can also be an English textual datetime description like "2 days". + * Defaults to "-24 hours". + * + * --parallel= + * Allow parallel scanning of targets up to the specified number. Defaults to 256. + * May cause **too many open files** error if set to a number higher than the configured one (ulimit). + * + * --rescan + * Rescan only targets that have been scanned before. + * + * --full + * (Re)scan all known and unknown targets. + * This will override the "rescan" and "since-last-scan" options. + * + * EXAMPLES + * + * Scan all targets that have not yet been scanned, or whose last scan is older than a certain date/time: + * + * icingacli x509 scan --job --since-last-scan="3 days" + * + * Scan only unknown targets + * + * icingacli x509 scan --job --since-last-scan=null + * + * Scan only known targets + * + * icingacli x509 scan --job --rescan + * + * Scan only known targets whose last scan is older than a certain date/time: + * + * icingacli x509 scan --job --rescan --since-last-scan="5 days" + * + * Scan all known and unknown targets: + * + * icingacli x509 scan --job --full + */ + public function indexAction(): void + { + /** @var string $name */ + $name = $this->params->shiftRequired('job'); + $fullScan = (bool) $this->params->get('full', false); + $rescan = (bool) $this->params->get('rescan', false); + + /** @var string $sinceLastScan */ + $sinceLastScan = $this->params->get('since-last-scan', Job::DEFAULT_SINCE_LAST_SCAN); + if ($sinceLastScan === 'null') { + $sinceLastScan = null; + } + + /** @var int $parallel */ + $parallel = $this->params->get('parallel', Job::DEFAULT_PARALLEL); + if ($parallel <= 0) { + throw new Exception('The \'parallel\' option must be set to at least 1'); + } + + /** @var X509Job $jobConfig */ + $jobConfig = X509Job::on(Database::get()) + ->filter(Filter::equal('name', $name)) + ->first(); + if ($jobConfig === null) { + throw new Exception(sprintf('Job %s not found', $name)); + } + + if (! strlen($jobConfig->cidrs)) { + throw new Exception(sprintf('The job %s does not specify any CIDRs', $name)); + } + + $cidrs = $this->parseCIDRs($jobConfig->cidrs); + $ports = $this->parsePorts($jobConfig->ports); + $job = (new Job($name, $cidrs, $ports, SniHook::getAll())) + ->setId($jobConfig->id) + ->setFullScan($fullScan) + ->setRescan($rescan) + ->setParallel($parallel) + ->setExcludes($this->parseExcludes($jobConfig->exclude_targets)) + ->setLastScan($sinceLastScan); + + $promise = $job->run(); + $signalHandler = function () use (&$promise, $job) { + $promise->cancel(); + + Logger::info('Job %s canceled', $job->getName()); + + Loop::futureTick(function () { + Loop::stop(); + }); + }; + Loop::addSignal(SIGINT, $signalHandler); + Loop::addSignal(SIGTERM, $signalHandler); + + $promise->then(function ($targets = 0) use ($job) { + if ($targets === 0) { + Logger::warning('The job %s does not have any targets', $job->getName()); + } else { + Logger::info('Scanned %d target(s) from job %s', $targets, $job->getName()); + + try { + $verified = CertificateUtils::verifyCertificates(Database::get()); + + Logger::info('Checked %d certificate chain(s)', $verified); + } catch (Exception $err) { + Logger::error($err->getMessage()); + Logger::debug($err->getTraceAsString()); + } + } + }, function (Throwable $err) use ($job) { + Logger::error('Failed to run job %s: %s', $job->getName(), $err->getMessage()); + Logger::debug($err->getTraceAsString()); + })->always(function () { + Loop::futureTick(function () { + Loop::stop(); + }); + }); + } +} diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php new file mode 100644 index 0000000..15976fc --- /dev/null +++ b/application/clicommands/VerifyCommand.php @@ -0,0 +1,27 @@ +addTitleTab($this->translate('X.509 Certificate')); + $this->getTabs()->disableLegacyExtensions(); + + $certId = $this->params->getRequired('cert'); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + + return; + } + + /** @var ?X509Certificate $cert */ + $cert = X509Certificate::on($conn) + ->filter(Filter::equal('id', $certId)) + ->first(); + + if (! $cert) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $this->view->certificateDetails = (new CertificateDetails()) + ->setCert($cert); + } +} diff --git a/application/controllers/CertificatesController.php b/application/controllers/CertificatesController.php new file mode 100644 index 0000000..37434fa --- /dev/null +++ b/application/controllers/CertificatesController.php @@ -0,0 +1,117 @@ +addTitleTab($this->translate('Certificates')); + $this->getTabs()->enableDataExports(); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + + return; + } + + $certificates = X509Certificate::on($conn); + + $sortColumns = [ + 'subject' => $this->translate('Certificate'), + 'issuer' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'duration' => $this->translate('Duration') + ]; + + $limitControl = $this->createLimitControl(); + $paginator = $this->createPaginationControl($certificates); + $sortControl = $this->createSortControl($certificates, $sortColumns); + + $searchBar = $this->createSearchBar($certificates, [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ]); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $certificates->peekAhead($this->view->compact); + + $certificates->filter($filter); + + $this->addControl($paginator); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + + $this->handleFormatRequest($certificates, function (Query $certificates) { + /** @var X509Certificate $cert */ + foreach ($certificates as $cert) { + $cert->valid_from = $cert->valid_from->format('l F jS, Y H:i:s e'); + $cert->valid_to = $cert->valid_to->format('l F jS, Y H:i:s e'); + + yield array_intersect_key(iterator_to_array($cert), array_flip($cert->getExportableColumns())); + } + }); + + $this->addContent((new CertificatesTable())->setData($certificates)); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); // Updates the browser search bar + } + } + + public function completeAction() + { + $this->getDocument()->add( + (new ObjectSuggestions()) + ->setModel(X509Certificate::class) + ->forRequest($this->getServerRequest()) + ); + } + + public function searchEditorAction() + { + $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM + ]); + + $this->getDocument()->add($editor); + $this->setTitle(t('Adjust Filter')); + } +} diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php new file mode 100644 index 0000000..5408526 --- /dev/null +++ b/application/controllers/ChainController.php @@ -0,0 +1,77 @@ +addTitleTab($this->translate('X.509 Certificate Chain')); + $this->getTabs()->disableLegacyExtensions(); + + $id = $this->params->getRequired('id'); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + /** @var ?X509CertificateChain $chain */ + $chain = X509CertificateChain::on($conn) + ->with(['target']) + ->filter(Filter::equal('id', $id)) + ->first(); + + if (! $chain) { + $this->httpNotFound($this->translate('Certificate not found.')); + } + + $chainInfo = Html::tag('div'); + $chainInfo->add(Html::tag('dl', [ + Html::tag('dt', $this->translate('Host')), + Html::tag('dd', $chain->target->hostname), + Html::tag('dt', $this->translate('IP')), + Html::tag('dd', $chain->target->ip), + Html::tag('dt', $this->translate('Port')), + Html::tag('dd', $chain->target->port) + ])); + + $valid = Html::tag('div', ['class' => 'cert-chain']); + + if ($chain['valid']) { + $valid->getAttributes()->add('class', '-valid'); + $valid->add(Html::tag('p', $this->translate('Certificate chain is valid.'))); + } else { + $valid->getAttributes()->add('class', '-invalid'); + $valid->add(Html::tag('p', sprintf( + $this->translate('Certificate chain is invalid: %s.'), + $chain['invalid_reason'] + ))); + } + + $certs = X509Certificate::on($conn)->with(['chain']); + $certs + ->filter(Filter::equal('chain.id', $id)) + ->getSelectBase() + ->orderBy('certificate_link.order'); + + $this->view->chain = (new HtmlDocument()) + ->add($chainInfo) + ->add($valid) + ->add((new ChainDetails())->setData($certs)); + } +} diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php new file mode 100644 index 0000000..b4300ef --- /dev/null +++ b/application/controllers/ConfigController.php @@ -0,0 +1,30 @@ +assertPermission('config/modules'); + + parent::init(); + } + + public function backendAction() + { + $form = (new BackendConfigForm()) + ->setIniConfig(Config::module('x509')); + + $form->handleRequest(); + + $this->view->tabs = $this->Module()->getConfigTabs()->activate('backend'); + $this->view->form = $form; + } +} diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php new file mode 100644 index 0000000..8b43761 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,153 @@ +addTitleTab($this->translate('Certificate Dashboard')); + $this->getTabs()->disableLegacyExtensions(); + + try { + $db = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $byCa = X509Certificate::on($db) + ->columns([ + 'issuer_certificate.subject', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->orderBy('issuer_certificate.subject') + ->filter(Filter::equal('issuer_certificate.ca', true)) + ->limit(5); + + $byCa + ->getSelectBase() + ->groupBy('certificate_issuer_certificate.id'); + + $this->view->byCa = (new Donut()) + ->setHeading($this->translate('Certificates by CA'), 2) + ->setData($byCa) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath('x509/certificates', [ + 'issuer' => $data->issuer_certificate->subject + ])->getAbsoluteUrl() + ], + $data->issuer_certificate->subject + ); + }); + + $duration = X509Certificate::on($db) + ->columns([ + 'duration', + 'cnt' => new Expression('COUNT(*)') + ]) + ->filter(Filter::equal('ca', false)) + ->orderBy('cnt', SORT_DESC) + ->limit(5); + + $duration + ->getSelectBase() + ->groupBy('duration'); + + $this->view->duration = (new Donut()) + ->setHeading($this->translate('Certificates by Duration'), 2) + ->setData($duration) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + "x509/certificates?duration={$data->duration->getTimestamp()}&ca=n" + )->getAbsoluteUrl() + ], + CertificateUtils::duration($data->duration->getTimestamp()) + ); + }); + + $keyStrength = X509Certificate::on($db) + ->columns([ + 'pubkey_algo', + 'pubkey_bits', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->limit(5); + + $keyStrength + ->getSelectBase() + ->groupBy(['pubkey_algo', 'pubkey_bits']); + + $this->view->keyStrength = (new Donut()) + ->setHeading($this->translate('Key Strength'), 2) + ->setData($keyStrength) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'pubkey_algo' => $data->pubkey_algo, + 'pubkey_bits' => $data->pubkey_bits + ] + )->getAbsoluteUrl() + ], + "{$data->pubkey_algo} {$data->pubkey_bits} bits" + ); + }); + + $sigAlgos = X509Certificate::on($db) + ->columns([ + 'signature_algo', + 'signature_hash_algo', + 'cnt' => new Expression('COUNT(*)') + ]) + ->orderBy('cnt', SORT_DESC) + ->limit(5); + + $sigAlgos + ->getSelectBase() + ->groupBy(['signature_algo', 'signature_hash_algo']); + + $this->view->sigAlgos = (new Donut()) + ->setHeading($this->translate('Signature Algorithms'), 2) + ->setData($sigAlgos) + ->setLabelCallback(function ($data) { + return Html::tag( + 'a', + [ + 'href' => Url::fromPath( + 'x509/certificates', + [ + 'signature_hash_algo' => $data->signature_hash_algo, + 'signature_algo' => $data->signature_algo + ] + )->getAbsoluteUrl() + ], + "{$data->signature_hash_algo} with {$data->signature_algo}" + ); + }); + } +} diff --git a/application/controllers/JobController.php b/application/controllers/JobController.php new file mode 100644 index 0000000..7655a74 --- /dev/null +++ b/application/controllers/JobController.php @@ -0,0 +1,226 @@ +getTabs()->disableLegacyExtensions(); + + /** @var int $jobId */ + $jobId = $this->params->getRequired('id'); + + /** @var X509Job $job */ + $job = X509Job::on(Database::get()) + ->filter(Filter::equal('id', $jobId)) + ->first(); + + if ($job === null) { + $this->httpNotFound($this->translate('Job not found')); + } + + $this->job = $job; + } + + public function indexAction(): void + { + $this->assertPermission('config/x509'); + + $this->initTabs(); + $this->getTabs()->activate('job-activities'); + + $jobRuns = $this->job->job_run->with(['job', 'schedule']); + + $limitControl = $this->createLimitControl(); + $sortControl = $this->createSortControl($jobRuns, [ + 'schedule.name' => $this->translate('Schedule Name'), + 'schedule.author' => $this->translate('Author'), + 'total_targets' => $this->translate('Total Targets'), + 'finished_targets' => $this->translate('Finished Targets'), + 'start_time desc' => $this->translate('Started At'), + 'end_time' => $this->translate('Ended At') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($this->createActionBar()); + + $this->addContent(new JobDetails($jobRuns)); + } + + public function updateAction(): void + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('Update Job')); + + $form = (new JobConfigForm($this->job)) + ->setAction((string) Url::fromRequest()) + ->populate([ + 'name' => $this->job->name, + 'cidrs' => $this->job->cidrs, + 'ports' => $this->job->ports, + 'exclude_targets' => $this->job->exclude_targets + ]) + ->on(JobConfigForm::ON_SUCCESS, function (JobConfigForm $form) { + /** @var FormSubmitElement $button */ + $button = $form->getPressedSubmitElement(); + if ($button->getName() === 'btn_remove') { + $this->switchToSingleColumnLayout(); + } else { + $this->closeModalAndRefreshRelatedView(Links::job($this->job)); + } + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + } + + public function schedulesAction(): void + { + $this->assertPermission('config/x509'); + + $this->initTabs(); + $this->getTabs()->activate('schedules'); + + $schedules = $this->job->schedule->with(['job']); + + $sortControl = $this->createSortControl($schedules, [ + 'name' => $this->translate('Name'), + 'author' => $this->translate('Author'), + 'ctime' => $this->translate('Date Created'), + 'mtime' => $this->translate('Date Modified') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->addControl( + (new ButtonLink($this->translate('New Schedule'), Links::scheduleJob($this->job), 'plus')) + ->openInModal() + ); + $this->addControl($sortControl); + + $this->addContent(new Schedules($schedules)); + } + + public function scheduleAction(): void + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('Schedule Job')); + + $form = (new ScheduleForm()) + ->setAction((string) Url::fromRequest()) + ->setJobId($this->job->id) + ->on(JobConfigForm::ON_SUCCESS, function () { + $this->redirectNow(Links::schedules($this->job)); + }) + ->handleRequest($this->getServerRequest()); + + $parts = $form->getPartUpdates(); + if (! empty($parts)) { + $this->sendMultipartUpdate(...$parts); + } + + $this->addContent($form); + } + + public function updateScheduleAction(): void + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('Update Schedule')); + + /** @var int $id */ + $id = $this->params->getRequired('scheduleId'); + /** @var X509Schedule $schedule */ + $schedule = X509Schedule::on(Database::get()) + ->filter(Filter::equal('id', $id)) + ->first(); + if ($schedule === null) { + $this->httpNotFound($this->translate('Schedule not found')); + } + + /** @var stdClass $config */ + $config = Json::decode($schedule->config); + /** @var Frequency $type */ + $type = $config->type; + $frequency = $type::fromJson($config->frequency); + + $form = (new ScheduleForm($schedule)) + ->setAction((string) Url::fromRequest()) + ->populate([ + 'name' => $schedule->name, + 'full_scan' => $config->full_scan ?? 'n', + 'rescan' => $config->rescan ?? 'n', + 'since_last_scan' => $config->since_last_scan ?? null, + 'schedule_element' => $frequency + ]) + ->on(JobConfigForm::ON_SUCCESS, function () { + $this->redirectNow('__BACK__'); + }) + ->handleRequest($this->getServerRequest()); + + $parts = $form->getPartUpdates(); + if (! empty($parts)) { + $this->sendMultipartUpdate(...$parts); + } + + $this->addContent($form); + } + + protected function createActionBar(): ValidHtml + { + $actions = new ActionBar(); + $actions->addHtml( + (new ActionLink($this->translate('Modify'), Links::updateJob($this->job), 'edit')) + ->openInModal(), + (new ActionLink($this->translate('Schedule'), Links::scheduleJob($this->job), 'calendar')) + ->openInModal() + ); + + return $actions; + } + + protected function initTabs(): void + { + $tabs = $this->getTabs(); + $tabs + ->add('job-activities', [ + 'label' => $this->translate('Job Activities'), + 'url' => Links::job($this->job) + ]) + ->add('schedules', [ + 'label' => $this->translate('Schedules'), + 'url' => Links::schedules($this->job) + ]); + } +} diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php new file mode 100644 index 0000000..48deede --- /dev/null +++ b/application/controllers/JobsController.php @@ -0,0 +1,66 @@ +addTitleTab($this->translate('Jobs')); + $this->getTabs()->add('sni', [ + 'title' => $this->translate('Configure SNI'), + 'label' => $this->translate('SNI'), + 'url' => 'x509/sni', + 'baseTarget' => '_main' + ]); + + $jobs = X509Job::on(Database::get()); + if ($this->hasPermission('config/x509')) { + $this->addControl( + (new ButtonLink($this->translate('New Job'), Url::fromPath('x509/jobs/new'), 'plus')) + ->openInModal() + ); + } + + $sortControl = $this->createSortControl($jobs, [ + 'name' => $this->translate('Name'), + 'author' => $this->translate('Author'), + 'ctime' => $this->translate('Date Created'), + 'mtime' => $this->translate('Date Modified') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->addControl($sortControl); + + $this->addContent(new Jobs($jobs)); + } + + public function newAction() + { + $this->assertPermission('config/x509'); + + $this->addTitleTab($this->translate('New Job')); + + $form = (new JobConfigForm()) + ->setAction((string) Url::fromRequest()) + ->on(JobConfigForm::ON_SUCCESS, function () { + $this->closeModalAndRefreshRelatedView(Url::fromPath('x509/jobs')); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($form); + } +} diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php new file mode 100644 index 0000000..cde4807 --- /dev/null +++ b/application/controllers/SniController.php @@ -0,0 +1,103 @@ +getTabs()->add('jobs', [ + 'title' => $this->translate('Configure Jobs'), + 'label' => $this->translate('Jobs'), + 'url' => 'x509/jobs', + 'baseTarget' => '_main' + + ]); + $this->addTitleTab($this->translate('SNI')); + + $this->addControl( + (new ButtonLink($this->translate('New SNI Map'), Url::fromPath('x509/sni/new'), 'plus')) + ->openInModal() + ); + $this->controls->getAttributes()->add('class', 'default-layout'); + + $this->view->controls = $this->controls; + + $repo = new SniIniRepository(); + + $this->view->sni = $repo->select(array('ip')); + } + + /** + * Create a map + */ + public function newAction() + { + $this->addTitleTab($this->translate('New SNI Map')); + + $form = $this->prepareForm()->add(); + + $form->handleRequest(); + + $this->addContent(new HtmlString($form->render())); + } + + /** + * Update a map + */ + public function updateAction() + { + $form = $this->prepareForm()->edit($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Update SNI Map')); + } + + /** + * Remove a map + */ + public function removeAction() + { + $form = $this->prepareForm()->remove($this->params->getRequired('ip')); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound($this->translate('IP not found')); + } + + $this->renderForm($form, $this->translate('Remove SNI Map')); + } + + /** + * Assert config permission and return a prepared RepositoryForm + * + * @return SniConfigForm + */ + protected function prepareForm() + { + $this->assertPermission('config/x509'); + + return (new SniConfigForm()) + ->setRepository(new SniIniRepository()) + ->setRedirectUrl(Url::fromPath('x509/sni')); + } +} diff --git a/application/controllers/UsageController.php b/application/controllers/UsageController.php new file mode 100644 index 0000000..079d24a --- /dev/null +++ b/application/controllers/UsageController.php @@ -0,0 +1,141 @@ +addTitleTab($this->translate('Certificate Usage')); + $this->getTabs()->enableDataExports(); + + try { + $conn = Database::get(); + } catch (ConfigurationError $_) { + $this->render('missing-resource', null, true); + return; + } + + $targets = X509Certificate::on($conn) + ->with(['chain', 'chain.target']) + ->withColumns([ + 'chain.id', + 'chain.valid', + 'chain.target.ip', + 'chain.target.port', + 'chain.target.hostname', + ]); + + $targets + ->getSelectBase() + ->where(new Expression('certificate_link.order = 0')); + + $sortColumns = [ + 'chain.target.hostname' => $this->translate('Hostname'), + 'chain.target.ip' => $this->translate('IP'), + 'chain.target.port' => $this->translate('Port'), + 'subject' => $this->translate('Certificate'), + 'issuer' => $this->translate('Issuer'), + 'version' => $this->translate('Version'), + 'self_signed' => $this->translate('Is Self-Signed'), + 'ca' => $this->translate('Is Certificate Authority'), + 'trusted' => $this->translate('Is Trusted'), + 'pubkey_algo' => $this->translate('Public Key Algorithm'), + 'pubkey_bits' => $this->translate('Public Key Strength'), + 'signature_algo' => $this->translate('Signature Algorithm'), + 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'), + 'valid_from' => $this->translate('Valid From'), + 'valid_to' => $this->translate('Valid To'), + 'chain.valid' => $this->translate('Chain Is Valid'), + 'duration' => $this->translate('Duration') + ]; + + $limitControl = $this->createLimitControl(); + $paginator = $this->createPaginationControl($targets); + $sortControl = $this->createSortControl($targets, $sortColumns); + + $searchBar = $this->createSearchBar($targets, [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ]); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $targets->peekAhead($this->view->compact); + + $targets->filter($filter); + + $this->addControl($paginator); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + + $this->handleFormatRequest($targets, function (Query $targets) { + /** @var X509Certificate $usage */ + foreach ($targets as $usage) { + $usage->valid_from = $usage->valid_from->format('l F jS, Y H:i:s e'); + $usage->valid_to = $usage->valid_to->format('l F jS, Y H:i:s e'); + + $usage->ip = $usage->chain->target->ip; + $usage->hostname = $usage->chain->target->hostname; + $usage->port = $usage->chain->target->port; + $usage->valid = $usage->chain->valid; + + yield array_intersect_key( + iterator_to_array($usage), + array_flip(array_merge(['valid', 'hostname', 'ip', 'port'], $usage->getExportableColumns())) + ); + } + }); + + $this->addContent((new UsageTable())->setData($targets)); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); // Updates the browser search bar + } + } + + public function completeAction() + { + $this->getDocument()->add( + (new ObjectSuggestions()) + ->setModel(X509Certificate::class) + ->forRequest($this->getServerRequest()) + ); + } + + public function searchEditorAction() + { + $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM + ]); + + $this->getDocument()->add($editor); + $this->setTitle(t('Adjust Filter')); + } +} diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php new file mode 100644 index 0000000..e806d26 --- /dev/null +++ b/application/forms/Config/BackendConfigForm.php @@ -0,0 +1,29 @@ +setName('x509_backend'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + public function createElements(array $formData) + { + $dbResources = ResourceFactory::getResourceConfigs('db')->keys(); + + $this->addElement('select', 'backend_resource', [ + 'label' => $this->translate('Database'), + 'description' => $this->translate('Database resource'), + 'multiOptions' => array_combine($dbResources, $dbResources), + 'required' => true + ]); + } +} diff --git a/application/forms/Config/SniConfigForm.php b/application/forms/Config/SniConfigForm.php new file mode 100644 index 0000000..27a4823 --- /dev/null +++ b/application/forms/Config/SniConfigForm.php @@ -0,0 +1,79 @@ +addElements([ + [ + 'text', + 'ip', + [ + 'description' => $this->translate('IP'), + 'label' => $this->translate('IP'), + 'required' => true + ] + ], + [ + 'textarea', + 'hostnames', + [ + 'description' => $this->translate('Comma-separated list of hostnames'), + 'label' => $this->translate('Hostnames'), + 'required' => true + ] + ] + ]); + + $this->setSubmitLabel($this->translate('Create')); + } + + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit map for %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove map for %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + protected function createFilter() + { + return Filter::where('ip', $this->getIdentifier()); + } + + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Map created') + : $this->translate('Failed to create map'); + } + + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Map updated') + : $this->translate('Failed to update map'); + } + + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Map removed') + : $this->translate('Failed to remove map'); + } +} diff --git a/application/forms/Jobs/JobConfigForm.php b/application/forms/Jobs/JobConfigForm.php new file mode 100644 index 0000000..539bc58 --- /dev/null +++ b/application/forms/Jobs/JobConfigForm.php @@ -0,0 +1,154 @@ +job = $job; + } + + protected function isUpdating(): bool + { + return $this->job !== null; + } + + public function hasBeenSubmitted(): bool + { + if (! $this->hasBeenSent()) { + return false; + } + + $button = $this->getPressedSubmitElement(); + + return $button && ($button->getName() === 'btn_submit' || $button->getName() === 'btn_remove'); + } + + protected function assemble(): void + { + $this->addElement('text', 'name', [ + 'required' => true, + 'label' => $this->translate('Name'), + 'description' => $this->translate('Job name'), + ]); + + $this->addElement('textarea', 'cidrs', [ + 'required' => true, + 'label' => $this->translate('CIDRs'), + 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator): bool { + $cidrValidator = new CidrValidator(); + $cidrs = Str::trimSplit($value); + + foreach ($cidrs as $cidr) { + if (! $cidrValidator->isValid($cidr)) { + $validator->addMessage(...$cidrValidator->getMessages()); + + return false; + } + } + + return true; + }) + ] + ]); + + $this->addElement('textarea', 'ports', [ + 'required' => true, + 'label' => $this->translate('Ports'), + 'description' => $this->translate('Comma-separated list of ports to scan'), + ]); + + $this->addElement('textarea', 'exclude_targets', [ + 'required' => false, + 'label' => $this->translate('Exclude Targets'), + 'description' => $this->translate('Comma-separated list of addresses/hostnames to exclude'), + ]); + + $this->addElement('submit', 'btn_submit', [ + 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Create') + ]); + + if ($this->isUpdating()) { + $removeButton = $this->createElement('submit', 'btn_remove', [ + 'class' => 'btn-remove', + 'label' => $this->translate('Remove Job'), + ]); + $this->registerElement($removeButton); + + /** @var HtmlDocument $wrapper */ + $wrapper = $this->getElement('btn_submit')->getWrapper(); + $wrapper->prepend($removeButton); + } + } + + protected function onSuccess(): void + { + $conn = Database::get(); + /** @var FormSubmitElement $submitElement */ + $submitElement = $this->getPressedSubmitElement(); + if ($submitElement->getName() === 'btn_remove') { + try { + /** @var X509Job $job */ + $job = $this->job; + $conn->delete('x509_job', ['id = ?' => $job->id]); + + Notification::success($this->translate('Removed job successfully')); + } catch (Exception $err) { + Notification::error($this->translate('Failed to remove job') . ': ' . $err->getMessage()); + } + } else { + $values = $this->getValues(); + + try { + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + if ($this->job === null) { + $values['author'] = $user->getUsername(); + $values['ctime'] = (new DateTime())->getTimestamp() * 1000.0; + $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0; + + $conn->insert('x509_job', $values); + $message = $this->translate('Created job successfully'); + } else { + $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0; + + $conn->update('x509_job', $values, ['id = ?' => $this->job->id]); + $message = $this->translate('Updated job successfully'); + } + + Notification::success($message); + } catch (Exception $err) { + $message = $this->isUpdating() + ? $this->translate('Failed to update job') + : $this->translate('Failed to create job'); + + Notification::error($message . ': ' . $err->getMessage()); + } + } + } +} diff --git a/application/forms/Jobs/ScheduleForm.php b/application/forms/Jobs/ScheduleForm.php new file mode 100644 index 0000000..ae47e58 --- /dev/null +++ b/application/forms/Jobs/ScheduleForm.php @@ -0,0 +1,201 @@ +schedule = $schedule; + $this->scheduleElement = new ScheduleElement('schedule_element'); + + /** @var Web $app */ + $app = Icinga::app(); + $this->scheduleElement->setIdProtector([$app->getRequest(), 'protectId']); + } + + protected function isUpdating(): bool + { + return $this->schedule !== null; + } + + public function setJobId(int $jobId): self + { + $this->jobId = $jobId; + + return $this; + } + + /** + * Get multipart updates + * + * @return array + */ + public function getPartUpdates(): array + { + /** @var RequestInterface $request */ + $request = $this->getRequest(); + + return $this->scheduleElement->prepareMultipartUpdate($request); + } + + public function hasBeenSubmitted(): bool + { + if (! $this->hasBeenSent()) { + return false; + } + + $button = $this->getPressedSubmitElement(); + + return $button && ($button->getName() === 'submit' || $button->getName() === 'btn_remove'); + } + + protected function assemble(): void + { + $this->addElement('text', 'name', [ + 'required' => true, + 'label' => $this->translate('Name'), + 'description' => $this->translate('Schedule name'), + ]); + + $this->addElement('checkbox', 'full_scan', [ + 'required' => false, + 'class' => 'autosubmit', + 'label' => $this->translate('Full Scan'), + 'description' => $this->translate( + 'Scan all known and unknown targets of this job. (Defaults to only scan unknown targets)' + ) + ]); + + if ($this->getPopulatedValue('full_scan', 'n') === 'n') { + $this->addElement('checkbox', 'rescan', [ + 'required' => false, + 'class' => 'autosubmit', + 'label' => $this->translate('Rescan'), + 'description' => $this->translate('Rescan only targets that have been scanned before') + ]); + + $this->addElement('text', 'since_last_scan', [ + 'required' => false, + 'label' => $this->translate('Since Last Scan'), + 'placeholder' => '-24 hours', + 'description' => $this->translate( + 'Scan targets whose last scan is older than the specified date/time, which can also be an' + . ' English textual datetime description like "2 days". If you want to scan only unknown targets' + . ' you can set this to "null".' + ), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if ($value !== null && $value !== 'null') { + try { + new DateTime($value); + } catch (Exception $_) { + $validator->addMessage($this->translate('Invalid textual date time')); + + return false; + } + } + + return true; + }) + ] + ]); + } + + $this->addHtml(HtmlElement::create('div', ['class' => 'schedule-element-separator'])); + $this->addElement($this->scheduleElement); + + $this->addElement('submit', 'submit', [ + 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Schedule') + ]); + + if ($this->isUpdating()) { + $removeButton = $this->createElement('submit', 'btn_remove', [ + 'class' => 'btn-remove', + 'label' => $this->translate('Remove Schedule'), + ]); + $this->registerElement($removeButton); + + /** @var HtmlDocument $wrapper */ + $wrapper = $this->getElement('submit')->getWrapper(); + $wrapper->prepend($removeButton); + } + } + + protected function onSuccess(): void + { + /** @var X509Schedule $schedule */ + $schedule = $this->schedule; + $conn = Database::get(); + /** @var FormSubmitElement $submitElement */ + $submitElement = $this->getPressedSubmitElement(); + if ($submitElement->getName() === 'btn_remove') { + $conn->delete('x509_schedule', ['id = ?' => $schedule->id]); + + Notification::success($this->translate('Deleted schedule successfully')); + } else { + $config = $this->getValues(); + unset($config['name']); + unset($config['schedule_element']); + + $frequency = $this->scheduleElement->getValue(); + $config['type'] = get_php_type($frequency); + $config['frequency'] = Json::encode($frequency); + + /** @var User $user */ + $user = Auth::getInstance()->getUser(); + if (! $this->isUpdating()) { + $conn->insert('x509_schedule', [ + 'job_id' => $this->schedule ? $this->schedule->job_id : $this->jobId, + 'name' => $this->getValue('name'), + 'author' => $user->getUsername(), + 'config' => Json::encode($config), + 'ctime' => (new DateTime())->getTimestamp() * 1000.0, + 'mtime' => (new DateTime())->getTimestamp() * 1000.0 + ]); + $message = $this->translate('Created schedule successfully'); + } else { + $conn->update('x509_schedule', [ + 'name' => $this->getValue('name'), + 'config' => Json::encode($config), + 'mtime' => (new DateTime())->getTimestamp() * 1000.0 + ], ['id = ?' => $schedule->id]); + $message = $this->translate('Updated schedule successfully'); + } + + Notification::success($message); + } + } +} diff --git a/application/views/scripts/certificate/index.phtml b/application/views/scripts/certificate/index.phtml new file mode 100644 index 0000000..08cfadb --- /dev/null +++ b/application/views/scripts/certificate/index.phtml @@ -0,0 +1,6 @@ +
+ +
+
+ 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/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..2be5280 --- /dev/null +++ b/application/views/scripts/sni/index.phtml @@ -0,0 +1,31 @@ +controls->render() ?> +
+ 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/config/systemd/icinga-x509.service b/config/systemd/icinga-x509.service new file mode 100644 index 0000000..01cda66 --- /dev/null +++ b/config/systemd/icinga-x509.service @@ -0,0 +1,10 @@ +[Unit] +Description=Icinga Certificate Monitoring Module Jobs Runner + +[Service] +Type=simple +ExecStart=/usr/bin/icingacli x509 jobs run +Restart=on-success + +[Install] +WantedBy=multi-user.target diff --git a/configuration.php b/configuration.php new file mode 100644 index 0000000..c7adad4 --- /dev/null +++ b/configuration.php @@ -0,0 +1,33 @@ +menuSection(N_('Certificate Monitoring'), array( + 'icon' => 'check', + 'url' => 'x509/dashboard', + 'priority' => 40 +)); + +$section->add(N_('Certificate Overview'), array( + 'url' => 'x509/certificates', + 'priority' => 10 +)); + +$section->add(N_('Certificate Usage'), array( + 'url' => 'x509/usage', + 'priority' => 20 +)); + +$section->add(N_('Configuration'), [ + 'url' => 'x509/jobs', + 'priority' => 100, + 'description' => $this->translate('Configure the scan jobs and SNI map') +]); + +$this->provideConfigTab('backend', array( + 'title' => $this->translate('Configure the database backend'), + 'label' => $this->translate('Backend'), + 'url' => 'config/backend' +)); diff --git a/doc/01-About.md b/doc/01-About.md new file mode 100644 index 0000000..38fa06a --- /dev/null +++ b/doc/01-About.md @@ -0,0 +1,22 @@ +# Icinga Certificate Monitoring + +The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment. +It does this by scanning networks for TLS services and collects whatever certificates it finds along the way. +The certificates are verified using its own trust store. + +The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information +about any discovered certificate of your landscape: + +![X.509 Usage](res/x509-usage.png "X.509 Usage") + +![X.509 Certificates](res/x509-certificates.png "X.509 Certificates") + +At a glance you see which CAs have issued your certificates and key counters of your environment: + +![X.509 Dashboard](res/x509-dashboard.png "X.509 Dashboard") + +## Documentation + +* [Installation](02-Installation.md) +* [Configuration](03-Configuration.md) +* [Monitoring](10-Monitoring.md) diff --git a/doc/02-Installation.md b/doc/02-Installation.md new file mode 100644 index 0000000..af2eaf3 --- /dev/null +++ b/doc/02-Installation.md @@ -0,0 +1,73 @@ + +# Installing Icinga Certificate Monitoring + +The recommended way to install Icinga Certificate Monitoring +and its dependencies is to use prebuilt packages for +all supported platforms from our official release repository. +Please note that [Icinga Web](https://icinga.com/docs/icinga-web) is required +and if it is not already set up, it is best to do this first. + +The following steps will guide you through installing and setting up Icinga Certificate Monitoring. + + + +## Installing the Package + +If the [repository](https://packages.icinga.com) is not configured yet, please add it first. +Then use your distribution's package manager to install the `icinga-x509` package +or install [from source](02-Installation.md.d/From-Source.md). + + +## Setting up the Database + +### Setting up a MySQL or MariaDB Database + +The module needs a MySQL/MariaDB database with the schema that's provided in the `/usr/share/icingaweb2/modules/x509/schema/mysql.schema.sql` file. + + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + + + +You can use the following sample command for creating the MySQL/MariaDB database. Please change the password: + +``` +CREATE DATABASE x509; +GRANT CREATE, SELECT, INSERT, UPDATE, DELETE, DROP, ALTER, CREATE VIEW, INDEX, EXECUTE ON x509.* TO x509@localhost IDENTIFIED BY 'secret'; +``` + +After, you can import the schema using the following command: + +``` +mysql -p -u root x509 < /usr/share/icingaweb2/modules/x509/schema/mysql.schema.sql +``` + +### Setting up a PostgreSQL Database + +The module needs a PostgreSQL database with the schema that's provided in the `/usr/share/icingaweb2/modules/x509/schema/pgsql.schema.sql` file. + + +**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path. + + + +You can use the following sample command for creating the PostgreSQL database. Please change the password: + +```sql +CREATE USER x509 WITH PASSWORD 'secret'; +CREATE DATABASE x509 + WITH OWNER x509 + ENCODING 'UTF8' + LC_COLLATE = 'en_US.UTF-8' + LC_CTYPE = 'en_US.UTF-8'; +``` + +After, you can import the schema using the following command: + +``` +psql -U x509 x509 -a -f /usr/share/icingaweb2/modules/x509/schema/pgsql.schema.sql +``` + +This concludes the installation. You should now be able to import CA certificates and set up scan jobs. +Please read the [Configuration](03-Configuration.md) section for details. + diff --git a/doc/02-Installation.md.d/From-Source.md b/doc/02-Installation.md.d/From-Source.md new file mode 100644 index 0000000..31f3d2b --- /dev/null +++ b/doc/02-Installation.md.d/From-Source.md @@ -0,0 +1,16 @@ +# Installing Icinga Certificate Monitoring from Source + +Please see the Icinga Web documentation on +[how to install modules](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation) from source. +Make sure you use `x509` as the module name. The following requirements must also be met. + +## Requirements + +* PHP (≥7.2) +* MySQL or PostgreSQL PDO PHP libraries +* The following PHP modules must be installed: `gmp`, `pcntl`, `openssl` +* [Icinga Web](https://github.com/Icinga/icingaweb2) (≥2.9) +* [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (≥0.13.0) +* [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.12.0) + + diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md new file mode 100644 index 0000000..4ecde77 --- /dev/null +++ b/doc/03-Configuration.md @@ -0,0 +1,77 @@ +# Configuration + +## Importing CA certificates + +The module tries to verify certificates using its own trust store. By default, this trust store is empty, and it +is up to the Icinga Web 2 admin to import CA certificates into it. + +Using the `icingacli x509 import` command CA certificates can be imported. The certificate chain file that is specified +with the `--file` option should contain a PEM-encoded list of X.509 certificates which should be added to the trust +store: + +``` +icingacli x509 import --file /etc/ssl/certs/ca-certificates.crt +``` + +## Configure Jobs + +Scan jobs have a name which uniquely identifies them, e.g. `lan`. These names are used by the CLI command to start +scanning for specific jobs. + +Each scan job can have one or more IP address ranges and one or more port ranges. The module scans each port in +a job's port ranges for all the individual IP addresses in the IP ranges. IP address ranges have to be specified using +the CIDR format. Multiple IP address ranges can be separated with commas, e.g.: + +`192.0.2.0/24,10.0.10.0/24` + +Port ranges are separated with dashes (`-`). If you only want to scan a single port you don't need to specify the second +port: + +`443,5665-5669` + +Additionally, each job may also exclude specific **hosts** and **IP** addresses from scan. These hosts won't be scanned +when you run the [scan](04-Scanning.md#scan-command) or [jobs](04-Scanning.md#scheduling-jobs) command. Excluding an entire network and specifying IP addresses in CIDR +format will not work. You must specify concrete **IP**s and **host CN**s separated with commas, e.g: + +`192.0.2.2,192.0.2.5,icinga.com` + +### Job Schedules + +Schedules are [`cron`](https://crontab.guru) and rule based configs used to run jobs periodically at the given interval. +Every job is allowed to have multiple schedules that can be run independently of each other. Each job schedule provides +different options that you can use to control the scheduling behavior of the [jobs command](04-Scanning.md#scheduling-jobs). + +#### Examples + +A schedule that runs weekly on **Friday** and scans all targets that have not yet been scanned, or +whose last scan is older than `1 week`. + +![Weekly Schedules](res/weekly-schedules.png "Weekly Schedules") + +## Server Name Indication + +In case you are serving multiple virtual hosts under a single IP you can configure those in +`Configuration -> Modules -> x509 -> SNI`. + +Each entry defines an IP with multiple hostnames associated with it. These are then utilized when jobs run. + +Modules may also provide sources for SNI. At this time the module monitoring is the only one with known support. + +## Icinga Certificate Monitoring Daemon + +The default `systemd` service of this module, shipped with package installations, uses the [jobs command](04-Scanning.md#scheduling-jobs) +and runs all your configured jobs and schedules. + + + +> **Note** +> +> If you haven't installed this module from packages, you have to configure this as a `systemd` service yourself by just +> copying the example service definition from `/usr/share/icingaweb2/modules/x509/config/systemd/icinga-x509.service` +> to `/etc/systemd/system/icinga-x509.service`. + + +You can run the following command to enable and start the daemon. +``` +systemctl enable --now icinga-x509.service +``` diff --git a/doc/04-Scanning.md b/doc/04-Scanning.md new file mode 100644 index 0000000..608d18a --- /dev/null +++ b/doc/04-Scanning.md @@ -0,0 +1,85 @@ +# Scanning + +The Icinga Certificate Monitoring provides CLI commands to scan **hosts** and **IPs** in various ways. +These commands are listed below and can be used individually. It is necessary for all commands to know which IP address +ranges and ports to scan. These can be configured as described [here](03-Configuration.md#configure-jobs). + +## Scan Command + +The scan command, scans targets to find their X.509 certificates and track changes to them. +A **target** is an **IP-port** combination that is generated from the job configuration, taking into account configured +[**SNI**](03-Configuration.md#server-name-indication) maps, so that targets with multiple certificates are also properly +scanned. + +By default, successive calls to the scan command perform partial scans, checking both targets not yet scanned and +targets whose scan is older than 24 hours, to ensure that all targets are rescanned over time and new certificates +are collected. This behavior can be customized through the command [options](#usage-1). + +> **Note** +> +> When rescanning due targets, they will be rescanned regardless of whether the target previously provided a certificate +> or not, to collect new certificates, track changed certificates, and remove decommissioned certificates. + +### Usage + +This scan command can be used like any other Icinga Web cli operations like this: `icingacli x509 scan [OPTIONS]` + +**Options:** + +``` +--job= Scan targets that belong to the specified job. (Required) +--since-last-scan=