summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-14 13:31:28 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-14 13:31:28 +0000
commit067008c5f094ba9606daacbe540f6b929dc124ea (patch)
tree3092ce2cd8bf1ac6db6c97f4c98c7f71a51c6ac8
parentInitial commit. (diff)
downloadicingaweb2-module-x509-upstream.tar.xz
icingaweb2-module-x509-upstream.zip
Adding upstream version 1:1.3.2.upstream/1%1.3.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.mailmap17
-rw-r--r--AUTHORS17
-rw-r--r--LICENSE339
-rw-r--r--README.md28
-rw-r--r--application/clicommands/CheckCommand.php268
-rw-r--r--application/clicommands/CleanupCommand.php95
-rw-r--r--application/clicommands/ImportCommand.php61
-rw-r--r--application/clicommands/JobsCommand.php279
-rw-r--r--application/clicommands/MigrateCommand.php121
-rw-r--r--application/clicommands/ScanCommand.php163
-rw-r--r--application/clicommands/VerifyCommand.php27
-rw-r--r--application/controllers/CertificateController.php43
-rw-r--r--application/controllers/CertificatesController.php117
-rw-r--r--application/controllers/ChainController.php77
-rw-r--r--application/controllers/ConfigController.php30
-rw-r--r--application/controllers/DashboardController.php153
-rw-r--r--application/controllers/JobController.php226
-rw-r--r--application/controllers/JobsController.php66
-rw-r--r--application/controllers/SniController.php103
-rw-r--r--application/controllers/UsageController.php141
-rw-r--r--application/forms/Config/BackendConfigForm.php29
-rw-r--r--application/forms/Config/SniConfigForm.php79
-rw-r--r--application/forms/Jobs/JobConfigForm.php154
-rw-r--r--application/forms/Jobs/ScheduleForm.php201
-rw-r--r--application/views/scripts/certificate/index.phtml6
-rw-r--r--application/views/scripts/chain/index.phtml8
-rw-r--r--application/views/scripts/config/backend.phtml6
-rw-r--r--application/views/scripts/dashboard/index.phtml13
-rw-r--r--application/views/scripts/missing-resource.phtml12
-rw-r--r--application/views/scripts/simple-form.phtml6
-rw-r--r--application/views/scripts/sni/index.phtml31
-rw-r--r--config/systemd/icinga-x509.service10
-rw-r--r--configuration.php33
-rw-r--r--doc/01-About.md22
-rw-r--r--doc/02-Installation.md73
-rw-r--r--doc/02-Installation.md.d/From-Source.md16
-rw-r--r--doc/03-Configuration.md77
-rw-r--r--doc/04-Scanning.md85
-rw-r--r--doc/10-Monitoring.md212
-rw-r--r--doc/11-Housekeeping.md38
-rw-r--r--doc/80-Upgrading.md91
-rw-r--r--doc/res/check-host-perf-data.pngbin0 -> 7466 bytes
-rw-r--r--doc/res/host-check-multiple-services.pngbin0 -> 49022 bytes
-rw-r--r--doc/res/host-check-single-service.pngbin0 -> 32771 bytes
-rw-r--r--doc/res/host-template-fields.pngbin0 -> 15012 bytes
-rw-r--r--doc/res/hosts-import-result.pngbin0 -> 7182 bytes
-rw-r--r--doc/res/hosts-import-source.pngbin0 -> 8344 bytes
-rw-r--r--doc/res/multiple-services-result.pngbin0 -> 13992 bytes
-rw-r--r--doc/res/new-host-template.pngbin0 -> 11630 bytes
-rw-r--r--doc/res/new-service-template.pngbin0 -> 14294 bytes
-rw-r--r--doc/res/ports-property-modifier.pngbin0 -> 12071 bytes
-rw-r--r--doc/res/service-template-fields.pngbin0 -> 27772 bytes
-rw-r--r--doc/res/single-service-result.pngbin0 -> 82322 bytes
-rw-r--r--doc/res/sync-rule-properties.pngbin0 -> 19091 bytes
-rw-r--r--doc/res/weekly-schedules.pngbin0 -> 140091 bytes
-rw-r--r--doc/res/x509-certificates.pngbin0 -> 475307 bytes
-rw-r--r--doc/res/x509-dashboard.pngbin0 -> 208525 bytes
-rw-r--r--doc/res/x509-usage.pngbin0 -> 286297 bytes
-rw-r--r--library/X509/CertificateDetails.php120
-rw-r--r--library/X509/CertificateUtils.php538
-rw-r--r--library/X509/CertificatesTable.php104
-rw-r--r--library/X509/ChainDetails.php111
-rw-r--r--library/X509/ColorScheme.php37
-rw-r--r--library/X509/Command.php18
-rw-r--r--library/X509/Common/Database.php56
-rw-r--r--library/X509/Common/JobOptions.php162
-rw-r--r--library/X509/Common/JobUtils.php77
-rw-r--r--library/X509/Common/Links.php37
-rw-r--r--library/X509/Controller.php87
-rw-r--r--library/X509/DataTable.php150
-rw-r--r--library/X509/DbTool.php45
-rw-r--r--library/X509/Donut.php92
-rw-r--r--library/X509/ExpirationWidget.php80
-rw-r--r--library/X509/FilterAdapter.php56
-rw-r--r--library/X509/Hook/SniHook.php54
-rw-r--r--library/X509/Job.php755
-rw-r--r--library/X509/Model/Behavior/DERBase64.php44
-rw-r--r--library/X509/Model/Behavior/ExpressionInjector.php62
-rw-r--r--library/X509/Model/Behavior/Ip.php39
-rw-r--r--library/X509/Model/Schema.php49
-rw-r--r--library/X509/Model/X509Certificate.php159
-rw-r--r--library/X509/Model/X509CertificateChain.php58
-rw-r--r--library/X509/Model/X509CertificateChainLink.php46
-rw-r--r--library/X509/Model/X509CertificateSubjectAltName.php50
-rw-r--r--library/X509/Model/X509Dn.php51
-rw-r--r--library/X509/Model/X509Job.php73
-rw-r--r--library/X509/Model/X509JobRun.php77
-rw-r--r--library/X509/Model/X509Schedule.php70
-rw-r--r--library/X509/Model/X509Target.php74
-rw-r--r--library/X509/ProvidedHook/DbMigration.php95
-rw-r--r--library/X509/ProvidedHook/HostsImportSource.php91
-rw-r--r--library/X509/ProvidedHook/ServicesImportSource.php143
-rw-r--r--library/X509/ProvidedHook/X509ImportSource.php11
-rw-r--r--library/X509/React/StreamOptsCaptureConnector.php60
-rw-r--r--library/X509/Schedule.php125
-rw-r--r--library/X509/SniIniRepository.php21
-rw-r--r--library/X509/Table.php39
-rw-r--r--library/X509/UsageTable.php91
-rw-r--r--library/X509/Web/Control/SearchBar/ObjectSuggestions.php203
-rw-r--r--library/X509/Widget/JobDetails.php61
-rw-r--r--library/X509/Widget/Jobs.php64
-rw-r--r--library/X509/Widget/Schedules.php61
-rw-r--r--module.info6
-rw-r--r--phpstan-baseline-7x.neon96
-rw-r--r--phpstan-baseline-8x.neon96
-rw-r--r--phpstan-baseline-by-php-version.php10
-rw-r--r--phpstan-baseline-common.neon1111
-rw-r--r--phpstan.neon42
-rw-r--r--phpunit.xml16
-rw-r--r--public/css/module.less171
-rw-r--r--run.php10
-rw-r--r--schema/mysql-upgrades/1.0.0.sql27
-rw-r--r--schema/mysql-upgrades/1.1.0.sql4
-rw-r--r--schema/mysql-upgrades/1.2.0.sql103
-rw-r--r--schema/mysql-upgrades/1.3.0.sql51
-rw-r--r--schema/mysql.schema.sql136
-rw-r--r--schema/pgsql-upgrades/1.3.0.sql49
-rw-r--r--schema/pgsql.schema.sql162
-rw-r--r--test/php/Lib/TestModel.php30
-rw-r--r--test/php/library/X509/Common/JobUtilsTest.php44
-rw-r--r--test/php/library/X509/JobTest.php53
-rw-r--r--test/php/library/X509/Model/Behavior/DERBase64Test.php69
-rw-r--r--test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php38
-rw-r--r--test/php/library/X509/Model/Behavior/IpTest.php92
124 files changed, 10485 insertions, 0 deletions
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 0000000..b83b0a3
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1,17 @@
+Alexander A. Klimov <alexander.klimov@icinga.com> <alexander.klimov@icinga.com>
+Eric Lippmann <eric.lippmann@icinga.com> <eric.lippmann@netways.de>
+Florian Strohmaier <florian.strohmaier@icinga.com> <florian.strohmaier@netways.de>
+Gunnar Beutner <gunnar.beutner@icinga.com>
+Jan Wagner <waja@cyconet.org>
+Jens Meißner <meissner@b1-systems.de>
+Johannes Meyer <johannes.meyer@icinga.com> <johannes.meyer@netways.de>
+Michael Friedrich <michael.friedrich@netways.de>
+Ravi Kumar Kempapura Srinivasa <ravi.srinivasa@icinga.com> <33730024+raviks789@users.noreply.github.com>
+Robert Rettig <robert@rettig.bayern>
+Sukhwinder Dhillon <sukhwinder.Dhillon@icinga.com>
+Timm Ortloff <tim.ortloff@icinga.com> <tim.ortloff@netways.de>
+Yonas Habteab <yonas.habteab@icinga.com> <yonas.habteab@netways.de>
+lx <pdolinic@netways.de>
+moreamazingnick <github@nicolas-schneider.at>
+nmartinii <51709615+nmartinii@users.noreply.github.com>
+pgress <pgress@noris.de>
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..8efaa3d
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,17 @@
+Alexander A. Klimov <alexander.klimov@icinga.com>
+Eric Lippmann <eric.lippmann@icinga.com>
+Florian Strohmaier <florian.strohmaier@icinga.com>
+Gunnar Beutner <gunnar.beutner@icinga.com>
+Jan Wagner <waja@cyconet.org>
+Jens Meißner <meissner@b1-systems.de>
+Johannes Meyer <johannes.meyer@icinga.com>
+Michael Friedrich <michael.friedrich@netways.de>
+Ravi Kumar Kempapura Srinivasa <ravi.srinivasa@icinga.com>
+Robert Rettig <robert@rettig.bayern>
+Sukhwinder Dhillon <sukhwinder.Dhillon@icinga.com>
+Timm Ortloff <timm.ortloff@icinga.com>
+Yonas Habteab <yonas.habteab@icinga.com>
+lx <pdolinic@netways.de>
+moreamazingnick <github@nicolas-schneider.at>
+nmartinii <51709615+nmartinii@users.noreply.github.com>
+pgress <pgress@noris.de>
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.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..35f1d6f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,28 @@
+# Icinga Certificate Monitoring
+
+[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.2-777BB4?logo=PHP)](https://php.net/)
+![Build Status](https://github.com/icinga/icingaweb2-module-x509/workflows/PHP%20Tests/badge.svg?branch=master)
+[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-x509.svg)](https://github.com/Icinga/icingaweb2-module-x509)
+
+![Icinga Logo](https://icinga.com/wp-content/uploads/2014/06/icinga_logo.png)
+
+The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment.
+It does this by scanning networks for TLS services and collects whatever certificates it finds along the way.
+The certificates are verified using its own trust store.
+
+The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information
+about any discovered certificate of your landscape:
+
+![X.509 Usage](doc/res/x509-usage.png "X.509 Usage")
+
+![X.509 Certificates](doc/res/x509-certificates.png "X.509 Certificates")
+
+At a glance you see which CAs have issued your certificates and key counters of your environment:
+
+![X.509 Dashboard](doc/res/x509-dashboard.png "X.509 Dashboard")
+
+## Documentation
+
+* [Installation](doc/02-Installation.md)
+* [Configuration](doc/03-Configuration.md)
+* [Monitoring](doc/10-Monitoring.md)
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php
new file mode 100644
index 0000000..0c369d9
--- /dev/null
+++ b/application/clicommands/CheckCommand.php
@@ -0,0 +1,268 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateInterval;
+use DateTime;
+use DateTimeInterface;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+
+class CheckCommand extends Command
+{
+ /**
+ * Check a host's certificate
+ *
+ * This command utilizes this module's database to check if the given host serves valid certificates.
+ *
+ * USAGE
+ *
+ * icingacli x509 check host [options]
+ *
+ * OPTIONS
+ *
+ * You can either pass --ip or --host or both at the same time but at least one is mandatory.
+ *
+ * --ip A hosts IP address
+ * --host A hosts name
+ * --port The port to check in particular
+ * --warning Less remaining time results in state WARNING
+ * Default: 25%
+ * --critical Less remaining time results in state CRITICAL
+ * Default: 10%
+ * --allow-self-signed Ignore if a certificate or its issuer has been
+ * self-signed
+ *
+ * EXAMPLES
+ *
+ * icingacli x509 check host --ip 10.0.10.78
+ * icingacli x509 check host --host mail.example.org
+ * icingacli x509 check host --ip 10.0.10.78 --host mail.example.org --port 993
+ *
+ * THRESHOLD DEFINITION
+ *
+ * Thresholds can either be defined relative (in percent) or absolute
+ * (time interval). Time intervals consist of a digit and an accompanying
+ * unit (e.g. "3M" are three months). Supported units are:
+ *
+ * Year: y, Y
+ * Month: M
+ * Day: d, D
+ * Hour: h, H
+ * Minute: m
+ * Second: s, S
+ */
+ public function hostAction()
+ {
+ $ip = $this->params->get('ip');
+ $hostname = $this->params->get('host');
+ if ($ip === null && $hostname === null) {
+ $this->showUsage('host');
+ exit(3);
+ }
+
+ $targets = X509Target::on(Database::get())->with([
+ 'chain',
+ 'chain.certificate',
+ 'chain.certificate.issuer_certificate'
+ ]);
+
+ $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT');
+
+ $targets->columns([
+ 'port',
+ 'chain.valid',
+ 'chain.invalid_reason',
+ 'subject' => 'chain.certificate.subject',
+ 'self_signed' => new Expression('COALESCE(%s, %s)', [
+ 'chain.certificate.issuer_certificate.self_signed',
+ 'chain.certificate.self_signed'
+ ])
+ ]);
+
+ // Sub query for `valid_from` column
+ $validFrom = $targets->createSubQuery(new X509Certificate(), 'chain.certificate');
+ $validFrom
+ ->columns([new Expression('MAX(GREATEST(%s, %s))', ['valid_from', 'issuer_certificate.valid_from'])])
+ ->getSelectBase()
+ ->resetWhere()
+ ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id'));
+
+ // Sub query for `valid_to` column
+ $validTo = $targets->createSubQuery(new X509Certificate(), 'chain.certificate');
+ $validTo
+ ->columns([new Expression('MIN(LEAST(%s, %s))', ['valid_to', 'issuer_certificate.valid_to'])])
+ ->getSelectBase()
+ // Reset the where clause generated within the createSubQuery() method.
+ ->resetWhere()
+ ->where(new Expression('sub_certificate_link.certificate_chain_id = target_chain.id'));
+
+ list($validFromSelect, $_) = $validFrom->dump();
+ list($validToSelect, $_) = $validTo->dump();
+ $targets
+ ->withColumns([
+ 'valid_from' => new Expression($validFromSelect),
+ 'valid_to' => new Expression($validToSelect)
+ ])
+ ->getSelectBase()
+ ->where(new Expression('target_chain_link.order = 0'));
+
+ if ($ip !== null) {
+ $targets->filter(Filter::equal('ip', $ip));
+ }
+ if ($hostname !== null) {
+ $targets->filter(Filter::equal('hostname', $hostname));
+ }
+ if ($this->params->has('port')) {
+ $targets->filter(Filter::equal('port', $this->params->get('port')));
+ }
+
+ $allowSelfSigned = (bool) $this->params->get('allow-self-signed', false);
+ $warningThreshold = $this->splitThreshold($this->params->get('warning', '25%'));
+ $criticalThreshold = $this->splitThreshold($this->params->get('critical', '10%'));
+
+ $output = [];
+ $perfData = [];
+
+ $state = 3;
+ foreach ($targets as $target) {
+ if (! $target->chain->valid && (! $target['self_signed'] || ! $allowSelfSigned)) {
+ $invalidMessage = $target['subject'] . ': ' . $target->chain->invalid_reason;
+ $output[$invalidMessage] = $invalidMessage;
+ $state = 2;
+ }
+
+ $now = new DateTime();
+ $validFrom = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_from / 1000.0));
+ $validTo = DateTime::createFromFormat('U.u', sprintf('%F', $target->valid_to / 1000.0));
+ $criticalAfter = $this->thresholdToDateTime($validFrom, $validTo, $criticalThreshold);
+ $warningAfter = $this->thresholdToDateTime($validFrom, $validTo, $warningThreshold);
+
+ if ($now > $criticalAfter) {
+ $state = 2;
+ } elseif ($state !== 2 && $now > $warningAfter) {
+ $state = 1;
+ } elseif ($state === 3) {
+ $state = 0;
+ }
+
+ $remainingTime = $now->diff($validTo);
+ if (! $remainingTime->invert) {
+ // The certificate has not expired yet
+ $output[$target->subject] = sprintf(
+ '%s expires in %d days',
+ $target->subject,
+ $remainingTime->days
+ );
+ } else {
+ $output[$target->subject] = sprintf(
+ '%s has expired since %d days',
+ $target->subject,
+ $remainingTime->days
+ );
+ }
+
+ $perfData[$target->subject] = sprintf(
+ "'%s'=%ds;%d:;%d:;0;%d",
+ $target->subject,
+ $remainingTime->invert
+ ? 0
+ : $validTo->getTimestamp() - time(),
+ $validTo->getTimestamp() - $warningAfter->getTimestamp(),
+ $validTo->getTimestamp() - $criticalAfter->getTimestamp(),
+ $validTo->getTimestamp() - $validFrom->getTimestamp()
+ );
+ }
+
+ echo ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN'][$state];
+ echo ' - ';
+
+ if (! empty($output)) {
+ echo join('; ', $output);
+ } elseif ($state === 3) {
+ echo 'Host not found';
+ }
+
+ if (! empty($perfData)) {
+ echo '|' . join(' ', $perfData);
+ }
+
+ echo PHP_EOL;
+ exit($state);
+ }
+
+ /**
+ * Parse the given threshold definition
+ *
+ * @param string $threshold
+ *
+ * @return int|DateInterval
+ */
+ protected function splitThreshold(string $threshold)
+ {
+ $match = preg_match('/(\d+)([%\w]{1})/', $threshold, $matches);
+ if (! $match) {
+ Logger::error('Invalid threshold definition: %s', $threshold);
+ exit(3);
+ }
+
+ switch ($matches[2]) {
+ case '%':
+ return (int) $matches[1];
+ case 'y':
+ case 'Y':
+ $intervalSpec = 'P' . $matches[1] . 'Y';
+ break;
+ case 'M':
+ $intervalSpec = 'P' . $matches[1] . 'M';
+ break;
+ case 'd':
+ case 'D':
+ $intervalSpec = 'P' . $matches[1] . 'D';
+ break;
+ case 'h':
+ case 'H':
+ $intervalSpec = 'PT' . $matches[1] . 'H';
+ break;
+ case 'm':
+ $intervalSpec = 'PT' . $matches[1] . 'M';
+ break;
+ case 's':
+ case 'S':
+ $intervalSpec = 'PT' . $matches[1] . 'S';
+ break;
+ default:
+ Logger::error('Unknown threshold unit given: %s', $threshold);
+ exit(3);
+ }
+
+ return new DateInterval($intervalSpec);
+ }
+
+ /**
+ * Convert the given threshold information to a DateTime object
+ *
+ * @param DateTime $from
+ * @param DateTime $to
+ * @param int|DateInterval $thresholdValue
+ *
+ * @return DateTimeInterface
+ */
+ protected function thresholdToDateTime(DateTime $from, DateTime $to, $thresholdValue): DateTimeInterface
+ {
+ $to = clone $to;
+ if ($thresholdValue instanceof DateInterval) {
+ return $to->sub($thresholdValue);
+ }
+
+ $coveredDays = (int) round($from->diff($to)->days * ($thresholdValue / 100));
+ return $to->sub(new DateInterval('P' . $coveredDays . 'D'));
+ }
+}
diff --git a/application/clicommands/CleanupCommand.php b/application/clicommands/CleanupCommand.php
new file mode 100644
index 0000000..61c43d4
--- /dev/null
+++ b/application/clicommands/CleanupCommand.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateTime;
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use InvalidArgumentException;
+use Throwable;
+
+class CleanupCommand extends Command
+{
+ /**
+ * Remove targets whose last scan is older than a certain date/time and certificates that are no longer used.
+ *
+ * By default, any targets whose last scan is older than 1 month are removed. The last scan information is
+ * always updated when scanning a target, regardless of whether a successful connection is made or not.
+ * Therefore, targets that have been decommissioned or are no longer part of a job configuration are removed
+ * after the specified period. Any certificates that are no longer used are also removed. This can either be
+ * because the associated target has been removed or because it is presenting a new certificate chain.
+ *
+ * This command will also remove jobs activities created before the given date/time. Jobs activities are usually
+ * some stats about the job runs performed by the scheduler or/and manually executed using the `scan` and/or
+ * `jobs` command.
+ *
+ * USAGE
+ *
+ * icingacli x509 cleanup [OPTIONS]
+ *
+ * OPTIONS
+ *
+ * --since-last-scan=<datetime>
+ * Clean up targets whose last scan is older than the specified date/time,
+ * which can also be an English textual datetime description like "2 days".
+ * Defaults to "1 month".
+ *
+ * EXAMPLES
+ *
+ * Remove any targets that have not been scanned for at least two months and any certificates that are no longer
+ * used.
+ *
+ * icingacli x509 cleanup --since-last-scan="2 months"
+ *
+ */
+ public function indexAction()
+ {
+ /** @var string $sinceLastScan */
+ $sinceLastScan = $this->params->get('since-last-scan', '-1 month');
+ $lastScan = $sinceLastScan;
+ if ($lastScan[0] !== '-') {
+ // When the user specified "2 days" as a threshold strtotime() will compute the
+ // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days)
+ $lastScan = "-$lastScan";
+ }
+
+ try {
+ $sinceLastScan = new DateTime($lastScan);
+ } catch (Exception $_) {
+ throw new InvalidArgumentException(sprintf(
+ 'The specified last scan time is in an unknown format: %s',
+ $sinceLastScan
+ ));
+ }
+
+ try {
+ $conn = Database::get();
+ $query = $conn->delete(
+ 'x509_target',
+ ['last_scan < ?' => $sinceLastScan->format('Uv')]
+ );
+
+ if ($query->rowCount() > 0) {
+ Logger::info(
+ 'Removed %d targets matching since last scan filter: %s',
+ $query->rowCount(),
+ $sinceLastScan->format('Y-m-d H:i:s')
+ );
+ }
+
+ $query = $conn->delete('x509_job_run', ['start_time < ?' => $sinceLastScan->getTimestamp() * 1000]);
+ if ($query->rowCount() > 0) {
+ Logger::info('Removed %d jobs activities', $query->rowCount());
+ }
+
+ CertificateUtils::cleanupNoLongerUsedCertificates($conn);
+ } catch (Throwable $err) {
+ Logger::error($err);
+ }
+ }
+}
diff --git a/application/clicommands/ImportCommand.php b/application/clicommands/ImportCommand.php
new file mode 100644
index 0000000..2e7b157
--- /dev/null
+++ b/application/clicommands/ImportCommand.php
@@ -0,0 +1,61 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+
+class ImportCommand extends Command
+{
+ /**
+ * Import all X.509 certificates from the given file and mark them as trusted
+ *
+ * USAGE:
+ *
+ * icingacli x509 import --file <file>
+ *
+ * EXAMPLES:
+ *
+ * icingacli x509 import --file /etc/ssl/certs/ca-bundle.crt
+ */
+ public function indexAction()
+ {
+ $file = $this->params->getRequired('file');
+
+ if (! file_exists($file)) {
+ Logger::warning('The specified certificate file does not exist.');
+ exit(1);
+ }
+
+ $bundle = CertificateUtils::parseBundle($file);
+
+ $count = 0;
+
+ Database::get()->transaction(function (Connection $db) use ($bundle, &$count) {
+ foreach ($bundle as $data) {
+ $cert = openssl_x509_read($data);
+
+ list($id, $_) = CertificateUtils::findOrInsertCert($db, $cert);
+
+ $db->update(
+ 'x509_certificate',
+ [
+ 'trusted' => 'y',
+ 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ],
+ ['id = ?' => $id]
+ );
+
+ $count++;
+ }
+ });
+
+ printf("Processed %d X.509 certificate%s.\n", $count, $count !== 1 ? 's' : '');
+ }
+}
diff --git a/application/clicommands/JobsCommand.php b/application/clicommands/JobsCommand.php
new file mode 100644
index 0000000..27f7202
--- /dev/null
+++ b/application/clicommands/JobsCommand.php
@@ -0,0 +1,279 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateTime;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ResourceFactory;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\JobUtils;
+use Icinga\Module\X509\Hook\SniHook;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\Module\X509\Schedule;
+use InvalidArgumentException;
+use ipl\Orm\Query;
+use ipl\Scheduler\Contract\Frequency;
+use ipl\Scheduler\Scheduler;
+use ipl\Stdlib\Filter;
+use React\EventLoop\Loop;
+use React\Promise\ExtendedPromiseInterface;
+use stdClass;
+use Throwable;
+
+class JobsCommand extends Command
+{
+ use JobUtils;
+
+ /**
+ * Run all configured jobs based on their schedule
+ *
+ * USAGE:
+ *
+ * icingacli x509 jobs run [OPTIONS]
+ *
+ * OPTIONS
+ *
+ * --job=<name>
+ * Run all configured schedules only of the specified job.
+ *
+ * --schedule=<name>
+ * Run only the given schedule of the specified job. Providing a schedule name
+ * without a job will fail immediately.
+ *
+ * --parallel=<number>
+ * Allow parallel scanning of targets up to the specified number. Defaults to 256.
+ * May cause **too many open files** error if set to a number higher than the configured one (ulimit).
+ */
+ public function runAction(): void
+ {
+ $parallel = (int) $this->params->get('parallel', Job::DEFAULT_PARALLEL);
+ if ($parallel <= 0) {
+ $this->fail("The 'parallel' option must be set to at least 1");
+ }
+
+ $jobName = (string) $this->params->get('job');
+ $scheduleName = (string) $this->params->get('schedule');
+ if (! $jobName && $scheduleName) {
+ throw new InvalidArgumentException('You cannot provide a schedule without a job');
+ }
+
+ $scheduler = new Scheduler();
+ $this->attachJobsLogging($scheduler);
+
+ $signalHandler = function () use ($scheduler) {
+ $scheduler->removeTasks();
+
+ Loop::futureTick(function () {
+ Loop::stop();
+ });
+ };
+ Loop::addSignal(SIGINT, $signalHandler);
+ Loop::addSignal(SIGTERM, $signalHandler);
+
+ /** @var Job[] $scheduled Caches scheduled jobs */
+ $scheduled = [];
+ // Periodically check configuration changes to ensure that new jobs are scheduled, jobs are updated,
+ // and deleted jobs are canceled.
+ $watchdog = function () use (&$watchdog, &$scheduled, $scheduler, $parallel, $jobName, $scheduleName) {
+ $jobs = [];
+ try {
+ // Since this is a long-running daemon, the resources or module config may change meanwhile.
+ // Therefore, reload the resources and module config from disk each time (at 5m intervals)
+ // before reconnecting to the database.
+ ResourceFactory::setConfig(Config::app('resources', true));
+ Config::module('x509', 'config', true);
+
+ $jobs = $this->fetchSchedules($jobName, $scheduleName);
+ } catch (Throwable $err) {
+ Logger::error('Failed to fetch job schedules from the database: %s', $err);
+ Logger::debug($err->getTraceAsString());
+ }
+
+ $outdatedJobs = array_diff_key($scheduled, $jobs);
+ foreach ($outdatedJobs as $job) {
+ Logger::info(
+ 'Removing schedule %s of job %s, as it either no longer exists in the configuration or its'
+ . ' config has been changed',
+ $job->getSchedule()->getName(),
+ $job->getName()
+ );
+
+ $scheduler->remove($job);
+
+ unset($scheduled[$job->getUuid()->toString()]);
+ }
+
+ $newJobs = array_diff_key($jobs, $scheduled);
+ foreach ($newJobs as $key => $job) {
+ $job->setParallel($parallel);
+
+ /** @var stdClass $config */
+ $config = $job->getSchedule()->getConfig();
+ try {
+ /** @var Frequency $type */
+ $type = $config->type;
+ $frequency = $type::fromJson($config->frequency);
+ } catch (Throwable $err) {
+ Logger::error(
+ 'Cannot create schedule %s of job %s: %s',
+ $job->getSchedule()->getName(),
+ $job->getName(),
+ $err->getMessage()
+ );
+
+ continue;
+ }
+
+ $scheduler->schedule($job, $frequency);
+
+ $scheduled[$key] = $job;
+ }
+
+ Loop::addTimer(5 * 60, $watchdog);
+ };
+ // Check configuration and add jobs directly after starting the scheduler.
+ Loop::futureTick($watchdog);
+ }
+
+ /**
+ * Fetch job schedules from database
+ *
+ * @param ?string $jobName
+ * @param ?string $scheduleName
+ *
+ * @return Job[]
+ */
+ protected function fetchSchedules(?string $jobName, ?string $scheduleName): array
+ {
+ $conn = Database::get();
+ // Even if the Job class regularly pings the same connection whenever its frequency becomes due and is run by
+ // the scheduler, we need to explicitly ping that same connection here, as the interval of the schedule jobs
+ // could be larger than the daemon configuration reload interval (5m).
+ $conn->ping();
+
+ $jobs = X509Job::on($conn);
+ if ($jobName) {
+ $jobs->filter(Filter::equal('name', $jobName));
+ }
+
+ $jobSchedules = [];
+ $snimap = SniHook::getAll();
+ /** @var X509Job $jobConfig */
+ foreach ($jobs as $jobConfig) {
+ $cidrs = $this->parseCIDRs($jobConfig->cidrs);
+ $ports = $this->parsePorts($jobConfig->ports);
+
+ /** @var Query $schedules */
+ $schedules = $jobConfig->schedule;
+ if ($scheduleName) {
+ $schedules->filter(Filter::equal('name', $scheduleName));
+ }
+
+ $schedules = $schedules->execute();
+ $hasSchedules = $schedules->hasResult();
+
+ /** @var X509Schedule $scheduleModel */
+ foreach ($schedules as $scheduleModel) {
+ $job = (new Job($jobConfig->name, $cidrs, $ports, $snimap, Schedule::fromModel($scheduleModel)))
+ ->setId($jobConfig->id)
+ ->setExcludes($this->parseExcludes($jobConfig->exclude_targets));
+
+ $jobSchedules[$job->getUuid()->toString()] = $job;
+ }
+
+ if (! $hasSchedules) {
+ Logger::info('Skipping job %s because no schedules are configured', $jobConfig->name);
+ }
+ }
+
+ return $jobSchedules;
+ }
+
+ /**
+ * Set up logging of jobs states based on scheduler events
+ *
+ * @param Scheduler $scheduler
+ */
+ protected function attachJobsLogging(Scheduler $scheduler): void
+ {
+ $scheduler->on(Scheduler::ON_TASK_CANCEL, function (Job $task, array $_) {
+ Logger::info('Schedule %s of job %s canceled', $task->getSchedule()->getName(), $task->getName());
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_DONE, function (Job $task, $targets = 0) {
+ if ($targets === 0) {
+ $sinceLastScan = $task->getSinceLastScan();
+ if ($sinceLastScan) {
+ Logger::info(
+ 'Schedule %s of job %s does not have any targets to be rescanned matching since last scan: %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $sinceLastScan->format('Y-m-d H:i:s')
+ );
+ } else {
+ Logger::warning(
+ 'Schedule %s of job %s does not have any targets',
+ $task->getSchedule()->getName(),
+ $task->getName()
+ );
+ }
+ } else {
+ Logger::info(
+ 'Scanned %d target(s) by schedule %s of job %s',
+ $targets,
+ $task->getSchedule()->getName(),
+ $task->getName()
+ );
+
+ try {
+ $verified = CertificateUtils::verifyCertificates(Database::get());
+
+ Logger::info('Checked %d certificate chain(s)', $verified);
+ } catch (Exception $err) {
+ Logger::error($err->getMessage());
+ Logger::debug($err->getTraceAsString());
+ }
+ }
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_FAILED, function (Job $task, Throwable $e) {
+ Logger::error(
+ 'Failed to run schedule %s of job %s: %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $e->getMessage()
+ );
+ Logger::debug($e->getTraceAsString());
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_RUN, function (Job $task, ExtendedPromiseInterface $_) {
+ Logger::info('Running schedule %s of job %s', $task->getSchedule()->getName(), $task->getName());
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_SCHEDULED, function (Job $task, DateTime $dateTime) {
+ Logger::info(
+ 'Scheduling %s of job %s to run at %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $dateTime->format('Y-m-d H:i:s')
+ );
+ });
+
+ $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Job $task, DateTime $dateTime) {
+ Logger::info(
+ 'Detaching expired schedule %s of job %s at %s',
+ $task->getSchedule()->getName(),
+ $task->getName(),
+ $dateTime->format('Y-m-d H:i:s')
+ );
+ });
+ }
+}
diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php
new file mode 100644
index 0000000..cb4e389
--- /dev/null
+++ b/application/clicommands/MigrateCommand.php
@@ -0,0 +1,121 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Clicommands;
+
+use DateTime;
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Job;
+use Icinga\Repository\IniRepository;
+use Icinga\User;
+use Icinga\Util\Json;
+use ipl\Scheduler\Cron;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use stdClass;
+
+use function ipl\Stdlib\get_php_type;
+
+class MigrateCommand extends Command
+{
+ /**
+ * Migrate the jobs config rom INI to the database
+ *
+ * USAGE
+ *
+ * icingacli x509 migrate jobs --author=<name>
+ *
+ * OPTIONS
+ *
+ * --author=<name>
+ * An Icinga Web 2 user used to mark as an author for all the migrated jobs.
+ */
+ public function jobsAction(): void
+ {
+ /** @var string $author */
+ $author = $this->params->getRequired('author');
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ $user->setUsername($author);
+
+ $this->migrateJobs();
+
+ Logger::info('Successfully applied all pending migrations');
+ }
+
+ protected function migrateJobs(): void
+ {
+ $repo = new class () extends IniRepository {
+ /** @var array<string, array<int, string>> */
+ protected $queryColumns = [
+ 'jobs' => ['name', 'cidrs', 'ports', 'exclude_targets', 'schedule', 'frequencyType']
+ ];
+
+ /** @var array<string, array<string, string>> */
+ protected $configs = [
+ 'jobs' => [
+ 'module' => 'x509',
+ 'name' => 'jobs',
+ 'keyColumn' => 'name'
+ ]
+ ];
+ };
+
+ $conn = Database::get();
+ $conn->transaction(function (Connection $conn) use ($repo) {
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ /** @var stdClass $data */
+ foreach ($repo->select() as $data) {
+ $config = [];
+ if (! isset($data->frequencyType) && ! empty($data->schedule)) {
+ $frequency = new Cron($data->schedule);
+ $config = [
+ 'type' => get_php_type($frequency),
+ 'frequency' => Json::encode($frequency)
+ ];
+ } elseif (! empty($data->schedule)) {
+ $config = [
+ 'type' => $data->frequencyType,
+ 'frequency' => $data->schedule // Is already json encoded
+ ];
+ }
+
+ $excludes = $data->exclude_targets;
+ if (empty($excludes)) {
+ $excludes = new Expression('NULL');
+ }
+
+ $conn->insert('x509_job', [
+ 'name' => $data->name,
+ 'author' => $user->getUsername(),
+ 'cidrs' => $data->cidrs,
+ 'ports' => $data->ports,
+ 'exclude_targets' => $excludes,
+ 'ctime' => (new DateTime())->getTimestamp() * 1000,
+ 'mtime' => (new DateTime())->getTimestamp() * 1000
+ ]);
+
+ $jobId = (int) $conn->lastInsertId();
+ if (! empty($config)) {
+ $config['rescan'] = 'n';
+ $config['full_scan'] = 'n';
+ $config['since_last_scan'] = Job::DEFAULT_SINCE_LAST_SCAN;
+
+ $conn->insert('x509_schedule', [
+ 'job_id' => $jobId,
+ 'name' => $data->name . ' Schedule',
+ 'author' => $user->getUsername(),
+ 'config' => Json::encode($config),
+ 'ctime' => (new DateTime())->getTimestamp() * 1000,
+ 'mtime' => (new DateTime())->getTimestamp() * 1000,
+ ]);
+ }
+ }
+ });
+ }
+}
diff --git a/application/clicommands/ScanCommand.php b/application/clicommands/ScanCommand.php
new file mode 100644
index 0000000..3743adc
--- /dev/null
+++ b/application/clicommands/ScanCommand.php
@@ -0,0 +1,163 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\JobUtils;
+use Icinga\Module\X509\Hook\SniHook;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509Job;
+use ipl\Stdlib\Filter;
+use React\EventLoop\Loop;
+use Throwable;
+
+class ScanCommand extends Command
+{
+ use JobUtils;
+
+ /**
+ * Scan targets to find their X.509 certificates and track changes to them.
+ *
+ * A target is an IP-port combination that is generated from the job configuration, taking into account
+ * configured SNI maps, so that targets with multiple certificates are also properly scanned.
+ *
+ * By default, successive calls to the scan command perform partial scans, checking both targets not yet scanned
+ * and targets whose scan is older than 24 hours, to ensure that all targets are rescanned over time and new
+ * certificates are collected. This behavior can be customized through the command options.
+ *
+ * Note that when rescanning due targets, they will be rescanned regardless of whether the target previously
+ * provided a certificate or not, to collect new certificates, track changed certificates, and remove
+ * decommissioned certificates.
+ *
+ * USAGE
+ *
+ * icingacli x509 scan --job <name> [OPTIONS]
+ *
+ * OPTIONS
+ *
+ * --job=<name>
+ * Scan targets that belong to the specified job.
+ *
+ * --since-last-scan=<datetime>
+ * Scan targets whose last scan is older than the specified date/time,
+ * which can also be an English textual datetime description like "2 days".
+ * Defaults to "-24 hours".
+ *
+ * --parallel=<number>
+ * Allow parallel scanning of targets up to the specified number. Defaults to 256.
+ * May cause **too many open files** error if set to a number higher than the configured one (ulimit).
+ *
+ * --rescan
+ * Rescan only targets that have been scanned before.
+ *
+ * --full
+ * (Re)scan all known and unknown targets.
+ * This will override the "rescan" and "since-last-scan" options.
+ *
+ * EXAMPLES
+ *
+ * Scan all targets that have not yet been scanned, or whose last scan is older than a certain date/time:
+ *
+ * icingacli x509 scan --job <name> --since-last-scan="3 days"
+ *
+ * Scan only unknown targets
+ *
+ * icingacli x509 scan --job <name> --since-last-scan=null
+ *
+ * Scan only known targets
+ *
+ * icingacli x509 scan --job <name> --rescan
+ *
+ * Scan only known targets whose last scan is older than a certain date/time:
+ *
+ * icingacli x509 scan --job <name> --rescan --since-last-scan="5 days"
+ *
+ * Scan all known and unknown targets:
+ *
+ * icingacli x509 scan --job <name> --full
+ */
+ public function indexAction(): void
+ {
+ /** @var string $name */
+ $name = $this->params->shiftRequired('job');
+ $fullScan = (bool) $this->params->get('full', false);
+ $rescan = (bool) $this->params->get('rescan', false);
+
+ /** @var string $sinceLastScan */
+ $sinceLastScan = $this->params->get('since-last-scan', Job::DEFAULT_SINCE_LAST_SCAN);
+ if ($sinceLastScan === 'null') {
+ $sinceLastScan = null;
+ }
+
+ /** @var int $parallel */
+ $parallel = $this->params->get('parallel', Job::DEFAULT_PARALLEL);
+ if ($parallel <= 0) {
+ throw new Exception('The \'parallel\' option must be set to at least 1');
+ }
+
+ /** @var X509Job $jobConfig */
+ $jobConfig = X509Job::on(Database::get())
+ ->filter(Filter::equal('name', $name))
+ ->first();
+ if ($jobConfig === null) {
+ throw new Exception(sprintf('Job %s not found', $name));
+ }
+
+ if (! strlen($jobConfig->cidrs)) {
+ throw new Exception(sprintf('The job %s does not specify any CIDRs', $name));
+ }
+
+ $cidrs = $this->parseCIDRs($jobConfig->cidrs);
+ $ports = $this->parsePorts($jobConfig->ports);
+ $job = (new Job($name, $cidrs, $ports, SniHook::getAll()))
+ ->setId($jobConfig->id)
+ ->setFullScan($fullScan)
+ ->setRescan($rescan)
+ ->setParallel($parallel)
+ ->setExcludes($this->parseExcludes($jobConfig->exclude_targets))
+ ->setLastScan($sinceLastScan);
+
+ $promise = $job->run();
+ $signalHandler = function () use (&$promise, $job) {
+ $promise->cancel();
+
+ Logger::info('Job %s canceled', $job->getName());
+
+ Loop::futureTick(function () {
+ Loop::stop();
+ });
+ };
+ Loop::addSignal(SIGINT, $signalHandler);
+ Loop::addSignal(SIGTERM, $signalHandler);
+
+ $promise->then(function ($targets = 0) use ($job) {
+ if ($targets === 0) {
+ Logger::warning('The job %s does not have any targets', $job->getName());
+ } else {
+ Logger::info('Scanned %d target(s) from job %s', $targets, $job->getName());
+
+ try {
+ $verified = CertificateUtils::verifyCertificates(Database::get());
+
+ Logger::info('Checked %d certificate chain(s)', $verified);
+ } catch (Exception $err) {
+ Logger::error($err->getMessage());
+ Logger::debug($err->getTraceAsString());
+ }
+ }
+ }, function (Throwable $err) use ($job) {
+ Logger::error('Failed to run job %s: %s', $job->getName(), $err->getMessage());
+ Logger::debug($err->getTraceAsString());
+ })->always(function () {
+ Loop::futureTick(function () {
+ Loop::stop();
+ });
+ });
+ }
+}
diff --git a/application/clicommands/VerifyCommand.php b/application/clicommands/VerifyCommand.php
new file mode 100644
index 0000000..15976fc
--- /dev/null
+++ b/application/clicommands/VerifyCommand.php
@@ -0,0 +1,27 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Clicommands;
+
+use Icinga\Application\Logger;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Command;
+use Icinga\Module\X509\Common\Database;
+
+class VerifyCommand extends Command
+{
+ /**
+ * Verify all currently collected X.509 certificates
+ *
+ * USAGE:
+ *
+ * icingacli x509 verify
+ */
+ public function indexAction()
+ {
+ $verified = CertificateUtils::verifyCertificates(Database::get());
+
+ Logger::info("Checked %d certificate chain%s.", $verified, $verified !== 1 ? 's' : '');
+ }
+}
diff --git a/application/controllers/CertificateController.php b/application/controllers/CertificateController.php
new file mode 100644
index 0000000..016b312
--- /dev/null
+++ b/application/controllers/CertificateController.php
@@ -0,0 +1,43 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\X509\CertificateDetails;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use ipl\Stdlib\Filter;
+
+class CertificateController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('X.509 Certificate'));
+ $this->getTabs()->disableLegacyExtensions();
+
+ $certId = $this->params->getRequired('cert');
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+
+ return;
+ }
+
+ /** @var ?X509Certificate $cert */
+ $cert = X509Certificate::on($conn)
+ ->filter(Filter::equal('id', $certId))
+ ->first();
+
+ if (! $cert) {
+ $this->httpNotFound($this->translate('Certificate not found.'));
+ }
+
+ $this->view->certificateDetails = (new CertificateDetails())
+ ->setCert($cert);
+ }
+}
diff --git a/application/controllers/CertificatesController.php b/application/controllers/CertificatesController.php
new file mode 100644
index 0000000..37434fa
--- /dev/null
+++ b/application/controllers/CertificatesController.php
@@ -0,0 +1,117 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\X509\CertificatesTable;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Orm\Query;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class CertificatesController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Certificates'));
+ $this->getTabs()->enableDataExports();
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+
+ return;
+ }
+
+ $certificates = X509Certificate::on($conn);
+
+ $sortColumns = [
+ 'subject' => $this->translate('Certificate'),
+ 'issuer' => $this->translate('Issuer'),
+ 'version' => $this->translate('Version'),
+ 'self_signed' => $this->translate('Is Self-Signed'),
+ 'ca' => $this->translate('Is Certificate Authority'),
+ 'trusted' => $this->translate('Is Trusted'),
+ 'pubkey_algo' => $this->translate('Public Key Algorithm'),
+ 'pubkey_bits' => $this->translate('Public Key Strength'),
+ 'signature_algo' => $this->translate('Signature Algorithm'),
+ 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'),
+ 'valid_from' => $this->translate('Valid From'),
+ 'valid_to' => $this->translate('Valid To'),
+ 'duration' => $this->translate('Duration')
+ ];
+
+ $limitControl = $this->createLimitControl();
+ $paginator = $this->createPaginationControl($certificates);
+ $sortControl = $this->createSortControl($certificates, $sortColumns);
+
+ $searchBar = $this->createSearchBar($certificates, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $certificates->peekAhead($this->view->compact);
+
+ $certificates->filter($filter);
+
+ $this->addControl($paginator);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->handleFormatRequest($certificates, function (Query $certificates) {
+ /** @var X509Certificate $cert */
+ foreach ($certificates as $cert) {
+ $cert->valid_from = $cert->valid_from->format('l F jS, Y H:i:s e');
+ $cert->valid_to = $cert->valid_to->format('l F jS, Y H:i:s e');
+
+ yield array_intersect_key(iterator_to_array($cert), array_flip($cert->getExportableColumns()));
+ }
+ });
+
+ $this->addContent((new CertificatesTable())->setData($certificates));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate(); // Updates the browser search bar
+ }
+ }
+
+ public function completeAction()
+ {
+ $this->getDocument()->add(
+ (new ObjectSuggestions())
+ ->setModel(X509Certificate::class)
+ ->forRequest($this->getServerRequest())
+ );
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ChainController.php b/application/controllers/ChainController.php
new file mode 100644
index 0000000..5408526
--- /dev/null
+++ b/application/controllers/ChainController.php
@@ -0,0 +1,77 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\X509\ChainDetails;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateChain;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Stdlib\Filter;
+
+class ChainController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('X.509 Certificate Chain'));
+ $this->getTabs()->disableLegacyExtensions();
+
+ $id = $this->params->getRequired('id');
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+ return;
+ }
+
+ /** @var ?X509CertificateChain $chain */
+ $chain = X509CertificateChain::on($conn)
+ ->with(['target'])
+ ->filter(Filter::equal('id', $id))
+ ->first();
+
+ if (! $chain) {
+ $this->httpNotFound($this->translate('Certificate not found.'));
+ }
+
+ $chainInfo = Html::tag('div');
+ $chainInfo->add(Html::tag('dl', [
+ Html::tag('dt', $this->translate('Host')),
+ Html::tag('dd', $chain->target->hostname),
+ Html::tag('dt', $this->translate('IP')),
+ Html::tag('dd', $chain->target->ip),
+ Html::tag('dt', $this->translate('Port')),
+ Html::tag('dd', $chain->target->port)
+ ]));
+
+ $valid = Html::tag('div', ['class' => 'cert-chain']);
+
+ if ($chain['valid']) {
+ $valid->getAttributes()->add('class', '-valid');
+ $valid->add(Html::tag('p', $this->translate('Certificate chain is valid.')));
+ } else {
+ $valid->getAttributes()->add('class', '-invalid');
+ $valid->add(Html::tag('p', sprintf(
+ $this->translate('Certificate chain is invalid: %s.'),
+ $chain['invalid_reason']
+ )));
+ }
+
+ $certs = X509Certificate::on($conn)->with(['chain']);
+ $certs
+ ->filter(Filter::equal('chain.id', $id))
+ ->getSelectBase()
+ ->orderBy('certificate_link.order');
+
+ $this->view->chain = (new HtmlDocument())
+ ->add($chainInfo)
+ ->add($valid)
+ ->add((new ChainDetails())->setData($certs));
+ }
+}
diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
new file mode 100644
index 0000000..b4300ef
--- /dev/null
+++ b/application/controllers/ConfigController.php
@@ -0,0 +1,30 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Application\Config;
+use Icinga\Module\X509\Forms\Config\BackendConfigForm;
+use Icinga\Web\Controller;
+
+class ConfigController extends Controller
+{
+ public function init()
+ {
+ $this->assertPermission('config/modules');
+
+ parent::init();
+ }
+
+ public function backendAction()
+ {
+ $form = (new BackendConfigForm())
+ ->setIniConfig(Config::module('x509'));
+
+ $form->handleRequest();
+
+ $this->view->tabs = $this->Module()->getConfigTabs()->activate('backend');
+ $this->view->form = $form;
+ }
+}
diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php
new file mode 100644
index 0000000..8b43761
--- /dev/null
+++ b/application/controllers/DashboardController.php
@@ -0,0 +1,153 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\X509\CertificateUtils;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Donut;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+
+class DashboardController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Certificate Dashboard'));
+ $this->getTabs()->disableLegacyExtensions();
+
+ try {
+ $db = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+ return;
+ }
+
+ $byCa = X509Certificate::on($db)
+ ->columns([
+ 'issuer_certificate.subject',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->orderBy('cnt', SORT_DESC)
+ ->orderBy('issuer_certificate.subject')
+ ->filter(Filter::equal('issuer_certificate.ca', true))
+ ->limit(5);
+
+ $byCa
+ ->getSelectBase()
+ ->groupBy('certificate_issuer_certificate.id');
+
+ $this->view->byCa = (new Donut())
+ ->setHeading($this->translate('Certificates by CA'), 2)
+ ->setData($byCa)
+ ->setLabelCallback(function ($data) {
+ return Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('x509/certificates', [
+ 'issuer' => $data->issuer_certificate->subject
+ ])->getAbsoluteUrl()
+ ],
+ $data->issuer_certificate->subject
+ );
+ });
+
+ $duration = X509Certificate::on($db)
+ ->columns([
+ 'duration',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->filter(Filter::equal('ca', false))
+ ->orderBy('cnt', SORT_DESC)
+ ->limit(5);
+
+ $duration
+ ->getSelectBase()
+ ->groupBy('duration');
+
+ $this->view->duration = (new Donut())
+ ->setHeading($this->translate('Certificates by Duration'), 2)
+ ->setData($duration)
+ ->setLabelCallback(function ($data) {
+ return Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath(
+ "x509/certificates?duration={$data->duration->getTimestamp()}&ca=n"
+ )->getAbsoluteUrl()
+ ],
+ CertificateUtils::duration($data->duration->getTimestamp())
+ );
+ });
+
+ $keyStrength = X509Certificate::on($db)
+ ->columns([
+ 'pubkey_algo',
+ 'pubkey_bits',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->orderBy('cnt', SORT_DESC)
+ ->limit(5);
+
+ $keyStrength
+ ->getSelectBase()
+ ->groupBy(['pubkey_algo', 'pubkey_bits']);
+
+ $this->view->keyStrength = (new Donut())
+ ->setHeading($this->translate('Key Strength'), 2)
+ ->setData($keyStrength)
+ ->setLabelCallback(function ($data) {
+ return Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath(
+ 'x509/certificates',
+ [
+ 'pubkey_algo' => $data->pubkey_algo,
+ 'pubkey_bits' => $data->pubkey_bits
+ ]
+ )->getAbsoluteUrl()
+ ],
+ "{$data->pubkey_algo} {$data->pubkey_bits} bits"
+ );
+ });
+
+ $sigAlgos = X509Certificate::on($db)
+ ->columns([
+ 'signature_algo',
+ 'signature_hash_algo',
+ 'cnt' => new Expression('COUNT(*)')
+ ])
+ ->orderBy('cnt', SORT_DESC)
+ ->limit(5);
+
+ $sigAlgos
+ ->getSelectBase()
+ ->groupBy(['signature_algo', 'signature_hash_algo']);
+
+ $this->view->sigAlgos = (new Donut())
+ ->setHeading($this->translate('Signature Algorithms'), 2)
+ ->setData($sigAlgos)
+ ->setLabelCallback(function ($data) {
+ return Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath(
+ 'x509/certificates',
+ [
+ 'signature_hash_algo' => $data->signature_hash_algo,
+ 'signature_algo' => $data->signature_algo
+ ]
+ )->getAbsoluteUrl()
+ ],
+ "{$data->signature_hash_algo} with {$data->signature_algo}"
+ );
+ });
+ }
+}
diff --git a/application/controllers/JobController.php b/application/controllers/JobController.php
new file mode 100644
index 0000000..7655a74
--- /dev/null
+++ b/application/controllers/JobController.php
@@ -0,0 +1,226 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\Links;
+use Icinga\Module\X509\Forms\Jobs\JobConfigForm;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\Module\X509\Forms\Jobs\ScheduleForm;
+use Icinga\Module\X509\Widget\JobDetails;
+use Icinga\Module\X509\Widget\Schedules;
+use Icinga\Util\Json;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\ValidHtml;
+use ipl\Scheduler\Contract\Frequency;
+use ipl\Stdlib\Filter;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+use ipl\Web\Widget\ActionBar;
+use ipl\Web\Widget\ActionLink;
+use ipl\Web\Widget\ButtonLink;
+use stdClass;
+
+class JobController extends CompatController
+{
+ /** @var X509Job */
+ protected $job;
+
+ public function init()
+ {
+ parent::init();
+
+ $this->getTabs()->disableLegacyExtensions();
+
+ /** @var int $jobId */
+ $jobId = $this->params->getRequired('id');
+
+ /** @var X509Job $job */
+ $job = X509Job::on(Database::get())
+ ->filter(Filter::equal('id', $jobId))
+ ->first();
+
+ if ($job === null) {
+ $this->httpNotFound($this->translate('Job not found'));
+ }
+
+ $this->job = $job;
+ }
+
+ public function indexAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->initTabs();
+ $this->getTabs()->activate('job-activities');
+
+ $jobRuns = $this->job->job_run->with(['job', 'schedule']);
+
+ $limitControl = $this->createLimitControl();
+ $sortControl = $this->createSortControl($jobRuns, [
+ 'schedule.name' => $this->translate('Schedule Name'),
+ 'schedule.author' => $this->translate('Author'),
+ 'total_targets' => $this->translate('Total Targets'),
+ 'finished_targets' => $this->translate('Finished Targets'),
+ 'start_time desc' => $this->translate('Started At'),
+ 'end_time' => $this->translate('Ended At')
+ ]);
+
+ $this->controls->getAttributes()->add('class', 'default-layout');
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($this->createActionBar());
+
+ $this->addContent(new JobDetails($jobRuns));
+ }
+
+ public function updateAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('Update Job'));
+
+ $form = (new JobConfigForm($this->job))
+ ->setAction((string) Url::fromRequest())
+ ->populate([
+ 'name' => $this->job->name,
+ 'cidrs' => $this->job->cidrs,
+ 'ports' => $this->job->ports,
+ 'exclude_targets' => $this->job->exclude_targets
+ ])
+ ->on(JobConfigForm::ON_SUCCESS, function (JobConfigForm $form) {
+ /** @var FormSubmitElement $button */
+ $button = $form->getPressedSubmitElement();
+ if ($button->getName() === 'btn_remove') {
+ $this->switchToSingleColumnLayout();
+ } else {
+ $this->closeModalAndRefreshRelatedView(Links::job($this->job));
+ }
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+
+ public function schedulesAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->initTabs();
+ $this->getTabs()->activate('schedules');
+
+ $schedules = $this->job->schedule->with(['job']);
+
+ $sortControl = $this->createSortControl($schedules, [
+ 'name' => $this->translate('Name'),
+ 'author' => $this->translate('Author'),
+ 'ctime' => $this->translate('Date Created'),
+ 'mtime' => $this->translate('Date Modified')
+ ]);
+
+ $this->controls->getAttributes()->add('class', 'default-layout');
+ $this->addControl(
+ (new ButtonLink($this->translate('New Schedule'), Links::scheduleJob($this->job), 'plus'))
+ ->openInModal()
+ );
+ $this->addControl($sortControl);
+
+ $this->addContent(new Schedules($schedules));
+ }
+
+ public function scheduleAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('Schedule Job'));
+
+ $form = (new ScheduleForm())
+ ->setAction((string) Url::fromRequest())
+ ->setJobId($this->job->id)
+ ->on(JobConfigForm::ON_SUCCESS, function () {
+ $this->redirectNow(Links::schedules($this->job));
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $parts = $form->getPartUpdates();
+ if (! empty($parts)) {
+ $this->sendMultipartUpdate(...$parts);
+ }
+
+ $this->addContent($form);
+ }
+
+ public function updateScheduleAction(): void
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('Update Schedule'));
+
+ /** @var int $id */
+ $id = $this->params->getRequired('scheduleId');
+ /** @var X509Schedule $schedule */
+ $schedule = X509Schedule::on(Database::get())
+ ->filter(Filter::equal('id', $id))
+ ->first();
+ if ($schedule === null) {
+ $this->httpNotFound($this->translate('Schedule not found'));
+ }
+
+ /** @var stdClass $config */
+ $config = Json::decode($schedule->config);
+ /** @var Frequency $type */
+ $type = $config->type;
+ $frequency = $type::fromJson($config->frequency);
+
+ $form = (new ScheduleForm($schedule))
+ ->setAction((string) Url::fromRequest())
+ ->populate([
+ 'name' => $schedule->name,
+ 'full_scan' => $config->full_scan ?? 'n',
+ 'rescan' => $config->rescan ?? 'n',
+ 'since_last_scan' => $config->since_last_scan ?? null,
+ 'schedule_element' => $frequency
+ ])
+ ->on(JobConfigForm::ON_SUCCESS, function () {
+ $this->redirectNow('__BACK__');
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $parts = $form->getPartUpdates();
+ if (! empty($parts)) {
+ $this->sendMultipartUpdate(...$parts);
+ }
+
+ $this->addContent($form);
+ }
+
+ protected function createActionBar(): ValidHtml
+ {
+ $actions = new ActionBar();
+ $actions->addHtml(
+ (new ActionLink($this->translate('Modify'), Links::updateJob($this->job), 'edit'))
+ ->openInModal(),
+ (new ActionLink($this->translate('Schedule'), Links::scheduleJob($this->job), 'calendar'))
+ ->openInModal()
+ );
+
+ return $actions;
+ }
+
+ protected function initTabs(): void
+ {
+ $tabs = $this->getTabs();
+ $tabs
+ ->add('job-activities', [
+ 'label' => $this->translate('Job Activities'),
+ 'url' => Links::job($this->job)
+ ])
+ ->add('schedules', [
+ 'label' => $this->translate('Schedules'),
+ 'url' => Links::schedules($this->job)
+ ]);
+ }
+}
diff --git a/application/controllers/JobsController.php b/application/controllers/JobsController.php
new file mode 100644
index 0000000..48deede
--- /dev/null
+++ b/application/controllers/JobsController.php
@@ -0,0 +1,66 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Forms\Jobs\JobConfigForm;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Widget\Jobs;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+use ipl\Web\Widget\ButtonLink;
+
+class JobsController extends CompatController
+{
+ /**
+ * List all jobs
+ */
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Jobs'));
+ $this->getTabs()->add('sni', [
+ 'title' => $this->translate('Configure SNI'),
+ 'label' => $this->translate('SNI'),
+ 'url' => 'x509/sni',
+ 'baseTarget' => '_main'
+ ]);
+
+ $jobs = X509Job::on(Database::get());
+ if ($this->hasPermission('config/x509')) {
+ $this->addControl(
+ (new ButtonLink($this->translate('New Job'), Url::fromPath('x509/jobs/new'), 'plus'))
+ ->openInModal()
+ );
+ }
+
+ $sortControl = $this->createSortControl($jobs, [
+ 'name' => $this->translate('Name'),
+ 'author' => $this->translate('Author'),
+ 'ctime' => $this->translate('Date Created'),
+ 'mtime' => $this->translate('Date Modified')
+ ]);
+
+ $this->controls->getAttributes()->add('class', 'default-layout');
+ $this->addControl($sortControl);
+
+ $this->addContent(new Jobs($jobs));
+ }
+
+ public function newAction()
+ {
+ $this->assertPermission('config/x509');
+
+ $this->addTitleTab($this->translate('New Job'));
+
+ $form = (new JobConfigForm())
+ ->setAction((string) Url::fromRequest())
+ ->on(JobConfigForm::ON_SUCCESS, function () {
+ $this->closeModalAndRefreshRelatedView(Url::fromPath('x509/jobs'));
+ })
+ ->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+}
diff --git a/application/controllers/SniController.php b/application/controllers/SniController.php
new file mode 100644
index 0000000..cde4807
--- /dev/null
+++ b/application/controllers/SniController.php
@@ -0,0 +1,103 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\X509\Forms\Config\SniConfigForm;
+use Icinga\Module\X509\SniIniRepository;
+use ipl\Html\HtmlString;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Url;
+use ipl\Web\Widget\ButtonLink;
+
+class SniController extends CompatController
+{
+ /**
+ * List all maps
+ */
+ public function indexAction()
+ {
+ $this->getTabs()->add('jobs', [
+ 'title' => $this->translate('Configure Jobs'),
+ 'label' => $this->translate('Jobs'),
+ 'url' => 'x509/jobs',
+ 'baseTarget' => '_main'
+
+ ]);
+ $this->addTitleTab($this->translate('SNI'));
+
+ $this->addControl(
+ (new ButtonLink($this->translate('New SNI Map'), Url::fromPath('x509/sni/new'), 'plus'))
+ ->openInModal()
+ );
+ $this->controls->getAttributes()->add('class', 'default-layout');
+
+ $this->view->controls = $this->controls;
+
+ $repo = new SniIniRepository();
+
+ $this->view->sni = $repo->select(array('ip'));
+ }
+
+ /**
+ * Create a map
+ */
+ public function newAction()
+ {
+ $this->addTitleTab($this->translate('New SNI Map'));
+
+ $form = $this->prepareForm()->add();
+
+ $form->handleRequest();
+
+ $this->addContent(new HtmlString($form->render()));
+ }
+
+ /**
+ * Update a map
+ */
+ public function updateAction()
+ {
+ $form = $this->prepareForm()->edit($this->params->getRequired('ip'));
+
+ try {
+ $form->handleRequest();
+ } catch (NotFoundError $_) {
+ $this->httpNotFound($this->translate('IP not found'));
+ }
+
+ $this->renderForm($form, $this->translate('Update SNI Map'));
+ }
+
+ /**
+ * Remove a map
+ */
+ public function removeAction()
+ {
+ $form = $this->prepareForm()->remove($this->params->getRequired('ip'));
+
+ try {
+ $form->handleRequest();
+ } catch (NotFoundError $_) {
+ $this->httpNotFound($this->translate('IP not found'));
+ }
+
+ $this->renderForm($form, $this->translate('Remove SNI Map'));
+ }
+
+ /**
+ * Assert config permission and return a prepared RepositoryForm
+ *
+ * @return SniConfigForm
+ */
+ protected function prepareForm()
+ {
+ $this->assertPermission('config/x509');
+
+ return (new SniConfigForm())
+ ->setRepository(new SniIniRepository())
+ ->setRedirectUrl(Url::fromPath('x509/sni'));
+ }
+}
diff --git a/application/controllers/UsageController.php b/application/controllers/UsageController.php
new file mode 100644
index 0000000..079d24a
--- /dev/null
+++ b/application/controllers/UsageController.php
@@ -0,0 +1,141 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Controllers;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Controller;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\UsageTable;
+use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Orm\Query;
+use ipl\Sql\Expression;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class UsageController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab($this->translate('Certificate Usage'));
+ $this->getTabs()->enableDataExports();
+
+ try {
+ $conn = Database::get();
+ } catch (ConfigurationError $_) {
+ $this->render('missing-resource', null, true);
+ return;
+ }
+
+ $targets = X509Certificate::on($conn)
+ ->with(['chain', 'chain.target'])
+ ->withColumns([
+ 'chain.id',
+ 'chain.valid',
+ 'chain.target.ip',
+ 'chain.target.port',
+ 'chain.target.hostname',
+ ]);
+
+ $targets
+ ->getSelectBase()
+ ->where(new Expression('certificate_link.order = 0'));
+
+ $sortColumns = [
+ 'chain.target.hostname' => $this->translate('Hostname'),
+ 'chain.target.ip' => $this->translate('IP'),
+ 'chain.target.port' => $this->translate('Port'),
+ 'subject' => $this->translate('Certificate'),
+ 'issuer' => $this->translate('Issuer'),
+ 'version' => $this->translate('Version'),
+ 'self_signed' => $this->translate('Is Self-Signed'),
+ 'ca' => $this->translate('Is Certificate Authority'),
+ 'trusted' => $this->translate('Is Trusted'),
+ 'pubkey_algo' => $this->translate('Public Key Algorithm'),
+ 'pubkey_bits' => $this->translate('Public Key Strength'),
+ 'signature_algo' => $this->translate('Signature Algorithm'),
+ 'signature_hash_algo' => $this->translate('Signature Hash Algorithm'),
+ 'valid_from' => $this->translate('Valid From'),
+ 'valid_to' => $this->translate('Valid To'),
+ 'chain.valid' => $this->translate('Chain Is Valid'),
+ 'duration' => $this->translate('Duration')
+ ];
+
+ $limitControl = $this->createLimitControl();
+ $paginator = $this->createPaginationControl($targets);
+ $sortControl = $this->createSortControl($targets, $sortColumns);
+
+ $searchBar = $this->createSearchBar($targets, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $targets->peekAhead($this->view->compact);
+
+ $targets->filter($filter);
+
+ $this->addControl($paginator);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->handleFormatRequest($targets, function (Query $targets) {
+ /** @var X509Certificate $usage */
+ foreach ($targets as $usage) {
+ $usage->valid_from = $usage->valid_from->format('l F jS, Y H:i:s e');
+ $usage->valid_to = $usage->valid_to->format('l F jS, Y H:i:s e');
+
+ $usage->ip = $usage->chain->target->ip;
+ $usage->hostname = $usage->chain->target->hostname;
+ $usage->port = $usage->chain->target->port;
+ $usage->valid = $usage->chain->valid;
+
+ yield array_intersect_key(
+ iterator_to_array($usage),
+ array_flip(array_merge(['valid', 'hostname', 'ip', 'port'], $usage->getExportableColumns()))
+ );
+ }
+ });
+
+ $this->addContent((new UsageTable())->setData($targets));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate(); // Updates the browser search bar
+ }
+ }
+
+ public function completeAction()
+ {
+ $this->getDocument()->add(
+ (new ObjectSuggestions())
+ ->setModel(X509Certificate::class)
+ ->forRequest($this->getServerRequest())
+ );
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(X509Certificate::on(Database::get()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/forms/Config/BackendConfigForm.php b/application/forms/Config/BackendConfigForm.php
new file mode 100644
index 0000000..e806d26
--- /dev/null
+++ b/application/forms/Config/BackendConfigForm.php
@@ -0,0 +1,29 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Forms\Config;
+
+use Icinga\Data\ResourceFactory;
+use Icinga\Forms\ConfigForm;
+
+class BackendConfigForm extends ConfigForm
+{
+ public function init()
+ {
+ $this->setName('x509_backend');
+ $this->setSubmitLabel($this->translate('Save Changes'));
+ }
+
+ public function createElements(array $formData)
+ {
+ $dbResources = ResourceFactory::getResourceConfigs('db')->keys();
+
+ $this->addElement('select', 'backend_resource', [
+ 'label' => $this->translate('Database'),
+ 'description' => $this->translate('Database resource'),
+ 'multiOptions' => array_combine($dbResources, $dbResources),
+ 'required' => true
+ ]);
+ }
+}
diff --git a/application/forms/Config/SniConfigForm.php b/application/forms/Config/SniConfigForm.php
new file mode 100644
index 0000000..27a4823
--- /dev/null
+++ b/application/forms/Config/SniConfigForm.php
@@ -0,0 +1,79 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Forms\Config;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Forms\RepositoryForm;
+
+/**
+ * Create, update and delete jobs
+ */
+class SniConfigForm extends RepositoryForm
+{
+ protected function createInsertElements(array $formData)
+ {
+ $this->addElements([
+ [
+ 'text',
+ 'ip',
+ [
+ 'description' => $this->translate('IP'),
+ 'label' => $this->translate('IP'),
+ 'required' => true
+ ]
+ ],
+ [
+ 'textarea',
+ 'hostnames',
+ [
+ 'description' => $this->translate('Comma-separated list of hostnames'),
+ 'label' => $this->translate('Hostnames'),
+ 'required' => true
+ ]
+ ]
+ ]);
+
+ $this->setSubmitLabel($this->translate('Create'));
+ }
+
+ protected function createUpdateElements(array $formData)
+ {
+ $this->createInsertElements($formData);
+ $this->setTitle(sprintf($this->translate('Edit map for %s'), $this->getIdentifier()));
+ $this->setSubmitLabel($this->translate('Save'));
+ }
+
+ protected function createDeleteElements(array $formData)
+ {
+ $this->setTitle(sprintf($this->translate('Remove map for %s?'), $this->getIdentifier()));
+ $this->setSubmitLabel($this->translate('Yes'));
+ }
+
+ protected function createFilter()
+ {
+ return Filter::where('ip', $this->getIdentifier());
+ }
+
+ protected function getInsertMessage($success)
+ {
+ return $success
+ ? $this->translate('Map created')
+ : $this->translate('Failed to create map');
+ }
+
+ protected function getUpdateMessage($success)
+ {
+ return $success
+ ? $this->translate('Map updated')
+ : $this->translate('Failed to update map');
+ }
+
+ protected function getDeleteMessage($success)
+ {
+ return $success
+ ? $this->translate('Map removed')
+ : $this->translate('Failed to remove map');
+ }
+}
diff --git a/application/forms/Jobs/JobConfigForm.php b/application/forms/Jobs/JobConfigForm.php
new file mode 100644
index 0000000..539bc58
--- /dev/null
+++ b/application/forms/Jobs/JobConfigForm.php
@@ -0,0 +1,154 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Forms\Jobs;
+
+use DateTime;
+use Exception;
+use Icinga\Authentication\Auth;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\User;
+use Icinga\Web\Notification;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Stdlib\Str;
+use ipl\Validator\CallbackValidator;
+use ipl\Validator\CidrValidator;
+use ipl\Web\Compat\CompatForm;
+
+/**
+ * Create, update and delete jobs
+ */
+class JobConfigForm extends CompatForm
+{
+ /** @var ?X509Job */
+ protected $job;
+
+ public function __construct(X509Job $job = null)
+ {
+ $this->job = $job;
+ }
+
+ protected function isUpdating(): bool
+ {
+ return $this->job !== null;
+ }
+
+ public function hasBeenSubmitted(): bool
+ {
+ if (! $this->hasBeenSent()) {
+ return false;
+ }
+
+ $button = $this->getPressedSubmitElement();
+
+ return $button && ($button->getName() === 'btn_submit' || $button->getName() === 'btn_remove');
+ }
+
+ protected function assemble(): void
+ {
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'label' => $this->translate('Name'),
+ 'description' => $this->translate('Job name'),
+ ]);
+
+ $this->addElement('textarea', 'cidrs', [
+ 'required' => true,
+ 'label' => $this->translate('CIDRs'),
+ 'description' => $this->translate('Comma-separated list of CIDR addresses to scan'),
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator): bool {
+ $cidrValidator = new CidrValidator();
+ $cidrs = Str::trimSplit($value);
+
+ foreach ($cidrs as $cidr) {
+ if (! $cidrValidator->isValid($cidr)) {
+ $validator->addMessage(...$cidrValidator->getMessages());
+
+ return false;
+ }
+ }
+
+ return true;
+ })
+ ]
+ ]);
+
+ $this->addElement('textarea', 'ports', [
+ 'required' => true,
+ 'label' => $this->translate('Ports'),
+ 'description' => $this->translate('Comma-separated list of ports to scan'),
+ ]);
+
+ $this->addElement('textarea', 'exclude_targets', [
+ 'required' => false,
+ 'label' => $this->translate('Exclude Targets'),
+ 'description' => $this->translate('Comma-separated list of addresses/hostnames to exclude'),
+ ]);
+
+ $this->addElement('submit', 'btn_submit', [
+ 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Create')
+ ]);
+
+ if ($this->isUpdating()) {
+ $removeButton = $this->createElement('submit', 'btn_remove', [
+ 'class' => 'btn-remove',
+ 'label' => $this->translate('Remove Job'),
+ ]);
+ $this->registerElement($removeButton);
+
+ /** @var HtmlDocument $wrapper */
+ $wrapper = $this->getElement('btn_submit')->getWrapper();
+ $wrapper->prepend($removeButton);
+ }
+ }
+
+ protected function onSuccess(): void
+ {
+ $conn = Database::get();
+ /** @var FormSubmitElement $submitElement */
+ $submitElement = $this->getPressedSubmitElement();
+ if ($submitElement->getName() === 'btn_remove') {
+ try {
+ /** @var X509Job $job */
+ $job = $this->job;
+ $conn->delete('x509_job', ['id = ?' => $job->id]);
+
+ Notification::success($this->translate('Removed job successfully'));
+ } catch (Exception $err) {
+ Notification::error($this->translate('Failed to remove job') . ': ' . $err->getMessage());
+ }
+ } else {
+ $values = $this->getValues();
+
+ try {
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ if ($this->job === null) {
+ $values['author'] = $user->getUsername();
+ $values['ctime'] = (new DateTime())->getTimestamp() * 1000.0;
+ $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0;
+
+ $conn->insert('x509_job', $values);
+ $message = $this->translate('Created job successfully');
+ } else {
+ $values['mtime'] = (new DateTime())->getTimestamp() * 1000.0;
+
+ $conn->update('x509_job', $values, ['id = ?' => $this->job->id]);
+ $message = $this->translate('Updated job successfully');
+ }
+
+ Notification::success($message);
+ } catch (Exception $err) {
+ $message = $this->isUpdating()
+ ? $this->translate('Failed to update job')
+ : $this->translate('Failed to create job');
+
+ Notification::error($message . ': ' . $err->getMessage());
+ }
+ }
+ }
+}
diff --git a/application/forms/Jobs/ScheduleForm.php b/application/forms/Jobs/ScheduleForm.php
new file mode 100644
index 0000000..ae47e58
--- /dev/null
+++ b/application/forms/Jobs/ScheduleForm.php
@@ -0,0 +1,201 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Forms\Jobs;
+
+use DateTime;
+use Exception;
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use Icinga\Authentication\Auth;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\User;
+use Icinga\Util\Json;
+use Icinga\Web\Notification;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\Compat\CompatForm;
+use ipl\Web\FormElement\ScheduleElement;
+use Psr\Http\Message\RequestInterface;
+
+use function ipl\Stdlib\get_php_type;
+
+class ScheduleForm extends CompatForm
+{
+ /** @var int */
+ protected $jobId;
+
+ /** @var ?X509Schedule */
+ protected $schedule;
+
+ /** @var ScheduleElement */
+ protected $scheduleElement;
+
+ public function __construct(X509Schedule $schedule = null)
+ {
+ $this->schedule = $schedule;
+ $this->scheduleElement = new ScheduleElement('schedule_element');
+
+ /** @var Web $app */
+ $app = Icinga::app();
+ $this->scheduleElement->setIdProtector([$app->getRequest(), 'protectId']);
+ }
+
+ protected function isUpdating(): bool
+ {
+ return $this->schedule !== null;
+ }
+
+ public function setJobId(int $jobId): self
+ {
+ $this->jobId = $jobId;
+
+ return $this;
+ }
+
+ /**
+ * Get multipart updates
+ *
+ * @return array<int, BaseHtmlElement>
+ */
+ public function getPartUpdates(): array
+ {
+ /** @var RequestInterface $request */
+ $request = $this->getRequest();
+
+ return $this->scheduleElement->prepareMultipartUpdate($request);
+ }
+
+ public function hasBeenSubmitted(): bool
+ {
+ if (! $this->hasBeenSent()) {
+ return false;
+ }
+
+ $button = $this->getPressedSubmitElement();
+
+ return $button && ($button->getName() === 'submit' || $button->getName() === 'btn_remove');
+ }
+
+ protected function assemble(): void
+ {
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'label' => $this->translate('Name'),
+ 'description' => $this->translate('Schedule name'),
+ ]);
+
+ $this->addElement('checkbox', 'full_scan', [
+ 'required' => false,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Full Scan'),
+ 'description' => $this->translate(
+ 'Scan all known and unknown targets of this job. (Defaults to only scan unknown targets)'
+ )
+ ]);
+
+ if ($this->getPopulatedValue('full_scan', 'n') === 'n') {
+ $this->addElement('checkbox', 'rescan', [
+ 'required' => false,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Rescan'),
+ 'description' => $this->translate('Rescan only targets that have been scanned before')
+ ]);
+
+ $this->addElement('text', 'since_last_scan', [
+ 'required' => false,
+ 'label' => $this->translate('Since Last Scan'),
+ 'placeholder' => '-24 hours',
+ 'description' => $this->translate(
+ 'Scan targets whose last scan is older than the specified date/time, which can also be an'
+ . ' English textual datetime description like "2 days". If you want to scan only unknown targets'
+ . ' you can set this to "null".'
+ ),
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator) {
+ if ($value !== null && $value !== 'null') {
+ try {
+ new DateTime($value);
+ } catch (Exception $_) {
+ $validator->addMessage($this->translate('Invalid textual date time'));
+
+ return false;
+ }
+ }
+
+ return true;
+ })
+ ]
+ ]);
+ }
+
+ $this->addHtml(HtmlElement::create('div', ['class' => 'schedule-element-separator']));
+ $this->addElement($this->scheduleElement);
+
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->isUpdating() ? $this->translate('Update') : $this->translate('Schedule')
+ ]);
+
+ if ($this->isUpdating()) {
+ $removeButton = $this->createElement('submit', 'btn_remove', [
+ 'class' => 'btn-remove',
+ 'label' => $this->translate('Remove Schedule'),
+ ]);
+ $this->registerElement($removeButton);
+
+ /** @var HtmlDocument $wrapper */
+ $wrapper = $this->getElement('submit')->getWrapper();
+ $wrapper->prepend($removeButton);
+ }
+ }
+
+ protected function onSuccess(): void
+ {
+ /** @var X509Schedule $schedule */
+ $schedule = $this->schedule;
+ $conn = Database::get();
+ /** @var FormSubmitElement $submitElement */
+ $submitElement = $this->getPressedSubmitElement();
+ if ($submitElement->getName() === 'btn_remove') {
+ $conn->delete('x509_schedule', ['id = ?' => $schedule->id]);
+
+ Notification::success($this->translate('Deleted schedule successfully'));
+ } else {
+ $config = $this->getValues();
+ unset($config['name']);
+ unset($config['schedule_element']);
+
+ $frequency = $this->scheduleElement->getValue();
+ $config['type'] = get_php_type($frequency);
+ $config['frequency'] = Json::encode($frequency);
+
+ /** @var User $user */
+ $user = Auth::getInstance()->getUser();
+ if (! $this->isUpdating()) {
+ $conn->insert('x509_schedule', [
+ 'job_id' => $this->schedule ? $this->schedule->job_id : $this->jobId,
+ 'name' => $this->getValue('name'),
+ 'author' => $user->getUsername(),
+ 'config' => Json::encode($config),
+ 'ctime' => (new DateTime())->getTimestamp() * 1000.0,
+ 'mtime' => (new DateTime())->getTimestamp() * 1000.0
+ ]);
+ $message = $this->translate('Created schedule successfully');
+ } else {
+ $conn->update('x509_schedule', [
+ 'name' => $this->getValue('name'),
+ 'config' => Json::encode($config),
+ 'mtime' => (new DateTime())->getTimestamp() * 1000.0
+ ], ['id = ?' => $schedule->id]);
+ $message = $this->translate('Updated schedule successfully');
+ }
+
+ Notification::success($message);
+ }
+ }
+}
diff --git a/application/views/scripts/certificate/index.phtml b/application/views/scripts/certificate/index.phtml
new file mode 100644
index 0000000..08cfadb
--- /dev/null
+++ b/application/views/scripts/certificate/index.phtml
@@ -0,0 +1,6 @@
+<div class="controls">
+ <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?>
+</div>
+<div class="content">
+ <?= /** @var \Icinga\Module\X509\CertificateDetails $certificateDetails */ $certificateDetails->render() ?>
+</div>
diff --git a/application/views/scripts/chain/index.phtml b/application/views/scripts/chain/index.phtml
new file mode 100644
index 0000000..ffa3872
--- /dev/null
+++ b/application/views/scripts/chain/index.phtml
@@ -0,0 +1,8 @@
+<?php if (! $this->compact): ?>
+<div class="controls">
+ <?= $this->tabs ?>
+</div>
+<?php endif ?>
+<div class="content">
+ <?= /** @var \ipl\Html\ValidHtml $chain */ $chain->render() ?>
+</div>
diff --git a/application/views/scripts/config/backend.phtml b/application/views/scripts/config/backend.phtml
new file mode 100644
index 0000000..78e312e
--- /dev/null
+++ b/application/views/scripts/config/backend.phtml
@@ -0,0 +1,6 @@
+<div class="controls">
+ <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?>
+</div>
+<div class="content">
+ <?= /** @var \Icinga\Module\X509\Forms\Config\BackendConfigForm $form */ $form ?>
+</div>
diff --git a/application/views/scripts/dashboard/index.phtml b/application/views/scripts/dashboard/index.phtml
new file mode 100644
index 0000000..3b6ec0f
--- /dev/null
+++ b/application/views/scripts/dashboard/index.phtml
@@ -0,0 +1,13 @@
+<?php if (! $this->compact): ?>
+ <div class="controls">
+ <?= $this->tabs ?>
+ </div>
+<?php endif ?>
+<div class="content">
+ <div class="cert-dashboard">
+ <?= $byCa->render() ?>
+ <?= $duration->render() ?>
+ <?= $keyStrength->render() ?>
+ <?= $sigAlgos->render() ?>
+ </div>
+</div>
diff --git a/application/views/scripts/missing-resource.phtml b/application/views/scripts/missing-resource.phtml
new file mode 100644
index 0000000..fcfa255
--- /dev/null
+++ b/application/views/scripts/missing-resource.phtml
@@ -0,0 +1,12 @@
+<div class="controls">
+ <?= $this->tabs ?>
+</div>
+<div class="content">
+ <h2><?= $this->translate('Database not configured') ?></h2>
+ <p data-base-target="_next"><?= sprintf(
+ $this->translate('You seem to not have configured a database resource yet. Please create one %1$shere%3$s and then set it in this %2$smodule\'s configuration%3$s.'),
+ '<a class="action-link" href="' . $this->href('config/resource') . '">',
+ '<a class="action-link" href="' . $this->href('x509/config/backend') . '">',
+ '</a>'
+ ) ?></p>
+</div>
diff --git a/application/views/scripts/simple-form.phtml b/application/views/scripts/simple-form.phtml
new file mode 100644
index 0000000..9bcba74
--- /dev/null
+++ b/application/views/scripts/simple-form.phtml
@@ -0,0 +1,6 @@
+<div class="controls">
+ <?= $tabs ?>
+</div>
+<div class="content">
+ <?= $form->create()->setTitle(null) // @TODO(el): create() has to be called because the UserForm is setting the title there ?>
+</div>
diff --git a/application/views/scripts/sni/index.phtml b/application/views/scripts/sni/index.phtml
new file mode 100644
index 0000000..2be5280
--- /dev/null
+++ b/application/views/scripts/sni/index.phtml
@@ -0,0 +1,31 @@
+<?= $this->controls->render() ?>
+<div class="content">
+ <?php /** @var \Icinga\Repository\RepositoryQuery $sni */ if (! $sni->hasResult()): ?>
+ <p><?= $this->escape($this->translate('No SNI maps configured yet.')) ?></p>
+ <?php else: ?>
+ <table class="common-table table-row-selectable" data-base-target="_next">
+ <thead>
+ <tr>
+ <th><?= $this->escape($this->translate('IP')) ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($sni as $data): ?>
+ <tr>
+ <td><?= $this->qlink($data->ip, 'x509/sni/update', ['ip' => $data->ip]) ?></td>
+ <td class="icon-col"><?= $this->qlink(
+ null,
+ 'x509/sni/remove',
+ array('ip' => $data->ip),
+ array(
+ 'class' => 'action-link',
+ 'icon' => 'cancel',
+ 'title' => $this->translate('Remove this SNI map')
+ )
+ ) ?></td>
+ </tr>
+ <?php endforeach ?>
+ </tbody>
+ </table>
+ <?php endif ?>
+</div>
diff --git a/config/systemd/icinga-x509.service b/config/systemd/icinga-x509.service
new file mode 100644
index 0000000..01cda66
--- /dev/null
+++ b/config/systemd/icinga-x509.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Icinga Certificate Monitoring Module Jobs Runner
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/icingacli x509 jobs run
+Restart=on-success
+
+[Install]
+WantedBy=multi-user.target
diff --git a/configuration.php b/configuration.php
new file mode 100644
index 0000000..c7adad4
--- /dev/null
+++ b/configuration.php
@@ -0,0 +1,33 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+/** @var \Icinga\Application\Modules\Module $this */
+
+$section = $this->menuSection(N_('Certificate Monitoring'), array(
+ 'icon' => 'check',
+ 'url' => 'x509/dashboard',
+ 'priority' => 40
+));
+
+$section->add(N_('Certificate Overview'), array(
+ 'url' => 'x509/certificates',
+ 'priority' => 10
+));
+
+$section->add(N_('Certificate Usage'), array(
+ 'url' => 'x509/usage',
+ 'priority' => 20
+));
+
+$section->add(N_('Configuration'), [
+ 'url' => 'x509/jobs',
+ 'priority' => 100,
+ 'description' => $this->translate('Configure the scan jobs and SNI map')
+]);
+
+$this->provideConfigTab('backend', array(
+ 'title' => $this->translate('Configure the database backend'),
+ 'label' => $this->translate('Backend'),
+ 'url' => 'config/backend'
+));
diff --git a/doc/01-About.md b/doc/01-About.md
new file mode 100644
index 0000000..38fa06a
--- /dev/null
+++ b/doc/01-About.md
@@ -0,0 +1,22 @@
+# Icinga Certificate Monitoring
+
+The certificate monitoring module for Icinga keeps track of certificates as they are deployed in a network environment.
+It does this by scanning networks for TLS services and collects whatever certificates it finds along the way.
+The certificates are verified using its own trust store.
+
+The module’s web frontend can be used to view scan results, allowing you to drill down into detailed information
+about any discovered certificate of your landscape:
+
+![X.509 Usage](res/x509-usage.png "X.509 Usage")
+
+![X.509 Certificates](res/x509-certificates.png "X.509 Certificates")
+
+At a glance you see which CAs have issued your certificates and key counters of your environment:
+
+![X.509 Dashboard](res/x509-dashboard.png "X.509 Dashboard")
+
+## Documentation
+
+* [Installation](02-Installation.md)
+* [Configuration](03-Configuration.md)
+* [Monitoring](10-Monitoring.md)
diff --git a/doc/02-Installation.md b/doc/02-Installation.md
new file mode 100644
index 0000000..af2eaf3
--- /dev/null
+++ b/doc/02-Installation.md
@@ -0,0 +1,73 @@
+<!-- {% if index %} -->
+# Installing Icinga Certificate Monitoring
+
+The recommended way to install Icinga Certificate Monitoring
+and its dependencies is to use prebuilt packages for
+all supported platforms from our official release repository.
+Please note that [Icinga Web](https://icinga.com/docs/icinga-web) is required
+and if it is not already set up, it is best to do this first.
+
+The following steps will guide you through installing and setting up Icinga Certificate Monitoring.
+<!-- {% else %} -->
+<!-- {% if not icingaDocs %} -->
+
+## Installing the Package
+
+If the [repository](https://packages.icinga.com) is not configured yet, please add it first.
+Then use your distribution's package manager to install the `icinga-x509` package
+or install [from source](02-Installation.md.d/From-Source.md).
+<!-- {% endif %} -->
+
+## Setting up the Database
+
+### Setting up a MySQL or MariaDB Database
+
+The module needs a MySQL/MariaDB database with the schema that's provided in the `/usr/share/icingaweb2/modules/x509/schema/mysql.schema.sql` file.
+<!-- {% if not icingaDocs %} -->
+
+**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path.
+
+<!-- {% endif %} -->
+
+You can use the following sample command for creating the MySQL/MariaDB database. Please change the password:
+
+```
+CREATE DATABASE x509;
+GRANT CREATE, SELECT, INSERT, UPDATE, DELETE, DROP, ALTER, CREATE VIEW, INDEX, EXECUTE ON x509.* TO x509@localhost IDENTIFIED BY 'secret';
+```
+
+After, you can import the schema using the following command:
+
+```
+mysql -p -u root x509 < /usr/share/icingaweb2/modules/x509/schema/mysql.schema.sql
+```
+
+### Setting up a PostgreSQL Database
+
+The module needs a PostgreSQL database with the schema that's provided in the `/usr/share/icingaweb2/modules/x509/schema/pgsql.schema.sql` file.
+<!-- {% if not icingaDocs %} -->
+
+**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path.
+
+<!-- {% endif %} -->
+
+You can use the following sample command for creating the PostgreSQL database. Please change the password:
+
+```sql
+CREATE USER x509 WITH PASSWORD 'secret';
+CREATE DATABASE x509
+ WITH OWNER x509
+ ENCODING 'UTF8'
+ LC_COLLATE = 'en_US.UTF-8'
+ LC_CTYPE = 'en_US.UTF-8';
+```
+
+After, you can import the schema using the following command:
+
+```
+psql -U x509 x509 -a -f /usr/share/icingaweb2/modules/x509/schema/pgsql.schema.sql
+```
+
+This concludes the installation. You should now be able to import CA certificates and set up scan jobs.
+Please read the [Configuration](03-Configuration.md) section for details.
+<!-- {% endif %} --><!-- {# end else if index #} -->
diff --git a/doc/02-Installation.md.d/From-Source.md b/doc/02-Installation.md.d/From-Source.md
new file mode 100644
index 0000000..31f3d2b
--- /dev/null
+++ b/doc/02-Installation.md.d/From-Source.md
@@ -0,0 +1,16 @@
+# Installing Icinga Certificate Monitoring from Source
+
+Please see the Icinga Web documentation on
+[how to install modules](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation) from source.
+Make sure you use `x509` as the module name. The following requirements must also be met.
+
+## Requirements
+
+* PHP (≥7.2)
+* MySQL or PostgreSQL PDO PHP libraries
+* The following PHP modules must be installed: `gmp`, `pcntl`, `openssl`
+* [Icinga Web](https://github.com/Icinga/icingaweb2) (≥2.9)
+* [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (≥0.13.0)
+* [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.12.0)
+
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md
new file mode 100644
index 0000000..4ecde77
--- /dev/null
+++ b/doc/03-Configuration.md
@@ -0,0 +1,77 @@
+# <a id="Configuration"></a>Configuration
+
+## Importing CA certificates
+
+The module tries to verify certificates using its own trust store. By default, this trust store is empty, and it
+is up to the Icinga Web 2 admin to import CA certificates into it.
+
+Using the `icingacli x509 import` command CA certificates can be imported. The certificate chain file that is specified
+with the `--file` option should contain a PEM-encoded list of X.509 certificates which should be added to the trust
+store:
+
+```
+icingacli x509 import --file /etc/ssl/certs/ca-certificates.crt
+```
+
+## Configure Jobs
+
+Scan jobs have a name which uniquely identifies them, e.g. `lan`. These names are used by the CLI command to start
+scanning for specific jobs.
+
+Each scan job can have one or more IP address ranges and one or more port ranges. The module scans each port in
+a job's port ranges for all the individual IP addresses in the IP ranges. IP address ranges have to be specified using
+the CIDR format. Multiple IP address ranges can be separated with commas, e.g.:
+
+`192.0.2.0/24,10.0.10.0/24`
+
+Port ranges are separated with dashes (`-`). If you only want to scan a single port you don't need to specify the second
+port:
+
+`443,5665-5669`
+
+Additionally, each job may also exclude specific **hosts** and **IP** addresses from scan. These hosts won't be scanned
+when you run the [scan](04-Scanning.md#scan-command) or [jobs](04-Scanning.md#scheduling-jobs) command. Excluding an entire network and specifying IP addresses in CIDR
+format will not work. You must specify concrete **IP**s and **host CN**s separated with commas, e.g:
+
+`192.0.2.2,192.0.2.5,icinga.com`
+
+### Job Schedules
+
+Schedules are [`cron`](https://crontab.guru) and rule based configs used to run jobs periodically at the given interval.
+Every job is allowed to have multiple schedules that can be run independently of each other. Each job schedule provides
+different options that you can use to control the scheduling behavior of the [jobs command](04-Scanning.md#scheduling-jobs).
+
+#### Examples
+
+A schedule that runs weekly on **Friday** and scans all targets that have not yet been scanned, or
+whose last scan is older than `1 week`.
+
+![Weekly Schedules](res/weekly-schedules.png "Weekly Schedules")
+
+## Server Name Indication
+
+In case you are serving multiple virtual hosts under a single IP you can configure those in
+`Configuration -> Modules -> x509 -> SNI`.
+
+Each entry defines an IP with multiple hostnames associated with it. These are then utilized when jobs run.
+
+Modules may also provide sources for SNI. At this time the module monitoring is the only one with known support.
+
+## Icinga Certificate Monitoring Daemon
+
+The default `systemd` service of this module, shipped with package installations, uses the [jobs command](04-Scanning.md#scheduling-jobs)
+and runs all your configured jobs and schedules.
+
+<!-- {% if not icingaDocs %} -->
+
+> **Note**
+>
+> If you haven't installed this module from packages, you have to configure this as a `systemd` service yourself by just
+> copying the example service definition from `/usr/share/icingaweb2/modules/x509/config/systemd/icinga-x509.service`
+> to `/etc/systemd/system/icinga-x509.service`.
+<!-- {% endif %} -->
+
+You can run the following command to enable and start the daemon.
+```
+systemctl enable --now icinga-x509.service
+```
diff --git a/doc/04-Scanning.md b/doc/04-Scanning.md
new file mode 100644
index 0000000..608d18a
--- /dev/null
+++ b/doc/04-Scanning.md
@@ -0,0 +1,85 @@
+# <a id="Scanning"></a>Scanning
+
+The Icinga Certificate Monitoring provides CLI commands to scan **hosts** and **IPs** in various ways.
+These commands are listed below and can be used individually. It is necessary for all commands to know which IP address
+ranges and ports to scan. These can be configured as described [here](03-Configuration.md#configure-jobs).
+
+## Scan Command
+
+The scan command, scans targets to find their X.509 certificates and track changes to them.
+A **target** is an **IP-port** combination that is generated from the job configuration, taking into account configured
+[**SNI**](03-Configuration.md#server-name-indication) maps, so that targets with multiple certificates are also properly
+scanned.
+
+By default, successive calls to the scan command perform partial scans, checking both targets not yet scanned and
+targets whose scan is older than 24 hours, to ensure that all targets are rescanned over time and new certificates
+are collected. This behavior can be customized through the command [options](#usage-1).
+
+> **Note**
+>
+> When rescanning due targets, they will be rescanned regardless of whether the target previously provided a certificate
+> or not, to collect new certificates, track changed certificates, and remove decommissioned certificates.
+
+### Usage
+
+This scan command can be used like any other Icinga Web cli operations like this: `icingacli x509 scan [OPTIONS]`
+
+**Options:**
+
+```
+--job=<name> Scan targets that belong to the specified job. (Required)
+--since-last-scan=<time> Scan targets whose last scan is older than the spcified date/time, which can also be an
+ English textual datetime description like "2 days". Defaults to "-24 hours".
+--rescan Rescn only targets that have been scanned before.
+--full (Re)scan all known and unknown targets. This will override the "rescan" and "since-last-scan" options.
+--parallel=<number> Allow parallel scanning of targets up to the specified number. Defaults to 256.
+ May cause **too many open files** error if set to a number higher than the configured one (ulimit).
+```
+
+#### Example
+
+Scan all targets that have not yet been scanned, or whose last scan is older than a certain date/time:
+```
+# icingacli x509 scan --job <name> --since-last-scan '3 days'
+```
+
+Scan only **unknown** targets:
+```
+# icingacli x509 scan --job <name> --since-last-scan=null
+```
+
+Scan only known targets:
+```
+# icingacli x509 scan --job <name> --rescan
+```
+
+Scan only known targets whose last scan is older than certain a given date/time:
+```
+# icingacli x509 scan --job <name> --rescan --since-last-scan '5 days'
+```
+
+Scan all known and unknown targets:
+```
+# icingacli x509 scan --job <name> --full
+```
+
+## Scheduling Jobs
+
+The jobs command is similar to the [scan command](#scan-command), but it additionally allows you to schedule your jobs
+in a more convenient way. This is used by the default `systemd` service of this module as well. By default, this command
+will run all your configured jobs based on their frequency. This behaviour can be customized through the command options
+too. Since you can have multiple schedules for a single job, all job schedules can also be scheduled individually.
+
+### Usage
+
+This scan command can be used like any other Icinga Web cli operations like this: `icingacli x509 jobs run [OPTIONS`
+
+**Options:**
+
+```
+--job=<name> Run all configured schedules only of the specified job.
+--schedule=<name> Run only the given schedule of the specified job.
+ Providing a schedule name without a job will fail immediately.
+--parallel=<number> Allow parallel scanning of targets up to the specified number. Defaults to 256.
+ May cause **too many open files** error if set to a number higher than the configured one (ulimit).
+```
diff --git a/doc/10-Monitoring.md b/doc/10-Monitoring.md
new file mode 100644
index 0000000..d729bb9
--- /dev/null
+++ b/doc/10-Monitoring.md
@@ -0,0 +1,212 @@
+# <a id="Monitoring"></a>Monitoring
+
+## Host Check Command
+
+The module provides a CLI command to check a host's certificate. It does so by
+fetching all the necessary information from this module's own database.
+
+### Usage
+
+General: `icingacli x509 check host [options]`
+
+Options:
+
+```
+--ip A hosts IP address
+--host A hosts name
+--port The port to check in particular
+--warning Less remaining time results in state WARNING [25%]
+--critical Less remaining time results in state CRITICAL [10%]
+--allow-self-signed Ignore if a certificate or its issuer has been self-signed
+```
+
+### Threshold Definition
+
+Thresholds can either be defined relative (in percent) or absolute (time interval).
+Time intervals consist of a digit and an accompanying unit (e.g. "3M" are three
+months). Supported units are:
+
+ Identifier | Description
+------------|------------
+y, Y | Year
+M | Month
+d, D | Day
+h, H | Hour
+m | Minute
+s, S | Second
+
+**Example:**
+
+```
+$ icingacli x509 check host --host example.org --warning 1y
+WARNING - *.example.org expires in 219 days|'*.example.org'=18985010s;25574400;10281600;0;102470399
+```
+
+### Performance Data
+
+The command outputs a performance data value for each certificate that is
+served by the host. The value measured is the amount of seconds remaining
+until the certificate expires.
+
+![check host perf data](res/check-host-perf-data.png)
+
+The value of `max` is the total amount of seconds the certificate is valid.
+`warning` and `critical` are the seconds remaining after which the respective
+state is reported.
+
+## Icinga 2 Integration
+
+First off, this chapter relies on the fact that you're using the Director
+already and that you're familiar with some of the terms and functionalities
+used there.
+
+If you don't want to use the Director, know that Icinga 2 already provides
+an appropriate template for the host check command in its template library:
+https://icinga.com/docs/icinga2/latest/doc/10-icinga-template-library/#x509
+
+### Director Import Sources
+
+The module provides two different import sources:
+
+#### Hosts (X509)
+
+Focuses on the hosts the module found when scanning the networks. Use this
+for the most straightforward way of integrating the results into your
+environment. It's also the utilized source in the example further below.
+
+Columns provided by this source:
+
+Name | Description
+----------------|--------------------------------------------------------------
+host_name_or_ip | Default key column. This is primarily `host_name`, though if this is not unique it falls back to `host_ip` for individual results
+host_ip | A host's IP address by which it is known to this module. May be IPv4 or IPv6
+host_name | A host's name as detected by SNI or a reverse DNS lookup during the scan process
+host_ports | Separated by comma. All ports where certificates were found
+host_address | Set to `host_ip` if it is IPv4 else `null`
+host_address6 | Set to `host_ip` if it is IPv6 else `null`
+
+#### Services (X509)
+
+While the hosts import source does not provide any details about the found
+certificates this one does. This also means that this source may generate
+multiple results for a single host since it focuses on the found certificates.
+
+Use this source if you want to import service objects directly and relate them
+to already existing hosts by their utilized certificates. The Director's many
+utilities provided in this regard will again come in handy here.
+
+Columns provided by this source:
+
+Name | Description
+----------------------|--------------------------------------------------------
+host_name_ip_and_port | Default key column. This is a combination of `host_name`, `host_ip` and `host_port` in the format `name/ip:port`
+host_ip | A host's IP address by which it is known to this module. May be IPv4 or IPv6
+host_name | A host's name as detected by SNI or a reverse DNS lookup during the scan process
+host_port | A host's port where a certificate has been found
+host_address | Set to `host_ip` if it is IPv4 else `null`
+host_address6 | Set to `host_ip` if it is IPv6 else `null`
+cert_subject | A certificate's common name
+cert_issuer | The issuer's common name
+cert_self_signed | Whether the certificate is self-signed (`y` or `n`)
+cert_trusted | Whether the certificate is trusted (`y` or `n`)
+cert_valid_from | The certificate's start time of validity (UNIX timestamp)
+cert_valid_to | The certificate's end time of validity (UNIX timestamp)
+cert_fingerprint | The certificate's fingerprint (hex-encoded)
+cert_dn | The certificate's distinguished name
+cert_subject_alt_name | The certificate's alternative subject names (Comma separated pairs of `type:name`)
+
+### Service Checks With the Hosts Import Source
+
+This example covers the setup of service checks by using a particular host
+template and suggests then two options utilizing service apply rules.
+
+#### Preparations
+
+Assuming the check command definition `icingacli-x509` has already been imported
+you need to define a few data fields now:
+
+Field name | Data type
+---------------------------------|----------
+certified_ports | Array
+icingacli_x509_ip | String
+icingacli_x509_host | String
+icingacli_x509_port | String
+icingacli_x509_warning | String
+icingacli_x509_critical | String
+icingacli_x509_allow_self_signed | Boolean
+
+Then please create a new host template with a name of your choosing. We've chosen
+`x509-host`. We're also importing our base template `base-host` here which defines
+all the default properties of our hosts.
+
+![new host template](res/new-host-template.png)
+
+This host template also requires three data fields which are shown below.
+
+![host template fields](res/host-template-fields.png)
+
+A service template is also needed. We chose the name `x509-host-check` and
+`icingacli-x509` as check command.
+
+![new service template](res/new-service-template.png)
+
+The service template now requires all data fields which correspond to the
+check command's parameters.
+
+![service template fields](res/service-template-fields.png)
+
+#### Import Source Setup
+
+Create a new import source of type `Hosts (X509)`.
+![hosts import source](res/hosts-import-source.png)
+
+Configure a property modifier for column `host_ports` of type `Split` and use
+the comma `,` as delimiter.
+![ports property modifier](res/ports-property-modifier.png)
+
+The preview should now produce a similar result to this:
+![hosts import result](res/hosts-import-result.png)
+
+#### Sync Rule Setup
+
+Create a new sync rule for objects of type `Host`. Depending on your environment
+you may choose either `Merge` or `Replace` as update policy. Choose `Merge` to
+continue with this example.
+
+Which properties this rule defines is also very dependent on what you want to
+achieve. We now assume that you already have host objects whose object names
+match exactly those the import source provides. (Hence you should choose
+`Merge` as update policy)
+
+![sync rule properties](res/sync-rule-properties.png)
+
+#### Service Check Setup
+
+There are two choices now. The first checks a host's certificates as a single
+service. The second creates for each individual certificate (port) a service.
+
+##### Single Service
+
+This is done by defining a new service as part of the host template created
+earlier. There add a service and choose the service template also created
+previously.
+
+![host check single service](res/host-check-single-service.png)
+
+Once you've triggered the import and synchronisation as well as deployed
+the resulting changes you should see this in Icinga Web 2:
+
+![single service result](res/single-service-result.png)
+
+##### Multiple Services
+
+This utilizes a service apply rule. Trigger the import and synchronisation
+first, otherwise you can't choose a custom variable for the *apply for* rule.
+
+Once the synchronisation is finished, set up the service apply rule like this:
+
+![host check multiple services](res/host-check-multiple-services.png)
+
+After deploying the resulting changes you should see this in Icinga Web 2:
+
+![multiple services result](res/multiple-services-result.png)
diff --git a/doc/11-Housekeeping.md b/doc/11-Housekeeping.md
new file mode 100644
index 0000000..174a9ef
--- /dev/null
+++ b/doc/11-Housekeeping.md
@@ -0,0 +1,38 @@
+# <a id="Datbase Housekeeping"></a>Database Housekeeping
+
+Your database may grow over time and contain some outdated information. Icinga Certificate Monitoring provides you
+the ability to clean up these outdated info in an easy way.
+
+## Certificates and Targets
+
+The default `cleanup` action removes targets whose last scan is older than a certain date/time and certificates that
+are no longer used.
+
+By default, any targets whose last scan is older than `1 month` are removed. The last scan information is always updated
+when scanning a target, regardless of whether a successful connection is made or not. Therefore, targets that have been
+decommissioned or are no longer part of a job configuration are removed after the specified period. Any certificates
+that are no longer used are also removed. This can either be because the associated target has been removed or because
+it is presenting a new certificate chain.
+
+The `cleanup` command will also remove additionally all jobs activities created before the given date/time.
+Jobs activities are usually just some stats about the job runs performed by the scheduler or/and manually
+executed using the [scan](04-Scanning.md#scan-command) and/or [jobs](04-Scanning.md#scheduling-jobs) command.
+
+### Usage
+
+This command can be used like any other Icinga Web cli operations like this: `icingacli x509 cleanup [OPTIONS]`
+
+**Options:**
+
+```
+--since-last-scan=<datetime> Clean up targets whose last scan is older than the specified date/time,
+ which can also be an English textual datetime description like "2 days".
+ Defaults to "1 month".
+```
+
+#### Example
+
+Remove any targets that have not been scanned for at least two months and any certificates that are no longer used.
+```
+icingacli x509 cleanup --since-last-scan="2 months"
+```
diff --git a/doc/80-Upgrading.md b/doc/80-Upgrading.md
new file mode 100644
index 0000000..a66ddc4
--- /dev/null
+++ b/doc/80-Upgrading.md
@@ -0,0 +1,91 @@
+# Upgrading Icinga Certificate Monitoring
+
+Upgrading Icinga Certificate Monitoring is straightforward.
+Usually the only manual steps involved are schema updates for the database.
+
+## Upgrading to version 1.3.0
+
+Icinga Certificate Monitoring version `1.3.0` requires a schema update for the database. We have dropped the use of **INI**
+files to store jobs and are using the database instead. So you need to migrate your job configs to the database.
+
+If you're already using Icinga Web 2 version `>= 2.12`, then you don't need to import the sql upgrade scripts manually.
+Icinga Web provides you the ability to perform such migrations in a simple way. You may be familiar with such an automation
+if you're an Icinga Director user.
+
+> **Note**
+>
+> Please note that it doesn't matter if you import the database upgrade script manually or via the new automation,
+> you will have to migrate your [Jobs config](#migrate-jobs) from INI to the database manually afterwards.
+
+Before migrating your jobs from **INI** to the database, you need to first apply the migration script. This will create
+the tables needed to store the jobs and schedules in the database.
+
+You may use the following command to apply the database schema upgrade file:
+<!-- {% if not icingaDocs %} -->
+
+**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path.
+
+<!-- {% endif %} -->
+```sql
+# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.3.0.sql
+```
+
+### Migrate Jobs
+
+Afterwards, you can safely migrate your jobs with the following command. Keep in mind that you need to specify an
+Icinga Web username that will be used as the author of these jobs in the database.
+
+```
+# icingacli x509 migrate jobs --author "icingaadmin"
+```
+
+## Upgrading to version 1.2.0
+
+Icinga Certificate Monitoring version 1.2.0 requires a schema update for the database. We have changed all `timestamp`
+columns in the database to biguint to store all timestamps in milliseconds. The sort column `expires` has been dropped
+as well, but you can sort the certificates by `valid_to` instead.
+
+You may use the following command to apply the database schema upgrade file:
+<!-- {% if not icingaDocs %} -->
+
+**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path.
+
+<!-- {% endif %} -->
+```sql
+# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.2.0.sql
+```
+
+## Upgrading to version 1.1.0
+
+Icinga Certificate Monitoring version 1.1.0 fixes issues that affect the database schema.
+To have these issues really fixed in your environment, the schema must be upgraded.
+Please find the upgrade script in **/usr/share/icingaweb2/modules/x509/schema/mysql-upgrades**.
+
+You may use the following command to apply the database schema upgrade file:
+<!-- {% if not icingaDocs %} -->
+
+**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path.
+
+<!-- {% endif %} -->
+
+```
+# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.1.0.sql
+```
+
+## Upgrading to version 1.0.0
+
+Icinga Certificate Monitoring version 1.0.0 requires a schema update for the database.
+The schema has been adjusted so that it is no longer necessary to adjust server settings
+if you're using a version of MySQL < 5.7 or MariaDB < 10.2.
+Please find the upgrade script in **/user/share/icingaweb2/modules/x509/schema/mysql-upgrades**.
+
+You may use the following command to apply the database schema upgrade file:
+<!-- {% if not icingaDocs %} -->
+
+**Note:** If you haven't installed this module from packages, then please adapt the schema path to the correct installation path.
+
+<!-- {% endif %} -->
+
+```
+# mysql -u root -p x509 < /usr/share/icingaweb2/modules/x509/schema/mysql-upgrades/1.0.0.sql
+```
diff --git a/doc/res/check-host-perf-data.png b/doc/res/check-host-perf-data.png
new file mode 100644
index 0000000..958a226
--- /dev/null
+++ b/doc/res/check-host-perf-data.png
Binary files differ
diff --git a/doc/res/host-check-multiple-services.png b/doc/res/host-check-multiple-services.png
new file mode 100644
index 0000000..a153e2e
--- /dev/null
+++ b/doc/res/host-check-multiple-services.png
Binary files differ
diff --git a/doc/res/host-check-single-service.png b/doc/res/host-check-single-service.png
new file mode 100644
index 0000000..1f4bdec
--- /dev/null
+++ b/doc/res/host-check-single-service.png
Binary files differ
diff --git a/doc/res/host-template-fields.png b/doc/res/host-template-fields.png
new file mode 100644
index 0000000..a6aa438
--- /dev/null
+++ b/doc/res/host-template-fields.png
Binary files differ
diff --git a/doc/res/hosts-import-result.png b/doc/res/hosts-import-result.png
new file mode 100644
index 0000000..c19b1f2
--- /dev/null
+++ b/doc/res/hosts-import-result.png
Binary files differ
diff --git a/doc/res/hosts-import-source.png b/doc/res/hosts-import-source.png
new file mode 100644
index 0000000..fe525c7
--- /dev/null
+++ b/doc/res/hosts-import-source.png
Binary files differ
diff --git a/doc/res/multiple-services-result.png b/doc/res/multiple-services-result.png
new file mode 100644
index 0000000..8bec4b9
--- /dev/null
+++ b/doc/res/multiple-services-result.png
Binary files differ
diff --git a/doc/res/new-host-template.png b/doc/res/new-host-template.png
new file mode 100644
index 0000000..2ff9074
--- /dev/null
+++ b/doc/res/new-host-template.png
Binary files differ
diff --git a/doc/res/new-service-template.png b/doc/res/new-service-template.png
new file mode 100644
index 0000000..fec3d22
--- /dev/null
+++ b/doc/res/new-service-template.png
Binary files differ
diff --git a/doc/res/ports-property-modifier.png b/doc/res/ports-property-modifier.png
new file mode 100644
index 0000000..7bb3ecc
--- /dev/null
+++ b/doc/res/ports-property-modifier.png
Binary files differ
diff --git a/doc/res/service-template-fields.png b/doc/res/service-template-fields.png
new file mode 100644
index 0000000..17c1cbd
--- /dev/null
+++ b/doc/res/service-template-fields.png
Binary files differ
diff --git a/doc/res/single-service-result.png b/doc/res/single-service-result.png
new file mode 100644
index 0000000..418f495
--- /dev/null
+++ b/doc/res/single-service-result.png
Binary files differ
diff --git a/doc/res/sync-rule-properties.png b/doc/res/sync-rule-properties.png
new file mode 100644
index 0000000..1b54553
--- /dev/null
+++ b/doc/res/sync-rule-properties.png
Binary files differ
diff --git a/doc/res/weekly-schedules.png b/doc/res/weekly-schedules.png
new file mode 100644
index 0000000..a7b9508
--- /dev/null
+++ b/doc/res/weekly-schedules.png
Binary files differ
diff --git a/doc/res/x509-certificates.png b/doc/res/x509-certificates.png
new file mode 100644
index 0000000..2a67029
--- /dev/null
+++ b/doc/res/x509-certificates.png
Binary files differ
diff --git a/doc/res/x509-dashboard.png b/doc/res/x509-dashboard.png
new file mode 100644
index 0000000..4d5eaf1
--- /dev/null
+++ b/doc/res/x509-dashboard.png
Binary files differ
diff --git a/doc/res/x509-usage.png b/doc/res/x509-usage.png
new file mode 100644
index 0000000..cb80267
--- /dev/null
+++ b/doc/res/x509-usage.png
Binary files differ
diff --git a/library/X509/CertificateDetails.php b/library/X509/CertificateDetails.php
new file mode 100644
index 0000000..f28e423
--- /dev/null
+++ b/library/X509/CertificateDetails.php
@@ -0,0 +1,120 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use DateTime;
+use Icinga\Module\X509\Model\X509Certificate;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\IcingaIcon;
+
+/**
+ * Widget to display X.509 certificate details
+ */
+class CertificateDetails extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'cert-details'];
+
+ /**
+ * @var X509Certificate
+ */
+ protected $cert;
+
+ public function setCert(X509Certificate $cert)
+ {
+ $this->cert = $cert;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $pem = $this->cert->certificate;
+ $cert = openssl_x509_parse($pem);
+// $pubkey = openssl_pkey_get_details(openssl_get_publickey($pem));
+
+ $subject = Html::tag('dl');
+ $sans = CertificateUtils::splitSANs($cert['extensions']['subjectAltName'] ?? null);
+ if (! isset($cert['subject']['CN']) && ! empty($sans)) {
+ foreach ($sans as $type => $values) {
+ foreach ($values as $value) {
+ $subject->addHtml(Html::tag('dt', $type), Html::tag('dd', $value));
+ }
+ }
+ } else {
+ foreach ($cert['subject'] as $key => $value) {
+ $subject->add([
+ Html::tag('dt', $key),
+ Html::tag('dd', $value)
+ ]);
+ }
+ }
+
+ $issuer = Html::tag('dl');
+ foreach ($cert['issuer'] as $key => $value) {
+ $issuer->add([
+ Html::tag('dt', $key),
+ Html::tag('dd', $value)
+ ]);
+ }
+
+ $certInfo = Html::tag('dl');
+ $certInfo->add([
+ Html::tag('dt', mt('x509', 'Serial Number')),
+ Html::tag('dd', bin2hex($this->cert->serial)),
+ Html::tag('dt', mt('x509', 'Version')),
+ Html::tag('dd', $this->cert->version),
+ Html::tag('dt', mt('x509', 'Signature Algorithm')),
+ Html::tag('dd', $this->cert->signature_algo . ' with ' . $this->cert->signature_hash_algo),
+ Html::tag('dt', mt('x509', 'Not Valid Before')),
+ Html::tag('dd', $this->cert->valid_from->format('l F jS, Y H:i:s e')),
+ Html::tag('dt', mt('x509', 'Not Valid After')),
+ Html::tag('dd', $this->cert->valid_to->format('l F jS, Y H:i:s e')),
+ ]);
+
+ $pubkeyInfo = Html::tag('dl');
+ $pubkeyInfo->add([
+ Html::tag('dt', mt('x509', 'Algorithm')),
+ Html::tag('dd', $this->cert->pubkey_algo),
+ Html::tag('dt', mt('x509', 'Key Size')),
+ Html::tag('dd', $this->cert->pubkey_bits)
+ ]);
+
+ $extensions = Html::tag('dl');
+ foreach ($cert['extensions'] as $key => $value) {
+ $extensions->add([
+ Html::tag('dt', ucwords(implode(' ', preg_split('/(?=[A-Z])/', $key)))),
+ Html::tag('dd', $value)
+ ]);
+ }
+
+ $fingerprints = Html::tag('dl');
+ $fingerprints->add([
+ Html::tag('dt', 'SHA-256'),
+ Html::tag(
+ 'dd',
+ wordwrap(strtoupper(bin2hex($this->cert->fingerprint)), 2, ' ', true)
+ )
+ ]);
+
+ $this->add([
+ Html::tag('h2', [new IcingaIcon('certificate'), $this->cert->subject]),
+ Html::tag('h3', mt('x509', 'Subject Name')),
+ $subject,
+ Html::tag('h3', mt('x509', 'Issuer Name')),
+ $issuer,
+ Html::tag('h3', mt('x509', 'Certificate Info')),
+ $certInfo,
+ Html::tag('h3', mt('x509', 'Public Key Info')),
+ $pubkeyInfo,
+ Html::tag('h3', mt('x509', 'Extensions')),
+ $extensions,
+ Html::tag('h3', mt('x509', 'Fingerprints')),
+ $fingerprints
+ ]);
+ }
+}
diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php
new file mode 100644
index 0000000..e524024
--- /dev/null
+++ b/library/X509/CertificateUtils.php
@@ -0,0 +1,538 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateSubjectAltName;
+use Icinga\Module\X509\Model\X509Dn;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Orm\Model;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Str;
+
+use function ipl\Stdlib\yield_groups;
+
+class CertificateUtils
+{
+ /**
+ * Possible public key types
+ *
+ * @var string[]
+ */
+ protected static $pubkeyTypes = [
+ -1 => 'unknown',
+ OPENSSL_KEYTYPE_RSA => 'RSA',
+ OPENSSL_KEYTYPE_DSA => 'DSA',
+ OPENSSL_KEYTYPE_DH => 'DH',
+ OPENSSL_KEYTYPE_EC => 'EC'
+ ];
+
+ /**
+ * Convert the given chunk from PEM to DER
+ *
+ * @param string $pem
+ *
+ * @return string
+ */
+ public static function pem2der($pem)
+ {
+ $lines = explode("\n", $pem);
+
+ $der = '';
+
+ foreach ($lines as $line) {
+ if (strpos($line, '-----') === 0) {
+ continue;
+ }
+
+ $der .= base64_decode($line);
+ }
+
+ return $der;
+ }
+
+ /**
+ * Convert the given chunk from DER to PEM
+ *
+ * @param string $der
+ *
+ * @return string
+ */
+ public static function der2pem($der)
+ {
+ $block = chunk_split(base64_encode($der), 64, "\n");
+
+ return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----";
+ }
+
+ /**
+ * Format seconds to human-readable duration
+ *
+ * @param int $seconds
+ *
+ * @return string
+ */
+ public static function duration($seconds)
+ {
+ if ($seconds < 60) {
+ return "$seconds Seconds";
+ }
+
+ if ($seconds < 3600) {
+ $minutes = round($seconds / 60);
+
+ return "$minutes Minutes";
+ }
+
+ if ($seconds < 86400) {
+ $hours = round($seconds / 3600);
+
+ return "$hours Hours";
+ }
+
+ if ($seconds < 604800) {
+ $days = round($seconds / 86400);
+
+ return "$days Days";
+ }
+
+ if ($seconds < 2592000) {
+ $weeks = round($seconds / 604800);
+
+ return "$weeks Weeks";
+ }
+
+ if ($seconds < 31536000) {
+ $months = round($seconds / 2592000);
+
+ return "$months Months";
+ }
+
+ $years = round($seconds / 31536000);
+
+ return "$years Years";
+ }
+
+ /**
+ * Get the short name from the given DN
+ *
+ * If the given DN contains a CN, the CN is returned. Else, the DN is returned as string.
+ *
+ * @param array $dn
+ *
+ * @return string The CN if it exists or the full DN as string
+ */
+ private static function shortNameFromDN(array $dn): string
+ {
+ if (isset($dn['CN'])) {
+ return ((array) $dn['CN'])[0];
+ } else {
+ $result = [];
+ foreach ($dn as $key => $value) {
+ if (is_array($value)) {
+ foreach ($value as $item) {
+ $result[] = "{$key}={$item}";
+ }
+ } else {
+ $result[] = "{$key}={$value}";
+ }
+ }
+
+ return implode(', ', $result);
+ }
+ }
+
+ /**
+ * Split the given Subject Alternative Names into key-value pairs
+ *
+ * @param ?string $sanStr
+ *
+ * @return array
+ */
+ public static function splitSANs(?string $sanStr): array
+ {
+ $sans = [];
+ foreach (Str::trimSplit($sanStr) as $altName) {
+ if (strpos($altName, ':') === false) {
+ [$k, $v] = Str::trimSplit($altName, '=', 2);
+ } else {
+ [$k, $v] = Str::trimSplit($altName, ':', 2);
+ }
+
+ $sans[$k][] = $v;
+ }
+
+ $order = array_flip(['DNS', 'URI', 'IP Address', 'email', 'DirName']);
+ uksort($sans, function ($a, $b) use ($order) {
+ return ($order[$a] ?? PHP_INT_MAX) <=> ($order[$b] ?? PHP_INT_MAX);
+ });
+
+ return $sans;
+ }
+
+ /**
+ * Yield certificates in the given bundle
+ *
+ * @param string $file Path to the bundle
+ *
+ * @return \Generator
+ */
+ public static function parseBundle($file)
+ {
+ $content = file_get_contents($file);
+
+ $blocks = explode('-----BEGIN CERTIFICATE-----', $content);
+
+ foreach ($blocks as $block) {
+ $end = strrpos($block, '-----END CERTIFICATE-----');
+
+ if ($end !== false) {
+ yield '-----BEGIN CERTIFICATE-----' . substr($block, 0, $end) . '-----END CERTIFICATE-----';
+ }
+ }
+ }
+
+ /**
+ * Find or insert the given certificate and return its ID
+ *
+ * @param Connection $db
+ * @param mixed $cert
+ *
+ * @return array
+ */
+ public static function findOrInsertCert(Connection $db, $cert)
+ {
+ $dbTool = new DbTool($db);
+
+ $certInfo = openssl_x509_parse($cert);
+
+ $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true);
+
+ $row = X509Certificate::on($db);
+ $row
+ ->columns(['id', 'issuer_hash'])
+ ->filter(Filter::equal('fingerprint', $fingerprint));
+
+ $row = $row->first();
+ if ($row) {
+ return [$row->id, $row->issuer_hash];
+ }
+
+ Logger::debug("Importing certificate: %s", $certInfo['name']);
+
+ $pem = null;
+ if (! openssl_x509_export($cert, $pem)) {
+ die('Failed to encode X.509 certificate.');
+ }
+ $der = CertificateUtils::pem2der($pem);
+
+ $ca = false;
+ if (isset($certInfo['extensions']['basicConstraints'])) {
+ if (strpos($certInfo['extensions']['basicConstraints'], 'CA:TRUE') !== false) {
+ $ca = true;
+ }
+ }
+
+ $subjectHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'subject');
+ $issuerHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'issuer');
+ $pubkey = openssl_pkey_get_details(openssl_pkey_get_public($cert));
+ $signature = explode('-', $certInfo['signatureTypeSN']);
+
+ $sans = static::splitSANs($certInfo['extensions']['subjectAltName'] ?? null);
+ if (! isset($certInfo['subject']['CN']) && ! empty($sans)) {
+ $subject = current($sans)[0];
+ } else {
+ $subject = self::shortNameFromDN($certInfo['subject']);
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_certificate',
+ [
+ 'subject' => $subject,
+ 'subject_hash' => $dbTool->marshalBinary($subjectHash),
+ 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']),
+ 'issuer_hash' => $dbTool->marshalBinary($issuerHash),
+ 'version' => $certInfo['version'] + 1,
+ 'self_signed' => $subjectHash === $issuerHash ? 'y' : 'n',
+ 'ca' => $ca ? 'y' : 'n',
+ 'pubkey_algo' => CertificateUtils::$pubkeyTypes[$pubkey['type']],
+ 'pubkey_bits' => $pubkey['bits'],
+ 'signature_algo' => array_shift($signature), // Support formats like RSA-SHA1 and
+ 'signature_hash_algo' => array_pop($signature), // ecdsa-with-SHA384
+ 'valid_from' => $certInfo['validFrom_time_t'] * 1000.0,
+ 'valid_to' => $certInfo['validTo_time_t'] * 1000.0,
+ 'fingerprint' => $dbTool->marshalBinary($fingerprint),
+ 'serial' => $dbTool->marshalBinary(gmp_export($certInfo['serialNumber'])),
+ 'certificate' => $dbTool->marshalBinary($der),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $certId = $db->lastInsertId();
+
+ CertificateUtils::insertSANs($db, $certId, $sans);
+
+ return [$certId, $issuerHash];
+ }
+
+ private static function insertSANs($db, $certId, iterable $sans): void
+ {
+ $dbTool = new DbTool($db);
+ foreach ($sans as $type => $values) {
+ foreach ($values as $value) {
+ $hash = hash('sha256', sprintf('%s=%s', $type, $value), true);
+
+ $row = X509CertificateSubjectAltName::on($db);
+ $row->columns([new Expression('1')]);
+
+ $filter = Filter::all(
+ Filter::equal('certificate_id', $certId),
+ Filter::equal('hash', $hash)
+ );
+
+ $row->filter($filter);
+
+ // Ignore duplicate SANs
+ if ($row->execute()->hasResult()) {
+ continue;
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_certificate_subject_alt_name',
+ [
+ 'certificate_id' => $certId,
+ 'hash' => $dbTool->marshalBinary($hash),
+ 'type' => $type,
+ 'value' => $value,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ }
+ }
+ }
+
+ private static function findOrInsertDn($db, $certInfo, $type)
+ {
+ $dbTool = new DbTool($db);
+
+ $dn = $certInfo[$type];
+
+ $data = '';
+ foreach ($dn as $key => $value) {
+ if (!is_array($value)) {
+ $values = [$value];
+ } else {
+ $values = $value;
+ }
+
+ foreach ($values as $value) {
+ $data .= "$key=$value, ";
+ }
+ }
+ $hash = hash('sha256', $data, true);
+
+ $row = X509Dn::on($db);
+ $row
+ ->columns(['hash'])
+ ->filter(Filter::all(
+ Filter::equal('hash', $hash),
+ Filter::equal('type', $type)
+ ))
+ ->limit(1);
+
+ $row = $row->first();
+ if ($row) {
+ return $row->hash;
+ }
+
+ $index = 0;
+ foreach ($dn as $key => $value) {
+ if (!is_array($value)) {
+ $values = [$value];
+ } else {
+ $values = $value;
+ }
+
+ foreach ($values as $value) {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_dn',
+ [
+ 'hash' => $dbTool->marshalBinary($hash),
+ $db->quoteIdentifier('key') => $key,
+ $db->quoteIdentifier('value') => $value,
+ $db->quoteIdentifier('order') => $index,
+ 'type' => $type,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ $index++;
+ }
+ }
+
+ return $hash;
+ }
+
+ /**
+ * Remove certificates that are no longer in use
+ *
+ * Remove chains that aren't used by any target, certificates that aren't part of any chain, and DNs
+ * that aren't used anywhere.
+ *
+ * @param Connection $conn
+ */
+ public static function cleanupNoLongerUsedCertificates(Connection $conn)
+ {
+ $chainQuery = $conn->delete(
+ 'x509_certificate_chain',
+ ['id NOT IN ?' => X509Target::on($conn)->columns('latest_certificate_chain_id')->assembleSelect()]
+ );
+
+ $rows = $chainQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d certificate chains that are not used by any targets', $rows);
+ }
+
+ $certsQuery = $conn->delete('x509_certificate', [
+ 'id NOT IN ?' => (new Select())
+ ->from('x509_certificate_chain_link ccl')
+ ->columns(['ccl.certificate_id'])
+ ->distinct(),
+ 'trusted = ?' => 'n',
+ ]);
+
+ $rows = $certsQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d certificates that are not part of any chains', $rows);
+ }
+
+ $dnQuery = $conn->delete('x509_dn', [
+ 'hash NOT IN ?' => X509Certificate::on($conn)->columns('subject_hash')->assembleSelect()
+ ]);
+
+ $rows = $dnQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d DNs that are not used anywhere', $rows);
+ }
+ }
+
+ /**
+ * Verify certificates
+ *
+ * @param Connection $db Connection to the X.509 database
+ *
+ * @return int
+ */
+ public static function verifyCertificates(Connection $db)
+ {
+ $files = new TemporaryLocalFileStorage();
+
+ $caFile = uniqid('ca');
+
+ $cas = X509Certificate::on($db);
+ $cas
+ ->columns(['certificate'])
+ ->filter(Filter::all(
+ Filter::equal('ca', true),
+ Filter::equal('trusted', true)
+ ));
+
+ $contents = [];
+ /** @var Model $ca */
+ foreach ($cas as $ca) {
+ $contents[] = $ca->certificate;
+ }
+
+ if (empty($contents)) {
+ throw new \RuntimeException('Trust store is empty');
+ }
+
+ $files->create($caFile, implode("\n", $contents));
+
+ $count = 0;
+ $certs = X509Certificate::on($db)
+ ->with(['chain'])
+ ->utilize('chain.target')
+ ->columns(['chain.id', 'certificate'])
+ ->filter(Filter::equal('chain.valid', false))
+ ->orderBy('chain.id')
+ ->orderBy(new Expression('certificate_link.order'), SORT_DESC);
+
+ $db->beginTransaction();
+
+ try {
+ $caFile = escapeshellarg($files->resolvePath($caFile));
+ $verifyCertsFunc = function (int $chainId, array $collection) use ($db, $caFile) {
+ $certFiles = new TemporaryLocalFileStorage();
+ $certFile = uniqid('cert');
+ $certFiles->create($certFile, array_pop($collection));
+
+ $untrusted = '';
+ if (! empty($collection)) {
+ $intermediateFile = uniqid('intermediate');
+ $certFiles->create($intermediateFile, implode("\n", $collection));
+
+ $untrusted = sprintf(
+ ' -untrusted %s',
+ escapeshellarg($certFiles->resolvePath($intermediateFile))
+ );
+ }
+
+ $command = sprintf(
+ 'openssl verify -CAfile %s%s %s 2>&1',
+ $caFile,
+ $untrusted,
+ escapeshellarg($certFiles->resolvePath($certFile))
+ );
+
+ $output = null;
+
+ exec($command, $output, $exitcode);
+
+ $output = implode("\n", $output);
+
+ if ($exitcode !== 0) {
+ Logger::debug('openssl verify failed for command %s: %s', $command, $output);
+ }
+
+ preg_match('/^error \d+ at \d+ depth lookup:(.+)$/m', $output, $match);
+
+ if (! empty($match)) {
+ $set = ['invalid_reason' => trim($match[1])];
+ } else {
+ $set = ['valid' => 'y', 'invalid_reason' => null];
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->update('x509_certificate_chain', $set, ['id = ?' => $chainId]);
+ };
+
+ $groupBy = function (X509Certificate $cert): array {
+ // Group all the certificates by their chain id.
+ return [$cert->chain->id, $cert->certificate];
+ };
+
+ foreach (yield_groups($certs, $groupBy) as $chainId => $collection) {
+ ++$count;
+ $verifyCertsFunc($chainId, $collection);
+ }
+
+ $db->commitTransaction();
+ } catch (Exception $e) {
+ Logger::error($e);
+ $db->rollBackTransaction();
+ }
+
+ return $count;
+ }
+}
diff --git a/library/X509/CertificatesTable.php b/library/X509/CertificatesTable.php
new file mode 100644
index 0000000..1c1970e
--- /dev/null
+++ b/library/X509/CertificatesTable.php
@@ -0,0 +1,104 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+use ipl\Web\Widget\IcingaIcon;
+use ipl\Web\Widget\Icon;
+
+/**
+ * Table widget to display X.509 certificates
+ */
+class CertificatesTable extends DataTable
+{
+ protected $defaultAttributes = [
+ 'class' => 'cert-table common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ protected function createColumns()
+ {
+ return [
+ 'version' => [
+ 'attributes' => ['class' => 'version-col'],
+ 'renderer' => function ($version) {
+ return Html::tag('div', ['class' => 'badge'], $version);
+ }
+ ],
+
+ 'subject' => mt('x509', 'Certificate'),
+
+ 'ca' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($ca) {
+ if (! $ca) {
+ return null;
+ }
+
+ return new IcingaIcon('ca-check-circle', ['title' => mt('x509', 'Is Certificate Authority')]);
+ }
+ ],
+
+ 'self_signed' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($selfSigned) {
+ if (! $selfSigned) {
+ return null;
+ }
+
+ return new IcingaIcon('refresh-cert', ['title' => mt('x509', 'Is Self-Signed')]);
+ }
+ ],
+
+ 'trusted' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($trusted) {
+ if (! $trusted) {
+ return null;
+ }
+
+ return new Icon('thumbs-up', ['title' => mt('x509', 'Is Trusted')]);
+ }
+ ],
+
+ 'issuer' => mt('x509', 'Issuer'),
+
+ 'signature_algo' => [
+ 'label' => mt('x509', 'Signature Algorithm'),
+ 'renderer' => function ($algo, $data) {
+ return "{$data->signature_hash_algo} with $algo";
+ }
+ ],
+
+ 'pubkey_algo' => [
+ 'label' => mt('x509', 'Public Key'),
+ 'renderer' => function ($algo, $data) {
+ return "$algo {$data->pubkey_bits} bits";
+ }
+ ],
+
+ 'valid_to' => [
+ 'attributes' => ['class' => 'expiration-col'],
+ 'label' => mt('x509', 'Expiration'),
+ 'renderer' => function ($to, $data) {
+ return new ExpirationWidget($data->valid_from, $to);
+ }
+ ]
+ ];
+ }
+
+ protected function renderRow(X509Certificate $row)
+ {
+ $tr = parent::renderRow($row);
+
+ $url = Url::fromPath('x509/certificate', ['cert' => $row->id]);
+
+ $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]);
+
+ return $tr;
+ }
+}
diff --git a/library/X509/ChainDetails.php b/library/X509/ChainDetails.php
new file mode 100644
index 0000000..722b7b3
--- /dev/null
+++ b/library/X509/ChainDetails.php
@@ -0,0 +1,111 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Html\Html;
+use ipl\Web\Widget\IcingaIcon;
+use ipl\Web\Widget\Icon;
+
+/**
+ * Table widget to display X.509 chain details
+ */
+class ChainDetails extends DataTable
+{
+ protected $defaultAttributes = [
+ 'class' => 'cert-table common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ public function createColumns()
+ {
+ return [
+ [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function () {
+ return new IcingaIcon('certificate', ['title' => mt('x509', 'Is a x509 certificate')]);
+ }
+ ],
+
+ 'version' => [
+ 'attributes' => ['class' => 'version-col'],
+ 'renderer' => function ($version) {
+ return Html::tag('div', ['class' => 'badge'], $version);
+ }
+ ],
+
+ 'subject' => [
+ 'label' => mt('x509', 'Subject', 'x509.certificate')
+ ],
+
+ 'ca' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($ca) {
+ if (! $ca) {
+ return null;
+ }
+
+ return new IcingaIcon('ca-check-circle', ['title' => mt('x509', 'Is Certificate Authority')]);
+ }
+ ],
+
+ 'self_signed' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($selfSigned) {
+ if (! $selfSigned) {
+ return null;
+ }
+
+ return new IcingaIcon('refresh-cert', ['title' => mt('x509', 'Is Self-Signed')]);
+ }
+ ],
+
+ 'trusted' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'renderer' => function ($trusted) {
+ if (! $trusted) {
+ return null;
+ }
+
+ return new Icon('thumbs-up', ['title' => mt('x509', 'Is Trusted')]);
+ }
+ ],
+
+ 'signature_algo' => [
+ 'label' => mt('x509', 'Signature Algorithm'),
+ 'renderer' => function ($algo, $data) {
+ return "{$data->signature_hash_algo} with $algo";
+ }
+ ],
+
+ 'pubkey_algo' => [
+ 'label' => mt('x509', 'Public Key'),
+ 'renderer' => function ($algo, $data) {
+ return "$algo {$data->pubkey_bits} bits";
+ }
+ ],
+
+ 'valid_to' => [
+ 'attributes' => ['class' => 'expiration-col'],
+ 'label' => mt('x509', 'Expiration'),
+ 'renderer' => function ($to, $data) {
+ return new ExpirationWidget($data->valid_from, $to);
+ }
+ ]
+ ];
+ }
+
+ protected function renderRow(X509Certificate $row)
+ {
+ $tr = parent::renderRow($row);
+
+ $url = Url::fromPath('x509/certificate', ['cert' => $row->id]);
+
+ $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]);
+
+ return $tr;
+ }
+}
diff --git a/library/X509/ColorScheme.php b/library/X509/ColorScheme.php
new file mode 100644
index 0000000..14a436e
--- /dev/null
+++ b/library/X509/ColorScheme.php
@@ -0,0 +1,37 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use ArrayIterator;
+use InfiniteIterator;
+
+class ColorScheme
+{
+ /**
+ * The colors of this scheme
+ *
+ * @var array
+ */
+ protected $colors;
+
+ public function __construct(array $colors)
+ {
+ $this->colors = $colors;
+ }
+
+ public function scheme()
+ {
+ $iter = new InfiniteIterator(new ArrayIterator($this->colors));
+ $iter->rewind();
+
+ return function () use ($iter) {
+ $color = $iter->current();
+
+ $iter->next();
+
+ return $color;
+ };
+ }
+}
diff --git a/library/X509/Command.php b/library/X509/Command.php
new file mode 100644
index 0000000..9f18727
--- /dev/null
+++ b/library/X509/Command.php
@@ -0,0 +1,18 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Application\Icinga;
+
+class Command extends \Icinga\Cli\Command
+{
+ // Fix Web 2 issue where $configs is not properly initialized
+ protected $configs = [];
+
+ public function init()
+ {
+ Icinga::app()->getModuleManager()->loadEnabledModules();
+ }
+}
diff --git a/library/X509/Common/Database.php b/library/X509/Common/Database.php
new file mode 100644
index 0000000..d6eb3e1
--- /dev/null
+++ b/library/X509/Common/Database.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Common;
+
+use Icinga\Application\Config;
+use Icinga\Data\ResourceFactory;
+use ipl\Sql;
+use PDO;
+
+final class Database
+{
+ /** @var Sql\Connection Database connection */
+ private static $instance;
+
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the database connection
+ *
+ * @return Sql\Connection
+ */
+ public static function get(): Sql\Connection
+ {
+ if (self::$instance === null) {
+ self::$instance = self::getDb();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get the connection to the X.509 database
+ *
+ * @return Sql\Connection
+ */
+ private static function getDb(): Sql\Connection
+ {
+ $config = new Sql\Config(ResourceFactory::getResourceConfig(
+ Config::module('x509')->get('backend', 'resource', 'x509')
+ ));
+
+ $options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ];
+ if ($config->db === 'mysql') {
+ $options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION SQL_MODE='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE"
+ . ",NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'";
+ }
+
+ $config->options = $options;
+
+ return new Sql\Connection($config);
+ }
+}
diff --git a/library/X509/Common/JobOptions.php b/library/X509/Common/JobOptions.php
new file mode 100644
index 0000000..5112272
--- /dev/null
+++ b/library/X509/Common/JobOptions.php
@@ -0,0 +1,162 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Common;
+
+use DateTime;
+use Exception;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Schedule;
+use InvalidArgumentException;
+use LogicException;
+use stdClass;
+
+trait JobOptions
+{
+ /** @var bool Whether this job should only perform a rescan */
+ protected $rescan;
+
+ /** @var bool Whether this job should perform a full scan */
+ protected $fullScan;
+
+ /** @var ?string Since last scan threshold used to filter out scan targets */
+ protected $sinceLastScan;
+
+ /** @var int Used to control how many targets can be scanned in parallel */
+ protected $parallel = Job::DEFAULT_PARALLEL;
+
+ /** @var Schedule The job schedule config */
+ protected $schedule;
+
+ /**
+ * Get whether this job is performing only a rescan
+ *
+ * @return bool
+ */
+ public function isRescan(): bool
+ {
+ return $this->rescan;
+ }
+
+ /**
+ * Set whether this job should do only a rescan or full scan
+ *
+ * @param bool $rescan
+ *
+ * @return $this
+ */
+ public function setRescan(bool $rescan): self
+ {
+ $this->rescan = $rescan;
+
+ return $this;
+ }
+
+ public function getParallel(): int
+ {
+ return $this->parallel;
+ }
+
+ public function setParallel(int $parallel): self
+ {
+ $this->parallel = $parallel;
+
+ return $this;
+ }
+
+ /**
+ * Set whether this job should scan all known and unknown targets
+ *
+ * @param bool $fullScan
+ *
+ * @return $this
+ */
+ public function setFullScan(bool $fullScan): self
+ {
+ $this->fullScan = $fullScan;
+
+ return $this;
+ }
+
+ /**
+ * Set since last scan threshold for the targets to rescan
+ *
+ * @param ?string $time
+ *
+ * @return $this
+ */
+ public function setLastScan(?string $time): self
+ {
+ if ($time && $time !== 'null') {
+ $sinceLastScan = $time;
+ if ($sinceLastScan[0] !== '-') {
+ // When the user specified "2 days" as a threshold strtotime() will compute the
+ // timestamp NOW() + 2 days, but it has to be NOW() + (-2 days)
+ $sinceLastScan = "-$sinceLastScan";
+ }
+
+ try {
+ // Ensure it's a valid date time string representation.
+ new DateTime($sinceLastScan);
+
+ $this->sinceLastScan = $sinceLastScan;
+ } catch (Exception $_) {
+ throw new InvalidArgumentException(sprintf(
+ 'The specified last scan time is in an unknown format: %s',
+ $time
+ ));
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the targets since last scan threshold
+ *
+ * @return ?DateTime
+ */
+ public function getSinceLastScan(): ?DateTime
+ {
+ if (! $this->sinceLastScan) {
+ return null;
+ }
+
+ return new DateTime($this->sinceLastScan);
+ }
+
+ /**
+ * Get the schedule config of this job
+ *
+ * @return Schedule
+ */
+ public function getSchedule(): Schedule
+ {
+ if (! $this->schedule) {
+ throw new LogicException('You are accessing an unset property. Please make sure to set it beforehand.');
+ }
+
+ return $this->schedule;
+ }
+
+ /**
+ * Set the schedule config of this job
+ *
+ * @param Schedule $schedule
+ *
+ * @return $this
+ */
+ public function setSchedule(Schedule $schedule): self
+ {
+ $this->schedule = $schedule;
+
+ /** @var stdClass $config */
+ $config = $schedule->getConfig();
+ $this->setFullScan($config->full_scan ?? false);
+ $this->setRescan($config->rescan ?? false);
+ $this->setLastScan($config->since_last_scan ?? Job::DEFAULT_SINCE_LAST_SCAN);
+
+ return $this;
+ }
+}
diff --git a/library/X509/Common/JobUtils.php b/library/X509/Common/JobUtils.php
new file mode 100644
index 0000000..54398fe
--- /dev/null
+++ b/library/X509/Common/JobUtils.php
@@ -0,0 +1,77 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Common;
+
+use GMP;
+use Icinga\Application\Logger;
+use ipl\Stdlib\Str;
+
+trait JobUtils
+{
+ /**
+ * Parse the given comma separated CIDRs
+ *
+ * @param string $cidrs
+ *
+ * @return array<string, array<int, int|string>>
+ */
+ public function parseCIDRs(string $cidrs): array
+ {
+ $result = [];
+ foreach (Str::trimSplit($cidrs) as $cidr) {
+ $pieces = Str::trimSplit($cidr, '/');
+ if (count($pieces) !== 2) {
+ Logger::warning('CIDR %s is in the wrong format', $cidr);
+ continue;
+ }
+
+ $result[$cidr] = $pieces;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Parse the given comma separated ports
+ *
+ * @param string $ports
+ *
+ * @return array<int, array<string>>
+ */
+ public function parsePorts(string $ports): array
+ {
+ $result = [];
+ foreach (Str::trimSplit($ports) as $portRange) {
+ $pieces = Str::trimSplit($portRange, '-');
+ if (count($pieces) === 2) {
+ list($start, $end) = $pieces;
+ } else {
+ $start = $pieces[0];
+ $end = $pieces[0];
+ }
+
+ $result[] = [$start, $end];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Parse the given comma separated excluded targets
+ *
+ * @param ?string $excludes
+ *
+ * @return array<string>
+ */
+ public function parseExcludes(?string $excludes): array
+ {
+ $result = [];
+ if (! empty($excludes)) {
+ $result = array_flip(Str::trimSplit($excludes));
+ }
+
+ return $result;
+ }
+}
diff --git a/library/X509/Common/Links.php b/library/X509/Common/Links.php
new file mode 100644
index 0000000..c1570dc
--- /dev/null
+++ b/library/X509/Common/Links.php
@@ -0,0 +1,37 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Common;
+
+use Icinga\Module\X509\Model\X509Job;
+use Icinga\Module\X509\Model\X509Schedule;
+use ipl\Web\Url;
+
+class Links
+{
+ public static function job(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job', ['id' => $job->id]);
+ }
+
+ public static function updateJob(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job/update', ['id' => $job->id]);
+ }
+
+ public static function schedules(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job/schedules', ['id' => $job->id]);
+ }
+
+ public static function scheduleJob(X509Job $job): Url
+ {
+ return Url::fromPath('x509/job/schedule', ['id' => $job->id]);
+ }
+
+ public static function updateSchedule(X509Schedule $schedule): Url
+ {
+ return Url::fromPath('x509/job/update-schedule', ['id' => $schedule->job->id, 'scheduleId' => $schedule->id]);
+ }
+}
diff --git a/library/X509/Controller.php b/library/X509/Controller.php
new file mode 100644
index 0000000..f16787d
--- /dev/null
+++ b/library/X509/Controller.php
@@ -0,0 +1,87 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\File\Csv;
+use Icinga\Module\X509\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Util\Json;
+use ipl\Html\Html;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Compat\SearchControls;
+use ipl\Web\Filter\QueryString;
+
+class Controller extends CompatController
+{
+ use SearchControls;
+
+ /** @var Filter\Rule */
+ protected $filter;
+
+ protected $format;
+
+ public function fetchFilterColumns(Query $query): array
+ {
+ return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()));
+ }
+
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ protected function handleFormatRequest(Query $query, callable $callback)
+ {
+ if ($this->format !== 'html' && ! $this->params->has('limit')) {
+ $query->limit(null); // Resets any default limit and offset
+ }
+
+ if ($this->format === 'sql') {
+ $this->content->add(Html::tag('pre', $query->dump()[0]));
+ return true;
+ }
+
+ switch ($this->format) {
+ case 'json':
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'inline; filename=' . $this->getRequest()->getActionName() . '.json'
+ )
+ ->appendBody(
+ Json::encode(iterator_to_array($callback($query)))
+ )
+ ->sendResponse();
+ exit;
+ case 'csv':
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv'
+ )
+ ->appendBody((string) Csv::fromQuery($callback($query)))
+ ->sendResponse();
+ exit;
+ }
+ }
+
+ public function preDispatch()
+ {
+ parent::preDispatch();
+
+ $this->format = $this->params->shift('format', 'html');
+ }
+}
diff --git a/library/X509/DataTable.php b/library/X509/DataTable.php
new file mode 100644
index 0000000..bb82959
--- /dev/null
+++ b/library/X509/DataTable.php
@@ -0,0 +1,150 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+
+class DataTable extends BaseHtmlElement
+{
+ protected $tag = 'table';
+
+ /**
+ * Columns of the table
+ *
+ * @var array
+ */
+ protected $columns;
+
+ /**
+ * The data to display
+ *
+ * @var array|\Traversable
+ */
+ protected $data = [];
+
+ /**
+ * Get data to display
+ *
+ * @return array|\Traversable
+ */
+ public function getData()
+ {
+ return $this->data;
+ }
+
+ /**
+ * Set the data to display
+ *
+ * @param array|\Traversable $data
+ *
+ * @return $this
+ */
+ public function setData($data)
+ {
+ if (! is_array($data) && ! $data instanceof \Traversable) {
+ throw new \InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $this->data = $data;
+
+ return $this;
+ }
+
+ protected function createColumns()
+ {
+ }
+
+ public function renderHeader()
+ {
+ $cells = [];
+
+ foreach ($this->columns as $column) {
+ if (is_array($column)) {
+ if (isset($column['label'])) {
+ $label = $column['label'];
+ } else {
+ $label = new HtmlString('&nbsp;');
+ }
+ } else {
+ $label = $column;
+ }
+
+ $cells[] = Html::tag('th', $label);
+ }
+
+ return Html::tag('thead', Html::tag('tr', $cells));
+ }
+
+ protected function renderRow(X509Certificate $row)
+ {
+ $cells = [];
+
+ foreach ($this->columns as $key => $column) {
+ if (! is_int($key) && $row->hasProperty($key)) {
+ $data = $row->$key;
+ } else {
+ $data = null;
+ if (isset($column['column'])) {
+ if (is_callable($column['column'])) {
+ $data = call_user_func(($column['column']), $row);
+ } elseif (isset($row->{$column['column']})) {
+ $data = $row->{$column['column']};
+ }
+ }
+ }
+
+ if (isset($column['renderer'])) {
+ $content = call_user_func(($column['renderer']), $data, $row);
+ } else {
+ $content = $data;
+ }
+
+ $cells[] = Html::tag('td', $column['attributes'] ?? null, $content);
+ }
+
+ return Html::tag('tr', $cells);
+ }
+
+ protected function renderBody($data)
+ {
+ if (! is_array($data) && ! $data instanceof \Traversable) {
+ throw new \InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $rows = [];
+
+ foreach ($data as $row) {
+ $rows[] = $this->renderRow($row);
+ }
+
+ if (empty($rows)) {
+ $colspan = count($this->columns);
+
+ $rows = Html::tag(
+ 'tr',
+ Html::tag(
+ 'td',
+ ['colspan' => $colspan],
+ mt('x509', 'No results found.')
+ )
+ );
+ }
+
+ return Html::tag('tbody', $rows);
+ }
+
+ protected function assemble()
+ {
+ $this->columns = $this->createColumns();
+
+ $this->add(array_filter([
+ $this->renderHeader(),
+ $this->renderBody($this->getData())
+ ]));
+ }
+}
diff --git a/library/X509/DbTool.php b/library/X509/DbTool.php
new file mode 100644
index 0000000..4049c5a
--- /dev/null
+++ b/library/X509/DbTool.php
@@ -0,0 +1,45 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2020 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use ipl\Sql\Connection;
+
+class DbTool
+{
+ protected $pgsql = false;
+
+ public function __construct(Connection $db)
+ {
+ $this->pgsql = $db->getConfig()->db === 'pgsql';
+ }
+
+ /**
+ * @param string $binary
+ *
+ * @return string
+ */
+ public function marshalBinary($binary)
+ {
+ if ($this->pgsql) {
+ return sprintf('\\x%s', bin2hex(static::unmarshalBinary($binary)));
+ }
+
+ return $binary;
+ }
+
+ /**
+ * @param resource|string $binary
+ *
+ * @return string
+ */
+ public static function unmarshalBinary($binary)
+ {
+ if (is_resource($binary)) {
+ return stream_get_contents($binary);
+ }
+
+ return $binary;
+ }
+}
diff --git a/library/X509/Donut.php b/library/X509/Donut.php
new file mode 100644
index 0000000..fe8b748
--- /dev/null
+++ b/library/X509/Donut.php
@@ -0,0 +1,92 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+
+class Donut extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'cert-donut'];
+
+ /**
+ * The donut data
+ *
+ * @var array|\Traversable
+ */
+ protected $data = [];
+
+ protected $heading;
+
+ protected $headingLevel;
+
+ protected $labelCallback;
+
+ /**
+ * Get data to display
+ *
+ * @return array|\Traversable
+ */
+ public function getData()
+ {
+ return $this->data;
+ }
+
+ /**
+ * Set the data to display
+ *
+ * @param array|\Traversable $data
+ *
+ * @return $this
+ */
+ public function setData($data)
+ {
+ if (! is_array($data) && ! $data instanceof \Traversable) {
+ throw new \InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $this->data = $data;
+
+ return $this;
+ }
+
+ public function setHeading($heading, $level)
+ {
+ $this->heading = $heading;
+ $this->headingLevel = (int) $level;
+
+ return $this;
+ }
+
+ public function setLabelCallback(callable $callback)
+ {
+ $this->labelCallback = $callback;
+
+ return $this;
+ }
+
+ public function assemble()
+ {
+ $donut = new \Icinga\Chart\Donut();
+ $legend = new Table();
+
+ foreach ($this->data as $index => $data) {
+ $donut->addSlice((int) $data['cnt'], ['class' => 'segment-' . $index]);
+ $legend->addRow(
+ [
+ Html::tag('span', ['class' => 'badge badge-' . $index]),
+ call_user_func($this->labelCallback, $data),
+ $data['cnt']
+ ]
+ );
+ }
+
+ $this->add([Html::tag("h{$this->headingLevel}", $this->heading), new HtmlString($donut->render()), $legend]);
+ }
+}
diff --git a/library/X509/ExpirationWidget.php b/library/X509/ExpirationWidget.php
new file mode 100644
index 0000000..dffc3a8
--- /dev/null
+++ b/library/X509/ExpirationWidget.php
@@ -0,0 +1,80 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Date\DateFormatter;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Web\Compat\StyleWithNonce;
+
+class ExpirationWidget extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'expiration-widget'];
+
+ protected $from;
+
+ protected $to;
+
+ public function __construct($from, $to)
+ {
+ $this->from = $from;
+ $this->to = $to;
+ }
+
+ protected function assemble()
+ {
+ $now = time();
+
+ $from = $this->from;
+
+ if ($from->getTimestamp() > $now) {
+ $ratio = 0;
+ $dateTip = $from->format('Y-m-d H:i:s');
+ $message = sprintf(mt('x509', 'not until after %s'), DateFormatter::timeUntil($from->getTimestamp(), true));
+ } else {
+ $to = $this->to;
+
+ $secondsRemaining = $to->getTimestamp() - $now;
+ $daysRemaining = ($secondsRemaining - $secondsRemaining % 86400) / 86400;
+ if ($daysRemaining > 0) {
+ $secondsTotal = $to->getTimestamp() - $from->getTimestamp();
+ $daysTotal = ($secondsTotal - $secondsTotal % 86400) / 86400;
+
+ $ratio = min(100, 100 - round(($daysRemaining * 100) / $daysTotal, 2));
+ $message = sprintf(mt('x509', 'in %d days'), $daysRemaining);
+ } else {
+ $ratio = 100;
+ if ($daysRemaining < 0) {
+ $message = sprintf(mt('x509', '%d days ago'), $daysRemaining * -1);
+ } else {
+ $message = mt('x509', 'today');
+ }
+ }
+
+ $dateTip = $to->format('Y-m-d H:i:s');
+ }
+
+ if ($ratio >= 75) {
+ if ($ratio >= 90) {
+ $state = 'state-critical';
+ } else {
+ $state = 'state-warning';
+ }
+ } else {
+ $state = 'state-ok';
+ }
+
+ $progressBar = Html::tag('div', ['class' => "bg-stateful $state"], new HtmlString('&nbsp;'));
+ $progressBarStyle = (new StyleWithNonce())
+ ->setModule('x509')
+ ->addFor($progressBar, ['width' => sprintf('%F%%', $ratio)]);
+
+ $this->addHtml(Html::tag('span', ['class' => 'progress-bar-label', 'title' => $dateTip], $message));
+ $this->addHtml($progressBarStyle, Html::tag('div', ['class' => 'progress-bar dont-print'], $progressBar));
+ }
+}
diff --git a/library/X509/FilterAdapter.php b/library/X509/FilterAdapter.php
new file mode 100644
index 0000000..5a43071
--- /dev/null
+++ b/library/X509/FilterAdapter.php
@@ -0,0 +1,56 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filterable;
+
+/**
+ * @internal
+ */
+class FilterAdapter implements Filterable
+{
+ /**
+ * @var Filter
+ */
+ protected $filter;
+
+ public function applyFilter(Filter $filter)
+ {
+ return $this->addFilter($filter);
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ public function getFilter()
+ {
+ return $this->filter;
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ if (! $filter->isEmpty()) {
+ if ($this->filter === null) {
+ $this->filter = $filter;
+ } else {
+ $this->filter->andFilter($filter);
+ }
+ }
+
+ return $this;
+ }
+
+ public function where($condition, $value = null)
+ {
+ $this->addFilter(Filter::expression($condition, '=', $value));
+
+ return $this;
+ }
+}
diff --git a/library/X509/Hook/SniHook.php b/library/X509/Hook/SniHook.php
new file mode 100644
index 0000000..0b707b6
--- /dev/null
+++ b/library/X509/Hook/SniHook.php
@@ -0,0 +1,54 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\Hook;
+
+use Icinga\Application\Config;
+use Icinga\Application\Hook;
+use Icinga\Data\Filter\Filter;
+use Icinga\Util\StringHelper;
+
+/**
+ * Hook for SNI maps
+ */
+abstract class SniHook
+{
+ /**
+ * Return the SNI maps of all hooks
+ *
+ * ['192.0.2.1' => ['example.com', 'mail.example.com']]
+ *
+ * @return string[][]
+ */
+ public static function getAll()
+ {
+ // This is implemented as map of maps to avoid duplicates,
+ // the caller is expected to handle it as map of sequences though
+ $sni = [];
+
+ /** @var self $hook */
+ foreach (Hook::all('X509\Sni') as $hook) {
+ foreach ($hook->getHosts() as $ip => $hostname) {
+ $sni[$ip][$hostname] = $hostname;
+ }
+ }
+
+ foreach (Config::module('x509', 'sni') as $ip => $config) {
+ foreach (array_filter(StringHelper::trimSplit($config->get('hostnames', []))) as $hostname) {
+ $sni[$ip][$hostname] = $hostname;
+ }
+ }
+
+ return $sni;
+ }
+
+ /**
+ * Aggregate pairs of ip => hostname
+ *
+ * @param Filter $filter
+ *
+ * @return \Generator
+ */
+ abstract public function getHosts(Filter $filter = null);
+}
diff --git a/library/X509/Job.php b/library/X509/Job.php
new file mode 100644
index 0000000..1e0b3f7
--- /dev/null
+++ b/library/X509/Job.php
@@ -0,0 +1,755 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use DateTime;
+use Exception;
+use Generator;
+use GMP;
+use Icinga\Application\Logger;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Common\JobOptions;
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateChain;
+use Icinga\Module\X509\Model\X509JobRun;
+use Icinga\Module\X509\Model\X509Target;
+use Icinga\Module\X509\React\StreamOptsCaptureConnector;
+use Icinga\Util\Json;
+use ipl\Scheduler\Common\TaskProperties;
+use ipl\Scheduler\Contract\Task;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+use LogicException;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+use React\EventLoop\Loop;
+use React\Promise;
+use React\Socket\ConnectionInterface;
+use React\Socket\Connector;
+use React\Socket\ConnectorInterface;
+use React\Socket\SecureConnector;
+use React\Socket\TimeoutConnector;
+use Throwable;
+
+class Job implements Task
+{
+ use JobOptions;
+ use TaskProperties;
+
+ /** @var int Number of targets to be scanned in parallel by default */
+ public const DEFAULT_PARALLEL = 256;
+
+ /** @var string Default since last scan threshold used to filter out scan targets */
+ public const DEFAULT_SINCE_LAST_SCAN = '-24 hours';
+
+ /** @var int The database id of this job */
+ protected $id;
+
+ /** @var Connection x509 database connection */
+ private $db;
+
+ /** @var DbTool Database utils for marshalling and unmarshalling binary data */
+ private $dbTool;
+
+ /** @var int Number of pending targets to be scanned */
+ private $pendingTargets = 0;
+
+ /** @var int Total number of scan targets */
+ private $totalTargets = 0;
+
+ /** @var int Number of scanned targets */
+ private $finishedTargets = 0;
+
+ /** @var Generator Scan targets generator */
+ private $targets;
+
+ /** @var array<string, array<string>> The configured SNI maps */
+ private $snimap;
+
+ /** @var int The id of the last inserted job run entry */
+ private $jobRunId;
+
+ /** @var Promise\Deferred React promise deferred instance used to resolve the running promise */
+ protected $deferred;
+
+ /** @var DateTime The start time of this job */
+ protected $jobRunStart;
+
+ /** @var array<string> A list of excluded IP addresses and host names */
+ private $excludedTargets = [];
+
+ /** @var array<string, array<int, int|string>> The configured CIDRs of this job */
+ private $cidrs;
+
+ /** @var array<int, array<string>> The configured ports of this job */
+ private $ports;
+
+ /**
+ * Construct a new Job instance
+ *
+ * @param string $name The name of this job
+ * @param array<string, array<int, int|string>> $cidrs The configured CIDRs to be used by this job
+ * @param array<int, array<string>> $ports The configured ports to be used by this job
+ * @param array<string, array<string>> $snimap The configured SNI maps to be used by this job
+ * @param ?Schedule $schedule
+ */
+ public function __construct(string $name, array $cidrs, array $ports, array $snimap, Schedule $schedule = null)
+ {
+ $this->name = $name;
+ $this->db = Database::get();
+ $this->dbTool = new DbTool($this->db);
+ $this->snimap = $snimap;
+ $this->cidrs = $cidrs;
+ $this->ports = $ports;
+
+ if ($schedule) {
+ $this->setSchedule($schedule);
+ }
+
+ $this->setName($name);
+ }
+
+ /**
+ * Transform the given human-readable IP address into a binary format
+ *
+ * @param string $addr
+ *
+ * @return string
+ */
+ public static function binary(string $addr): string
+ {
+ return str_pad(inet_pton($addr), 16, "\0", STR_PAD_LEFT);
+ }
+
+ /**
+ * Transform the given human-readable IP address into GMP number
+ *
+ * @param string $addr
+ *
+ * @return ?GMP
+ */
+ public static function addrToNumber(string $addr): ?GMP
+ {
+ return gmp_import(static::binary($addr));
+ }
+
+ /**
+ * Transform the given number into human-readable IP address
+ *
+ * @param $num
+ * @param bool $ipv6
+ *
+ * @return false|string
+ */
+ public static function numberToAddr($num, bool $ipv6 = true)
+ {
+ if ($ipv6) {
+ return inet_ntop(str_pad(gmp_export($num), 16, "\0", STR_PAD_LEFT));
+ } else {
+ return inet_ntop(gmp_export($num));
+ }
+ }
+
+ /**
+ * Check whether the given IP is inside the specified CIDR
+ *
+ * @param GMP $addr
+ * @param string $subnet
+ * @param int $mask
+ *
+ * @return bool
+ */
+ public static function isAddrInside(GMP $addr, string $subnet, int $mask): bool
+ {
+ // `gmp_pow()` is like PHP's pow() function, but handles also very large numbers
+ // and `gmp_com()` is like the bitwise NOT (~) operator.
+ $mask = gmp_com(gmp_pow(2, (static::isIPV6($subnet) ? 128 : 32) - $mask) - 1);
+ return gmp_strval(gmp_and($addr, $mask)) === gmp_strval(gmp_and(static::addrToNumber($subnet), $mask));
+ }
+
+ /**
+ * Get whether the given IP address is IPV6 address
+ *
+ * @param $addr
+ *
+ * @return bool
+ */
+ public static function isIPV6($addr): bool
+ {
+ return filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ }
+
+ /**
+ * Get the database id of this job
+ *
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set the database id of this job
+ *
+ * @param int $id
+ *
+ * @return $this
+ */
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getUuid(): UuidInterface
+ {
+ if (! $this->uuid) {
+ $this->setUuid(Uuid::fromBytes($this->getChecksum()));
+ }
+
+ return $this->uuid;
+ }
+
+ /**
+ * Get the configured job CIDRS
+ *
+ * @return array<string, array<int, int|string>>
+ */
+ public function getCIDRs(): array
+ {
+ return $this->cidrs;
+ }
+
+ /**
+ * Set the CIDRs of this job
+ *
+ * @param array<string, array<int, int|string>> $cidrs
+ *
+ * @return $this
+ */
+ public function setCIDRs(array $cidrs): self
+ {
+ $this->cidrs = $cidrs;
+
+ return $this;
+ }
+
+ /**
+ * Get the configured ports of this job
+ *
+ * @return array<int, array<string>>
+ */
+ public function getPorts(): array
+ {
+ return $this->ports;
+ }
+
+ /**
+ * Set the ports of this job to be scanned
+ *
+ * @param array<int, array<string>> $ports
+ *
+ * @return $this
+ */
+ public function setPorts(array $ports): self
+ {
+ $this->ports = $ports;
+
+ return $this;
+ }
+
+ /**
+ * Get excluded IPs and host names
+ *
+ * @return array<string>
+ */
+ public function getExcludes(): array
+ {
+ return $this->excludedTargets;
+ }
+
+ /**
+ * Set a set of IPs and host names to be excluded from scan
+ *
+ * @param array<string> $targets
+ *
+ * @return $this
+ */
+ public function setExcludes(array $targets): self
+ {
+ $this->excludedTargets = $targets;
+
+ return $this;
+ }
+
+ private function getConnector($peerName): array
+ {
+ $simpleConnector = new Connector();
+ $streamCaptureConnector = new StreamOptsCaptureConnector($simpleConnector);
+ $secureConnector = new SecureConnector($streamCaptureConnector, null, [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'capture_peer_cert_chain' => true,
+ 'SNI_enabled' => true,
+ 'peer_name' => $peerName
+ ]);
+ return [new TimeoutConnector($secureConnector, 5.0), $streamCaptureConnector];
+ }
+
+ /**
+ * Get whether this job has been completed scanning all targets
+ *
+ * @return bool
+ */
+ public function isFinished(): bool
+ {
+ return ! $this->targets->valid() && $this->pendingTargets === 0;
+ }
+
+ public function updateLastScan($target)
+ {
+ if (! $this->isRescan() && ! isset($target->id)) {
+ return;
+ }
+
+ $this->db->update('x509_target', [
+ 'last_scan' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ], ['id = ?' => $target->id]);
+ }
+
+ public function getChecksum(): string
+ {
+ $data = [
+ 'name' => $this->getName(),
+ 'cidrs' => $this->getCIDRs(),
+ 'ports' => $this->getPorts(),
+ 'exclude_targets' => $this->getExcludes(),
+ ];
+
+ $schedule = null;
+ if ($this->schedule) {
+ $schedule = $this->getSchedule();
+ }
+
+ return md5(Json::encode($data) . ($schedule ? bin2hex($schedule->getChecksum()) : ''), true);
+ }
+
+ protected function getScanTargets(): Generator
+ {
+ $generate = $this->fullScan || ! $this->isRescan();
+ if (! $generate) {
+ $run = X509JobRun::on($this->db)
+ ->columns([new Expression('1')])
+ ->filter(Filter::equal('schedule.job_id', $this->getId()))
+ ->filter(Filter::unequal('total_targets', 0))
+ ->limit(1)
+ ->execute();
+
+ $generate = ! $run->hasResult();
+ }
+
+ if ($generate) {
+ yield from $this->generateTargets();
+ }
+
+ $sinceLastScan = $this->getSinceLastScan();
+ if ((! $this->fullScan && $sinceLastScan !== null) || $this->isRescan()) {
+ $targets = X509Target::on($this->db)->columns(['id', 'ip', 'hostname', 'port']);
+ if (! $this->fullScan && $sinceLastScan) {
+ $targets->filter(Filter::lessThan('last_scan', $sinceLastScan));
+ }
+
+ foreach ($targets as $target) {
+ $addr = static::addrToNumber($target->ip);
+ $addrFound = false;
+ foreach ($this->getCIDRs() as $cidr) {
+ list($subnet, $mask) = $cidr;
+ if (static::isAddrInside($addr, (string) $subnet, (int) $mask)) {
+ $target->ip = static::numberToAddr($addr, static::isIPV6($subnet));
+ $addrFound = true;
+
+ break;
+ }
+ }
+
+ if ($addrFound) {
+ yield $target;
+ }
+ }
+ }
+ }
+
+ private function generateTargets(): Generator
+ {
+ $excludes = $this->getExcludes();
+ foreach ($this->getCIDRs() as $cidr) {
+ list($startIp, $prefix) = $cidr;
+ $ipv6 = static::isIPV6($startIp);
+ $subnet = $ipv6 ? 128 : 32;
+ $numIps = pow(2, ($subnet - (int) $prefix));
+
+ Logger::info('Scanning %d IPs in the CIDR %s', $numIps, implode('/', $cidr));
+
+ $start = static::addrToNumber((string) $startIp);
+ for ($i = 0; $i < $numIps; $i++) {
+ $ip = static::numberToAddr(gmp_add($start, $i), $ipv6);
+ if (isset($excludes[$ip])) {
+ Logger::debug('Excluding IP %s from scan', $ip);
+ continue;
+ }
+
+ foreach ($this->getPorts() as $portRange) {
+ list($startPort, $endPort) = $portRange;
+ foreach (range($startPort, $endPort) as $port) {
+ foreach ($this->snimap[$ip] ?? [null] as $hostname) {
+ if (array_key_exists((string) $hostname, $excludes)) {
+ Logger::debug('Excluding host %s from scan', $hostname);
+ continue;
+ }
+
+ if (! $this->fullScan) {
+ $targets = X509Target::on($this->db)
+ ->columns([new Expression('1')])
+ ->filter(
+ Filter::all(
+ Filter::equal('ip', $ip),
+ Filter::equal('port', $port),
+ $hostname !== null
+ ? Filter::equal('hostname', $hostname)
+ : Filter::unlike('hostname', '*')
+ )
+ );
+
+ if ($targets->execute()->hasResult()) {
+ continue;
+ }
+ }
+
+ yield (object) [
+ 'ip' => $ip,
+ 'port' => $port,
+ 'hostname' => $hostname
+ ];
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public function updateJobStats(bool $finished = false): void
+ {
+ $fields = ['finished_targets' => $this->finishedTargets];
+ if ($finished) {
+ $fields['end_time'] = new Expression('UNIX_TIMESTAMP() * 1000');
+ $fields['total_targets'] = $this->totalTargets;
+ }
+
+ $this->db->update('x509_job_run', $fields, ['id = ?' => $this->jobRunId]);
+ }
+
+ private static function formatTarget($target): string
+ {
+ $result = "tls://[{$target->ip}]:{$target->port}";
+
+ if ($target->hostname !== null) {
+ $result .= " [SNI hostname: {$target->hostname}]";
+ }
+
+ return $result;
+ }
+
+ private function finishTarget()
+ {
+ $this->pendingTargets--;
+ $this->finishedTargets++;
+ $this->startNextTarget();
+ }
+
+ private function startNextTarget()
+ {
+ if ($this->isFinished()) {
+ // No targets to process anymore, so we can now resolve the promise
+ $this->deferred->resolve($this->finishedTargets);
+
+ return;
+ }
+
+ if (! $this->targets->valid()) {
+ // When nothing is yielded, and it's still not finished yet, just get the next target
+ return;
+ }
+
+ $target = $this->targets->current();
+ $this->targets->next();
+
+ $this->totalTargets++;
+ $this->pendingTargets++;
+
+ $url = "tls://[{$target->ip}]:{$target->port}";
+ Logger::debug("Connecting to %s", self::formatTarget($target));
+
+ /** @var ConnectorInterface $connector */
+ /** @var StreamOptsCaptureConnector $streamCapture */
+ list($connector, $streamCapture) = $this->getConnector($target->hostname);
+ $connector->connect($url)->then(
+ function (ConnectionInterface $conn) use ($target, $streamCapture) {
+ Logger::info("Connected to %s", self::formatTarget($target));
+
+ // Close connection in order to capture stream context options
+ $conn->close();
+
+ $capturedStreamOptions = $streamCapture->getCapturedStreamOptions();
+
+ $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']);
+
+ $this->finishTarget();
+ },
+ function (Exception $exception) use ($target, $streamCapture) {
+ Logger::debug("Cannot connect to server: %s", $exception->getMessage());
+
+ $capturedStreamOptions = $streamCapture->getCapturedStreamOptions();
+
+ if (isset($capturedStreamOptions['ssl']['peer_certificate_chain'])) {
+ // The scanned target presented its certificate chain despite throwing an error
+ // This is the case for targets which require client certificates for example
+ $this->processChain($target, $capturedStreamOptions['ssl']['peer_certificate_chain']);
+ } else {
+ $this->db->update(
+ 'x509_target',
+ [
+ 'latest_certificate_chain_id' => null,
+ 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ],
+ [
+ 'hostname = ?' => $target->hostname,
+ 'ip = ?' => $this->dbTool->marshalBinary(static::binary($target->ip)),
+ 'port = ?' => $target->port
+ ]
+ );
+ }
+
+ $step = max($this->totalTargets / 100, 1);
+
+ if ($this->finishedTargets % (int) $step == 0) {
+ $this->updateJobStats();
+ }
+
+ $this->finishTarget();
+ }
+ )->always(function () use ($target) {
+ $this->updateLastScan($target);
+ })->otherwise(function (Throwable $e) {
+ Logger::error($e->getMessage());
+ Logger::error($e->getTraceAsString());
+ });
+ }
+
+ public function run(): Promise\ExtendedPromiseInterface
+ {
+ $this->jobRunStart = new DateTime();
+ // Update the job statistics regardless of whether the job was successful, failed, or canceled.
+ // Otherwise, some database columns might remain null.
+ $updateJobStats = function () {
+ $this->updateJobStats(true);
+ };
+ $this->deferred = new Promise\Deferred($updateJobStats);
+ $this->deferred->promise()->always($updateJobStats);
+
+ Loop::futureTick(function () {
+ if (! $this->db->ping()) {
+ $this->deferred->reject(new LogicException('Lost connection to database and failed to reconnect'));
+
+ return;
+ }
+
+ // Reset those statistics for the next run! Is only necessary when
+ // running this job using the scheduler
+ $this->totalTargets = 0;
+ $this->finishedTargets = 0;
+ $this->pendingTargets = 0;
+
+ if ($this->schedule) {
+ $scheduleId = $this->getSchedule()->getId();
+ } else {
+ $scheduleId = new Expression('NULL');
+ }
+
+ $this->db->insert('x509_job_run', [
+ 'job_id' => $this->getId(),
+ 'schedule_id' => $scheduleId,
+ 'start_time' => $this->jobRunStart->getTimestamp() * 1000.0,
+ 'total_targets' => 0,
+ 'finished_targets' => 0
+ ]);
+ $this->jobRunId = (int) $this->db->lastInsertId();
+
+ $this->targets = $this->getScanTargets();
+
+ if ($this->isFinished()) {
+ // There are no targets to scan, so we can resolve the promise earlier
+ $this->deferred->resolve(0);
+
+ return;
+ }
+
+ // Start scanning the first couple of targets...
+ for ($i = 0; $i < $this->getParallel() && ! $this->isFinished(); $i++) {
+ $this->startNextTarget();
+ }
+ });
+
+ /** @var Promise\ExtendedPromiseInterface $promise */
+ $promise = $this->deferred->promise();
+ return $promise;
+ }
+
+ protected function processChain($target, $chain)
+ {
+ if ($target->hostname === null) {
+ $hostname = gethostbyaddr($target->ip);
+
+ if ($hostname !== false) {
+ $target->hostname = $hostname;
+ }
+ }
+
+ $this->db->transaction(function () use ($target, $chain) {
+ $row = X509Target::on($this->db)
+ ->columns(['id'])
+ ->filter(
+ Filter::all(
+ Filter::equal('ip', $target->ip),
+ Filter::equal('port', $target->port),
+ Filter::equal('hostname', $target->hostname)
+ )
+ )->first();
+
+ if (! $row) {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $this->db->insert(
+ 'x509_target',
+ [
+ 'ip' => $this->dbTool->marshalBinary(static::binary($target->ip)),
+ 'port' => $target->port,
+ 'hostname' => $target->hostname,
+ 'last_scan' => new Expression('UNIX_TIMESTAMP() * 1000'),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ $targetId = $this->db->lastInsertId();
+ } else {
+ $targetId = $row->id;
+ }
+
+ $chainUptodate = false;
+
+ $lastChain = X509CertificateChain::on($this->db)
+ ->columns(['id'])
+ ->filter(Filter::equal('target_id', $targetId))
+ ->orderBy('id', SORT_DESC)
+ ->limit(1)
+ ->first();
+
+ if ($lastChain) {
+ $lastFingerprints = X509Certificate::on($this->db)->utilize('chain');
+ $lastFingerprints
+ ->columns(['fingerprint'])
+ ->getSelectBase()
+ ->where(new Expression(
+ 'certificate_link.certificate_chain_id = %d',
+ [$lastChain->id]
+ ))
+ ->orderBy('certificate_link.order');
+
+ $lastFingerprintsArr = [];
+ foreach ($lastFingerprints as $lastFingerprint) {
+ $lastFingerprintsArr[] = $lastFingerprint->fingerprint;
+ }
+
+ $currentFingerprints = [];
+
+ foreach ($chain as $cert) {
+ $currentFingerprints[] = openssl_x509_fingerprint($cert, 'sha256', true);
+ }
+
+ $chainUptodate = $currentFingerprints === $lastFingerprintsArr;
+ }
+
+ if ($lastChain && $chainUptodate) {
+ $chainId = $lastChain->id;
+ } else {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $this->db->insert(
+ 'x509_certificate_chain',
+ [
+ 'target_id' => $targetId,
+ 'length' => count($chain),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $chainId = $this->db->lastInsertId();
+
+ $lastCertInfo = [];
+ foreach ($chain as $index => $cert) {
+ $lastCertInfo = CertificateUtils::findOrInsertCert($this->db, $cert);
+ list($certId, $_) = $lastCertInfo;
+
+ $this->db->insert(
+ 'x509_certificate_chain_link',
+ [
+ 'certificate_chain_id' => $chainId,
+ $this->db->quoteIdentifier('order') => $index,
+ 'certificate_id' => $certId,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $lastCertInfo[] = $index;
+ }
+
+ // There might be chains that do not include the self-signed top-level Ca,
+ // so we need to include it manually here, as we need to display the full
+ // chain in the UI.
+ $rootCa = X509Certificate::on($this->db)
+ ->columns(['id'])
+ ->filter(Filter::equal('subject_hash', $lastCertInfo[1]))
+ ->filter(Filter::equal('self_signed', true))
+ ->first();
+
+ if ($rootCa && $rootCa->id !== $lastCertInfo[0]) {
+ $this->db->update(
+ 'x509_certificate_chain',
+ ['length' => count($chain) + 1],
+ ['id = ?' => $chainId]
+ );
+
+ $this->db->insert(
+ 'x509_certificate_chain_link',
+ [
+ 'certificate_chain_id' => $chainId,
+ $this->db->quoteIdentifier('order') => $lastCertInfo[2] + 1,
+ 'certificate_id' => $rootCa->id,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ }
+ }
+
+ $this->db->update(
+ 'x509_target',
+ [
+ 'latest_certificate_chain_id' => $chainId,
+ 'mtime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ],
+ ['id = ?' => $targetId]
+ );
+ });
+ }
+}
diff --git a/library/X509/Model/Behavior/DERBase64.php b/library/X509/Model/Behavior/DERBase64.php
new file mode 100644
index 0000000..f7b7215
--- /dev/null
+++ b/library/X509/Model/Behavior/DERBase64.php
@@ -0,0 +1,44 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+/**
+ * Support automatically transformation of DER-encoded certificates to PEM and vice versa.
+ */
+class DERBase64 extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ if (! $value) {
+ return null;
+ }
+
+ $block = chunk_split(base64_encode($value), 64, "\n");
+
+ return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----";
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if (! $value) {
+ return null;
+ }
+
+ $lines = explode("\n", $value);
+ $der = '';
+
+ foreach ($lines as $line) {
+ if (strpos($line, '-----') === 0) {
+ continue;
+ }
+
+ $der .= base64_decode($line);
+ }
+
+ return $der;
+ }
+}
diff --git a/library/X509/Model/Behavior/ExpressionInjector.php b/library/X509/Model/Behavior/ExpressionInjector.php
new file mode 100644
index 0000000..c3fa2cb
--- /dev/null
+++ b/library/X509/Model/Behavior/ExpressionInjector.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model\Behavior;
+
+use ipl\Orm\Contract\QueryAwareBehavior;
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Orm\Query;
+use ipl\Sql\ExpressionInterface;
+use ipl\Stdlib\Filter;
+
+/**
+ * Support expression columns (which don't really exist in the database, but rather
+ * resulted e.g. from a `case..when` expression), being used as filter columns
+ */
+class ExpressionInjector implements RewriteFilterBehavior, QueryAwareBehavior
+{
+ /** @var array */
+ protected $columns;
+
+ /** @var Query */
+ protected $query;
+
+ public function __construct(...$columns)
+ {
+ $this->columns = $columns;
+ }
+
+ public function setQuery(Query $query)
+ {
+ $this->query = $query;
+
+ return $this;
+ }
+
+ public function rewriteCondition(Filter\Condition $condition, $relation = null)
+ {
+ $columnName = $condition->metaData()->get('columnName');
+ if (in_array($columnName, $this->columns, true)) {
+ $relationPath = $condition->metaData()->get('relationPath');
+ if ($relationPath && $relationPath !== $this->query->getModel()->getTableAlias()) {
+ $subject = $this->query->getResolver()->resolveRelation($relationPath)->getTarget();
+ } else {
+ $subject = $this->query->getModel();
+ }
+
+ /** @var ExpressionInterface $column */
+ $column = $subject->getColumns()[$columnName];
+ $expression = clone $column;
+ $expression->setColumns($this->query->getResolver()->qualifyColumns(
+ $this->query->getResolver()->requireAndResolveColumns(
+ $expression->getColumns(),
+ $subject
+ ),
+ $subject
+ ));
+
+ $condition->setColumn($this->query->getDb()->getQueryBuilder()->buildExpression($expression));
+ }
+ }
+}
diff --git a/library/X509/Model/Behavior/Ip.php b/library/X509/Model/Behavior/Ip.php
new file mode 100644
index 0000000..79c9e80
--- /dev/null
+++ b/library/X509/Model/Behavior/Ip.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model\Behavior;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Contract\PropertyBehavior;
+
+/**
+ * Support automatically transformation of human-readable IP addresses into their respective packed
+ * binary representation and vice versa.
+ */
+class Ip extends Binary
+{
+ public function fromDb($value, $key, $_)
+ {
+ $value = parent::fromDb($value, $key, $_);
+ if ($value === null) {
+ return null;
+ }
+
+ $ipv4 = ltrim($value, "\0");
+ if (strlen($ipv4) === 4) {
+ $value = $ipv4;
+ }
+
+ return inet_ntop($value);
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if ($value === null || $value === '*' || ! ctype_print($value)) {
+ return $value;
+ }
+
+ return parent::toDb(str_pad(inet_pton($value), 16, "\0", STR_PAD_LEFT), $key, $_);
+ }
+}
diff --git a/library/X509/Model/Schema.php b/library/X509/Model/Schema.php
new file mode 100644
index 0000000..02ec0c0
--- /dev/null
+++ b/library/X509/Model/Schema.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+
+/**
+ * A database model for x509 schema version table
+ *
+ * @property int $id Unique identifier of the database schema entries
+ * @property string $version The current schema version of Icinga Web
+ * @property DateTime $timestamp The insert/modify time of the schema entry
+ * @property bool $success Whether the database migration of the current version was successful
+ * @property ?string $reason The reason why the database migration has failed
+ */
+class Schema extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_schema';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'version',
+ 'timestamp',
+ 'success',
+ 'reason'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new BoolCast(['success']));
+ $behaviors->add(new MillisecondTimestamp(['timestamp']));
+ }
+}
diff --git a/library/X509/Model/X509Certificate.php b/library/X509/Model/X509Certificate.php
new file mode 100644
index 0000000..63bdf95
--- /dev/null
+++ b/library/X509/Model/X509Certificate.php
@@ -0,0 +1,159 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use Icinga\Module\X509\Model\Behavior\DERBase64;
+use Icinga\Module\X509\Model\Behavior\ExpressionInjector;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+use ipl\Sql\Expression;
+
+class X509Certificate extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'certificate';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'subject',
+ 'subject_hash',
+ 'issuer',
+ 'issuer_hash',
+ 'issuer_certificate_id',
+ 'version',
+ 'self_signed',
+ 'ca',
+ 'trusted',
+ 'pubkey_algo',
+ 'pubkey_bits',
+ 'signature_algo',
+ 'signature_hash_algo',
+ 'valid_from',
+ 'valid_to',
+ 'fingerprint',
+ 'serial',
+ 'certificate',
+ 'ctime',
+ 'mtime',
+ 'duration' => new Expression('%s - %s', ['valid_to', 'valid_from'])
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'subject' => t('Certificate'),
+ 'issuer' => t('Issuer'),
+ 'version' => t('Version'),
+ 'self_signed' => t('Is Self-Signed'),
+ 'ca' => t('Is Certificate Authority'),
+ 'trusted' => t('Is Trusted'),
+ 'pubkey_algo' => t('Public Key Algorithm'),
+ 'pubkey_bits' => t('Public Key Strength'),
+ 'signature_algo' => t('Signature Algorithm'),
+ 'signature_hash_algo' => t('Signature Hash Algorithm'),
+ 'valid_from' => t('Valid From'),
+ 'valid_to' => t('Valid To'),
+ 'duration' => t('Duration'),
+ 'subject_hash' => t('Subject Hash'),
+ 'issuer_hash' => t('Issuer Hash'),
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['subject', 'issuer'];
+ }
+
+ /**
+ * Get list of allowed columns to be exported
+ *
+ * @return string[]
+ */
+ public function getExportableColumns(): array
+ {
+ return [
+ 'id',
+ 'subject',
+ 'issuer',
+ 'version',
+ 'self_signed',
+ 'ca',
+ 'trusted',
+ 'pubkey_algo',
+ 'pubkey_bits',
+ 'signature_algo',
+ 'signature_hash_algo',
+ 'valid_from',
+ 'valid_to'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'subject_hash',
+ 'issuer_hash',
+ 'fingerprint',
+ 'serial',
+ 'certificate'
+ ]));
+
+ $behaviors->add(new DERBase64(['certificate']));
+
+ $behaviors->add(new BoolCast([
+ 'ca',
+ 'trusted',
+ 'self_signed'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'valid_from',
+ 'valid_to',
+ 'ctime',
+ 'mtime',
+ 'duration'
+ ]));
+
+ $behaviors->add(new ExpressionInjector('duration'));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('issuer_certificate', static::class)
+ ->setForeignKey('subject_hash')
+ ->setCandidateKey('issuer_hash');
+ $relations->belongsToMany('chain', X509CertificateChain::class)
+ ->through(X509CertificateChainLink::class)
+ ->setForeignKey('certificate_id');
+
+ $relations->hasMany('certificate', static::class)
+ ->setForeignKey('issuer_hash')
+ ->setCandidateKey('subject_hash');
+ $relations->hasMany('alt_name', X509CertificateSubjectAltName::class)
+ ->setJoinType('LEFT');
+ $relations->hasMany('dn', X509Dn::class)
+ ->setForeignKey('hash')
+ ->setCandidateKey('subject_hash')
+ ->setJoinType('LEFT');
+ }
+}
diff --git a/library/X509/Model/X509CertificateChain.php b/library/X509/Model/X509CertificateChain.php
new file mode 100644
index 0000000..189c38d
--- /dev/null
+++ b/library/X509/Model/X509CertificateChain.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\BoolCast;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509CertificateChain extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate_chain';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'chain';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'target_id',
+ 'length',
+ 'valid',
+ 'invalid_reason',
+ 'ctime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast(['valid']));
+
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('target', X509Target::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('latest_certificate_chain_id');
+
+ $relations->belongsToMany('certificate', X509Certificate::class)
+ ->through(X509CertificateChainLink::class)
+ ->setForeignKey('certificate_chain_id');
+ }
+}
diff --git a/library/X509/Model/X509CertificateChainLink.php b/library/X509/Model/X509CertificateChainLink.php
new file mode 100644
index 0000000..d093793
--- /dev/null
+++ b/library/X509/Model/X509CertificateChainLink.php
@@ -0,0 +1,46 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509CertificateChainLink extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate_chain_link';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'link';
+ }
+
+ public function getKeyName()
+ {
+ return ['certificate_chain_id', 'certificate_id', 'order'];
+ }
+
+ public function getColumns()
+ {
+ return ['ctime'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('certificate', X509Certificate::class)
+ ->setCandidateKey('certificate_id');
+ $relations->belongsTo('chain', X509CertificateChain::class)
+ ->setCandidateKey('certificate_chain_id');
+ }
+}
diff --git a/library/X509/Model/X509CertificateSubjectAltName.php b/library/X509/Model/X509CertificateSubjectAltName.php
new file mode 100644
index 0000000..62aac5c
--- /dev/null
+++ b/library/X509/Model/X509CertificateSubjectAltName.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509CertificateSubjectAltName extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_certificate_subject_alt_name';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'alt_name';
+ }
+
+ public function getKeyName()
+ {
+ return ['certificate_id', 'hash'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'type',
+ 'value',
+ 'ctime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary(['hash']));
+
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('certificate', X509Certificate::class);
+ }
+}
diff --git a/library/X509/Model/X509Dn.php b/library/X509/Model/X509Dn.php
new file mode 100644
index 0000000..fa0406f
--- /dev/null
+++ b/library/X509/Model/X509Dn.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509Dn extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_dn';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'dn';
+ }
+
+ public function getKeyName()
+ {
+ return ['hash', 'type', 'order'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'key',
+ 'value',
+ 'ctime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary(['hash']));
+
+ $behaviors->add(new MillisecondTimestamp(['ctime']));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('certificate', X509Certificate::class)
+ ->setForeignKey('subject_hash');
+ }
+}
diff --git a/library/X509/Model/X509Job.php b/library/X509/Model/X509Job.php
new file mode 100644
index 0000000..1b3a855
--- /dev/null
+++ b/library/X509/Model/X509Job.php
@@ -0,0 +1,73 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\Relations;
+
+/**
+ * A database model for all x509 jobs
+ *
+ * @property int $id Unique identifier of this job
+ * @property string $name The name of this job
+ * @property string $author The author of this job
+ * @property string $cidrs The configured cidrs of this job
+ * @property string $ports The configured ports of this job
+ * @property ?string $exclude_targets The configured excluded targets of this job
+ * @property DateTime $ctime The creation time of this job
+ * @property DateTime $mtime The modification time of this job
+ * @property Query|X509Schedule $schedule The configured schedules of this job
+ * @property Query|X509JobRun $job_run Job activities
+ */
+class X509Job extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_job';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'job';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'name',
+ 'author',
+ 'cidrs',
+ 'ports',
+ 'exclude_targets',
+ 'ctime',
+ 'mtime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'ctime',
+ 'mtime'
+ ]));
+ }
+
+ public function createRelations(Relations $relations): void
+ {
+ $relations->hasMany('schedule', X509Schedule::class)
+ ->setForeignKey('job_id');
+ $relations->hasMany('job_run', X509JobRun::class)
+ ->setForeignKey('job_id');
+ }
+}
diff --git a/library/X509/Model/X509JobRun.php b/library/X509/Model/X509JobRun.php
new file mode 100644
index 0000000..d776622
--- /dev/null
+++ b/library/X509/Model/X509JobRun.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\Relations;
+
+/**
+ * A database model for all x509 job schedules
+ *
+ * @property int $id Unique identifier of this job
+ * @property ?int $job_id The id of the x509 job this job run belongs to
+ * @property ?int $schedule_id The id of the x509 job schedule this run belongs to
+ * @property int $total_targets All the x509 targets found by this job run
+ * @property int $finished_targets All the x509 targets scanned by this job run
+ * @property DateTime $start_time The start time of this job run
+ * @property DateTime $end_time The end time of this job run
+ * @property Query|X509Job $job The x509 job this job run belongs to
+ * @property Query|X509Schedule $schedule The x509 job schedule this job run belongs to
+ */
+class X509JobRun extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_job_run';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'job_run';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'job_id',
+ 'schedule_id',
+ 'total_targets',
+ 'finished_targets',
+ 'start_time',
+ 'end_time'
+ ];
+ }
+
+ public function getDefaultSort(): string
+ {
+ return 'start_time desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'start_time',
+ 'end_time',
+ ]));
+ }
+
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('job', X509Job::class)
+ ->setCandidateKey('job_id');
+ $relations->belongsTo('schedule', X509Schedule::class)
+ ->setJoinType('LEFT')
+ ->setCandidateKey('schedule_id');
+ }
+}
diff --git a/library/X509/Model/X509Schedule.php b/library/X509/Model/X509Schedule.php
new file mode 100644
index 0000000..476641a
--- /dev/null
+++ b/library/X509/Model/X509Schedule.php
@@ -0,0 +1,70 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use DateTime;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * A database model for all x509 job schedules
+ *
+ * @property int $id Unique identifier of this job
+ * @property int $job_id The id of the x509 job this schedule belongs to
+ * @property string $name The name of this job schedule
+ * @property string $author The author of this job schedule
+ * @property string $config The config of this job schedule
+ * @property DateTime $ctime The creation time of this job
+ * @property DateTime $mtime The modification time of this job
+ * @property X509Job $job The x509 job this schedule belongs to
+ * @property X509JobRun $job_run Schedule activities
+ */
+class X509Schedule extends Model
+{
+ public function getTableName(): string
+ {
+ return 'x509_schedule';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'schedule';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns(): array
+ {
+ return [
+ 'job_id',
+ 'name',
+ 'author',
+ 'config',
+ 'ctime',
+ 'mtime'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors): void
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'ctime',
+ 'mtime'
+ ]));
+ }
+
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('job', X509Job::class)
+ ->setCandidateKey('job_id');
+ $relations->hasMany('job_run', X509JobRun::class)
+ ->setForeignKey('schedule_id');
+ }
+}
diff --git a/library/X509/Model/X509Target.php b/library/X509/Model/X509Target.php
new file mode 100644
index 0000000..7705d57
--- /dev/null
+++ b/library/X509/Model/X509Target.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Model;
+
+use Icinga\Module\X509\Model\Behavior\Ip;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class X509Target extends Model
+{
+ public function getTableName()
+ {
+ return 'x509_target';
+ }
+
+ public function getTableAlias(): string
+ {
+ return 'target';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'ip',
+ 'port',
+ 'hostname',
+ 'latest_certificate_chain_id',
+ 'last_scan',
+ 'ctime',
+ 'mtime'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'hostname' => t('Host Name'),
+ 'ip' => t('IP'),
+ 'port' => t('Port')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['hostname'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Ip(['ip']));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'ctime',
+ 'mtime',
+ 'last_scan'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('chain', X509CertificateChain::class)
+ ->setCandidateKey('latest_certificate_chain_id');
+ }
+}
diff --git a/library/X509/ProvidedHook/DbMigration.php b/library/X509/ProvidedHook/DbMigration.php
new file mode 100644
index 0000000..8314e3c
--- /dev/null
+++ b/library/X509/ProvidedHook/DbMigration.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Model\Schema;
+use ipl\Orm\Query;
+use ipl\Sql;
+use ipl\Sql\Adapter\Pgsql;
+
+class DbMigration extends DbMigrationHook
+{
+ public function getName(): string
+ {
+ return $this->translate('Icinga Certificate Monitoring');
+ }
+
+ public function providedDescriptions(): array
+ {
+ return [
+ '1.0.0' => $this->translate(
+ 'Adjusts the database type of several columns and changes some composed primary keys.'
+ ),
+ '1.1.0' => $this->translate(
+ 'Changes the composed x509_target index and x509_certificate valid from/to types to bigint.'
+ ),
+ '1.2.0' => $this->translate(
+ 'Changes all timestamp columns to bigint and adjusts enum types of "yes/no" to "n/y".'
+ ),
+ '1.3.0' => $this->translate(
+ 'Introduces the required tables to store jobs and job schedules in the database.'
+ )
+ ];
+ }
+
+ public function getVersion(): string
+ {
+ if ($this->version === null) {
+ $conn = $this->getDb();
+ $schema = $this->getSchemaQuery()
+ ->columns(['version', 'success'])
+ ->orderBy('id', SORT_DESC)
+ ->limit(2);
+
+ if (static::tableExists($conn, $schema->getModel()->getTableName())) {
+ /** @var Schema $version */
+ foreach ($schema as $version) {
+ if ($version->success) {
+ $this->version = $version->version;
+
+ break;
+ }
+ }
+
+ if (! $this->version) {
+ // Schema version table exist, but the user has probably deleted the entry!
+ $this->version = '1.3.0';
+ }
+ } elseif (
+ $this->getDb()->getAdapter() instanceof Pgsql
+ || static::getColumnType($conn, 'x509_certificate', 'ctime') === 'bigint(20) unsigned'
+ ) {
+ // We modified a bunch of timestamp columns to bigint in x509 version 1.2.0.
+ // We have also added Postgres support with x509 version 1.2 and never had an upgrade scripts until now.
+ $this->version = '1.2.0';
+ } elseif (static::getColumnType($conn, 'x509_certificate_subject_alt_name', 'hash') !== null) {
+ if (static::getColumnType($conn, 'x509_certificate', 'valid_from') === 'bigint(20) unsigned') {
+ $this->version = '1.0.0';
+ } else {
+ $this->version = '1.1.0';
+ }
+ } else {
+ // X509 version 1.0 was the first release of this module, but due to some reason it also contains
+ // an upgrade script and adds `hash` column. However, if this column doesn't exist yet, we need
+ // to use the lowest possible release value as the initial (last migrated) version.
+ $this->version = '0.0.0';
+ }
+ }
+
+ return $this->version;
+ }
+
+ public function getDb(): Sql\Connection
+ {
+ return Database::get();
+ }
+
+ protected function getSchemaQuery(): Query
+ {
+ return Schema::on($this->getDb());
+ }
+}
diff --git a/library/X509/ProvidedHook/HostsImportSource.php b/library/X509/ProvidedHook/HostsImportSource.php
new file mode 100644
index 0000000..70d584c
--- /dev/null
+++ b/library/X509/ProvidedHook/HostsImportSource.php
@@ -0,0 +1,91 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Sql;
+
+class HostsImportSource extends X509ImportSource
+{
+ public function fetchData()
+ {
+ $conn = Database::get();
+ $targets = X509Target::on($conn)
+ ->utilize('chain')
+ ->utilize('chain.certificate')
+ ->columns([
+ 'ip',
+ 'host_name' => 'hostname'
+ ]);
+
+ $targets
+ ->getSelectBase()
+ ->where(new Sql\Expression('target_chain_link.order = 0'))
+ ->groupBy(['ip', 'hostname']);
+
+ if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) {
+ $targets->withColumns([
+ 'host_ports' => new Sql\Expression("ARRAY_TO_STRING(ARRAY_AGG(DISTINCT port), ',')")
+ ]);
+ } else {
+ $targets->withColumns([
+ 'host_ports' => new Sql\Expression("GROUP_CONCAT(DISTINCT port SEPARATOR ',')")
+ ]);
+ }
+
+ $results = [];
+ $foundDupes = [];
+ /** @var X509Target $target */
+ foreach ($targets as $target) {
+ $isV6 = Job::isIPV6($target->ip);
+ $target->host_ip = $target->ip;
+ $target->host_address = $isV6 ? null : $target->ip;
+ $target->host_address6 = $isV6 ? $target->ip : null;
+
+ if (isset($foundDupes[$target->host_name])) {
+ // For load balanced systems the IP address is the better choice
+ $target->host_name_or_ip = $target->host_ip;
+ } elseif (! isset($results[$target->host_name])) {
+ // Hostnames are usually preferred, especially in the case of SNI
+ $target->host_name_or_ip = $target->host_name;
+ } else {
+ $dupe = $results[$target->host_name];
+ unset($results[$target->host_name]);
+ $foundDupes[$dupe->host_name] = true;
+ $dupe->host_name_or_ip = $dupe->host_ip;
+ $results[$dupe->host_name_or_ip] = $dupe;
+ $target->host_name_or_ip = $target->host_ip;
+ }
+
+ // Target ip is now obsolete and must not be included in the results.
+ // The relation is only used to utilize the query and must not be in the result set as well.
+ unset($target->ip);
+ unset($target->chain);
+
+ $results[$target->host_name_or_ip] = (object) iterator_to_array($target);
+ }
+
+ return $results;
+ }
+
+ public function listColumns()
+ {
+ return [
+ 'host_name_or_ip',
+ 'host_ip',
+ 'host_name',
+ 'host_ports',
+ 'host_address',
+ 'host_address6'
+ ];
+ }
+
+ public static function getDefaultKeyColumnName()
+ {
+ return 'host_name_or_ip';
+ }
+}
diff --git a/library/X509/ProvidedHook/ServicesImportSource.php b/library/X509/ProvidedHook/ServicesImportSource.php
new file mode 100644
index 0000000..7b87cd8
--- /dev/null
+++ b/library/X509/ProvidedHook/ServicesImportSource.php
@@ -0,0 +1,143 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Module\X509\Common\Database;
+use Icinga\Module\X509\Job;
+use Icinga\Module\X509\Model\X509CertificateSubjectAltName;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Sql;
+
+class ServicesImportSource extends X509ImportSource
+{
+ public function fetchData()
+ {
+ $conn = Database::get();
+ $targets = X509Target::on($conn)
+ ->with([
+ 'chain',
+ 'chain.certificate',
+ 'chain.certificate.dn',
+ 'chain.certificate.issuer_certificate'
+ ])
+ ->columns([
+ 'ip',
+ 'host_name' => 'hostname',
+ 'host_port' => 'port',
+ 'cert_subject' => 'chain.certificate.subject',
+ 'cert_issuer' => 'chain.certificate.issuer',
+ 'cert_trusted' => 'chain.certificate.trusted',
+ 'cert_valid_from' => 'chain.certificate.valid_from',
+ 'cert_valid_to' => 'chain.certificate.valid_to',
+ 'cert_self_signed' => new Sql\Expression('COALESCE(%s, %s)', [
+ 'chain.certificate.issuer_certificate.self_signed',
+ 'chain.certificate.self_signed'
+ ])
+ ]);
+
+ $targets->getWith()['target.chain.certificate.issuer_certificate']->setJoinType('LEFT');
+ $targets
+ ->getSelectBase()
+ ->where(new Sql\Expression('target_chain_link.order = 0'))
+ ->groupBy(['ip, hostname, port']);
+
+ $certAltName = X509CertificateSubjectAltName::on($conn);
+ $certAltName
+ ->getSelectBase()
+ ->where(new Sql\Expression('certificate_id = target_chain_certificate.id'))
+ ->groupBy(['alt_name.certificate_id']);
+
+ if ($conn->getAdapter() instanceof Sql\Adapter\Pgsql) {
+ $targets
+ ->withColumns([
+ 'cert_fingerprint' => new Sql\Expression("ENCODE(%s, 'hex')", [
+ 'chain.certificate.fingerprint'
+ ]),
+ 'cert_dn' => new Sql\Expression(
+ "ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, '=', %s)), ',')",
+ [
+ 'chain.certificate.dn.key',
+ 'chain.certificate.dn.value'
+ ]
+ )
+ ])
+ ->getSelectBase()
+ ->groupBy(['target_chain_certificate.id', 'target_chain_certificate_issuer_certificate.id']);
+
+ $certAltName->columns([
+ new Sql\Expression("ARRAY_TO_STRING(ARRAY_AGG(CONCAT(%s, ':', %s)), ',')", ['type', 'value'])
+ ]);
+ } else {
+ $targets->withColumns([
+ 'cert_fingerprint' => new Sql\Expression('HEX(%s)', ['chain.certificate.fingerprint']),
+ 'cert_dn' => new Sql\Expression(
+ "GROUP_CONCAT(CONCAT(%s, '=', %s) SEPARATOR ',')",
+ [
+ 'chain.certificate.dn.key',
+ 'chain.certificate.dn.value'
+ ]
+ )
+ ]);
+
+ $certAltName->columns([
+ new Sql\Expression("GROUP_CONCAT(CONCAT(%s, ':', %s) SEPARATOR ',')", ['type', 'value'])
+ ]);
+ }
+
+ list($select, $values) = $certAltName->dump();
+ $targets->withColumns(['cert_subject_alt_name' => new Sql\Expression("$select", null, ...$values)]);
+
+ $results = [];
+ /** @var X509Target $target */
+ foreach ($targets as $target) {
+ $isV6 = Job::isIPV6($target->ip);
+ $target->host_ip = $target->ip;
+ $target->host_address = $isV6 ? null : $target->ip;
+ $target->host_address6 = $isV6 ? $target->ip : null;
+
+ $target->host_name_ip_and_port = sprintf(
+ '%s/%s:%d',
+ $target->host_name,
+ $target->host_ip,
+ $target->host_port
+ );
+
+ // Target ip is now obsolete and must not be included in the results.
+ // The relation is only used to utilize the query and must not be in the result set as well.
+ unset($target->ip);
+ unset($target->chain);
+
+ $results[$target->host_name_ip_and_port] = (object) iterator_to_array($target);
+ }
+
+ return $results;
+ }
+
+ public function listColumns()
+ {
+ return [
+ 'host_name_ip_and_port',
+ 'host_ip',
+ 'host_name',
+ 'host_port',
+ 'host_address',
+ 'host_address6',
+ 'cert_subject',
+ 'cert_issuer',
+ 'cert_self_signed',
+ 'cert_trusted',
+ 'cert_valid_from',
+ 'cert_valid_to',
+ 'cert_fingerprint',
+ 'cert_dn',
+ 'cert_subject_alt_name'
+ ];
+ }
+
+ public static function getDefaultKeyColumnName()
+ {
+ return 'host_name_ip_and_port';
+ }
+}
diff --git a/library/X509/ProvidedHook/X509ImportSource.php b/library/X509/ProvidedHook/X509ImportSource.php
new file mode 100644
index 0000000..dc280c0
--- /dev/null
+++ b/library/X509/ProvidedHook/X509ImportSource.php
@@ -0,0 +1,11 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2019 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\ProvidedHook;
+
+use Icinga\Module\Director\Hook\ImportSourceHook;
+
+abstract class X509ImportSource extends ImportSourceHook
+{
+}
diff --git a/library/X509/React/StreamOptsCaptureConnector.php b/library/X509/React/StreamOptsCaptureConnector.php
new file mode 100644
index 0000000..56a44e4
--- /dev/null
+++ b/library/X509/React/StreamOptsCaptureConnector.php
@@ -0,0 +1,60 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2020 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509\React;
+
+use React\Socket\ConnectionInterface;
+use React\Socket\ConnectorInterface;
+
+use function React\Promise\resolve;
+
+/**
+ * Connector that captures stream context options upon close of the underlying connection
+ */
+class StreamOptsCaptureConnector implements ConnectorInterface
+{
+ /** @var array|null */
+ protected $capturedStreamOptions;
+
+ /** @var ConnectorInterface */
+ protected $connector;
+
+ public function __construct(ConnectorInterface $connector)
+ {
+ $this->connector = $connector;
+ }
+
+ /**
+ * @return array
+ */
+ public function getCapturedStreamOptions()
+ {
+ return (array) $this->capturedStreamOptions;
+ }
+
+ /**
+ * @param array $capturedStreamOptions
+ *
+ * @return $this
+ */
+ public function setCapturedStreamOptions($capturedStreamOptions)
+ {
+ $this->capturedStreamOptions = $capturedStreamOptions;
+
+ return $this;
+ }
+
+ public function connect($uri)
+ {
+ return $this->connector->connect($uri)->then(function (ConnectionInterface $conn) {
+ $conn->on('close', function () use ($conn) {
+ if (is_resource($conn->stream)) {
+ $this->setCapturedStreamOptions(stream_context_get_options($conn->stream));
+ }
+ });
+
+ return resolve($conn);
+ });
+ }
+}
diff --git a/library/X509/Schedule.php b/library/X509/Schedule.php
new file mode 100644
index 0000000..3f80932
--- /dev/null
+++ b/library/X509/Schedule.php
@@ -0,0 +1,125 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Schedule;
+use Icinga\Util\Json;
+use stdClass;
+
+class Schedule
+{
+ /** @var int The database id of this schedule */
+ protected $id;
+
+ /** @var string The name of this job schedule */
+ protected $name;
+
+ /** @var object The config of this schedule */
+ protected $config;
+
+ public function __construct(string $name, int $id, object $config)
+ {
+ $this->id = $id;
+ $this->name = $name;
+ $this->config = $config;
+ }
+
+ public static function fromModel(X509Schedule $schedule): self
+ {
+ /** @var stdClass $config */
+ $config = Json::decode($schedule->config);
+ if (isset($config->rescan)) {
+ $config->rescan = $config->rescan === 'y';
+ }
+
+ if (isset($config->full_scan)) {
+ $config->full_scan = $config->full_scan === 'y';
+ }
+
+ return new static($schedule->name, $schedule->id, $config);
+ }
+
+ /**
+ * Get the name of this schedule
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of this schedule
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the database id of this job
+ *
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set the database id of this job
+ *
+ * @param int $id
+ *
+ * @return $this
+ */
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ /**
+ * Get the config of this schedule
+ *
+ * @return object
+ */
+ public function getConfig(): object
+ {
+ return $this->config;
+ }
+
+ /**
+ * Set the config of this schedule
+ *
+ * @param object $config
+ *
+ * @return $this
+ */
+ public function setConfig(object $config): self
+ {
+ $this->config = $config;
+
+ return $this;
+ }
+
+ /**
+ * Get the checksum of this schedule
+ *
+ * @return string
+ */
+ public function getChecksum(): string
+ {
+ return md5($this->getName() . Json::encode($this->getConfig()), true);
+ }
+}
diff --git a/library/X509/SniIniRepository.php b/library/X509/SniIniRepository.php
new file mode 100644
index 0000000..432494b
--- /dev/null
+++ b/library/X509/SniIniRepository.php
@@ -0,0 +1,21 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Repository\IniRepository;
+
+/**
+ * Collection of hostnames stored in the sni.ini file
+ */
+class SniIniRepository extends IniRepository
+{
+ protected $queryColumns = array('sni' => array('ip', 'hostnames'));
+
+ protected $configs = array('sni' => array(
+ 'module' => 'x509',
+ 'name' => 'sni',
+ 'keyColumn' => 'ip'
+ ));
+}
diff --git a/library/X509/Table.php b/library/X509/Table.php
new file mode 100644
index 0000000..00fe6cf
--- /dev/null
+++ b/library/X509/Table.php
@@ -0,0 +1,39 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class Table extends BaseHtmlElement
+{
+ protected $tag = 'table';
+
+ protected $rows = [];
+
+ public function addRow(array $cells, $attributes = null)
+ {
+ $row = Html::tag('tr', $attributes);
+
+ foreach ($cells as $cell) {
+ $row->add(Html::tag('td', $cell));
+ }
+
+ $this->rows[] = $row;
+ }
+
+ public function renderContent()
+ {
+ $tbody = Html::tag('tbody');
+
+ foreach ($this->rows as $row) {
+ $tbody->add($row);
+ }
+
+ $this->add($tbody);
+
+ return parent::renderContent(); // TODO: Change the autogenerated stub
+ }
+}
diff --git a/library/X509/UsageTable.php b/library/X509/UsageTable.php
new file mode 100644
index 0000000..109e5ee
--- /dev/null
+++ b/library/X509/UsageTable.php
@@ -0,0 +1,91 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\X509;
+
+use Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Web\Url;
+use ipl\Web\Widget\Icon;
+
+/**
+ * Table widget to display X.509 certificate usage
+ */
+class UsageTable extends DataTable
+{
+ protected $defaultAttributes = [
+ 'class' => 'usage-table common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ public function createColumns()
+ {
+ return [
+ 'valid' => [
+ 'attributes' => ['class' => 'icon-col'],
+ 'column' => function ($data) {
+ return $data->chain->valid;
+ },
+ 'renderer' => function ($valid) {
+ return new Icon($valid ? 'circle-check' : 'ban', ['class' => $valid ? '-ok' : '-critical']);
+ }
+ ],
+
+ 'hostname' => [
+ 'label' => mt('x509', 'Hostname'),
+ 'column' => function ($data) {
+ return $data->chain->target->hostname;
+ }
+ ],
+
+ 'ip' => [
+ 'label' => mt('x509', 'IP'),
+ 'column' => function ($data) {
+ return $data->chain->target->ip;
+ },
+ ],
+
+ 'port' => [
+ 'label' => mt('x509', 'Port'),
+ 'column' => function ($data) {
+ return $data->chain->target->port;
+ }
+ ],
+
+ 'subject' => mt('x509', 'Certificate'),
+
+ 'signature_algo' => [
+ 'label' => mt('x509', 'Signature Algorithm'),
+ 'renderer' => function ($algo, $data) {
+ return "{$data->signature_hash_algo} with $algo";
+ }
+ ],
+
+ 'pubkey_algo' => [
+ 'label' => mt('x509', 'Public Key'),
+ 'renderer' => function ($algo, $data) {
+ return "$algo {$data->pubkey_bits} bits";
+ }
+ ],
+
+ 'valid_to' => [
+ 'attributes' => ['class' => 'expiration-col'],
+ 'label' => mt('x509', 'Expiration'),
+ 'renderer' => function ($to, $data) {
+ return new ExpirationWidget($data->valid_from, $to);
+ }
+ ]
+ ];
+ }
+
+ protected function renderRow(X509Certificate $row)
+ {
+ $tr = parent::renderRow($row);
+
+ $url = Url::fromPath('x509/chain', ['id' => $row->chain->id]);
+
+ $tr->getAttributes()->add(['href' => $url->getAbsoluteUrl()]);
+
+ return $tr;
+ }
+}
diff --git a/library/X509/Web/Control/SearchBar/ObjectSuggestions.php b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php
new file mode 100644
index 0000000..ca9630f
--- /dev/null
+++ b/library/X509/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Icinga\Module\X509\Web\Control\SearchBar;
+
+use Exception;
+use Icinga\Module\X509\Common\Database;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Model;
+use ipl\Orm\Relation;
+use ipl\Orm\Relation\HasOne;
+use ipl\Orm\Resolver;
+use ipl\Orm\UnionModel;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Seq;
+use ipl\Stdlib\Str;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Control\SearchBar\Suggestions;
+
+class ObjectSuggestions extends Suggestions
+{
+ /** @var Model */
+ protected $model;
+
+ /**
+ * Set the model to show suggestions for
+ *
+ * @param string|Model $model
+ *
+ * @return $this
+ */
+ public function setModel($model): self
+ {
+ if (is_string($model)) {
+ /** @var Model $model */
+ $model = new $model();
+ }
+
+ $this->model = $model;
+
+ return $this;
+ }
+
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ $columns = Str::trimSplit($column, '.');
+
+ switch (count($columns)) {
+ case 2:
+ return $columns[0] !== $this->model->getTableAlias();
+ default:
+ return true;
+ }
+ }
+
+ protected function createQuickSearchFilter($searchTerm)
+ {
+ $model = $this->model;
+ $resolver = $model::on(Database::get())->getResolver();
+
+ $quickFilter = Filter::any();
+ foreach ($model->getSearchColumns() as $column) {
+ $where = Filter::like($resolver->qualifyColumn($column, $model->getTableAlias()), $searchTerm);
+ $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel());
+ $quickFilter->add($where);
+ }
+
+ return $quickFilter;
+ }
+
+ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
+ {
+ $model = $this->model;
+ $query = $model::on(Database::get());
+ $query->limit(static::DEFAULT_LIMIT);
+
+ if (strpos($column, ' ') !== false) {
+ // Searching for `Host Name` and continue typing without accepting/clicking the suggested
+ // column name will cause the search bar to use a label as a filter column
+ list($path, $_) = Seq::find(
+ self::collectFilterColumns($query->getModel(), $query->getResolver()),
+ $column,
+ false
+ );
+ if ($path !== null) {
+ $column = $path;
+ }
+ }
+
+ $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableAlias());
+ $inputFilter = Filter::like($columnPath, $searchTerm);
+
+ $query->columns($columnPath);
+ $query->orderBy($columnPath);
+
+ if ($searchFilter instanceof Filter\None) {
+ $query->filter($inputFilter);
+ } elseif ($searchFilter instanceof Filter\All) {
+ $searchFilter->add($inputFilter);
+
+ // When 10 hosts are sharing the same certificate, filtering in the search bar by
+ // `Host Name=foo&Host Name=` will suggest only `foo` for the second filter. So, we have
+ // to force the filter processor to optimize search bar filter
+ $searchFilter->metaData()->set('forceOptimization', true);
+ $inputFilter->metaData()->set('forceOptimization', false);
+ } else {
+ $searchFilter = $inputFilter;
+ }
+
+ $query->filter($searchFilter);
+ // Not to suggest something like Port=443,443,443....
+ $query->getSelectBase()->distinct();
+
+ try {
+ $steps = Str::trimSplit($column, '.');
+ $columnName = array_pop($steps);
+ if ($steps[0] === $model->getTableAlias()) {
+ array_shift($steps);
+ }
+
+ foreach ($query as $row) {
+ $model = $row;
+ foreach ($steps as $step) {
+ try {
+ $model = $model->$step;
+ } catch (Exception $_) {
+ // pass
+ break;
+ }
+ }
+
+ $value = $model->$columnName;
+ if ($value && is_string($value) && ! ctype_print($value)) { // Is binary
+ $value = bin2hex($value);
+ } elseif ($value === false || $value === true) {
+ // TODO: The search bar is never going to suggest boolean types, so this
+ // is a hack to workaround this limitation!!
+ $value = $value ? 'y' : 'n';
+ }
+
+ yield $value;
+ }
+ } catch (InvalidColumnException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn()));
+ }
+ }
+
+ protected function fetchColumnSuggestions($searchTerm)
+ {
+ $model = $this->model;
+ $query = $model::on(Database::get());
+
+ yield from self::collectFilterColumns($model, $query->getResolver());
+ }
+
+ public static function collectFilterColumns(Model $model, Resolver $resolver)
+ {
+ if ($model instanceof UnionModel) {
+ $models = [];
+ foreach ($model->getUnions() as $union) {
+ /** @var Model $unionModel */
+ $unionModel = new $union[0]();
+ $models[$unionModel->getTableAlias()] = $unionModel;
+ self::collectRelations($resolver, $unionModel, $models, []);
+ }
+ } else {
+ $models = [$model->getTableAlias() => $model];
+ self::collectRelations($resolver, $model, $models, []);
+ }
+
+ /** @var Model $targetModel */
+ foreach ($models as $path => $targetModel) {
+ foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
+ yield "$path.$columnName" => $definition->getLabel();
+ }
+ }
+ }
+
+ protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
+ {
+ foreach ($resolver->getRelations($subject) as $name => $relation) {
+ /** @var Relation $relation */
+ $isHasOne = $relation instanceof HasOne;
+ $relationPath = [$name];
+
+ if (! isset($models[$name]) && ! in_array($name, $path, true)) {
+ if ($isHasOne || empty($path)) {
+ array_unshift($relationPath, $subject->getTableAlias());
+ }
+
+ $relationPath = array_merge($path, $relationPath);
+ $targetPath = implode('.', $relationPath);
+
+ if (! isset($models[$targetPath])) {
+ $models[$targetPath] = $relation->getTarget();
+ self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
+ return;
+ }
+ } else {
+ $path = [];
+ }
+ }
+ }
+}
diff --git a/library/X509/Widget/JobDetails.php b/library/X509/Widget/JobDetails.php
new file mode 100644
index 0000000..c1e3843
--- /dev/null
+++ b/library/X509/Widget/JobDetails.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Widget;
+
+use Icinga\Module\X509\Model\X509JobRun;
+use ipl\Html\Table;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Widget\EmptyStateBar;
+
+class JobDetails extends Table
+{
+ use Translation;
+
+ protected $defaultAttributes = ['class' => 'common-table'];
+
+ /** @var Query */
+ protected $runs;
+
+ public function __construct(Query $runs)
+ {
+ $this->runs = $runs;
+ }
+
+ protected function assemble(): void
+ {
+ /** @var X509JobRun $run */
+ foreach ($this->runs as $run) {
+ $row = static::tr();
+ $row->addHtml(
+ static::td($run->job->name),
+ static::td($run->schedule->name ?: $this->translate('N/A')),
+ static::td((string) $run->total_targets),
+ static::td((string) $run->finished_targets),
+ static::td($run->start_time->format('Y-m-d H:i')),
+ static::td($run->end_time ? $run->end_time->format('Y-m-d H:i') : 'N/A')
+ );
+
+ $this->addHtml($row);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar($this->translate('Job never run.')));
+ } else {
+ $row = static::tr();
+ $row->addHtml(
+ static::th($this->translate('Name')),
+ static::th($this->translate('Schedule Name')),
+ static::th($this->translate('Total')),
+ static::th($this->translate('Scanned')),
+ static::th($this->translate('Started')),
+ static::th($this->translate('Finished'))
+ );
+
+ $this->getHeader()->addHtml($row);
+ }
+ }
+}
diff --git a/library/X509/Widget/Jobs.php b/library/X509/Widget/Jobs.php
new file mode 100644
index 0000000..997e7ef
--- /dev/null
+++ b/library/X509/Widget/Jobs.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Widget;
+
+use Icinga\Module\X509\Common\Links;
+use Icinga\Module\X509\Model\X509Job;
+use ipl\Html\Table;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Widget\EmptyStateBar;
+use ipl\Web\Widget\Link;
+
+class Jobs extends Table
+{
+ use Translation;
+
+ /** @var Query */
+ protected $jobs;
+
+ protected $defaultAttributes = [
+ 'class' => 'common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ public function __construct(Query $jobs)
+ {
+ $this->jobs = $jobs;
+ }
+
+ protected function assemble(): void
+ {
+ $jobs = $this->jobs->execute();
+ if (! $jobs->hasResult()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar($this->translate('No jobs configured yet.')));
+
+ return;
+ }
+
+ $headers = static::tr();
+ $headers->addHtml(
+ static::th($this->translate('Name')),
+ static::th($this->translate('Author')),
+ static::th($this->translate('Date Created')),
+ static::th($this->translate('Date Modified'))
+ );
+ $this->getHeader()->addHtml($headers);
+
+ /** @var X509Job $job */
+ foreach ($jobs as $job) {
+ $row = static::tr();
+ $row->addHtml(
+ static::td(new Link($job->name, Links::job($job))),
+ static::td($job->author),
+ static::td($job->ctime->format('Y-m-d H:i')),
+ static::td($job->mtime->format('Y-m-d H:i'))
+ );
+
+ $this->addHtml($row);
+ }
+ }
+}
diff --git a/library/X509/Widget/Schedules.php b/library/X509/Widget/Schedules.php
new file mode 100644
index 0000000..9f37986
--- /dev/null
+++ b/library/X509/Widget/Schedules.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\X509\Widget;
+
+use Icinga\Module\X509\Common\Links;
+use Icinga\Module\X509\Model\X509Schedule;
+use ipl\Html\Table;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Widget\EmptyStateBar;
+use ipl\Web\Widget\Link;
+
+class Schedules extends Table
+{
+ use Translation;
+
+ protected $defaultAttributes = [
+ 'class' => 'common-table table-row-selectable',
+ 'data-base-target' => '_next'
+ ];
+
+ /** @var Query */
+ protected $schedules;
+
+ public function __construct(Query $schedules)
+ {
+ $this->schedules = $schedules;
+ }
+
+ protected function assemble(): void
+ {
+ /** @var X509Schedule $schedule */
+ foreach ($this->schedules as $schedule) {
+ $row = static::tr();
+ $row->addHtml(
+ static::td(new Link($schedule->name, Links::updateSchedule($schedule))),
+ static::td($schedule->author),
+ static::td($schedule->ctime->format('Y-m-d H:i')),
+ static::td($schedule->mtime->format('Y-m-d H:i'))
+ );
+
+ $this->addHtml($row);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar($this->translate('No job schedules.')));
+ } else {
+ $row = static::tr();
+ $row->addHtml(
+ static::th($this->translate('Name')),
+ static::th($this->translate('Author')),
+ static::th($this->translate('Date Created')),
+ static::th($this->translate('Date Modified'))
+ );
+ $this->getHeader()->addHtml($row);
+ }
+ }
+}
diff --git a/module.info b/module.info
new file mode 100644
index 0000000..aec9dfb
--- /dev/null
+++ b/module.info
@@ -0,0 +1,6 @@
+Module: Certificate Monitoring
+Version: 1.3.2
+Requires:
+ Libraries: icinga-php-library (>=0.13.0), icinga-php-thirdparty (>=0.12.0)
+ Modules: monitoring (>=2.9.0), icingadb (>=1.0.0)
+Description: Scan and view X.509 certificate usage
diff --git a/phpstan-baseline-7x.neon b/phpstan-baseline-7x.neon
new file mode 100644
index 0000000..62ec10c
--- /dev/null
+++ b/phpstan-baseline-7x.neon
@@ -0,0 +1,96 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Parameter \\#1 \\$x509certdata of function openssl_x509_read expects resource\\|string, mixed given\\.$#"
+ count: 1
+ path: application/clicommands/ImportCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$data of function bin2hex expects string, mixed given\\.$#"
+ count: 2
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Parameter \\#1 \\$x509cert of function openssl_x509_parse expects resource\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Parameter \\#2 \\$pieces of function implode expects array, array\\<int, string\\>\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Parameter \\#1 \\$certificate of function openssl_pkey_get_public expects resource\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$key of function openssl_pkey_get_details expects resource, resource\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$x509 of function openssl_x509_export expects resource\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$x509 of function openssl_x509_fingerprint expects resource\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$x509cert of function openssl_x509_parse expects resource\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#2 \\$str of function explode expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$a of function gmp_add expects GMP\\|int\\|string, GMP\\|null given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$a of function gmp_and expects GMP\\|int\\|string, GMP\\|null given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$input of function str_pad expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$str of function base64_encode expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/DERBase64.php
+
+ -
+ message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/DERBase64.php
+
+ -
+ message: "#^Parameter \\#1 \\$in_addr of function inet_ntop expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
+
+ -
+ message: "#^Parameter \\#1 \\$input of function str_pad expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
+
+ -
+ message: "#^Parameter \\#1 \\$ip_address of function inet_pton expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
+
+ -
+ message: "#^Parameter \\#1 \\$str of function ltrim expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
diff --git a/phpstan-baseline-8x.neon b/phpstan-baseline-8x.neon
new file mode 100644
index 0000000..7ffc0fe
--- /dev/null
+++ b/phpstan-baseline-8x.neon
@@ -0,0 +1,96 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Parameter \\#1 \\$certificate of function openssl_x509_read expects OpenSSLCertificate\\|string, mixed given\\.$#"
+ count: 1
+ path: application/clicommands/ImportCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$certificate of function openssl_x509_parse expects OpenSSLCertificate\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#"
+ count: 2
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function implode expects array\\|null, array\\<int, string\\>\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Parameter \\#1 \\$certificate of function openssl_x509_export expects OpenSSLCertificate\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$certificate of function openssl_x509_fingerprint expects OpenSSLCertificate\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$certificate of function openssl_x509_parse expects OpenSSLCertificate\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$key of function openssl_pkey_get_details expects OpenSSLAsymmetricKey, OpenSSLAsymmetricKey\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$public_key of function openssl_pkey_get_public expects array\\|OpenSSLAsymmetricKey\\|OpenSSLCertificate\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$num1 of function gmp_add expects GMP\\|int\\|string, GMP\\|null given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$num1 of function gmp_and expects GMP\\|int\\|string, GMP\\|null given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function str_pad expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function base64_encode expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/DERBase64.php
+
+ -
+ message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/DERBase64.php
+
+ -
+ message: "#^Parameter \\#1 \\$ip of function inet_ntop expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
+
+ -
+ message: "#^Parameter \\#1 \\$ip of function inet_pton expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function ltrim expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function str_pad expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/Ip.php
diff --git a/phpstan-baseline-by-php-version.php b/phpstan-baseline-by-php-version.php
new file mode 100644
index 0000000..2099535
--- /dev/null
+++ b/phpstan-baseline-by-php-version.php
@@ -0,0 +1,10 @@
+<?php
+
+$includes = [];
+if (PHP_VERSION_ID >= 80000) {
+ $includes[] = __DIR__ . '/phpstan-baseline-8x.neon';
+} else {
+ $includes[] = __DIR__ . '/phpstan-baseline-7x.neon';
+}
+
+return ['includes' => $includes];
diff --git a/phpstan-baseline-common.neon b/phpstan-baseline-common.neon
new file mode 100644
index 0000000..651eb56
--- /dev/null
+++ b/phpstan-baseline-common.neon
@@ -0,0 +1,1111 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Cannot access offset 'self_signed' on mixed\\.$#"
+ count: 1
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Cannot access offset 'subject' on mixed\\.$#"
+ count: 1
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Cannot access property \\$chain on mixed\\.$#"
+ count: 2
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Cannot access property \\$subject on mixed\\.$#"
+ count: 6
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Cannot access property \\$valid_from on mixed\\.$#"
+ count: 1
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Cannot access property \\$valid_to on mixed\\.$#"
+ count: 1
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|false\\.$#"
+ count: 5
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:hostAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$from of method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:thresholdToDateTime\\(\\) expects DateTime, DateTime\\|false given\\.$#"
+ count: 2
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$targetObject of method DateTime\\:\\:diff\\(\\) expects DateTimeInterface, DateTime\\|false given\\.$#"
+ count: 1
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$threshold of method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:splitThreshold\\(\\) expects string, mixed given\\.$#"
+ count: 2
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Parameter \\#2 \\$to of method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CheckCommand\\:\\:thresholdToDateTime\\(\\) expects DateTime, DateTime\\|false given\\.$#"
+ count: 2
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 3
+ path: application/clicommands/CheckCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\CleanupCommand\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/clicommands/CleanupCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\ImportCommand\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/clicommands/ImportCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$file of static method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:parseBundle\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/clicommands/ImportCommand.php
+
+ -
+ message: "#^Parameter \\#1 \\$filename of function file_exists expects string, mixed given\\.$#"
+ count: 1
+ path: application/clicommands/ImportCommand.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: application/clicommands/JobsCommand.php
+
+ -
+ message: "#^Cannot cast mixed to string\\.$#"
+ count: 2
+ path: application/clicommands/JobsCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Clicommands\\\\VerifyCommand\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/clicommands/VerifyCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificateController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CertificateController.php
+
+ -
+ message: "#^Parameter \\#1 \\$cert of method Icinga\\\\Module\\\\X509\\\\CertificateDetails\\:\\:setCert\\(\\) expects Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate, Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\|null given\\.$#"
+ count: 1
+ path: application/controllers/CertificateController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/CertificateController.php
+
+ -
+ message: "#^Cannot call method format\\(\\) on mixed\\.$#"
+ count: 2
+ path: application/controllers/CertificatesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificatesController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CertificatesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificatesController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CertificatesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\CertificatesController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CertificatesController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/CertificatesController.php
+
+ -
+ message: "#^Cannot access property \\$hostname on mixed\\.$#"
+ count: 1
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Cannot access property \\$ip on mixed\\.$#"
+ count: 1
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Cannot access property \\$port on mixed\\.$#"
+ count: 1
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Cannot access property \\$target on Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\|null\\.$#"
+ count: 3
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\ChainController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Offset 'invalid_reason' does not exist on Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\|null\\.$#"
+ count: 1
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Offset 'valid' does not exist on Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\|null\\.$#"
+ count: 1
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: application/controllers/ChainController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\ConfigController\\:\\:backendAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ConfigController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\DashboardController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DashboardController.php
+
+ -
+ message: "#^Call to an undefined method Icinga\\\\Module\\\\X509\\\\Model\\\\X509JobRun\\|ipl\\\\Orm\\\\Query\\:\\:with\\(\\)\\.$#"
+ count: 1
+ path: application/controllers/JobController.php
+
+ -
+ message: "#^Call to an undefined method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Schedule\\|ipl\\\\Orm\\\\Query\\:\\:with\\(\\)\\.$#"
+ count: 1
+ path: application/controllers/JobController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\JobsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/JobsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\JobsController\\:\\:newAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/JobsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/SniController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:newAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/SniController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:removeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/SniController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\SniController\\:\\:updateAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/SniController.php
+
+ -
+ message: "#^Parameter \\#1 \\$name of method Icinga\\\\Forms\\\\RepositoryForm\\:\\:edit\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/SniController.php
+
+ -
+ message: "#^Parameter \\#1 \\$name of method Icinga\\\\Forms\\\\RepositoryForm\\:\\:remove\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/SniController.php
+
+ -
+ message: "#^Cannot access property \\$target on mixed\\.$#"
+ count: 3
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Cannot access property \\$valid on mixed\\.$#"
+ count: 1
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Cannot call method format\\(\\) on mixed\\.$#"
+ count: 2
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\UsageController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\UsageController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controllers\\\\UsageController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/UsageController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\BackendConfigForm\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Config/BackendConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\BackendConfigForm\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Config/BackendConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createDeleteElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Config/SniConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createDeleteElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Config/SniConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createInsertElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Config/SniConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createInsertElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Config/SniConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createUpdateElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Config/SniConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Forms\\\\Config\\\\SniConfigForm\\:\\:createUpdateElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Config/SniConfigForm.php
+
+ -
+ message: "#^Cannot access offset 'issuer' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Cannot access offset 'subject' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Cannot call method format\\(\\) on mixed\\.$#"
+ count: 2
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateDetails\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateDetails\\:\\:setCert\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateDetails.php
+
+ -
+ message: "#^Cannot access offset 'bits' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'issuer' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'name' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'serialNumber' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'signatureTypeSN' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'subject' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'type' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'validFrom_time_t' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'validTo_time_t' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access offset 'version' on array\\|false\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Cannot access property \\$id on mixed\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:cleanupNoLongerUsedCertificates\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertCert\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has parameter \\$certInfo with no type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has parameter \\$db with no type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:findOrInsertDn\\(\\) has parameter \\$type with no type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:insertSANs\\(\\) has parameter \\$certId with no type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:insertSANs\\(\\) has parameter \\$db with no type specified\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:insertSANs\\(\\) has parameter \\$sans with no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:shortNameFromDN\\(\\) has parameter \\$dn with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificateUtils\\:\\:splitSANs\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$binary of method Icinga\\\\Module\\\\X509\\\\DbTool\\:\\:marshalBinary\\(\\) expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$chainId of closure expects int, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#1 \\$orderBy of method ipl\\\\Orm\\\\Query\\:\\:orderBy\\(\\) expects array\\|int\\|string, ipl\\\\Sql\\\\Expression given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#2 \\$collection of closure expects array, mixed given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Parameter \\#2 \\$groupBy of function ipl\\\\Stdlib\\\\yield_groups expects callable\\(mixed, mixed\\)\\: array\\{0\\: mixed, 1\\?\\: mixed, 2\\?\\: mixed\\}, Closure\\(Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\)\\: array\\{mixed, mixed\\} given\\.$#"
+ count: 1
+ path: library/X509/CertificateUtils.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificatesTable\\:\\:createColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/CertificatesTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\CertificatesTable\\:\\:renderRow\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/CertificatesTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ChainDetails\\:\\:createColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/ChainDetails.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ChainDetails\\:\\:renderRow\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/ChainDetails.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ColorScheme\\:\\:__construct\\(\\) has parameter \\$colors with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/ColorScheme.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ColorScheme\\:\\:scheme\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/ColorScheme.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\ColorScheme\\:\\:\\$colors type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/ColorScheme.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Command\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Command.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Command\\:\\:\\$configs has no type specified\\.$#"
+ count: 1
+ path: library/X509/Command.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controller\\:\\:fetchFilterColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Controller\\:\\:handleFormatRequest\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Controller.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Controller\\:\\:\\$format has no type specified\\.$#"
+ count: 1
+ path: library/X509/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:createColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:getData\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\|Traversable\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderBody\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderBody\\(\\) has parameter \\$data with no type specified\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderHeader\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:renderRow\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\|Traversable\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$columns type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$data type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\DataTable\\:\\:\\$data type has no value type specified in iterable type array\\|Traversable\\.$#"
+ count: 1
+ path: library/X509/DataTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\DbTool\\:\\:unmarshalBinary\\(\\) should return string but returns string\\|false\\.$#"
+ count: 1
+ path: library/X509/DbTool.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\DbTool\\:\\:\\$pgsql has no type specified\\.$#"
+ count: 1
+ path: library/X509/DbTool.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:getData\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\|Traversable\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\|Traversable\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setHeading\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setHeading\\(\\) has parameter \\$heading with no type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setHeading\\(\\) has parameter \\$level with no type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Donut\\:\\:setLabelCallback\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$data type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$data type has no value type specified in iterable type array\\|Traversable\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$heading has no type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$headingLevel has no type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Donut\\:\\:\\$labelCallback has no type specified\\.$#"
+ count: 1
+ path: library/X509/Donut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:__construct\\(\\) has parameter \\$from with no type specified\\.$#"
+ count: 1
+ path: library/X509/ExpirationWidget.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:__construct\\(\\) has parameter \\$to with no type specified\\.$#"
+ count: 1
+ path: library/X509/ExpirationWidget.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/ExpirationWidget.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:\\$from has no type specified\\.$#"
+ count: 1
+ path: library/X509/ExpirationWidget.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\ExpirationWidget\\:\\:\\$to has no type specified\\.$#"
+ count: 1
+ path: library/X509/ExpirationWidget.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:addFilter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:applyFilter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:getFilter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:setFilter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:where\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:where\\(\\) has parameter \\$condition with no type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\FilterAdapter\\:\\:where\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/X509/FilterAdapter.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of static method Icinga\\\\Util\\\\StringHelper\\:\\:trimSplit\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Hook/SniHook.php
+
+ -
+ message: "#^Cannot access property \\$fingerprint on mixed\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Cannot access property \\$hostname on mixed\\.$#"
+ count: 2
+ path: library/X509/Job.php
+
+ -
+ message: "#^Cannot access property \\$ip on mixed\\.$#"
+ count: 4
+ path: library/X509/Job.php
+
+ -
+ message: "#^Cannot access property \\$port on mixed\\.$#"
+ count: 2
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:finishTarget\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:formatTarget\\(\\) has parameter \\$target with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:getConnector\\(\\) has parameter \\$peerName with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:getConnector\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:isIPV6\\(\\) has parameter \\$addr with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:numberToAddr\\(\\) has parameter \\$num with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:processChain\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:processChain\\(\\) has parameter \\$chain with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:processChain\\(\\) has parameter \\$target with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:startNextTarget\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:updateLastScan\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Job\\:\\:updateLastScan\\(\\) has parameter \\$target with no type specified\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Negated boolean expression is always true\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#1 \\$addr of static method Icinga\\\\Module\\\\X509\\\\Job\\:\\:isAddrInside\\(\\) expects GMP, GMP\\|null given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:lessThan\\(\\) expects float\\|int\\|string, DateTime given\\.$#"
+ count: 1
+ path: library/X509/Job.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\Behavior\\\\ExpressionInjector\\:\\:__construct\\(\\) has parameter \\$columns with no type specified\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/ExpressionInjector.php
+
+ -
+ message: "#^Parameter \\#1 \\$path of method ipl\\\\Orm\\\\Resolver\\:\\:resolveRelation\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/ExpressionInjector.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Model\\\\Behavior\\\\ExpressionInjector\\:\\:\\$columns type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Model/Behavior/ExpressionInjector.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509Certificate.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509Certificate.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Model/X509Certificate.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Certificate\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Model/X509Certificate.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509CertificateChain.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChain\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509CertificateChain.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChainLink\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509CertificateChainLink.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateChainLink\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509CertificateChainLink.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateSubjectAltName\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509CertificateSubjectAltName.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509CertificateSubjectAltName\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509CertificateSubjectAltName.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Dn\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509Dn.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Dn\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509Dn.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509Target.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Model/X509Target.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Model/X509Target.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Model\\\\X509Target\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Model/X509Target.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\HostsImportSource\\:\\:fetchData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/HostsImportSource.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\HostsImportSource\\:\\:listColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/HostsImportSource.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\ServicesImportSource\\:\\:fetchData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/ServicesImportSource.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\ProvidedHook\\\\ServicesImportSource\\:\\:listColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/ServicesImportSource.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/ServicesImportSource.php
+
+ -
+ message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/ServicesImportSource.php
+
+ -
+ message: "#^Parameter \\#4 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/X509/ProvidedHook/ServicesImportSource.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\React\\\\StreamOptsCaptureConnector\\:\\:getCapturedStreamOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/React/StreamOptsCaptureConnector.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\React\\\\StreamOptsCaptureConnector\\:\\:setCapturedStreamOptions\\(\\) has parameter \\$capturedStreamOptions with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/React/StreamOptsCaptureConnector.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\React\\\\StreamOptsCaptureConnector\\:\\:\\$capturedStreamOptions type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/React/StreamOptsCaptureConnector.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\SniIniRepository\\:\\:\\$configs type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/SniIniRepository.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\SniIniRepository\\:\\:\\$queryColumns type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/SniIniRepository.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Table\\:\\:addRow\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Table.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Table\\:\\:addRow\\(\\) has parameter \\$attributes with no type specified\\.$#"
+ count: 1
+ path: library/X509/Table.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Table\\:\\:addRow\\(\\) has parameter \\$cells with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Table.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\X509\\\\Table\\:\\:\\$rows has no type specified\\.$#"
+ count: 1
+ path: library/X509/Table.php
+
+ -
+ message: "#^Cannot access property \\$id on mixed\\.$#"
+ count: 1
+ path: library/X509/UsageTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\UsageTable\\:\\:createColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/UsageTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\UsageTable\\:\\:renderRow\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/UsageTable.php
+
+ -
+ message: "#^Dead catch \\- Exception is never thrown in the try block\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectFilterColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has parameter \\$models with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has parameter \\$path with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:fetchColumnSuggestions\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\X509\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:fetchValueSuggestions\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#1 \\$path of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyPath\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#1 \\$subject of static method ipl\\\\Stdlib\\\\Str\\:\\:trimSplit\\(\\) expects string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/X509/Web/Control/SearchBar/ObjectSuggestions.php
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..efeb32e
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,42 @@
+includes:
+ - phpstan-baseline-common.neon
+ - phpstan-baseline-by-php-version.php
+
+parameters:
+ level: max
+
+ checkFunctionNameCase: true
+ checkInternalClassCaseSensitivity: true
+ treatPhpDocTypesAsCertain: false
+
+ paths:
+ - application
+ - library
+
+ scanDirectories:
+ - /icingaweb2
+ - /usr/share/icingaweb2-modules/director
+ - /usr/share/icinga-php/ipl
+ - /usr/share/icinga-php/vendor
+
+ ignoreErrors:
+ -
+ messages:
+ - '#Unsafe usage of new static\(\)#'
+ - '#. but return statement is missing#'
+ reportUnmatched: false
+
+ - '#Call to an undefined method React\\Promise#'
+
+ - '#Call to an undefined method ipl\\Sql\\Connection::lastInsertId\(\)#'
+
+ - '#Access to an undefined property React\\Socket\\ConnectionInterface::\$stream#'
+
+ - '#Binary operation .* between GMP and .* results in an error#'
+
+ - '#Access to an undefined property (Icinga\\Module\\X509\\Model\\.*|ipl\\Orm\\Query::.*)#'
+
+ universalObjectCratesClasses:
+ - ipl\Orm\Model
+ - Icinga\Web\View
+ - Icinga\Data\ConfigObject
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..09e75ee
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit backupGlobals="false"
+ backupStaticAttributes="false"
+ colors="true"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ processIsolation="false"
+ stopOnFailure="false"
+>
+ <testsuites>
+ <testsuite name="Icinga Certificate Monitoring PHP Unit Tests">
+ <directory suffix="Test.php">test/php</directory>
+ </testsuite>
+ </testsuites>
+</phpunit>
diff --git a/public/css/module.less b/public/css/module.less
new file mode 100644
index 0000000..2a59ae7
--- /dev/null
+++ b/public/css/module.less
@@ -0,0 +1,171 @@
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+@cert-segment-color-0: #FF595E;
+@cert-segment-color-1: #FFCA3A;
+@cert-segment-color-2: #8AC926;
+@cert-segment-color-3: #1982C4;
+@cert-segment-color-4: #6A4C93;
+
+.action-bar {
+ line-height: 2.5em;
+}
+
+.cert-details {
+ .iicon-certificate {
+ font-size: 5em;
+ }
+
+ h3 {
+ text-align: right;
+ //text-decoration: underline;
+ width: 10.25em;
+ border-bottom: 1px solid @gray-lighter;
+ }
+
+ dl {
+ > dd {
+ // Reset default margin
+ margin: 0;
+ }
+
+ > dt {
+ margin-right: 1em;
+ text-align: right;
+ width: 12em;
+
+ float: left;
+ clear: left;
+ }
+ }
+}
+
+.expiration-col .progress-bar {
+ background-color: @gray-lighter;
+ border-radius: 2px;
+ box-shadow: 0 2px 3px rgba(0, 0, 0, 0.25) inset;
+ height: 0.5em;
+
+ > div {
+ border-radius: 2px;
+ height: 100%;
+ }
+}
+
+.certificate-days-remaining {
+ font-size: @font-size-small;
+ margin-left: 2em;
+}
+
+.expiration-col {
+ width: 18em;
+
+ span.progress-bar-label {
+ font-size: 0.9em;
+ }
+}
+
+.icon-col > i {
+ font-size: 120%;
+}
+
+.version-col {
+ width: 1em;
+}
+
+.cert-table, .usage-table {
+ width: 98%;
+}
+
+.cert-dashboard {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.cert-donut {
+ align-self: flex-start;
+ padding: 1em;
+
+ .donut-graph {
+ .segment-0 {
+ stroke: @cert-segment-color-0;
+ }
+
+ .segment-1 {
+ stroke: @cert-segment-color-1;
+ }
+
+ .segment-2 {
+ stroke: @cert-segment-color-2;
+ }
+
+ .segment-3 {
+ stroke: @cert-segment-color-3;
+ }
+
+ .segment-4 {
+ stroke: @cert-segment-color-4;
+ }
+ }
+
+ .badge {
+ height: 1.75em;
+
+ &.badge-0 {
+ background: @cert-segment-color-0;
+ }
+
+ &.badge-1 {
+ background: @cert-segment-color-1;
+ }
+
+ &.badge-2 {
+ background: @cert-segment-color-2;
+ }
+
+ &.badge-3 {
+ background: @cert-segment-color-3;
+ }
+
+ &.badge-4 {
+ background: @cert-segment-color-4;
+ }
+ }
+}
+
+.cert-chain {
+ .rounded-corners();
+
+ color: @text-color-inverted;
+ font-size: 120%;
+ font-weight: @font-weight-bold;
+ padding: 0.75em;
+ text-align: center;
+
+ > p {
+ margin: 0;
+ }
+
+ &.-valid {
+ background-color: @color-ok;
+ }
+
+ &.-invalid {
+ background-color: @color-critical;
+ }
+}
+
+.icon {
+ &.-ok {
+ color: @color-ok;
+ }
+
+ &.-critical {
+ color: @color-critical;
+ }
+}
+
+.schedule-element-separator {
+ border-top: 1px solid @gray-lighter;
+}
diff --git a/run.php b/run.php
new file mode 100644
index 0000000..b1977a9
--- /dev/null
+++ b/run.php
@@ -0,0 +1,10 @@
+<?php
+
+// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2
+
+/** @var \Icinga\Application\Modules\Module $this */
+
+$this->provideHook('DbMigration', '\\Icinga\\Module\\X509\\ProvidedHook\\DbMigration');
+
+$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\HostsImportSource');
+$this->provideHook('director/ImportSource', '\\Icinga\\Module\\X509\\ProvidedHook\\ServicesImportSource');
diff --git a/schema/mysql-upgrades/1.0.0.sql b/schema/mysql-upgrades/1.0.0.sql
new file mode 100644
index 0000000..28b3e7d
--- /dev/null
+++ b/schema/mysql-upgrades/1.0.0.sql
@@ -0,0 +1,27 @@
+ALTER TABLE x509_target MODIFY COLUMN `port` smallint unsigned NOT NULL;
+
+ALTER TABLE x509_certificate_subject_alt_name DROP FOREIGN KEY x509_fk_certificate_subject_alt_name_certificate_id;
+
+ALTER TABLE x509_certificate_subject_alt_name DROP PRIMARY KEY;
+
+ALTER TABLE x509_certificate_subject_alt_name ADD COLUMN hash binary(32) NOT NULL
+ COMMENT 'sha256 hash of type=value'
+ AFTER certificate_id;
+
+UPDATE x509_certificate_subject_alt_name SET hash = UNHEX(SHA2(CONCAT(type, '=', value), 256));
+
+ALTER TABLE x509_certificate_subject_alt_name ADD PRIMARY KEY(certificate_id, hash);
+
+ALTER TABLE x509_certificate_subject_alt_name ADD
+ CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id
+ FOREIGN KEY (certificate_id)
+ REFERENCES x509_certificate (id)
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE x509_certificate_subject_alt_name ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=default;
+
+ALTER TABLE x509_target DROP INDEX x509_idx_target_ip_port_hostname;
+
+ALTER TABLE x509_target ADD INDEX x509_idx_target_ip_port_hostname(ip,port,hostname(191));
+
+ALTER TABLE x509_target ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=default;
diff --git a/schema/mysql-upgrades/1.1.0.sql b/schema/mysql-upgrades/1.1.0.sql
new file mode 100644
index 0000000..055d783
--- /dev/null
+++ b/schema/mysql-upgrades/1.1.0.sql
@@ -0,0 +1,4 @@
+ALTER TABLE x509_target DROP INDEX x509_idx_target_ip_port_hostname;
+ALTER TABLE x509_target ADD INDEX x509_idx_target_ip_port (ip, port);
+ALTER TABLE x509_certificate MODIFY COLUMN valid_from bigint(20) NOT NULL;
+ALTER TABLE x509_certificate MODIFY COLUMN valid_to bigint(20) NOT NULL;
diff --git a/schema/mysql-upgrades/1.2.0.sql b/schema/mysql-upgrades/1.2.0.sql
new file mode 100644
index 0000000..1fdd74f
--- /dev/null
+++ b/schema/mysql-upgrades/1.2.0.sql
@@ -0,0 +1,103 @@
+ALTER TABLE x509_certificate
+ MODIFY self_signed enum('n', 'y', 'yes', 'no') NOT NULL DEFAULT 'n',
+ MODIFY ca enum('n', 'y', 'yes', 'no') NOT NULL,
+ MODIFY trusted enum('n', 'y', 'yes', 'no') NOT NULL DEFAULT 'n',
+ ADD COLUMN ctime_tmp bigint unsigned DEFAULT NULL,
+ ADD COLUMN mtime_tmp bigint unsigned DEFAULT NULL;
+
+UPDATE x509_certificate SET self_signed = 'y' WHERE self_signed = 'yes';
+UPDATE x509_certificate SET self_signed = 'n' WHERE self_signed = 'no';
+
+UPDATE x509_certificate SET ca = 'y' WHERE ca = 'yes';
+UPDATE x509_certificate SET ca = 'n' WHERE ca = 'no';
+
+UPDATE x509_certificate SET trusted = 'y' WHERE trusted = 'yes';
+UPDATE x509_certificate SET trusted = 'n' WHERE trusted = 'no';
+
+UPDATE x509_certificate SET mtime_tmp = UNIX_TIMESTAMP(mtime) * 1000.0, ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0;
+UPDATE x509_certificate SET valid_from = valid_from * 1000.0, valid_to = valid_to * 1000.0;
+
+ALTER TABLE x509_certificate
+ MODIFY self_signed enum('n', 'y') NOT NULL DEFAULT 'n',
+ MODIFY ca enum('n', 'y') NOT NULL,
+ MODIFY trusted enum('n', 'y') NOT NULL DEFAULT 'n',
+ DROP COLUMN mtime,
+ DROP COLUMN ctime,
+ CHANGE COLUMN ctime_tmp ctime bigint unsigned DEFAULT NULL,
+ CHANGE COLUMN mtime_tmp mtime bigint unsigned DEFAULT NULL;
+
+ALTER TABLE x509_certificate_chain
+ MODIFY valid enum('n', 'y', 'yes', 'no') NOT NULL DEFAULT 'n',
+ ADD COLUMN ctime_tmp bigint unsigned NOT NULL;
+
+UPDATE x509_certificate_chain SET valid = 'y' WHERE valid = 'yes';
+UPDATE x509_certificate_chain SET valid = 'n' WHERE valid = 'no';
+
+UPDATE x509_certificate_chain SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0;
+
+ALTER TABLE x509_certificate_chain
+ MODIFY valid enum('n', 'y') NOT NULL DEFAULT 'n',
+ DROP ctime,
+ CHANGE ctime_tmp ctime bigint unsigned NOT NULL;
+
+ALTER TABLE x509_certificate_chain_link ADD COLUMN ctime_tmp bigint unsigned NOT NULL;
+
+UPDATE x509_certificate_chain_link SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0;
+
+ALTER TABLE x509_certificate_chain_link
+ DROP COLUMN ctime,
+ CHANGE ctime_tmp ctime bigint unsigned NOT NULL;
+
+ALTER TABLE x509_certificate_subject_alt_name ADD COLUMN ctime_tmp bigint unsigned NOT NULL;
+
+UPDATE x509_certificate_subject_alt_name SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0;
+
+ALTER TABLE x509_certificate_subject_alt_name
+ DROP COLUMN ctime,
+ CHANGE ctime_tmp ctime bigint unsigned NOT NULL;
+
+ALTER TABLE x509_dn ADD COLUMN ctime_tmp bigint unsigned NOT NULL;
+
+UPDATE x509_dn SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0;
+
+ALTER TABLE x509_dn
+ DROP COLUMN ctime,
+ CHANGE ctime_tmp ctime bigint unsigned NOT NULL;
+
+ALTER TABLE x509_job_run
+ ADD COLUMN starttime_tmp bigint unsigned DEFAULT NULL,
+ ADD COLUMN endtime_tmp bigint unsigned DEFAULT NULL,
+ ADD COLUMN ctime_tmp bigint unsigned DEFAULT NULL,
+ ADD COLUMN mtime_tmp bigint unsigned DEFAULT NULL;
+
+UPDATE x509_job_run SET
+ starttime_tmp = UNIX_TIMESTAMP(start_time) * 1000.0,
+ endtime_tmp = UNIX_TIMESTAMP(end_time) * 1000.0,
+ ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0,
+ mtime_tmp = UNIX_TIMESTAMP(mtime) * 1000.0;
+
+ALTER TABLE x509_job_run
+ DROP COLUMN start_time,
+ DROP COLUMN end_time,
+ DROP COLUMN mtime,
+ DROP COLUMN ctime,
+ CHANGE starttime_tmp start_time bigint unsigned DEFAULT NULL,
+ CHANGE endtime_tmp end_time bigint unsigned DEFAULT NULL,
+ CHANGE ctime_tmp ctime bigint unsigned DEFAULT NULL,
+ CHANGE mtime_tmp mtime bigint unsigned DEFAULT NULL;
+
+ALTER TABLE x509_target ADD COLUMN last_scan bigint unsigned DEFAULT NULL AFTER latest_certificate_chain_id;
+UPDATE x509_target SET last_scan = UNIX_TIMESTAMP() * 1000.0;
+ALTER TABLE x509_target MODIFY COLUMN last_scan bigint unsigned NOT NULL;
+
+ALTER TABLE x509_target
+ ADD COLUMN ctime_tmp bigint unsigned DEFAULT NULL,
+ ADD COLUMN mtime_tmp bigint unsigned DEFAULT NULL;
+
+UPDATE x509_target SET ctime_tmp = UNIX_TIMESTAMP(ctime) * 1000.0, mtime_tmp = UNIX_TIMESTAMP(mtime) * 1000.0;
+
+ALTER TABLE x509_target
+ DROP COLUMN ctime,
+ DROP COLUMN mtime,
+ CHANGE ctime_tmp ctime bigint unsigned DEFAULT NULL,
+ CHANGE mtime_tmp mtime bigint unsigned DEFAULT NULL;
diff --git a/schema/mysql-upgrades/1.3.0.sql b/schema/mysql-upgrades/1.3.0.sql
new file mode 100644
index 0000000..f31e8bd
--- /dev/null
+++ b/schema/mysql-upgrades/1.3.0.sql
@@ -0,0 +1,51 @@
+CREATE TABLE x509_job (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ cidrs text NOT NULL,
+ ports text NOT NULL,
+ exclude_targets text DEFAULT NULL,
+ ctime bigint unsigned NOT NULL,
+ mtime bigint unsigned NOT NULL,
+
+ PRIMARY KEY (id),
+ UNIQUE (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_schedule (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ job_id int(10) unsigned NOT NULL,
+ name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ config text NOT NULL, -- json
+ ctime bigint unsigned NOT NULL,
+ mtime bigint unsigned NOT NULL,
+
+ PRIMARY KEY (id),
+ CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+DELETE FROM x509_job_run;
+ALTER TABLE x509_job_run
+ ADD COLUMN job_id int(10) unsigned NOT NULL AFTER id,
+ ADD COLUMN schedule_id int(10) unsigned DEFAULT NULL AFTER job_id,
+ DROP COLUMN `name`,
+ DROP COLUMN ctime,
+ DROP COLUMN mtime;
+ALTER TABLE x509_job_run
+ ADD CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE,
+ ADD CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE;
+
+CREATE TABLE x509_schema (
+ id int unsigned NOT NULL AUTO_INCREMENT,
+ version varchar(64) NOT NULL,
+ timestamp bigint unsigned NOT NULL,
+ success enum ('n', 'y') DEFAULT NULL,
+ reason text DEFAULT NULL,
+
+ PRIMARY KEY (id),
+ CONSTRAINT idx_x509_schema_version UNIQUE (version)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
+
+INSERT INTO x509_schema (version, timestamp, success, reason)
+ VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y', NULL);
diff --git a/schema/mysql.schema.sql b/schema/mysql.schema.sql
new file mode 100644
index 0000000..7e56746
--- /dev/null
+++ b/schema/mysql.schema.sql
@@ -0,0 +1,136 @@
+CREATE TABLE x509_certificate (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `subject` varchar(255) NOT NULL COMMENT 'CN of the subject DN if present else full subject DN',
+ subject_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full subject DN',
+ `issuer` varchar(255) NOT NULL COMMENT 'CN of the issuer DN if present else full issuer DN',
+ issuer_hash binary(32) NOT NULL COMMENT 'sha256 hash of the full issuer DN',
+ issuer_certificate_id int(10) unsigned DEFAULT NULL,
+ version enum('1','2','3') NOT NULL,
+ self_signed enum('n', 'y') NOT NULL DEFAULT 'n',
+ ca enum('n', 'y') NOT NULL,
+ trusted enum('n', 'y') NOT NULL DEFAULT 'n',
+ pubkey_algo enum('unknown','RSA','DSA','DH','EC') NOT NULL,
+ pubkey_bits smallint(6) unsigned NOT NULL,
+ signature_algo varchar(255) NOT NULL,
+ signature_hash_algo varchar(255) NOT NULL,
+ valid_from bigint unsigned NOT NULL,
+ valid_to bigint unsigned NOT NULL,
+ fingerprint binary(32) NOT NULL COMMENT 'sha256 hash',
+ `serial` blob NOT NULL,
+ certificate blob NOT NULL COMMENT 'DER encoded certificate',
+ ctime bigint unsigned DEFAULT NULL,
+ mtime bigint unsigned DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY x509_idx_certificate_fingerprint (fingerprint),
+ KEY x509_fk_certificate_issuer_certificate_id (issuer_certificate_id),
+ CONSTRAINT x509_fk_certificate_issuer_certificate_id FOREIGN KEY (issuer_certificate_id) REFERENCES x509_certificate (id) ON DELETE SET NULL ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_certificate_chain (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ target_id int(10) unsigned NOT NULL,
+ length smallint(6) NOT NULL,
+ valid enum('n', 'y') NOT NULL DEFAULT 'n',
+ invalid_reason varchar(255) NULL DEFAULT NULL,
+ ctime bigint unsigned NOT NULL,
+ PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_certificate_chain_link (
+ certificate_chain_id int(10) unsigned NOT NULL,
+ certificate_id int(10) unsigned NOT NULL,
+ `order` tinyint(4) NOT NULL,
+ ctime bigint unsigned NOT NULL,
+ PRIMARY KEY (certificate_chain_id,certificate_id,`order`),
+ KEY x509_fk_certificate_chain_link_certificate_id (certificate_id),
+ CONSTRAINT x509_fk_certificate_chain_link_certificate_chain_id FOREIGN KEY (certificate_chain_id) REFERENCES x509_certificate_chain (id) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT x509_fk_certificate_chain_link_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_certificate_subject_alt_name (
+ certificate_id int(10) unsigned NOT NULL,
+ hash binary(32) NOT NULL COMMENT 'sha256 hash of type=value',
+ `type` varchar(255) NOT NULL,
+ `value` varchar(255) NOT NULL,
+ ctime bigint unsigned NOT NULL,
+ PRIMARY KEY (certificate_id,hash),
+ CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_dn (
+ `hash` binary(32) NOT NULL,
+ `type` enum('issuer','subject') NOT NULL,
+ `order` tinyint(4) unsigned NOT NULL,
+ `key` varchar(255) NOT NULL,
+ `value` varchar(255) NOT NULL,
+ ctime bigint unsigned NOT NULL,
+ PRIMARY KEY (`hash`,`type`,`order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_target (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ ip binary(16) NOT NULL,
+ `port` smallint unsigned NOT NULL,
+ hostname varchar(255) NULL DEFAULT NULL,
+ latest_certificate_chain_id int(10) unsigned NULL DEFAULT NULL,
+ last_scan bigint unsigned NOT NULL,
+ ctime bigint unsigned DEFAULT NULL,
+ mtime bigint unsigned DEFAULT NULL,
+ PRIMARY KEY (id),
+ INDEX x509_idx_target_ip_port (ip, port)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_job (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ cidrs text NOT NULL,
+ ports text NOT NULL,
+ exclude_targets text DEFAULT NULL,
+ ctime bigint unsigned NOT NULL,
+ mtime bigint unsigned NOT NULL,
+
+ PRIMARY KEY (id),
+ UNIQUE (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_schedule (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ job_id int(10) unsigned NOT NULL,
+ name varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ author varchar(255) NOT NULL COLLATE utf8mb4_unicode_ci,
+ config text NOT NULL, -- json
+ ctime bigint unsigned NOT NULL,
+ mtime bigint unsigned NOT NULL,
+
+ PRIMARY KEY (id),
+ CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_job_run (
+ id int(10) unsigned NOT NULL AUTO_INCREMENT,
+ job_id int(10) unsigned NOT NULL,
+ schedule_id int(10) unsigned DEFAULT NULL,
+ total_targets int(10) NOT NULL,
+ finished_targets int(10) NOT NULL,
+ start_time bigint unsigned DEFAULT NULL,
+ end_time bigint unsigned DEFAULT NULL,
+
+ PRIMARY KEY (id),
+ CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE,
+ CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE x509_schema (
+ id int unsigned NOT NULL AUTO_INCREMENT,
+ version varchar(64) NOT NULL,
+ timestamp bigint unsigned NOT NULL,
+ success enum ('n', 'y') DEFAULT NULL,
+ reason text DEFAULT NULL,
+
+ PRIMARY KEY (id),
+ CONSTRAINT idx_x509_schema_version UNIQUE (version)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
+
+INSERT INTO x509_schema (version, timestamp, success)
+ VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y');
diff --git a/schema/pgsql-upgrades/1.3.0.sql b/schema/pgsql-upgrades/1.3.0.sql
new file mode 100644
index 0000000..7e1f43a
--- /dev/null
+++ b/schema/pgsql-upgrades/1.3.0.sql
@@ -0,0 +1,49 @@
+CREATE TABLE x509_job (
+ id serial PRIMARY KEY,
+ name varchar(255) NOT NULL,
+ author varchar(255) NOT NULL,
+ cidrs text NOT NULL,
+ ports text NOT NULL,
+ exclude_targets text DEFAULT NULL,
+ ctime bigint NOT NULL,
+ mtime bigint NOT NULL,
+
+ UNIQUE (name)
+);
+
+CREATE TABLE x509_schedule (
+ id serial PRIMARY KEY,
+ job_id int NOT NULL,
+ name varchar(255) NOT NULL,
+ author varchar(255) NOT NULL,
+ config text NOT NULL, -- json
+ ctime bigint NOT NULL,
+ mtime bigint NOT NULL,
+
+ CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE
+);
+
+DELETE FROM x509_job_run;
+ALTER TABLE x509_job_run
+ ADD COLUMN job_id int NOT NULL,
+ ADD COLUMN schedule_id int DEFAULT NULL,
+ DROP COLUMN name,
+ DROP COLUMN ctime,
+ DROP COLUMN mtime;
+ALTER TABLE x509_job_run
+ ADD CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE,
+ ADD CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE;
+
+CREATE TABLE x509_schema (
+ id serial,
+ version varchar(64) NOT NULL,
+ timestamp bigint NOT NULL,
+ success boolenum DEFAULT NULL,
+ reason text DEFAULT NULL,
+
+ CONSTRAINT pk_x509_schema PRIMARY KEY (id),
+ CONSTRAINT idx_x509_schema_version UNIQUE (version)
+);
+
+INSERT INTO x509_schema (version, timestamp, success, reason)
+ VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y', NULL);
diff --git a/schema/pgsql.schema.sql b/schema/pgsql.schema.sql
new file mode 100644
index 0000000..1d93ef3
--- /dev/null
+++ b/schema/pgsql.schema.sql
@@ -0,0 +1,162 @@
+CREATE DOMAIN uint2 AS int4
+ CHECK(VALUE >= 0 AND VALUE < 65536);
+CREATE DOMAIN biguint AS bigint CONSTRAINT positive CHECK ( VALUE IS NULL OR 0 <= VALUE );
+CREATE TYPE boolenum AS ENUM ('n', 'y');
+CREATE TYPE certificate_version AS ENUM('1','2','3');
+CREATE TYPE dn_type AS ENUM('issuer','subject');
+CREATE TYPE pubkey_algo AS ENUM('unknown','RSA','DSA','DH','EC');
+
+-- Used when sorting certificates by expiration date.
+CREATE OR REPLACE FUNCTION UNIX_TIMESTAMP(datetime timestamptz DEFAULT NOW())
+ RETURNS biguint
+ LANGUAGE plpgsql
+ PARALLEL SAFE
+ AS $$
+BEGIN
+ RETURN EXTRACT(EPOCH FROM datetime);
+END;
+$$;
+
+-- IPL ORM renders SQL queries with LIKE operators for all suggestions in the search bar,
+-- which fails for numeric and enum types on PostgreSQL. Just like in Icinga DB Web.
+CREATE OR REPLACE FUNCTION anynonarrayliketext(anynonarray, text)
+ RETURNS bool
+ LANGUAGE plpgsql
+ IMMUTABLE
+ PARALLEL SAFE
+ AS $$
+BEGIN
+ RETURN $1::TEXT LIKE $2;
+END;
+$$;
+CREATE OPERATOR ~~ (LEFTARG=anynonarray, RIGHTARG=text, PROCEDURE=anynonarrayliketext);
+
+CREATE TABLE x509_certificate (
+ id serial PRIMARY KEY,
+ subject varchar(255) NOT NULL,
+ subject_hash bytea NOT NULL,
+ issuer varchar(255) NOT NULL,
+ issuer_hash bytea NOT NULL,
+ issuer_certificate_id int DEFAULT NULL,
+ version certificate_version NOT NULL,
+ self_signed boolenum NOT NULL DEFAULT 'n',
+ ca boolenum NOT NULL,
+ trusted boolenum NOT NULL DEFAULT 'n',
+ pubkey_algo pubkey_algo NOT NULL,
+ pubkey_bits uint2 NOT NULL,
+ signature_algo varchar(255) NOT NULL,
+ signature_hash_algo varchar(255) NOT NULL,
+ valid_from biguint NOT NULL,
+ valid_to biguint NOT NULL,
+ fingerprint bytea NOT NULL,
+ serial bytea NOT NULL,
+ certificate bytea NOT NULL,
+ ctime biguint NOT NULL,
+ mtime biguint DEFAULT NULL,
+ CONSTRAINT x509_idx_certificate_fingerprint UNIQUE(fingerprint),
+ CONSTRAINT x509_fk_certificate_issuer_certificate_id FOREIGN KEY (issuer_certificate_id) REFERENCES x509_certificate (id) ON DELETE SET NULL ON UPDATE CASCADE
+);
+
+CREATE TABLE x509_certificate_chain (
+ id serial PRIMARY KEY,
+ target_id int NOT NULL,
+ length uint2 NOT NULL,
+ valid boolenum NOT NULL DEFAULT 'n',
+ invalid_reason varchar(255) NULL DEFAULT NULL,
+ ctime biguint NOT NULL
+);
+
+CREATE TABLE x509_certificate_chain_link (
+ certificate_chain_id int NOT NULL,
+ certificate_id int NOT NULL,
+ "order" uint2 NOT NULL,
+ ctime biguint NOT NULL,
+ PRIMARY KEY(certificate_chain_id,certificate_id,"order"),
+ CONSTRAINT x509_fk_certificate_chain_link_certificate_chain_id FOREIGN KEY (certificate_chain_id) REFERENCES x509_certificate_chain (id) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT x509_fk_certificate_chain_link_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE TABLE x509_certificate_subject_alt_name (
+ certificate_id int NOT NULL,
+ hash bytea NOT NULL,
+ type varchar(255) NOT NULL,
+ value varchar(255) NOT NULL,
+ ctime biguint NOT NULL,
+ PRIMARY KEY (certificate_id,hash),
+ CONSTRAINT x509_fk_certificate_subject_alt_name_certificate_id FOREIGN KEY (certificate_id) REFERENCES x509_certificate (id) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE TABLE x509_dn (
+ hash bytea NOT NULL,
+ type dn_type NOT NULL,
+ "order" uint2 NOT NULL,
+ key varchar(255) NOT NULL,
+ value varchar(255) NOT NULL,
+ ctime biguint NOT NULL,
+ PRIMARY KEY (hash,type,"order")
+);
+
+CREATE TABLE x509_target (
+ id serial PRIMARY KEY,
+ ip bytea NOT NULL,
+ port uint2 NOT NULL,
+ hostname varchar(255) NULL DEFAULT NULL,
+ latest_certificate_chain_id int NULL DEFAULT NULL,
+ last_scan biguint NOT NULL,
+ ctime biguint NOT NULL,
+ mtime biguint DEFAULT NULL
+);
+
+CREATE INDEX x509_idx_target ON x509_target (ip,port,hostname);
+
+CREATE TABLE x509_job (
+ id serial PRIMARY KEY,
+ name varchar(255) NOT NULL,
+ author varchar(255) NOT NULL,
+ cidrs text NOT NULL,
+ ports text NOT NULL,
+ exclude_targets text DEFAULT NULL,
+ ctime bigint NOT NULL,
+ mtime bigint NOT NULL,
+
+ UNIQUE (name)
+);
+
+CREATE TABLE x509_schedule (
+ id serial PRIMARY KEY,
+ job_id int NOT NULL,
+ name varchar(255) NOT NULL,
+ author varchar(255) NOT NULL,
+ config text NOT NULL, -- json
+ ctime bigint NOT NULL,
+ mtime bigint NOT NULL,
+
+ CONSTRAINT fk_x509_schedule_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE
+);
+
+CREATE TABLE x509_job_run (
+ id serial PRIMARY KEY,
+ job_id int NOT NULL,
+ schedule_id int DEFAULT NULL,
+ total_targets int NOT NULL,
+ finished_targets int NOT NULL,
+ start_time biguint NULL DEFAULT NULL,
+ end_time biguint NULL DEFAULT NULL,
+
+ CONSTRAINT fk_x509_job_run_job FOREIGN KEY (job_id) REFERENCES x509_job (id) ON DELETE CASCADE,
+ CONSTRAINT fk_x509_job_run_schedule FOREIGN KEY (schedule_id) REFERENCES x509_schedule (id) ON DELETE CASCADE
+);
+
+CREATE TABLE x509_schema (
+ id serial,
+ version varchar(64) NOT NULL,
+ timestamp bigint NOT NULL,
+ success boolenum DEFAULT NULL,
+ reason text DEFAULT NULL,
+
+ CONSTRAINT pk_x509_schema PRIMARY KEY (id),
+ CONSTRAINT idx_x509_schema_version UNIQUE (version)
+);
+
+INSERT INTO x509_schema (version, timestamp, success)
+ VALUES ('1.3.0', UNIX_TIMESTAMP() * 1000, 'y');
diff --git a/test/php/Lib/TestModel.php b/test/php/Lib/TestModel.php
new file mode 100644
index 0000000..050ca48
--- /dev/null
+++ b/test/php/Lib/TestModel.php
@@ -0,0 +1,30 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Module\X509\Lib;
+
+use ipl\Orm\Model;
+use ipl\Sql\Expression;
+
+class TestModel extends Model
+{
+ public const EXPRESSION = 'CASE WHEN 1 THEN YES ELSE NO';
+
+ public function getTableName()
+ {
+ return 'test';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'duration' => new Expression(static::EXPRESSION)
+ ];
+ }
+}
diff --git a/test/php/library/X509/Common/JobUtilsTest.php b/test/php/library/X509/Common/JobUtilsTest.php
new file mode 100644
index 0000000..9283344
--- /dev/null
+++ b/test/php/library/X509/Common/JobUtilsTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\X509\Common;
+
+use Icinga\Module\X509\Common\JobUtils;
+use PHPUnit\Framework\TestCase;
+
+class JobUtilsTest extends TestCase
+{
+ use JobUtils;
+
+ public function testGetCidrs()
+ {
+ $cidrs = $this->parseCIDRs('10.211.55.30/24,127.0.0.1/8,192.168.178.1/28');
+
+ $this->assertCount(3, $cidrs);
+ $this->assertCount(2, $cidrs['10.211.55.30/24']);
+
+ $this->assertSame('10.211.55.30', $cidrs['10.211.55.30/24'][0]);
+ $this->assertSame('24', $cidrs['10.211.55.30/24'][1]);
+ }
+
+ public function testGetPorts()
+ {
+ $ports = $this->parsePorts('5665,3306,6379,8000-9000');
+
+ $this->assertCount(4, $ports);
+ $this->assertCount(2, $ports[3]);
+
+ $this->assertSame('8000', $ports[3][0]);
+ $this->assertSame('9000', $ports[3][1]);
+ }
+
+ public function testGetExcludes()
+ {
+ $excludes = $this->parseExcludes('icinga.com,netways.de');
+
+ $this->assertCount(2, $excludes);
+ $this->assertArrayHasKey('icinga.com', $excludes);
+ $this->assertArrayHasKey('netways.de', $excludes);
+ }
+}
diff --git a/test/php/library/X509/JobTest.php b/test/php/library/X509/JobTest.php
new file mode 100644
index 0000000..8b5ba53
--- /dev/null
+++ b/test/php/library/X509/JobTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Tests\Icinga\Modules\X509;
+
+use Icinga\Module\X509\Job;
+use PHPUnit\Framework\TestCase;
+
+class JobTest extends TestCase
+{
+ public function testBinaryTransformsHumanReadableIPToItsPaddedVersionCorrectly()
+ {
+ $this->assertSame('0000000000000000000000000ad33720', bin2hex(Job::binary('10.211.55.32')));
+ $this->assertSame(
+ '2a0104a00004210208a951031cba4915',
+ bin2hex(Job::binary('2a01:4a0:4:2102:8a9:5103:1cba:4915'))
+ );
+ }
+
+ public function testIsIPV6()
+ {
+ $this->assertTrue(Job::isIPV6('::1'), 'Job::isIPV6() could not determine valid IPv6 as an IPv6');
+ $this->assertFalse(Job::isIPV6('10.211.54.35'), 'Job::isIPV6() could determines IPv4 as an IPv6');
+ }
+
+ public function testAddrToNumberUndViceVersa()
+ {
+ $this->assertSame('10.211.55.32', Job::numberToAddr(Job::addrToNumber('10.211.55.32'), false));
+
+ $this->assertSame('::1', Job::numberToAddr(Job::addrToNumber('::1')));
+ $this->assertSame(
+ '2a01:4a0:4:2102:8a9:5103:1cba:4915',
+ Job::numberToAddr(Job::addrToNumber('2a01:4a0:4:2102:8a9:5103:1cba:4915'))
+ );
+ }
+
+ public function testIsAddrInsideCidr()
+ {
+ $this->assertTrue(Job::isAddrInside(Job::addrToNumber('10.211.55.31'), '10.211.55.30', 24));
+ $this->assertFalse(Job::isAddrInside(Job::addrToNumber('10.211.54.35'), '10.211.55.30', 24));
+
+ $this->assertTrue(
+ Job::isAddrInside(Job::addrToNumber('2001:db8:abcd:0012::1'), '2001:db8:abcd:0012::', 64)
+ );
+ $this->assertTrue(
+ Job::isAddrInside(Job::addrToNumber('2001:db8:abcd:0012:ffff::1'), '2001:db8:abcd:0012::', 64)
+ );
+
+ $this->assertFalse(Job::isAddrInside(Job::addrToNumber('2001:db8:abcd::1'), '2001:db8:abcd:0012::', 64));
+ $this->assertFalse(
+ Job::isAddrInside(Job::addrToNumber('2001:db8:abcd:0011::'), '2001:db8:abcd:0012::', 64)
+ );
+ }
+}
diff --git a/test/php/library/X509/Model/Behavior/DERBase64Test.php b/test/php/library/X509/Model/Behavior/DERBase64Test.php
new file mode 100644
index 0000000..d451ac2
--- /dev/null
+++ b/test/php/library/X509/Model/Behavior/DERBase64Test.php
@@ -0,0 +1,69 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\X509\Model\Behavior;
+
+use Icinga\Module\X509\Model\Behavior\DERBase64;
+use PHPUnit\Framework\TestCase;
+
+class DERBase64Test extends TestCase
+{
+ protected const COLUMN = 'cert';
+
+ protected const CERT = <<<'EOD'
+-----BEGIN CERTIFICATE-----
+MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAUAwggE8AgEAAkEA6QnU5eXu9ugwYsR3
+LcHVZwpag+GlLzASRmQXoaWFTVVTsdnxYqFTs4+/raVtN0/GUtXX8YTN95VE1y/H
+pwyTgQIDAQABAkEA3EtX/9BB+xR5kRSKWS4QTyzhbiRj49y8meBK2ps/DV8bP4nE
+E6VadMSpWFIjuUKZ+D8rdI/7BNUPmgS7Gtk4BQIhAPd3u0fiFje2PWNye9mZX3f+
+zbeAKXXrpWEGpNvi72wPAiEA8RK9fNLOBFUXsPtcGsQZD4DhthLfgTMbA/iGLC8i
+t28CIDpKRJ3o/ky/K3SaSdv2iYtNRI2draZuDDVviDOXH8g3AiEAlFvAGW1yM+Ba
+MCTAzggYlB3wyihbPBvDaHItwEtRxikCIQC2GzXRVDW6rbDJyX1Zhd/l7EC6heib
+LErnxieVVzJglw==
+-----END CERTIFICATE-----
+EOD;
+
+ protected const CERT_BASE64_HEX = <<<'EOD'
+30820156020100300d06092a864886f70d0101010500048201403082013c020100024100e909d
+4e5e5eef6e83062c4772dc1d5670a5a83e1a52f3012466417a1a5854d5553b1d9f162a153b38fb
+fada56d374fc652d5d7f184cdf79544d72fc7a70c93810203010001024100dc4b57ffd041fb1479
+91148a592e104f2ce16e2463e3dcbc99e04ada9b3f0d5f1b3f89c413a55a74c4a9585223b94299f8
+3f2b748ffb04d50f9a04bb1ad93805022100f777bb47e21637b63d63727bd9995f77fecdb7802975e
+ba56106a4dbe2ef6c0f022100f112bd7cd2ce045517b0fb5c1ac4190f80e1b612df81331b03f8862c2
+f22b76f02203a4a449de8fe4cbf2b749a49dbf6898b4d448d9dada66e0c356f8833971fc83702210094
+5bc0196d7233e05a3024c0ce0818941df0ca285b3c1bc368722dc04b51c629022100b61b35d15435baa
+db0c9c97d5985dfe5ec40ba85e89b2c4ae7c6279557326097
+EOD;
+
+ public function testFromDbReturnsNullWhenNullIsPassed()
+ {
+ $this->assertNull($this->behavior()->retrieveProperty(null, static::COLUMN));
+ }
+
+ public function testFromDBTransformsPemToDer()
+ {
+ $this->assertSame(
+ static::CERT,
+ $this->behavior()->retrieveProperty(hex2bin(str_replace("\n", '', static::CERT_BASE64_HEX)), static::COLUMN)
+ );
+ }
+
+ public function testToDbReturnsNullWhenNullIsPassed()
+ {
+ $this->assertNull($this->behavior()->persistProperty(null, static::COLUMN));
+ }
+
+ public function testToDbTransformsDerToPem()
+ {
+ $this->assertSame(
+ hex2bin(str_replace("\n", '', static::CERT_BASE64_HEX)),
+ $this->behavior()->persistProperty(static::CERT, static::COLUMN)
+ );
+ }
+
+ protected function behavior(): DERBase64
+ {
+ return new DERBase64(['cert']);
+ }
+}
diff --git a/test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php b/test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php
new file mode 100644
index 0000000..5481ce9
--- /dev/null
+++ b/test/php/library/X509/Model/Behavior/ExpressionInjectorTest.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\X509\Model\Behavior;
+
+use Icinga\Module\X509\Model\Behavior\ExpressionInjector;
+use ipl\Orm\Query;
+use ipl\Sql\Connection;
+use ipl\Stdlib\Filter\Equal;
+use PHPUnit\Framework\TestCase;
+use Tests\Icinga\Module\X509\Lib\TestModel;
+
+class ExpressionInjectorTest extends TestCase
+{
+ public function testRewriteConditionReplacesExpressionColumnByItsExpression()
+ {
+ $cond = new Equal('duration', 'FOOO');
+ $cond->metaData()->set('columnName', 'duration');
+ $this->assertSame('duration', $cond->getColumn());
+ $this->assertSame('FOOO', $cond->getValue());
+
+ $this->behavior()->rewriteCondition($cond);
+
+ $this->assertSame('FOOO', $cond->getValue());
+ $this->assertSame(TestModel::EXPRESSION, $cond->getColumn());
+ }
+
+ protected function behavior(): ExpressionInjector
+ {
+ return (new ExpressionInjector('duration'))
+ ->setQuery(
+ (new Query())
+ ->setDb(new Connection(['db' => 'mysql']))
+ ->setModel(new TestModel())
+ );
+ }
+}
diff --git a/test/php/library/X509/Model/Behavior/IpTest.php b/test/php/library/X509/Model/Behavior/IpTest.php
new file mode 100644
index 0000000..87c4c68
--- /dev/null
+++ b/test/php/library/X509/Model/Behavior/IpTest.php
@@ -0,0 +1,92 @@
+<?php
+
+/* Icinga Web 2 X.509 Module | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\X509\Model\Behavior;
+
+use Icinga\Module\X509\Model\Behavior\Ip;
+use ipl\Orm\Query;
+use ipl\Sql\Connection;
+use PHPUnit\Framework\TestCase;
+
+class IpTest extends TestCase
+{
+ protected const IPV4 = '10.211.55.32';
+
+ protected const IPV6 = '2a01:4a0:4:2102:8a9:5103:1cba:4915';
+
+ protected const IPV4_HEX = '0000000000000000000000000ad33720';
+
+ protected const IPV6_HEX = '2a0104a00004210208a951031cba4915';
+
+ protected const COLUMN = 'ip';
+
+ public function testFromDbReturnsNullWhenNullIsPassed()
+ {
+ $this->assertNull($this->behavior()->retrieveProperty(null, static::COLUMN));
+ $this->assertNull($this->behavior(true)->retrieveProperty(null, static::COLUMN));
+ }
+
+ public function testFromDBTransformsBinaryIpToHumanReadable()
+ {
+ $this->assertSame(
+ static::IPV4,
+ $this->behavior()->retrieveProperty(hex2bin(static::IPV4_HEX), static::COLUMN)
+ );
+ $this->assertSame(
+ static::IPV6,
+ $this->behavior()->retrieveProperty(hex2bin(static::IPV6_HEX), static::COLUMN)
+ );
+
+ $this->assertSame(
+ static::IPV4,
+ $this->behavior(true)->retrieveProperty(hex2bin(static::IPV4_HEX), static::COLUMN)
+ );
+ $this->assertSame(
+ static::IPV6,
+ $this->behavior(true)->retrieveProperty(hex2bin(static::IPV6_HEX), static::COLUMN)
+ );
+ }
+
+ public function testToDbReturnsInvalidValueAsIs()
+ {
+ $this->assertNull($this->behavior()->persistProperty(null, static::COLUMN));
+ $this->assertSame('*', $this->behavior()->persistProperty('*', static::COLUMN));
+
+ $this->assertNull($this->behavior(true)->persistProperty(null, static::COLUMN));
+ $this->assertSame('*', $this->behavior(true)->persistProperty('*', static::COLUMN));
+
+ $ipv4Bin = hex2bin(static::IPV4_HEX);
+ $ipv6Bin = hex2bin(static::IPV6_HEX);
+
+ $this->assertSame($ipv4Bin, $this->behavior()->persistProperty($ipv4Bin, static::COLUMN));
+ $this->assertSame($ipv6Bin, $this->behavior()->persistProperty($ipv6Bin, static::COLUMN));
+
+ $this->assertSame($ipv4Bin, $this->behavior(true)->persistProperty($ipv4Bin, static::COLUMN));
+ $this->assertSame($ipv6Bin, $this->behavior(true)->persistProperty($ipv6Bin, static::COLUMN));
+ }
+
+ public function testToDbTransformsIpToBinaryCorrectly()
+ {
+ $this->assertSame(hex2bin(static::IPV4_HEX), $this->behavior()->persistProperty(static::IPV4, static::COLUMN));
+ $this->assertSame(hex2bin(static::IPV6_HEX), $this->behavior()->persistProperty(static::IPV6, static::COLUMN));
+
+ $this->assertSame(
+ sprintf('\\x%s', static::IPV4_HEX),
+ $this->behavior(true)->persistProperty(static::IPV4, static::COLUMN)
+ );
+ $this->assertSame(
+ sprintf('\\x%s', static::IPV6_HEX),
+ $this->behavior(true)->persistProperty(static::IPV6, static::COLUMN)
+ );
+ }
+
+ protected function behavior(bool $postgres = false): Ip
+ {
+ return (new Ip(['ip']))
+ ->setQuery(
+ (new Query())
+ ->setDb(new Connection(['db' => $postgres ? 'pgsql' : 'mysql']))
+ );
+ }
+}