diff --git a/plugins/tls/.editorconfig b/plugins/tls/.editorconfig new file mode 100644 index 000000000..88ef111ed --- /dev/null +++ b/plugins/tls/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +insert_final_newline = true +indent_style = space +indent_size = 4 +max_line_length = 200 + +[*.java] +ij_continuation_indent_size = 4 +ij_java_class_count_to_use_import_on_demand = 100 + +[{*.yml,*.yaml,*.sh}] +indent_size = 2 \ No newline at end of file diff --git a/plugins/tls/.github/workflows/build.yaml b/plugins/tls/.github/workflows/build.yaml new file mode 100644 index 000000000..e771a2d63 --- /dev/null +++ b/plugins/tls/.github/workflows/build.yaml @@ -0,0 +1,96 @@ +name: Build plugin + +on: + workflow_dispatch: + push: + branch: + - master + +jobs: + build-webui: + runs-on: ubuntu-latest + steps: + - name: Checkout WebUI + uses: actions/checkout@v6 + with: + sparse-checkout: | + web-ui/ + + - name: Prepare directory + run: | + shopt -s dotglob + mv web-ui/* . + rm -rf web-ui + + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "24" + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Prepare archive + run: | + mkdir tls-manager/WEB-INF + cp static/web.xml tls-manager/WEB-INF/ + + - name: Create archive + run: jar -cvf tls-manager.war -C tls-manager . + + - uses: actions/upload-artifact@v6 + with: + name: tls-manager-ui + path: tls-manager.war + + build-plugin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: maven + + - name: Build plugin + run: ./build.sh + + - uses: actions/upload-artifact@v6 + with: + name: tls-manager-plugin + path: target/tls-manager*.zip + + package-ui: + runs-on: ubuntu-latest + needs: + - build-plugin + - build-webui + steps: + - uses: actions/download-artifact@v6 + with: + pattern: tls-manager-* + + - name: Add UI to plugin zip + run: | + tree . + mv tls-manager-ui/ tls-manager/ + ZIP_NAME=$(find tls-manager-plugin/ -type f -maxdepth 1 -name "tls-manager-*.zip") + zip -g "$ZIP_NAME" tls-manager/tls-manager.war + + - uses: actions/upload-artifact@v6 + with: + name: tls-manager-bundle + path: tls-manager-plugin/tls-manager-*.zip diff --git a/plugins/tls/.gitignore b/plugins/tls/.gitignore new file mode 100644 index 000000000..ccd6eaab4 --- /dev/null +++ b/plugins/tls/.gitignore @@ -0,0 +1,15 @@ +.idea/ +target/ +*.iml +*.zip +*.json +plugin.xml + + +docker/certs/ +docker/custom-extensions +docker/pgdata +docker/conf +docker/appdata + +tools/cert-revocation/mini-ca diff --git a/plugins/tls/.sdkmanrc b/plugins/tls/.sdkmanrc new file mode 100644 index 000000000..b47567c2c --- /dev/null +++ b/plugins/tls/.sdkmanrc @@ -0,0 +1,4 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=17.0.16.fx-zulu +maven=3.9.11 \ No newline at end of file diff --git a/plugins/tls/LICENSE b/plugins/tls/LICENSE new file mode 100644 index 000000000..eebe0ec84 --- /dev/null +++ b/plugins/tls/LICENSE @@ -0,0 +1,592 @@ +============================================================================== + LICENSE FOR THIS PROJECT +============================================================================== + +This project contains code under two licenses: + +1. Apache License 2.0 — for original code from the template repository . +2. Mozilla Public License 2.0 — for modifications and new code added starting with commit d2fbac7328eda7b7a68348a4adcbb3a9961868b9. + +------------------------------------------------------------------------------ + APACHE LICENSE 2.0 +------------------------------------------------------------------------------ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +------------------------------------------------------------------------------ + MOZILLA PUBLIC LICENSE 2.0 +------------------------------------------------------------------------------ + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/plugins/tls/NOTICE b/plugins/tls/NOTICE new file mode 100644 index 000000000..0d431af2b --- /dev/null +++ b/plugins/tls/NOTICE @@ -0,0 +1,2 @@ +This product includes software originally developed by Kaur Palang (2012) under the Apache License 2.0. +Modifications by NovaMap Health Limited (2025) starting with commit d2fbac7328eda7b7a68348a4adcbb3a9961868b9 are licensed under Mozilla Public License 2.0. diff --git a/plugins/tls/README.md b/plugins/tls/README.md new file mode 100644 index 000000000..a3bd990b6 --- /dev/null +++ b/plugins/tls/README.md @@ -0,0 +1,101 @@ +# TLS Manager Plugin for Open Integration Engine + +# Introduction + +This TLS Manager Plugin for OIE has been sponsored by [NovaMap Health Limited](https://www.novamap.health/) (UK) and [Diridium Technologies Inc.](https://diridium.com/) (USA) and donated to the Open Integration Engine initiative. Copyright is retained by NovaMap Health Limited but is licensed under MPL 2.0. + +The objective was to implement security features for OIE which were felt to be missing from what we would argue are necessary to consider OIE as a minimum viable product. + +# Functionality + +The plugin adds significant additional TLS functionality to both the Senders and Listeners for the HTTP, Web Service and TCP Connectors, including: + +* mTLS (for both senders and listeners) +* CRL and OSCP (including strict fail) +* Subject DN validation +* Hostname validation +* Selection of permitted cipher suites +* Selection of permitted TLS versions +* TCP Connectors can operate in client or server mode +* A connection testing facility. + +In addition, the following global features have been implemented: + +* View list of trusted certificates in the OIE truststore +* View list of certificates in the Java default trust store +* View local key pairs +* Import trusted root and intermediate signing certs from PEM format file +* Import trusted root and intermediate signing certs in PEM format from URL +* Import key pairs in PEM format from file. +* View certificate details +* Edit certificate alias +* Remove trusted certificate +* Remove local key pairs + +# Installation notes + +We will be distributing the plugin as a ZIP file for importing in the usual manner. It will be signed by NovaMap Health Limited using a code signing cert issued by a common CA. + +A minimum of Java 17 is required. + +Once the plugin is installed, you will see additional options in the Connectors listed above, plus you will be able to access the web-based Certificate Manager at *\[base URL\]/tls-manager*, which most commonly is [*https://localhost:8443/tls-manager*](https://localhost:8443/tls-manager) + +As already mentioned, there are some environment variables that can be tweaked, however they have sensible defaults so that plugin will work out-of-the-box. The default persistence mechanism for the keystore is the OIE DB. + +The user guide can be downloaded from the [NovaMap Health website](https://www.novamap.health/products/open-integration-engine). + +# Known issues and limitations + +Over the course of the project identified a few features that we have taken note of but decided are not necessary for an MVP and so can be picked up at a later date. Our intention is to record these in GitHub soon. +There are a small number of known issues which aren’t substantial enough to warrant delaying the release, but are significant enough to make you aware of. They have been recorded in the GitHub Issues page. + +# Architectural and Other Non-Functional Requirements + +We set the following architectural and non-functional requirements for the project: + +* From the user’s perspective, they will see the existing HTTP/TCP/WebService Connectors replaced by TLS-capable connectors. +* Existing channels that use the current non-TLS connectors should continue to function once the new TLS Manager plugin has been installed. +* All certs, keys, aliases, configuration settings, and TLS policies must be stored in the native mirthdb. Note that there is an option, configurable via environment variables, to use keystores in file-based storage. +* All reading and writing of settings, whether global or channel-related should be performed via the API. +* The global settings should be available as a web page. +* The global settings manager should be in static JS form so that it can be served by any web server, but it will be packaged with the plugin so that each OIE instance can be self-contained. +* It will be necessary for the user to authenticate themselves using OIE’s existing stored credentials before being able to access the global settings. +* Certificates are associated with channels by their alias. +* Although only tested with OIE 4.5.2, nothing should prevent the plugin from working with other compatible products that implement the same API. + +# Testing Regime + +### We have developed and tested it against the current main branch of OIE 4.5.2. + +The testing regime took on a life of its own in this project\! The UX testing was relatively straightforward, but verifying the correct operation of all the functionality across all 6 senders and receivers, including mocking CRL and OSCP endpoints was much more than we had anticipated; there are now over 400 integration test cases that have been written to ensure correct operation. Indeed the creation of the automated integration test framework (using K8s, Caddy, Zephyr and JIRA) became a significant engineering project in its own right, but we took the view that it was important to invest now in order to reduce maintenance effort down the line and also to demonstrate that we take quality and security exceptionally importantly. + +In addition, we have performed some backward compatibility testing that is focused on ensuring that installing the TLS Manager Plugin doesn’t affect existing channels, and also some load testing to ensure that we haven’t done anything to break the multi-threading or to introduce significant latency. + +# The use of the Web UI \- A project for the future + +It was an easy decision for us to decide to implement the Certificate Manager using a web UI rather than within the existing Administration Console, but it raised some bigger architectural issues that we had to park for now. These issues included: + +* What tech stack to use +* The creation of an overarching UI container into which new plugins could add their web UIs +* How to accommodate multi-OIE instance management tools whilst ensuring that each OIE instance can operate in a completely self-contained manner. + +We already have some thoughts on the above, and we are aware that other contributors are also champing at the bit, so we would like to propose the creation of a small sub-project group (maybe focusing specifically on the framework for Plugin Web UIs) to take this forward. + +# What happens next + +From this point we consider the code to be a community asset and hence, responsibility, but of course will be happy to contribute to the maintenance, including the continued provision of NovaMap’s integration test suite, and in particular assisting with any rapid responses required to address any security vulnerabilities identified. + +# Acknowledgements + +The core team members involved: + +* Alex Frîncu +* Andreea Dincă +* Andrei Haiducu +* Ed Riordan +* Kaur Palang +* Paul Coyne +* Paul Hristea +* Paul Richardson + +Many thanks to all the above for their hard work, dedication and professionalism. diff --git a/plugins/tls/THIRD_PARTY_LICENSES.md b/plugins/tls/THIRD_PARTY_LICENSES.md new file mode 100644 index 000000000..49c5c5df1 --- /dev/null +++ b/plugins/tls/THIRD_PARTY_LICENSES.md @@ -0,0 +1,24 @@ +# Third-Party Licenses + +## [Phosphor Icons](https://github.com/phosphor-icons) +MIT License + +Copyright (c) 2023 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/tls/build.sh b/plugins/tls/build.sh new file mode 100755 index 000000000..22a77f566 --- /dev/null +++ b/plugins/tls/build.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024 Kaur Palang +# Copyright (c) 2026 NovaMap Health Limited + +function buildPlugin() { + + echo "########################################" + echo + echo " mvn cleaning..." + echo + echo "########################################" + mvn clean + + echo "########################################" + echo + echo " Building jars..." + echo + echo "########################################" + shortHash=$(git rev-parse --short HEAD) + mvn install package -DskipTests -Dgit.hash="$shortHash" + + PLUGIN_PATH=$(mvn exec:exec --non-recursive --quiet -Dexec.executable="echo" -Dexec.args='${mirth.plugin.path}') + ARTIFACT_ID=$(mvn exec:exec --non-recursive --quiet -Dexec.executable="echo" -Dexec.args='${project.artifactId}') + + echo "########################################" + echo + echo " Copying libraries..." + echo + echo "########################################" + mkdir -p "$STAGING_DIR/libs" + + modules=("server" "client" "shared") + for module in "${modules[@]}"; do + if find "libs/runtime/$module/" -maxdepth 1 -iname "*.jar" | grep -q .; then + cp libs/runtime/"$module"/*.jar "$STAGING_DIR/libs/" + else + echo "No .jar files in libs/runtime/$module/" + fi + done + + echo "########################################" + echo + echo " Generating plugin.xml..." + echo + echo "########################################" + mvn -N com.kaurpalang:mirth-plugin-maven-plugin:3.0.0:generate-plugin-xml -Dgit.hash="$shortHash" + + mv plugin.xml "$STAGING_DIR" + + echo "########################################" + echo + echo " Packaging plugin..." + echo + echo "########################################" + cp {client,server,shared}/target/*.jar "$STAGING_DIR/" +} + +function buildWebUi() { + pushd web-ui + + rm -rf tls-manager/ + + npm i + npm run build + + mkdir tls-manager/WEB-INF + cp static/web.xml tls-manager/WEB-INF/ + + jar -cvf tls-manager.war -C tls-manager . + + cp tls-manager.war ../"$STAGING_DIR" + + popd +} + +function package() { + pushd target + mv staging "$PLUGIN_PATH" + zip -r "$PLUGIN_PATH-$shortHash" "$PLUGIN_PATH" + popd +} + +set -euxo pipefail + +STAGING_DIR=target/staging + +buildPlugin +buildWebUi +package diff --git a/plugins/tls/certificate/keystore.jks b/plugins/tls/certificate/keystore.jks new file mode 100644 index 000000000..9264d2df2 Binary files /dev/null and b/plugins/tls/certificate/keystore.jks differ diff --git a/plugins/tls/checkstyle.xml b/plugins/tls/checkstyle.xml new file mode 100644 index 000000000..64bfd54ea --- /dev/null +++ b/plugins/tls/checkstyle.xml @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/tls/client/pom.xml b/plugins/tls/client/pom.xml new file mode 100644 index 000000000..c080667c3 --- /dev/null +++ b/plugins/tls/client/pom.xml @@ -0,0 +1,106 @@ + + + + + + + + 4.0.0 + + + org.openintegrationengine + tlsmanager + 1.0.0 + + + client + + + + com.mirth.connect + mirth-client + ${mirth.version} + + + + com.miglayout + miglayout + ${miglayout.version} + + + + org.openintegrationengine + shared + ${project.version} + + + + com.mirth.connect + donkey-model + ${mirth.version} + provided + + + + com.mirth.connect.plugins + http-client + ${mirth.version} + provided + + + + com.mirth.connect.connectors + tcp-client + ${mirth.version} + provided + + + + com.mirth.connect.connectors + ws-client + ${mirth.version} + provided + + + + com.thoughtworks.xstream + xstream + 1.4.20 + provided + + + + org.swinglabs + swingx-core + 1.6.2-2 + provided + + + + org.apache.commons + commons-lang3 + 3.13.0 + provided + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + true + + + + + + + + diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/TLSConnectorPropertiesPlugin.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/TLSConnectorPropertiesPlugin.java new file mode 100644 index 000000000..e7f9dbe73 --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/TLSConnectorPropertiesPlugin.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client; + +import com.kaurpalang.mirth.annotationsplugin.annotation.MirthClientClass; +import com.mirth.connect.client.ui.AbstractConnectorPropertiesPanel; +import com.mirth.connect.plugins.ConnectorPropertiesPlugin; +import org.openintegrationengine.tlsmanager.client.panel.TLSConnectorPanel; +import org.openintegrationengine.tlsmanager.shared.SerializationController; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; + +import java.util.Set; + +@MirthClientClass +public class TLSConnectorPropertiesPlugin extends ConnectorPropertiesPlugin { + public TLSConnectorPropertiesPlugin(String pluginName) { + super(pluginName); + SerializationController.registerSerializableClasses(); + } + + @Override + public String getSettingsTitle() { + return "TLS Settings"; + } + + @Override + public AbstractConnectorPropertiesPanel getConnectorPropertiesPanel() { + return new TLSConnectorPanel(); + } + + @Override + public boolean isSupported(String transportName) { + return Set + .of( + "HTTP Listener", "TCP Listener", "Web Service Listener", + "HTTP Sender", "TCP Sender", "Web Service Sender" + ) + .contains(transportName); + } + + @Override + public boolean isConnectorPropertiesPluginSupported(String s) { + return false; + } + + @Override + public String getPluginPointName() { + return TLSPluginConstants.TLS_LISTENER_PROPERTIES_PLUGIN_POINT_NAME; + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/TLSSettingsPanelPlugin.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/TLSSettingsPanelPlugin.java new file mode 100644 index 000000000..86fd94b04 --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/TLSSettingsPanelPlugin.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client; + +import com.kaurpalang.mirth.annotationsplugin.annotation.MirthClientClass; +import com.mirth.connect.client.ui.AbstractSettingsPanel; +import com.mirth.connect.plugins.SettingsPanelPlugin; +import org.openintegrationengine.tlsmanager.client.panel.TLSManagerSettingsPanel; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; + +@MirthClientClass +public class TLSSettingsPanelPlugin extends SettingsPanelPlugin { + + private final AbstractSettingsPanel settingsPanel; + + public TLSSettingsPanelPlugin(String name) { + super(name); + + settingsPanel = new TLSManagerSettingsPanel("TLS Manager", this); + } + + @Override + public AbstractSettingsPanel getSettingsPanel() { + return settingsPanel; + } + + @Override + public void start() { + } + + @Override + public void stop() { + } + + @Override + public void reset() { + } + + @Override + public String getPluginPointName() { + return TLSPluginConstants.TLS_TASK_PLUGIN_POINT_NAME; + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/AbstractDialog.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/AbstractDialog.java new file mode 100644 index 000000000..1f888ad7a --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/AbstractDialog.java @@ -0,0 +1,245 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client.dialog; + +import com.mirth.connect.client.ui.Mirth; +import com.mirth.connect.client.ui.MirthDialog; +import com.mirth.connect.client.ui.PlatformUI; +import com.mirth.connect.client.ui.RefreshTableModel; +import com.mirth.connect.client.ui.UIConstants; +import com.mirth.connect.client.ui.components.MirthTable; +import net.miginfocom.swing.MigLayout; +import org.apache.commons.lang3.StringUtils; +import org.jdesktop.swingx.decorator.HighlighterFactory; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSeparator; +import javax.swing.JTextField; +import javax.swing.RowFilter; +import javax.swing.SwingWorker; +import javax.swing.WindowConstants; +import javax.swing.border.TitledBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.table.TableModel; +import javax.swing.table.TableRowSorter; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Font; +import java.util.Comparator; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.function.Supplier; +import java.util.prefs.Preferences; + +public abstract class AbstractDialog extends MirthDialog { + + protected JPanel containerPanel; + + protected JLabel optionFilterLabel; + protected JTextField optionFilterField; + + protected JLabel selectAllLabel; + protected JLabel optionSelectSeparator; + protected JLabel deselectAllLabel; + + protected JScrollPane optionsScrollPane; + protected MirthTable optionsTable; + + protected JButton okButton; + protected JButton cancelButton; + + protected static final int SELECTED_COLUMN = 0; + protected static final int NAME_COLUMN = 1; + + private final boolean shouldShowAllSelects; + + protected RefreshTableModel tableModel; + + protected Supplier> dataSupplier; + + public AbstractDialog( + String windowTitle, + Supplier> dataSupplier, + boolean shouldShowAllSelects + ) { + super(PlatformUI.MIRTH_FRAME, windowTitle, true); + this.dataSupplier = dataSupplier; + this.shouldShowAllSelects = shouldShowAllSelects; + + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + setLocationRelativeTo(getOwner()); + } + + protected void initComponents() { + setBackground(UIConstants.BACKGROUND_COLOR); + getContentPane().setBackground(getBackground()); + + containerPanel = new JPanel(); + containerPanel.setBackground(getBackground()); + containerPanel.setBorder( + BorderFactory.createTitledBorder( + BorderFactory.createMatteBorder( + 1, 1, 1, 1, + new Color(204, 204, 204) + ), + "TLS settings", + TitledBorder.DEFAULT_JUSTIFICATION, + TitledBorder.DEFAULT_POSITION, + new Font(Font.SANS_SERIF, Font.BOLD, 11) + ) + ); + + optionFilterLabel = new JLabel("Filter:"); + optionFilterField = new JTextField(); + optionFilterField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void removeUpdate(DocumentEvent evt) { + filterChanged(); + } + + @Override + public void insertUpdate(DocumentEvent evt) { + filterChanged(); + } + + @Override + public void changedUpdate(DocumentEvent evt) { + filterChanged(); + } + + private void filterChanged() { + optionsTable.getRowSorter().allRowsChanged(); + } + }); + + selectAllLabel = new JLabel("Select All"); + selectAllLabel.setForeground(Color.BLUE); + selectAllLabel.setCursor(new Cursor(Cursor.HAND_CURSOR)); + // Add actionlisteners in subclasses + + optionSelectSeparator = new JLabel("|"); + + deselectAllLabel = new JLabel("Deselect All"); + deselectAllLabel.setForeground(Color.BLUE); + deselectAllLabel.setCursor(new Cursor(Cursor.HAND_CURSOR)); + // Add actionlisteners in subclasses + + tableModel = new RefreshTableModel(new String[]{"", "Option"}, 0); + optionsTable = new MirthTable(); + + optionsTable.setModel(tableModel); + + optionsTable.setDragEnabled(false); + optionsTable.setRowSelectionAllowed(false); + optionsTable.setRowHeight(UIConstants.ROW_HEIGHT); + optionsTable.setFocusable(false); + optionsTable.setOpaque(true); + optionsTable.getTableHeader().setReorderingAllowed(false); + optionsTable.setEditable(true); + optionsTable.setSortable(true); + + formatTable(); + + var rowSorter = new TableRowSorter<>(optionsTable.getModel()); + rowSorter.setComparator(0, (Comparator) (o1, o2) -> { + // 0, 2, 1 + if (Objects.equals(o1, o2)) { + return 0; + } else if (o1 == 0 || (o1 == 2 && o2 == 1)) { + return -1; + } else { + return 1; + } + }); + optionsTable.setRowSorter(rowSorter); + + var rowFilter = new RowFilter() { + @Override + public boolean include(Entry entry) { + String name = entry.getStringValue(1); + return StringUtils.containsIgnoreCase(name, optionFilterField.getText()); + } + }; + rowSorter.setRowFilter(rowFilter); + optionsTable.setRowFilter(rowFilter); + + if (Preferences.userNodeForPackage(Mirth.class).getBoolean("highlightRows", true)) { + optionsTable.setHighlighters(HighlighterFactory.createAlternateStriping(UIConstants.HIGHLIGHTER_COLOR, UIConstants.BACKGROUND_COLOR)); + } + + optionsScrollPane = new JScrollPane(optionsTable); + + okButton = new JButton("OK"); + + cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(evt -> dispose()); + } + + protected void initLayout() { + setLayout(new MigLayout("insets 8, novisualpadding, hidemode 3, fill", "", "[grow][][]")); + + containerPanel.setLayout(new MigLayout("insets 8, novisualpadding, hidemode 3, fill", "[]13[grow]", "[][][][][][][][][grow]")); + + containerPanel.add(optionFilterLabel, "right, split 5"); + containerPanel.add(optionFilterField, "w 100:350"); + + if (shouldShowAllSelects) { + containerPanel.add(selectAllLabel, "gapbefore 12"); + containerPanel.add(optionSelectSeparator); + containerPanel.add(deselectAllLabel); + } + + containerPanel.add(optionsScrollPane, "newline, grow 25, sx"); + + add(containerPanel, "grow, push"); + + add(new JSeparator(), "newline, growx, sx"); + + add(okButton, "newline, w 50!, sx, right, split"); + add(cancelButton, "w 50!"); + } + + protected void formatTable() { + optionsTable.getColumnExt(SELECTED_COLUMN).setMinWidth(20); + optionsTable.getColumnExt(SELECTED_COLUMN).setMaxWidth(20); + + applyRenderers(); + } + + protected abstract void applyRenderers(); + + protected final void fetchData() { + final var workerId = PlatformUI.MIRTH_FRAME.startWorking("Fetching data..."); + + var worker = new SwingWorker, Void>() { + protected Set doInBackground() { + return dataSupplier.get(); + } + + protected void done() { + try { + var allOptions = get(); + handleDataFetchResult(allOptions); + } catch (InterruptedException | ExecutionException e) { + PlatformUI.MIRTH_FRAME.alertThrowable(PlatformUI.MIRTH_FRAME, e, "Fetching failed"); + throw new RuntimeException(e); + } + + PlatformUI.MIRTH_FRAME.stopWorking(workerId); + } + }; + + worker.execute(); + } + + protected abstract void handleDataFetchResult(Set options); +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/MultiSelectDialog.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/MultiSelectDialog.java new file mode 100644 index 000000000..7ac8aa11a --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/MultiSelectDialog.java @@ -0,0 +1,249 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client.dialog; + +import com.mirth.connect.client.ui.RefreshTableModel; +import com.mirth.connect.client.ui.UIConstants; +import com.mirth.connect.client.ui.components.MirthTriStateCheckBox; +import net.miginfocom.swing.MigLayout; + +import javax.swing.DefaultCellEditor; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.border.TitledBorder; +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; +import java.awt.Color; +import java.awt.Component; +import java.awt.Font; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.Collections; +import java.util.EventObject; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +public class MultiSelectDialog extends AbstractDialog { + + private JScrollPane unknownOptionsScrollPane; + private JTextArea unknownOptionsPanel; + + private final Set selectedOptions; + private final boolean isDefaultSelected; + private final String defaultValue; + + private final BiConsumer> onSaveConsumer; + + private TableCellRenderer tableCellRenderer; + private TableCellEditor tableCellEditor; + + public MultiSelectDialog( + String windowTitle, + Set selectedOptions, + boolean isDefaultSelected, + String defaultValue, + BiConsumer> onSaveConsumer, + Supplier> dataSupplier + ) { + super(windowTitle, dataSupplier, true); + + this.selectedOptions = Objects.requireNonNullElseGet(selectedOptions, Collections::emptySet); + + this.isDefaultSelected = isDefaultSelected; + this.defaultValue = defaultValue; + this.onSaveConsumer = onSaveConsumer; + + initComponents(); + initLayout(); + + handleDataFetchResult(Set.of("Loading data...")); + fetchData(); + + pack(); + setVisible(true); + } + + @Override + protected final void initComponents() { + super.initComponents(); + + tableCellEditor = new TagSelectionCellEditor(); + tableCellRenderer = new TagSelectionCellRenderer(); + + selectAllLabel.addMouseListener(new MouseAdapter() { + @Override + public void mouseReleased(MouseEvent evt) { + if (evt.getComponent().isEnabled()) { + setAllSelected(true); + } + } + }); + + deselectAllLabel.addMouseListener(new MouseAdapter() { + @Override + public void mouseReleased(MouseEvent evt) { + if (evt.getComponent().isEnabled()) { + setAllSelected(false); + } + } + }); + + tableModel = new RefreshTableModel(new String[] { "", "Option" }, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return column == SELECTED_COLUMN; + } + }; + + optionsTable.setModel(tableModel); + formatTable(); + + unknownOptionsPanel = new JTextArea(); + unknownOptionsPanel.setEditable(false); + unknownOptionsPanel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + unknownOptionsPanel.setBackground(Color.WHITE); + + unknownOptionsScrollPane = new JScrollPane(unknownOptionsPanel); + unknownOptionsScrollPane.setBorder(new TitledBorder("Unknown Options")); + unknownOptionsScrollPane.setBackground(Color.WHITE); + + okButton.addActionListener(evt -> { + var firstValue = (int) tableModel.getValueAt(0, SELECTED_COLUMN); + var isDefaultSelected = firstValue == MirthTriStateCheckBox.CHECKED; + + var selectedOptions = getSelectedOptions(true); + onSaveConsumer.accept(isDefaultSelected, selectedOptions); + dispose(); + }); + } + + @Override + protected final void initLayout() { + super.initLayout(); + + + containerPanel.add(optionsScrollPane, "newline, grow 25, sx"); + containerPanel.add(unknownOptionsScrollPane, "newline, grow 25, sx"); + } + + @Override + protected void applyRenderers() { + optionsTable.getColumn(SELECTED_COLUMN).setCellEditor(tableCellEditor); + optionsTable.getColumn(SELECTED_COLUMN).setCellRenderer(tableCellRenderer); + } + + @Override + protected void handleDataFetchResult(Set options) { + var data = new Object[options.size() + 1][2]; + + data[0][SELECTED_COLUMN] = isDefaultSelected ? MirthTriStateCheckBox.CHECKED : MirthTriStateCheckBox.UNCHECKED; + data[0][NAME_COLUMN] = defaultValue; + + int i = 1; + for (var option : options) { + data[i][SELECTED_COLUMN] = selectedOptions.contains(option) ? MirthTriStateCheckBox.CHECKED : MirthTriStateCheckBox.UNCHECKED; + data[i][NAME_COLUMN] = option; + i++; + } + + tableModel.refreshDataVector(data); + } + + private Set getSelectedOptions(boolean skipFirstOption) { + var localSelectedOptions = new LinkedHashSet(); + + for (int row = skipFirstOption ? 1 : 0; row < optionsTable.getModel().getRowCount(); row++) { + var state = (int) optionsTable.getModel().getValueAt(row, SELECTED_COLUMN); + var certificateAlias = (String) optionsTable.getModel().getValueAt(row, NAME_COLUMN); + + if (state == MirthTriStateCheckBox.CHECKED) { + localSelectedOptions.add(certificateAlias); + } + } + + return localSelectedOptions; + } + + /* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2016 Mirth Corporation + * Source: https://github.com/OpenIntegrationEngine/engine/blob/788a150f36a6bcd1db672e00d2e7ee609e2842d9/client/src/com/mirth/connect/client/ui/tag/SettingsPanelTags.java#L780-L800 + */ + private class TagSelectionCellEditor extends DefaultCellEditor { + + private MirthTriStateCheckBox checkBox; + private JPanel panel; + + public TagSelectionCellEditor() { + super(new MirthTriStateCheckBox()); + checkBox = (MirthTriStateCheckBox) editorComponent; + panel = new JPanel(new MigLayout("insets 0, novisualpadding, hidemode 3, fill")); + panel.add(checkBox, "center"); + } + + @Override + public Object getCellEditorValue() { + return checkBox.getState(); + } + + @Override + public boolean isCellEditable(EventObject anEvent) { + return true; + } + + @Override + public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { + super.getTableCellEditorComponent(table, value, isSelected, row, column); + if (value != null) { + checkBox.setState((int) value); + } + panel.setBackground(row % 2 == 0 ? UIConstants.HIGHLIGHTER_COLOR : UIConstants.BACKGROUND_COLOR); + checkBox.setBackground(panel.getBackground()); + return panel; + } + } + + /* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2016 Mirth Corporation + * Source: https://github.com/OpenIntegrationEngine/engine/blob/788a150f36a6bcd1db672e00d2e7ee609e2842d9/client/src/com/mirth/connect/client/ui/tag/SettingsPanelTags.java#L746-L778 + */ + private class TagSelectionCellRenderer implements TableCellRenderer { + + private MirthTriStateCheckBox checkBox; + private JPanel panel; + + public TagSelectionCellRenderer() { + panel = new JPanel(new MigLayout("insets 0, novisualpadding, hidemode 3, fill")); + checkBox = new MirthTriStateCheckBox(); + panel.add(checkBox, "center"); + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { + if (value != null) { + checkBox.setState((int) value); + } + panel.setBackground(row % 2 == 0 ? UIConstants.HIGHLIGHTER_COLOR : UIConstants.BACKGROUND_COLOR); + checkBox.setBackground(panel.getBackground()); + return panel; + } + } + private void setAllSelected(boolean isSelected) { + for (int row = 0; row < optionsTable.getRowCount(); row++) { + optionsTable.setValueAt( + isSelected ? MirthTriStateCheckBox.CHECKED : MirthTriStateCheckBox.UNCHECKED, + row, + SELECTED_COLUMN + ); + } + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/SingleSelectDialog.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/SingleSelectDialog.java new file mode 100644 index 000000000..f53e755bc --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/dialog/SingleSelectDialog.java @@ -0,0 +1,154 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client.dialog; + +import com.mirth.connect.client.ui.RefreshTableModel; +import com.mirth.connect.client.ui.UIConstants; +import net.miginfocom.swing.MigLayout; + +import javax.swing.AbstractCellEditor; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.table.TableCellRenderer; +import javax.swing.table.TableModel; +import java.awt.Component; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public class SingleSelectDialog extends AbstractDialog { + + private final String selectedOption; + + private final Consumer onSaveConsumer; + + private TableCellRenderer cellRenderer; + + public SingleSelectDialog( + String windowTitle, + String selectedOption, + Supplier> dataSupplier, + Consumer onSaveConsumer + ) { + super(windowTitle, dataSupplier, false); + + this.selectedOption = selectedOption; + this.onSaveConsumer = onSaveConsumer; + + initComponents(); + initLayout(); + + handleDataFetchResult(Set.of("Loading data...")); + fetchData(); + + pack(); + setVisible(true); + } + + @Override + protected final void initComponents() { + super.initComponents(); + + cellRenderer = new RadioCellEditorRenderer(); + + tableModel = new RefreshTableModel(new String[]{"", "Options"}, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + + @Override + public Class getColumnClass(int column) { + return column == SELECTED_COLUMN ? Boolean.class : String.class; + } + }; + optionsTable.setModel(tableModel); + formatTable(); + + optionsTable.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + int column = optionsTable.columnAtPoint(e.getPoint()); + int row = optionsTable.rowAtPoint(e.getPoint()); + + if (row != -1 && optionsTable.convertColumnIndexToModel(column) == SELECTED_COLUMN) { + int modelRow = optionsTable.convertRowIndexToModel(row); + TableModel model = optionsTable.getModel(); + for (int i = 0; i < model.getRowCount(); i++) { + model.setValueAt(i == modelRow, i, SELECTED_COLUMN); + } + } + } + }); + + optionsTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + okButton.addActionListener(evt -> { + var selectedItem = getSelectedItem(); + onSaveConsumer.accept(selectedItem); + dispose(); + }); + } + + @Override + protected final void initLayout() { + super.initLayout(); + } + + private String getSelectedItem() { + var selectedIndex = optionsTable.getSelectedModelIndex(); + return optionsTable.getModel().getValueAt(selectedIndex, NAME_COLUMN).toString(); + } + + @Override + protected void applyRenderers() { + optionsTable.getColumnExt(SELECTED_COLUMN).setCellRenderer(cellRenderer); + } + + @Override + protected void handleDataFetchResult(Set options) { + var data = new Object[options.size()][2]; + + int i = 0; + for (var option : options) { + data[i][SELECTED_COLUMN] = option.equals(selectedOption); + data[i][NAME_COLUMN] = option; + i++; + } + + tableModel.refreshDataVector(data); + } + + private static class RadioCellEditorRenderer extends AbstractCellEditor implements TableCellRenderer { + private final JRadioButton radioButton; + private final JPanel panel; + + public RadioCellEditorRenderer() { + panel = new JPanel(new MigLayout("insets 0, novisualpadding, hidemode 3, fill")); + this.radioButton = new JRadioButton(); + + radioButton.setOpaque(false); + panel.add(radioButton, "center"); + } + + @Override + public Object getCellEditorValue() { + return true; + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { + radioButton.setSelected(Boolean.TRUE.equals(value)); + panel.setBackground(row % 2 == 0 ? UIConstants.HIGHLIGHTER_COLOR : UIConstants.BACKGROUND_COLOR); + radioButton.setBackground(panel.getBackground()); + return panel; + } + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/misc/DisplayTextEnumModeComboBoxRenderer.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/misc/DisplayTextEnumModeComboBoxRenderer.java new file mode 100644 index 000000000..8afde3ec2 --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/misc/DisplayTextEnumModeComboBoxRenderer.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client.misc; + +import org.openintegrationengine.tlsmanager.shared.models.DisplayTextEnum; + +import javax.swing.JList; +import javax.swing.plaf.basic.BasicComboBoxRenderer; +import java.awt.Component; + +public class DisplayTextEnumModeComboBoxRenderer extends BasicComboBoxRenderer { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof DisplayTextEnum action) { + setText(action.getDisplayText()); + } + return this; + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/misc/SwingMagic.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/misc/SwingMagic.java new file mode 100644 index 000000000..aa6a24c6e --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/misc/SwingMagic.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client.misc; + +import javax.swing.JLabel; +import java.awt.Component; +import java.awt.Container; + +public class SwingMagic { + public static Component findComponentFollowingLabel(Container container, String labelText) { + var containerComponents = container.getComponents(); + + for (int i = 0; i < containerComponents.length; i++) { + if (containerComponents[i] instanceof JLabel label) { + if (labelText.equals(label.getText()) && i + 1 < containerComponents.length) { + return containerComponents[i + 1]; + } + } + + if (containerComponents[i] instanceof Container) { + var result = findComponentFollowingLabel((Container) containerComponents[i], labelText); + if (result != null) return result; + } + } + + return null; + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/ConnectionTestResultPanel.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/ConnectionTestResultPanel.java new file mode 100644 index 000000000..1e7df6163 --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/ConnectionTestResultPanel.java @@ -0,0 +1,272 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2023 Phosphor Icons + * Copyright (c) 2025 NovaMap Health Limited + * + * This file uses Phosphor Icons (https://github.com/phosphor-icons) + * The Phosphor Icons portion is licensed under the MIT License: + * https://github.com/phosphor-icons/phosphor-icons/blob/master/LICENSE + */ + +package org.openintegrationengine.tlsmanager.client.panel; + +import com.mirth.connect.client.ui.MirthDialog; +import net.miginfocom.swing.MigLayout; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; + +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.WindowConstants; +import javax.swing.border.TitledBorder; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Window; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +public class ConnectionTestResultPanel extends MirthDialog { + + private JLabel iconLabel; + private JLabel messageLabel; + + private JScrollPane scrollPane; + private JTextArea resultArea; + private JButton okButton; + + private static final Color RED = new Color(179, 0, 0); + private static final Color GREEN = new Color(76, 174, 79); + + // https://github.com/phosphor-icons/core/blob/main/raw/duotone/seal-check-duotone.svg + private static final String CHECK_ICON_PATH = "images/tls_plugin_check.png"; + + // https://github.com/phosphor-icons/core/blob/main/raw/duotone/seal-warning-duotone.svg + private static final String ERROR_ICON_PATH = "images/tls_plugin_error.png"; + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.systemDefault()); + + private final ConnectionTestResult result; + + public ConnectionTestResultPanel(Window owner, ConnectionTestResult result) { + super(owner, "Connection Test Result", true); + + this.result = result; + + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + + initComponents(); + initLayout(); + + if (result.getSuccess()) { + setPreferredSize(new Dimension(900, 1000)); + resultArea.setText(renderSuccess()); + } else { + setPreferredSize(new Dimension(600, 400)); + resultArea.setText(renderFailure()); + } + + pack(); + setLocationRelativeTo(getOwner()); + setVisible(true); + } + + private void initComponents() { + var iconPath = result.getSuccess() ? CHECK_ICON_PATH : ERROR_ICON_PATH; + var iconUrl = this.getClass().getClassLoader().getResource(iconPath); + + if (iconUrl == null) { + System.out.printf("Could not find icon at %s%n", iconPath); + } else { + var imageIcon = new ImageIcon(iconUrl); + iconLabel = new JLabel(imageIcon); + } + + messageLabel = new JLabel( + "TLS Connection Test %ssuccessful".formatted(result.getSuccess() ? "" : "un") + ); + messageLabel.setFont(new Font(Font.DIALOG, Font.BOLD, 18)); + messageLabel.setForeground(result.getSuccess() ? GREEN : RED); + + resultArea = new JTextArea(); + resultArea.setEditable(false); + resultArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + resultArea.setBackground(Color.WHITE); + + scrollPane = new JScrollPane(resultArea); + scrollPane.setBorder(new TitledBorder("TLS Connection Results")); + + okButton = new JButton("OK"); + okButton.addActionListener(e -> dispose()); + } + + private void initLayout() { + setLayout(new MigLayout("insets 8, novisualpadding, hidemode 3, fillx", "[grow, fill]", "[][grow][]")); + + if (iconLabel != null) { + add(iconLabel, "w 64!, split"); + } + add(messageLabel); + + add(scrollPane, "newline, grow, push"); + + add(okButton, "newline, w 50!, sx, right"); + } + + private String renderHeader() { + return """ + === TLS Connection Test Results === + Host: %s + Test time: %s + """.formatted( + result.getRequestedAddress(), + DATE_FORMAT.format(result.getTimestamp()) + ); + } + + private String renderSuccess() { + var stringBuilder = new StringBuilder(); + + stringBuilder.append(renderHeader()).append("\n"); + + var sessionInfo = """ + === SSL/TLS Session Information === + Protocol: %s + Cipher Suite: %s + Session ID: %s + Peer Host: %s + Peer Port: %s + Session Valid: %s + Create Time: %s + Last Access Time: %s + """.formatted( + result.getProtocol(), + result.getCipherSuite(), + result.getSessionId(), + result.getPeerHost(), + result.getPeerPort(), + result.getSessionValid(), + DATE_FORMAT.format(result.getSessionCreationTime()), + DATE_FORMAT.format(result.getSessionLastAccessedTime()) + ); + stringBuilder.append(sessionInfo).append("\n"); + + var protocols = """ + === Supported Protocols === + %s + + === Enabled Protocols === + %s + """.formatted( + String.join("\n", result.getSupportedProtocols()), + String.join("\n", result.getEnabledProtocols()) + ); + stringBuilder.append(protocols).append("\n"); + + var certificates = """ + === Certificate Chain === + Number of certificates: %d + """.formatted( + result.getCertificates().size() + ); + stringBuilder.append(certificates).append("\n"); + + for (int i = 0; i < result.getCertificates().size(); i++) { + var certificate = result.getCertificates().get(i); + var certText = renderCertificate(certificate); + if (certText != null) { + stringBuilder.append("--- Certificate %d ---".formatted(i + 1)).append("\n"); + stringBuilder.append(certText).append("\n").append("\n"); + } + } + + var summary = """ + === Connection Summary === + ✓ TLS connection successful + ✓ Certificate chain retrieved (%d certificate(s)) + ✓ Using %s with %s + """.formatted( + result.getCertificates().size(), + result.getChosenProtocol(), + result.getChosenCipherSuite() + ); + stringBuilder.append(summary); + + return stringBuilder.toString(); + } + + private String renderFailure() { + var sb = new StringBuilder(); + + sb.append(renderHeader()).append("\n"); + + sb.append("=== Connection Failed ===").append("\n"); + + if (result.getExceptionName() != null) { + sb.append("Error: ").append(result.getExceptionName()).append("\n"); + sb.append(" ").append(result.getExceptionMessage()); + if (result.getCauseName() != null) { + sb.append("\n"); + sb.append("Cause: ").append(result.getCauseName()).append("\n"); + sb.append(" ").append(result.getCauseMessage()).append("\n"); + } + } else { + sb.append("Message: ").append(result.getMessage()); + } + + return sb.toString(); + } + + private String renderCertificate(Certificate certificate) { + if (certificate instanceof X509Certificate x509) { + var certBuilder = new StringBuilder(); + + certBuilder.append("Subject: ").append(x509.getSubjectX500Principal().toString()).append("\n"); + certBuilder.append("Issuer: ").append(x509.getIssuerX500Principal().toString()).append("\n"); + certBuilder.append("Serial Number: ").append(x509.getSerialNumber().toString(16).toUpperCase()).append("\n"); + certBuilder.append("Version: ").append(x509.getVersion()).append("\n"); + certBuilder.append("Not Before: ").append(DATE_FORMAT.format(x509.getNotBefore().toInstant())).append("\n"); + certBuilder.append("Not After: ").append(DATE_FORMAT.format(x509.getNotAfter().toInstant())).append("\n"); + certBuilder.append("Signature Algorithm: ").append(x509.getSigAlgName()).append("\n"); + certBuilder.append("Public Key Algorithm: ").append(x509.getPublicKey().getAlgorithm()).append("\n"); + certBuilder.append("Key Size: ").append(ConnectionTestResult.getKeySize(x509)).append(" bits\n"); + + try { + x509.checkValidity(); + certBuilder.append("Status: VALID").append("\n"); + } catch (Exception ex) { + certBuilder.append("Status: INVALID - ").append(ex.getMessage()).append("\n"); + } + + // Subject Alternative Names + try { + if (x509.getSubjectAlternativeNames() != null) { + certBuilder.append("Subject Alternative Names:\n"); + x509.getSubjectAlternativeNames().forEach(san -> { + certBuilder.append(" ").append(san.get(1)).append("\n"); + }); + } + } catch (Exception ex) { + // SANs might not be available + } + + certBuilder + .append("SHA-256 Fingerprint:").append("\n") + .append(" ") + .append(ConnectionTestResult.getCertificateFingerprint(x509, "SHA-256")) + .append("\n"); + + certBuilder.append("SHA-1 Fingerprint:").append("\n") + .append(" ") + .append(ConnectionTestResult.getCertificateFingerprint(x509, "SHA-1")); + + return certBuilder.toString(); + } else { + return null; + } + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/TLSConnectorPanel.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/TLSConnectorPanel.java new file mode 100644 index 000000000..d5bcca50f --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/TLSConnectorPanel.java @@ -0,0 +1,1341 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.client.panel; + +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.ui.AbstractConnectorPropertiesPanel; +import com.mirth.connect.client.ui.ConnectorTypeDecoration; +import com.mirth.connect.client.ui.Frame; +import com.mirth.connect.client.ui.PlatformUI; +import com.mirth.connect.client.ui.UIConstants; +import com.mirth.connect.client.ui.components.MirthComboBox; +import com.mirth.connect.client.ui.components.MirthEditableComboBox; +import com.mirth.connect.client.ui.components.MirthRadioButton; +import com.mirth.connect.client.ui.components.MirthTextField; +import com.mirth.connect.client.ui.panels.connectors.ConnectorSettingsPanel; +import com.mirth.connect.client.ui.panels.connectors.ResponseHandler; +import com.mirth.connect.connectors.http.HttpDispatcherProperties; +import com.mirth.connect.connectors.http.HttpListener; +import com.mirth.connect.connectors.http.HttpSender; +import com.mirth.connect.connectors.tcp.TcpDispatcherProperties; +import com.mirth.connect.connectors.tcp.TcpListener; +import com.mirth.connect.connectors.tcp.TcpSender; +import com.mirth.connect.connectors.ws.DefinitionServiceMap; +import com.mirth.connect.connectors.ws.WebServiceDispatcherProperties; +import com.mirth.connect.connectors.ws.WebServiceListener; +import com.mirth.connect.connectors.ws.WebServiceSender; +import com.mirth.connect.donkey.model.channel.ConnectorPluginProperties; +import com.mirth.connect.donkey.model.channel.ConnectorProperties; +import com.mirth.connect.model.Connector; +import com.mirth.connect.util.MirthSSLUtil; +import lombok.extern.slf4j.Slf4j; +import net.miginfocom.swing.MigLayout; +import org.openintegrationengine.tlsmanager.client.dialog.MultiSelectDialog; +import org.openintegrationengine.tlsmanager.client.dialog.SingleSelectDialog; +import org.openintegrationengine.tlsmanager.client.misc.DisplayTextEnumModeComboBoxRenderer; +import org.openintegrationengine.tlsmanager.client.misc.SwingMagic; +import org.openintegrationengine.tlsmanager.shared.models.ClientAuthMode; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; +import org.openintegrationengine.tlsmanager.shared.models.RevocationMode; +import org.openintegrationengine.tlsmanager.shared.models.SubjectDnValidationMode; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; +import org.openintegrationengine.tlsmanager.shared.servlet.TLSServletInterface; + +import javax.swing.ButtonGroup; +import javax.swing.DefaultComboBoxModel; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.SwingWorker; +import java.awt.Color; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +@Slf4j +public class TLSConnectorPanel extends AbstractConnectorPropertiesPanel { + + /* + Base UI components + */ + protected JLabel managerEnabledLabel; + protected MirthRadioButton managerEnabledRadioYes; + protected MirthRadioButton managerEnabledRadioNo; + + protected JLabel subjectDnValidationLabel; + protected MirthComboBox subjectDnValidationModeComboBox; + protected MirthTextField subjectDnValidationFilterTextField; + + protected JLabel crlModeLabel; + protected MirthComboBox crlModeComboBox; + + protected JLabel ocspModeLabel; + protected MirthComboBox ocspModeComboBox; + + protected JLabel protocolsLabel; + protected JButton protocolsButton; + protected JLabel protocolsText; + + protected JLabel ciphersLabel; + protected JButton ciphersButton; + protected JLabel ciphersText; + + /* + Client mode UI components + */ + private JLabel trustedServerCertsLabel; + private JButton trustedServerCertsButton; + private JLabel trustedServerCertsText; + + private JLabel hostnameValidationLabel; + private MirthRadioButton hostnameValidationRadioYes; + private MirthRadioButton hostnameValidationRadioNo; + + private JLabel clientCertLabel; + private JButton clientCertButton; + private JLabel clientCertText; + + /* + Server mode UI components + */ + private JLabel clientAuthLabel; + private MirthRadioButton clientAuthRadioNone; + private MirthRadioButton clientAuthRadioRequested; + private MirthRadioButton clientAuthRadioRequired; + + private JLabel trustedClientCertsLabel; + private JButton trustedClientCertsButton; + private JLabel trustedClientCertsText; + + private JLabel serverCertificateLabel; + private JButton serverCertificateButton; + private JLabel serverCertificateText; + + /* + Other crap + */ + private TLSConnectorProperties properties; + private boolean isServerMode; + private Transport transport; + private final ResponseHandler responseHandler; + + protected final ImageIcon wrenchIcon; + protected final Frame parentFrame; + + protected Set supportedProtocols; + protected Set supportedCiphers; + + protected List generalLayoutComponents; + protected List serverModeLayoutComponents; + protected List clientModeLayoutComponents; + + private enum Transport { + HTTP, TCP, WS + } + + public TLSConnectorPanel() { + this.wrenchIcon = new ImageIcon(Frame.class.getResource("images/wrench.png")); + this.parentFrame = PlatformUI.MIRTH_FRAME; + + this.supportedProtocols = new HashSet<>(); + this.supportedCiphers = new HashSet<>(); + + this.generalLayoutComponents = new ArrayList<>(); + this.serverModeLayoutComponents = new ArrayList<>(); + this.clientModeLayoutComponents = new ArrayList<>(); + + this.properties = getDefaults(); + this.isServerMode = false; + + this.responseHandler = new ResponseHandler() { + @Override + public void handle(Object response) { + var result = (ConnectionTestResult) response; + + if (result == null) { + parentFrame.alertError(parentFrame, "Failed to invoke service."); + } else { + new ConnectionTestResultPanel(PlatformUI.MIRTH_FRAME, result); + } + } + }; + + initComponents(); + initTooltips(); + initLayout(); + fetchData(); + } + + private void attemptDetermineTransportAndDirectionality() { + var settingsPanel = connectorPanel.getConnectorSettingsPanel(); + + if (settingsPanel instanceof TcpSender tcpSender) { + transport = Transport.TCP; + isServerMode = tcpSender.modeServerRadio.isSelected(); + } else if (settingsPanel instanceof TcpListener tcpListener) { + transport = Transport.TCP; + isServerMode = tcpListener.modeServerRadio.isSelected(); + } else if (settingsPanel instanceof HttpSender) { + transport = Transport.HTTP; + isServerMode = false; + } else if (settingsPanel instanceof HttpListener) { + transport = Transport.HTTP; + isServerMode = true; + } else if (settingsPanel instanceof WebServiceSender) { + transport = Transport.WS; + isServerMode = false; + } else if (settingsPanel instanceof WebServiceListener) { + transport = Transport.WS; + isServerMode = true; + } else { + throw new IllegalArgumentException("Unsupported settings panel type: " + settingsPanel.getClass().getName()); + } + + log.info("Determined transport: {}; isServerMode {}", transport, isServerMode); + } + + protected void handleManagerEnabledButton(boolean managerEnabled) { + properties.setTlsManagerEnabled(managerEnabled); + + generalLayoutComponents.forEach(component -> component.setEnabled(managerEnabled)); + + subjectDnValidationFilterTextField.setEnabled( + managerEnabled && properties.getSubjectDnValidationMode() != SubjectDnValidationMode.NONE + ); + + if (isServerMode) { + handleManagerEnabledButtonClientMode(false); + handleManagerEnabledButtonServerMode(managerEnabled); + } else { + handleManagerEnabledButtonClientMode(managerEnabled); + handleManagerEnabledButtonServerMode(false); + } + } + + private void handleManagerEnabledButtonClientMode(boolean managerEnabled) { + clientModeLayoutComponents.forEach(component -> component.setEnabled(managerEnabled)); + } + + private void handleManagerEnabledButtonServerMode(boolean managerEnabled) { + serverModeLayoutComponents.forEach(component -> component.setEnabled(managerEnabled)); + + if (managerEnabled) { + handleClientAuthModeChange(properties.getClientAuthMode(), false); + } else { + trustedClientCertsLabel.setEnabled(false); + trustedClientCertsButton.setEnabled(false); + trustedClientCertsText.setEnabled(false); + } + } + + protected void handleCrlModeChange() { + if (crlModeComboBox.getSelectedItem() instanceof RevocationMode revocationMode) { + properties.setCrlMode(revocationMode); + } + } + + protected void handleOcspModeChange() { + if (ocspModeComboBox.getSelectedItem() instanceof RevocationMode revocationMode) { + properties.setOcspMode(revocationMode); + } + } + + protected void handleSubjectDnValidationModeChange() { + if (subjectDnValidationModeComboBox.getSelectedItem() instanceof SubjectDnValidationMode validationMode) { + properties.setSubjectDnValidationMode(validationMode); + redrawState(); + } + } + + private void handleClientAuthModeChange(ClientAuthMode authMode, boolean persistChanges) { + if (persistChanges) { + properties.setClientAuthMode(authMode); + } + + var issuerSelectorEnabled = authMode != ClientAuthMode.NONE; + trustedClientCertsLabel.setEnabled(issuerSelectorEnabled); + trustedClientCertsButton.setEnabled(issuerSelectorEnabled); + trustedClientCertsText.setEnabled(issuerSelectorEnabled); + } + + protected void redrawState() { + if (properties.isTlsManagerEnabled()) { + managerEnabledRadioYes.setSelected(true); + } else { + managerEnabledRadioNo.setSelected(true); + } + + subjectDnValidationModeComboBox.setSelectedItem(properties.getSubjectDnValidationMode()); + subjectDnValidationFilterTextField.setEnabled(properties.getSubjectDnValidationMode() != SubjectDnValidationMode.NONE); + subjectDnValidationFilterTextField.setText(properties.getSubjectDnValidationFilter()); + + crlModeComboBox.setSelectedItem(properties.getCrlMode()); + ocspModeComboBox.setSelectedItem(properties.getOcspMode()); + + final var protocolsString = properties.isUseServerDefaultProtocols() + ? "Server default: %s".formatted(supportedProtocols) + : "%d selected".formatted(properties.getUsedProtocols().size()); + + protocolsText.setText(protocolsString); + + final var ciphersString = properties.isUseServerDefaultCiphers() + ? "Server default: %d selected".formatted(supportedCiphers.size()) + : "%d selected".formatted(properties.getUsedCiphers().size()); + + ciphersText.setText(ciphersString); + + redrawClientModeState(); + redrawServerModeState(); + } + + private void redrawClientModeState() { + trustedServerCertsText.setText( + !isServerMode ? renderTrustTginfWhatevs() : "" + ); + + if (properties.isHostnameVerificationEnabled()) { + hostnameValidationRadioYes.setSelected(true); + } else { + hostnameValidationRadioNo.setSelected(true); + } + + clientCertText.setText(properties.getClientCertificateAlias()); + } + + private void redrawServerModeState() { + serverCertificateText.setText(properties.getServerCertificateAlias()); + + if (properties.getClientAuthMode() == ClientAuthMode.NONE) { + clientAuthRadioNone.setSelected(true); + } else if (properties.getClientAuthMode() == ClientAuthMode.REQUESTED) { + clientAuthRadioRequested.setSelected(true); + } else if (properties.getClientAuthMode() == ClientAuthMode.REQUIRED) { + clientAuthRadioRequired.setSelected(true); + } else { + clientAuthRadioNone.setSelected(true); + log.warn("Unable to determine client auth mode: {}. Using NONE", properties.getClientAuthMode()); + } + + handleClientAuthModeChange(properties.getClientAuthMode(), false); + + trustedClientCertsText.setText( + isServerMode ? renderTrustTginfWhatevs() : "" + ); + } + + private String renderTrustTginfWhatevs() { + var thingsToTrust = new ArrayList(); + if (properties.isTrustSystemTruststore()) { + thingsToTrust.add("System Truststore"); + } + + if (properties.getTrustedServerCertificates() != null && !properties.getTrustedServerCertificates().isEmpty()) { + var count = properties.getTrustedServerCertificates().size(); + var plural = (count == 1) ? "" : "s"; + thingsToTrust.add("%d certificate%s".formatted(count, plural)); + } + + return thingsToTrust.isEmpty() + ? "None selected" + : "Trusting %s".formatted(String.join(" and ", thingsToTrust) + ); + } + + @Override + public TLSConnectorProperties getProperties() { + return properties.clone(); + } + + @Override + public void setProperties(ConnectorProperties connectorProperties, ConnectorPluginProperties connectorPluginProperties, Connector.Mode mode, String s) { + if (connectorPluginProperties instanceof TLSConnectorProperties tlsConnectorProperties) { + this.properties = tlsConnectorProperties; + + fetchData(); + redrawState(); + handleManagerEnabledButton(tlsConnectorProperties.isTlsManagerEnabled()); + + attemptDetermineTransportAndDirectionality(); + generalLayoutComponents.forEach(component -> component.setVisible(true)); + + if (transport == Transport.TCP) { + serverModeLayoutComponents.forEach(component -> component.setVisible(true)); + clientModeLayoutComponents.forEach(component -> component.setVisible(true)); + } else { + serverModeLayoutComponents.forEach(component -> component.setVisible(isServerMode)); + clientModeLayoutComponents.forEach(component -> component.setVisible(!isServerMode)); + } + } + } + + @Override + public TLSConnectorProperties getDefaults() { + return new TLSConnectorProperties(); + } + + @Override + public boolean checkProperties(ConnectorProperties connectorProperties, ConnectorPluginProperties connectorPluginProperties, Connector.Mode mode, String s, boolean shouldHighlight) { + boolean isValid = true; + + if (!properties.isTlsManagerEnabled()) { + return true; + } + + if (properties.getSubjectDnValidationMode() != SubjectDnValidationMode.NONE) { + final var filter = properties.getSubjectDnValidationFilter(); + if (filter == null || filter.isBlank()) { + isValid = false; + + if (shouldHighlight) { + subjectDnValidationFilterTextField.setBackground(UIConstants.INVALID_COLOR); + } + } + } + + if (!properties.isUseServerDefaultProtocols() && properties.getUsedProtocols().isEmpty()) { + isValid = false; + + if (shouldHighlight) { + protocolsButton.setBackground(UIConstants.INVALID_COLOR); + } + } + + if (!properties.isUseServerDefaultCiphers() && properties.getUsedCiphers().isEmpty()) { + isValid = false; + + if (shouldHighlight) { + ciphersButton.setBackground(UIConstants.INVALID_COLOR); + } + } + + if (isServerMode) { + + final var serverCertAlias = properties.getServerCertificateAlias(); + if (serverCertAlias == null || serverCertAlias.isBlank()) { + isValid = false; + + if (shouldHighlight) { + serverCertificateButton.setBackground(UIConstants.INVALID_COLOR); + } + } + + if ( + properties.getClientAuthMode() != ClientAuthMode.NONE + && !properties.isTrustSystemTruststore() + && properties.getTrustedServerCertificates().isEmpty() + ) { + isValid = false; + + if (shouldHighlight) { + trustedClientCertsButton.setBackground(UIConstants.INVALID_COLOR); + } + } + } else { + if (!properties.isTrustSystemTruststore() && properties.getTrustedServerCertificates().isEmpty()) { + isValid = false; + + if (shouldHighlight) { + trustedServerCertsButton.setBackground(UIConstants.INVALID_COLOR); + } + } + } + + return isValid; + } + + @Override + public void resetInvalidProperties() { + // This method seems to be called after other panels have been initialized. + // We need other panels to be initialized 'cause we'll be fiddling with one. + registerActionListeners(); + + // Reset validation error backgrounds for general components + subjectDnValidationFilterTextField.setBackground(null); + protocolsButton.setBackground(null); + ciphersButton.setBackground(null); + ciphersText.setBackground(null); + + // Reset validation error backgrounds for server mode components + serverCertificateButton.setBackground(null); + trustedClientCertsButton.setBackground(null); + + // Reset validation error backgrounds for client mode components + trustedServerCertsButton.setBackground(null); + clientCertButton.setBackground(null); + } + + @Override + public Component[][] getLayoutComponents() { + return new Component[0][]; + } + + @Override + public void setLayoutComponentsEnabled(boolean b) {} + + @Override + public ConnectorTypeDecoration getConnectorTypeDecoration() { + return new ConnectorTypeDecoration(Connector.Mode.DESTINATION); + } + + protected void initComponents() { + setBackground(UIConstants.BACKGROUND_COLOR); + + managerEnabledLabel = new JLabel("Use TLS Manager:"); + + var managerEnabledButtonGroup = new ButtonGroup(); + managerEnabledRadioYes = new MirthRadioButton(); + managerEnabledRadioYes.setText("Yes"); + managerEnabledRadioYes.setBackground(Color.white); + managerEnabledRadioYes.addActionListener(e -> handleManagerEnabledButton(true)); + managerEnabledButtonGroup.add(managerEnabledRadioYes); + + managerEnabledRadioNo = new MirthRadioButton(); + managerEnabledRadioNo.setText("No"); + managerEnabledRadioNo.setBackground(Color.white); + managerEnabledRadioNo.addActionListener(e -> handleManagerEnabledButton(false)); + managerEnabledButtonGroup.add(managerEnabledRadioNo); + + var comboBoxRenderer = new DisplayTextEnumModeComboBoxRenderer(); + + final var subjectDnValidationModeModel = new SubjectDnValidationMode[]{ + SubjectDnValidationMode.NONE, + SubjectDnValidationMode.PARTIAL, + SubjectDnValidationMode.EXACT, + }; + + subjectDnValidationLabel = new JLabel("Subject DN Validation Mode:"); + generalLayoutComponents.add(subjectDnValidationLabel); + + subjectDnValidationModeComboBox = new MirthComboBox<>(); + subjectDnValidationModeComboBox.setRenderer(comboBoxRenderer); + subjectDnValidationModeComboBox.setModel(new DefaultComboBoxModel<>(subjectDnValidationModeModel)); + subjectDnValidationModeComboBox.addActionListener(evt -> handleSubjectDnValidationModeChange()); + generalLayoutComponents.add(subjectDnValidationModeComboBox); + + subjectDnValidationFilterTextField = new MirthTextField(); + subjectDnValidationFilterTextField.addKeyListener(new KeyAdapter() { + @Override + public void keyReleased(KeyEvent e) { + properties.setSubjectDnValidationFilter(subjectDnValidationFilterTextField.getText()); + } + }); + generalLayoutComponents.add(subjectDnValidationFilterTextField); + + final var revocationModeModel = new RevocationMode[]{ + RevocationMode.DISABLED, + RevocationMode.SOFT_FAIL, + RevocationMode.HARD_FAIL + }; + + crlModeLabel = new JLabel("CRL Mode:"); + generalLayoutComponents.add(crlModeLabel); + + crlModeComboBox = new MirthComboBox<>(); + crlModeComboBox.setRenderer(comboBoxRenderer); + crlModeComboBox.setModel(new DefaultComboBoxModel<>(revocationModeModel)); + crlModeComboBox.addActionListener(evt -> handleCrlModeChange()); + generalLayoutComponents.add(crlModeComboBox); + + ocspModeLabel = new JLabel("OCSP Mode:"); + generalLayoutComponents.add(ocspModeLabel); + + ocspModeComboBox = new MirthComboBox<>(); + ocspModeComboBox.setRenderer(comboBoxRenderer); + ocspModeComboBox.setModel(new DefaultComboBoxModel<>(revocationModeModel)); + ocspModeComboBox.addActionListener(evt -> handleOcspModeChange()); + generalLayoutComponents.add(ocspModeComboBox); + + protocolsLabel = new JLabel("Enabled Protocols:"); + generalLayoutComponents.add(protocolsLabel); + + protocolsButton = new JButton(wrenchIcon); + protocolsButton.addActionListener(e -> { + BiConsumer> completionConsumer = (trustDefaultProtocols, selectedProtocols) -> { + properties.setUseServerDefaultProtocols(trustDefaultProtocols); + if (trustDefaultProtocols) { + properties.setUsedProtocols(Collections.emptySet()); + } else { + properties.setUsedProtocols(selectedProtocols); + } + + redrawState(); + PlatformUI.MIRTH_FRAME.setSaveEnabled(true); + }; + + Supplier> dataSupplier = () -> { + try { + var cryptoMap = PlatformUI.MIRTH_FRAME.mirthClient.getProtocolsAndCipherSuites(); + var protocolArray = cryptoMap.get(MirthSSLUtil.KEY_ENABLED_SERVER_PROTOCOLS); + var protocolSet = Set.of(protocolArray) + .stream() + .sorted() + .collect(Collectors.toCollection(LinkedHashSet::new)); + + return protocolSet; + } catch (ClientException ex) { + throw new RuntimeException(ex); + } + }; + + new MultiSelectDialog( + "Protocols Picker", + properties.getUsedProtocols(), + properties.isUseServerDefaultProtocols(), + "[Server default]", + completionConsumer, + dataSupplier + ); + }); + generalLayoutComponents.add(protocolsButton); + + protocolsText = new JLabel(); + generalLayoutComponents.add(protocolsText); + + ciphersLabel = new JLabel("Enabled Ciphers:"); + generalLayoutComponents.add(ciphersLabel); + + ciphersButton = new JButton(wrenchIcon); + ciphersButton.addActionListener(e -> { + BiConsumer> completionConsumer = (trustDefaultCiphers, selectedCiphers) -> { + properties.setUseServerDefaultCiphers(trustDefaultCiphers); + if (trustDefaultCiphers) { + properties.setUsedCiphers(Collections.emptySet()); + } else { + properties.setUsedCiphers(selectedCiphers); + } + + redrawState(); + PlatformUI.MIRTH_FRAME.setSaveEnabled(true); + }; + + Supplier> dataSupplier = () -> { + try { + var cryptoMap = PlatformUI.MIRTH_FRAME.mirthClient.getProtocolsAndCipherSuites(); + var ciphersArray = cryptoMap.get(MirthSSLUtil.KEY_ENABLED_CIPHER_SUITES); + + var ciphersSet = Set.of(ciphersArray) + .stream() + .sorted() + .collect(Collectors.toCollection(LinkedHashSet::new)); + + return ciphersSet; + } catch (ClientException ex) { + throw new RuntimeException(ex); + } + }; + + new MultiSelectDialog( + "Ciphers Picker", + properties.getUsedCiphers(), + properties.isUseServerDefaultCiphers(), + "[Server default]", + completionConsumer, + dataSupplier + ); + }); + generalLayoutComponents.add(ciphersButton); + + ciphersText = new JLabel(); + generalLayoutComponents.add(ciphersText); + + initClientModeComponents(); + initServerModeComponents(); + + clientModeLayoutComponents.forEach(component -> component.setVisible(false)); + serverModeLayoutComponents.forEach(component -> component.setVisible(false)); + } + + private void initClientModeComponents() { + trustedServerCertsLabel = new JLabel("Trusted Server Certificates:"); + clientModeLayoutComponents.add(trustedServerCertsLabel); + + trustedServerCertsButton = new JButton(wrenchIcon); + trustedServerCertsButton.addActionListener(e -> { + BiConsumer> completionConsumer = (isTrustSystemTrustStoreEnabled, selectedCerts) -> { + properties.setTrustSystemTruststore(isTrustSystemTrustStoreEnabled); + properties.setTrustedServerCertificates(selectedCerts); + redrawState(); + PlatformUI.MIRTH_FRAME.setSaveEnabled(true); + }; + + Supplier> dataSupplier = () -> { + var certs = PlatformUI.MIRTH_FRAME.mirthClient.getServlet(TLSServletInterface.class) + .getPublicCertificates() + .stream() + .sorted() + .collect(Collectors.toCollection(LinkedHashSet::new)); + return certs; + }; + + new MultiSelectDialog( + "Certificate Picker", + properties.getTrustedServerCertificates(), + properties.isTrustSystemTruststore(), + "[JVM Truststore]", + completionConsumer, + dataSupplier + ); + }); + clientModeLayoutComponents.add(trustedServerCertsButton); + + trustedServerCertsText = new JLabel("Trusting some certs as a placeholder"); + clientModeLayoutComponents.add(trustedServerCertsText); + + hostnameValidationLabel = new JLabel("Hostname verification:"); + clientModeLayoutComponents.add(hostnameValidationLabel); + + final var hostnameValidationButtonGroup = new ButtonGroup(); + + hostnameValidationRadioYes = new MirthRadioButton(); + hostnameValidationRadioYes.setBackground(Color.white); + hostnameValidationRadioYes.setText("Enabled"); + hostnameValidationRadioYes.addActionListener(e -> properties.setHostnameVerificationEnabled(true)); + hostnameValidationButtonGroup.add(hostnameValidationRadioYes); + clientModeLayoutComponents.add(hostnameValidationRadioYes); + + hostnameValidationRadioNo = new MirthRadioButton(); + hostnameValidationRadioNo.setBackground(Color.white); + hostnameValidationRadioNo.setText("Disabled"); + hostnameValidationRadioNo.addActionListener(e -> properties.setHostnameVerificationEnabled(false)); + hostnameValidationButtonGroup.add(hostnameValidationRadioNo); + clientModeLayoutComponents.add(hostnameValidationRadioNo); + + clientCertLabel = new JLabel("Client Certificate:"); + clientModeLayoutComponents.add(clientCertLabel); + + clientCertButton = new JButton(wrenchIcon); + clientCertButton.addActionListener(e -> { + Consumer completionConsumer = (selectedAlias) -> { + properties.setClientCertificateAlias(selectedAlias); + redrawState(); + PlatformUI.MIRTH_FRAME.setSaveEnabled(true); + }; + + var currentCertificateAlias = properties.getClientCertificateAlias(); + + Supplier> dataSupplier = () -> { + var certs = PlatformUI.MIRTH_FRAME.mirthClient.getServlet(TLSServletInterface.class) + .getClientCertificates() + .stream() + .sorted() + .collect(Collectors.toCollection(LinkedHashSet::new)); + return certs; + }; + + new SingleSelectDialog( + "Client Certificate Picker", + currentCertificateAlias, + dataSupplier, + completionConsumer + ); + }); + clientModeLayoutComponents.add(clientCertButton); + + clientCertText = new JLabel(); + clientModeLayoutComponents.add(clientCertText); + } + + protected void initServerModeComponents() { + serverCertificateLabel = new JLabel("Server Certificate:"); + serverModeLayoutComponents.add(serverCertificateLabel); + + serverCertificateButton = new JButton(wrenchIcon); + serverModeLayoutComponents.add(serverCertificateButton); + + serverCertificateButton.addActionListener(e -> { + Consumer completionConsumer = (selectedAlias) -> { + properties.setServerCertificateAlias(selectedAlias); + redrawState(); + PlatformUI.MIRTH_FRAME.setSaveEnabled(true); + }; + + var currentCertificateAlias = properties.getServerCertificateAlias(); + Supplier> dataSupplier = () -> { + var certs = PlatformUI.MIRTH_FRAME.mirthClient.getServlet(TLSServletInterface.class) + .getClientCertificates() + .stream() + .sorted() + .collect(Collectors.toCollection(LinkedHashSet::new)); + return certs; + }; + + new SingleSelectDialog( + "Server Certificate Picker", + currentCertificateAlias, + dataSupplier, + completionConsumer + ); + }); + serverModeLayoutComponents.add(serverCertificateButton); + + serverCertificateText = new JLabel(); + serverModeLayoutComponents.add(serverCertificateText); + + clientAuthLabel = new JLabel("Client Authentication Mode"); + serverModeLayoutComponents.add(clientAuthLabel); + + final var clientAuthModeButtonGroup = new ButtonGroup(); + clientAuthRadioNone = new MirthRadioButton(); + clientAuthRadioNone.setText("None"); + clientAuthRadioNone.setBackground(Color.white); + clientAuthRadioNone.addActionListener(e -> handleClientAuthModeChange(ClientAuthMode.NONE, true)); + clientAuthModeButtonGroup.add(clientAuthRadioNone); + serverModeLayoutComponents.add(clientAuthRadioNone); + + clientAuthRadioRequested = new MirthRadioButton(); + clientAuthRadioRequested.setText("Requested"); + clientAuthRadioRequested.setBackground(Color.white); + clientAuthRadioRequested.addActionListener(e -> handleClientAuthModeChange(ClientAuthMode.REQUESTED, true)); + clientAuthModeButtonGroup.add(clientAuthRadioRequested); + serverModeLayoutComponents.add(clientAuthRadioRequested); + + clientAuthRadioRequired = new MirthRadioButton(); + clientAuthRadioRequired.setText("Required"); + clientAuthRadioRequired.setBackground(Color.white); + clientAuthRadioRequired.addActionListener(e -> handleClientAuthModeChange(ClientAuthMode.REQUIRED, true)); + clientAuthModeButtonGroup.add(clientAuthRadioRequired); + serverModeLayoutComponents.add(clientAuthRadioRequired); + + trustedClientCertsLabel = new JLabel("Trusted Client Certificates:"); + serverModeLayoutComponents.add(trustedClientCertsLabel); + + trustedClientCertsButton = new JButton(wrenchIcon); + trustedClientCertsButton.addActionListener(e -> { + BiConsumer> completionConsumer = (isTrustSystemTrustStoreEnabled, selectedCertificates) -> { + properties.setTrustSystemTruststore(isTrustSystemTrustStoreEnabled); + if (isTrustSystemTrustStoreEnabled) { + properties.setTrustedServerCertificates(Collections.emptySet()); + } else { + properties.setTrustedServerCertificates(selectedCertificates); + } + + redrawState(); + PlatformUI.MIRTH_FRAME.setSaveEnabled(true); + }; + + Supplier> dataSupplier = () -> { + var certs = PlatformUI.MIRTH_FRAME.mirthClient.getServlet(TLSServletInterface.class) + .getPublicCertificates() + .stream() + .sorted() + .collect(Collectors.toCollection(LinkedHashSet::new)); + return certs; + }; + + new MultiSelectDialog( + "Trusted Client Certificates Picker", + properties.getTrustedServerCertificates(), + properties.isTrustSystemTruststore(), + "[Server default]", + completionConsumer, + dataSupplier + ); + }); + serverModeLayoutComponents.add(trustedClientCertsButton); + + trustedClientCertsText = new JLabel(); + serverModeLayoutComponents.add(trustedClientCertsText); + } + + private void initTooltips() { + final var tlsRadioToolTip = """ + + Enabling the TLS Manager permits a greater degree of control over the TLS connections.
+ When enabled, the connector will use the settings below for TLS connections, else the JVM settings will be used. + """; + managerEnabledLabel.setToolTipText(tlsRadioToolTip); + managerEnabledRadioYes.setToolTipText(tlsRadioToolTip); + managerEnabledRadioNo.setToolTipText(tlsRadioToolTip); + + final var subjectDNToolTip = """ + + Certificate's Subject DN Validation Mode determines how the connector validates the Subject DN of incoming TLS connections.
+ NONE: No validation is performed.
+ PARTIAL: The certificate's Subject DN must contain all of the specified RDNs.
+ EXACT: The certificate's Subject DN must match the specified Subject DN exactly. + """; + subjectDnValidationLabel.setToolTipText(subjectDNToolTip); + subjectDnValidationModeComboBox.setToolTipText(subjectDNToolTip); + subjectDnValidationFilterTextField.setToolTipText(subjectDNToolTip); + + final var crlModeToolTip = """ + + CRL Mode determines how the connector handles Certificate Revocation Lists (CRLs) for incoming TLS connections.
+ NONE: CRL checking is disabled.
+ SOFT FAIL: CRL checking is enabled but if the CRL cannot be retrieved, the connection will still be established.
+ HARD FAIL: CRL checking is enabled and must be successful for the connection to be established. + """; + crlModeLabel.setToolTipText(crlModeToolTip); + crlModeComboBox.setToolTipText(crlModeToolTip); + + final var ocspModeToolTip = """ + + OCSP Mode determines how the connector handles Online Certificate Status Protocol (OCSP) responses for incoming TLS connections.
+ NONE: OCSP checking is disabled.
+ SOFT FAIL: OCSP checking is enabled but if the response cannot be retrieved, the connection will still be established.
+ HARD FAIL: OCSP checking is enabled and must be successful for the connection to be established. + """; + ocspModeLabel.setToolTipText(ocspModeToolTip); + ocspModeComboBox.setToolTipText(ocspModeToolTip); + + final var protocolsToolTip = """ + + Determines which TLS protocols the connector supports. + """; + protocolsLabel.setToolTipText(protocolsToolTip); + protocolsButton.setToolTipText(protocolsToolTip); + protocolsText.setToolTipText(protocolsToolTip); + + final var cipherSuitesToolTip = """ + + Determines which cipher suites the connector supports. + """; + ciphersLabel.setToolTipText(cipherSuitesToolTip); + ciphersButton.setToolTipText(cipherSuitesToolTip); + ciphersText.setToolTipText(cipherSuitesToolTip); + + final var trustedServerCertsToolTip = """ + + Determines which remote certificates to trust. + """; + trustedServerCertsLabel.setToolTipText(trustedServerCertsToolTip); + trustedServerCertsButton.setToolTipText(trustedServerCertsToolTip); + trustedServerCertsText.setToolTipText(trustedServerCertsToolTip); + + final var hostnameVerificationToolTip = """ + + Determines whether the remote server's hostname is validated. + """; + hostnameValidationLabel.setToolTipText(hostnameVerificationToolTip); + hostnameValidationRadioYes.setToolTipText(hostnameVerificationToolTip); + hostnameValidationRadioNo.setToolTipText(hostnameVerificationToolTip); + + final var clientCertificateToolTip = """ + + Permits the selection of the certificate to be sent when using mTLS authentication. + """; + clientCertLabel.setToolTipText(clientCertificateToolTip); + clientCertButton.setToolTipText(clientCertificateToolTip); + clientCertText.setToolTipText(cipherSuitesToolTip); + + final var serverCertificateToolTip = """ + + Determines which certificate is used to serve the endpoint. + """; + serverCertificateLabel.setToolTipText(serverCertificateToolTip); + serverCertificateButton.setToolTipText(serverCertificateToolTip); + serverCertificateText.setToolTipText(cipherSuitesToolTip); + + final var clientAuthModeToolTip = """ + + Determines whether client authentication is required for the endpoint.
+ NONE: No client authentication is required.
+ REQUESTED: Client authentication is requested but not mandatory.
+ REQUIRED: Client authentication is required for the connection to be established. + """; + clientAuthLabel.setToolTipText(clientAuthModeToolTip); + clientAuthRadioNone.setToolTipText(clientAuthModeToolTip); + clientAuthRadioRequested.setToolTipText(clientAuthModeToolTip); + clientAuthRadioRequired.setToolTipText(clientAuthModeToolTip); + + final var trustedClientCertsToolTip = """ + + Determines which client certificates to trust. + Determines which client certificates to trust. + """; + trustedClientCertsLabel.setToolTipText(trustedClientCertsToolTip); + trustedClientCertsButton.setToolTipText(trustedClientCertsToolTip); + trustedClientCertsText.setToolTipText(trustedClientCertsToolTip); + } + + protected void initLayout() { + setLayout(new MigLayout("insets 0, novisualpadding, hidemode 3", "[]12[]", "")); + + add(managerEnabledLabel, "newline, right"); + add(managerEnabledRadioYes, "split"); + add(managerEnabledRadioNo); + + add(subjectDnValidationLabel, "newline, right"); + add(subjectDnValidationModeComboBox, "split"); + add(subjectDnValidationFilterTextField, "w 168!"); + + add(crlModeLabel, "newline, right"); + add(crlModeComboBox); + + add(ocspModeLabel, "newline, right"); + add(ocspModeComboBox); + + add(protocolsLabel, "newline, right"); + add(protocolsButton, "h 22!, w 22!, split"); + add(protocolsText); + + add(ciphersLabel, "newline, right"); + add(ciphersButton, "h 22!, w 22!, split"); + add(ciphersText); + + initClientModeLayout(); + initServerModeLayout(); + } + + private void initClientModeLayout() { + add(trustedServerCertsLabel, "newline, right"); + add(trustedServerCertsButton, "h 22!, w 22!, split"); + add(trustedServerCertsText); + + add(hostnameValidationLabel, "newline, right"); + add(hostnameValidationRadioYes, "split"); + add(hostnameValidationRadioNo); + + add(clientCertLabel, "newline, right"); + add(clientCertButton, "h 22!, w 22!, split"); + add(clientCertText); + } + + private void initServerModeLayout() { + add(serverCertificateLabel, "newline, right"); + add(serverCertificateButton, "h 22!, w 22!, split"); + add(serverCertificateText); + + add(clientAuthLabel, "newline, right"); + add(clientAuthRadioNone, "split"); + add(clientAuthRadioRequested, "split"); + add(clientAuthRadioRequired); + + add(trustedClientCertsLabel, "newline, right"); + add(trustedClientCertsButton, "h 22!, w 22!, split"); + add(trustedClientCertsText); + } + + private void registerActionListeners() { + var settingsPanel = connectorPanel.getConnectorSettingsPanel(); + + // Register client/server mode listener + if (settingsPanel instanceof TcpListener tcpListener) { + tcpListener.modeServerRadio.addActionListener((event) -> handleTcpModeChange(true)); + tcpListener.modeClientRadio.addActionListener((event) -> handleTcpModeChange(false)); + + this.isServerMode = tcpListener.modeServerRadio.isSelected(); + handleTcpModeChange(this.isServerMode); + } else if (settingsPanel instanceof TcpSender tcpSender) { + tcpSender.modeServerRadio.addActionListener((event) -> handleTcpModeChange(true)); + tcpSender.modeClientRadio.addActionListener((event) -> handleTcpModeChange(false)); + + this.isServerMode = tcpSender.modeServerRadio.isSelected(); + handleTcpModeChange(this.isServerMode); + } + + // Register Connection testing overrides + if (settingsPanel instanceof HttpSender) { + registerTestConnectionActionHandlers(settingsPanel, Transport.HTTP); + } else if (settingsPanel instanceof TcpSender) { + registerTestConnectionActionHandlers(settingsPanel, Transport.TCP); + } else if (settingsPanel instanceof WebServiceSender) { + registerWsTestConnectionActionHandlers(settingsPanel); + } + } + + private void handleTcpModeChange(boolean isServerMode) { + this.isServerMode = isServerMode; + var isTlsEnabled = properties.isTlsManagerEnabled(); + if (isServerMode) { + handleManagerEnabledButtonClientMode(false); + handleManagerEnabledButtonServerMode(isTlsEnabled); + } else { + handleManagerEnabledButtonClientMode(isTlsEnabled); + handleManagerEnabledButtonServerMode(false); + } + } + + private void registerTestConnectionActionHandlers(ConnectorSettingsPanel settingsPanel, Transport transport) { + var testConnectionButtons = getButtonsByText("Test Connection"); + if (!testConnectionButtons.isEmpty()) { + var button = testConnectionButtons.get(0); + + var actionListeners = button.getActionListeners().clone(); + + var previousActionListener = actionListeners[0]; // Hope it only has a single listener + + // Replace the ActionListener + button.removeActionListener(previousActionListener); + button.addActionListener(e -> testTlsConnection(previousActionListener, e, transport)); + } else { + log.warn("No test connection button found in settings panel {}", settingsPanel); + } + } + + private void registerWsTestConnectionActionHandlers(ConnectorSettingsPanel settingsPanel) { + var testConnectionButtons = getButtonsByText("Test Connection"); + if (!testConnectionButtons.isEmpty()) { + // This works on the faint hope the buttons are ordered, and the order of said buttons is not messed with during processing... + var testWsdlConnectionButton = testConnectionButtons.get(0); + var testLocationConnectionButton = testConnectionButtons.get(1); + + var wsdlActionListeners = testWsdlConnectionButton.getActionListeners().clone(); + var locationActionListeners = testLocationConnectionButton.getActionListeners().clone(); + + var previousWsdlActionListener = wsdlActionListeners[0]; + var previousLocationActionListener = locationActionListeners[0]; + + testWsdlConnectionButton.removeActionListener(previousWsdlActionListener); + testWsdlConnectionButton.addActionListener(e -> testWsTlsConnection(previousWsdlActionListener, e, true)); + + testLocationConnectionButton.removeActionListener(previousLocationActionListener); + testLocationConnectionButton.addActionListener(e -> testWsTlsConnection(previousLocationActionListener, e, false)); + } else { + log.warn("No Test Connection button found in settings panel {}", settingsPanel); + } + + var getOperationsButtons = getButtonsByText("Get Operations"); + if (!getOperationsButtons.isEmpty()) { + var button = getOperationsButtons.get(0); + + var actionListeners = button.getActionListeners().clone(); + + var previousActionListener = actionListeners[0]; // Hope it only has a single listener + + // Replace the ActionListener + button.removeActionListener(previousActionListener); + button.addActionListener(e -> getOperations(previousActionListener, e)); + } else { + log.warn("No Get Operations button found in settings panel {}", settingsPanel); + } + } + + private void testWsTlsConnection(ActionListener nonTlsActionListener, ActionEvent event, boolean isWsdlUrlBeingTested) { + if (!properties.isTlsManagerEnabled()) { + // If TLS management is disabled, run the previous non-tls connection test + // The isWsdlUrlBeingTested hopefully doesn't matter here as the listeners are already defined + // by the sender panel. + nonTlsActionListener.actionPerformed(event); + return; + } + + if (!canTestConnection(isWsdlUrlBeingTested)) { + return; + } + + var wsProperties = (WebServiceDispatcherProperties) connectorPanel.getProperties(); + + // Blank out the other property so that it isn't tested + if (isWsdlUrlBeingTested) { + wsProperties.setLocationURI(""); + } else { + wsProperties.setWsdlUrl(""); + } + + try { + connectorPanel + .getConnectorSettingsPanel() + .getServlet( + TLSServletInterface.class, + "Testing connection...", + "Error testing Web Service connection: ", + this.responseHandler + ).testWsConnection( + connectorPanel.getConnectorSettingsPanel().getChannelId(), + connectorPanel.getConnectorSettingsPanel().getChannelName(), + wsProperties + ); + } catch (ClientException e) { + // Should not happen + } + } + + private boolean canTestConnection(boolean isWsdlUrlBeingTested) { + var wsProperties = (WebServiceDispatcherProperties) connectorPanel.getProperties(); + + if (isWsdlUrlBeingTested) { + if (wsProperties.getWsdlUrl() == null || wsProperties.getWsdlUrl().isBlank()) { + parentFrame.alertError(parentFrame, "-WSDL URL is blank."); + } + } else if (wsProperties.getLocationURI() == null || wsProperties.getLocationURI().isBlank()) { + parentFrame.alertError(parentFrame, "-Location URI is blank."); + return false; + } + + return true; + } + + private void testTlsConnection(ActionListener nonTlsActionListener, ActionEvent event, Transport transport) { + if (!properties.isTlsManagerEnabled()) { + // If TLS management is disabled, run the previous non-tls connection test + nonTlsActionListener.actionPerformed(event); + return; + } + + try { + var servletInterface = connectorPanel + .getConnectorSettingsPanel() + .getServlet( + TLSServletInterface.class, + "Testing connection...", + "Error testing TLS connection", + this.responseHandler + ); + + if (transport == Transport.HTTP) { + servletInterface.testHttpsConnection( + connectorPanel.getConnectorSettingsPanel().getChannelId(), + connectorPanel.getConnectorSettingsPanel().getChannelName(), + (HttpDispatcherProperties) connectorPanel.getProperties() + ); + } else if (transport == Transport.TCP) { + servletInterface.testTcpConnection( + connectorPanel.getConnectorSettingsPanel().getChannelId(), + connectorPanel.getConnectorSettingsPanel().getChannelName(), + (TcpDispatcherProperties) connectorPanel.getProperties() + ); + } + + } catch (Exception e) { + log.error("Error testing TLS connection", e); + } + } + + private void getOperations(ActionListener nonTlsActionListener, ActionEvent event) { + if (!properties.isTlsManagerEnabled()) { + // If TLS management is disabled, run the previous non-tls connection test + // The isWsdlUrlBeingTested hopefully doesn't matter here as the listeners are already defined + // by the sender panel. + nonTlsActionListener.actionPerformed(event); + return; + } + + var webServiceSender = (WebServiceSender) connectorPanel.getConnectorSettingsPanel(); + if (!parentFrame.alertOkCancel(parentFrame, "This will replace your current service, port, location URI, and operation list. Press OK to continue.")) { + return; + } + + var wsProperties = (WebServiceDispatcherProperties) connectorPanel.getProperties(); + + // wtf... + var cacheWsdlHandler = new ResponseHandler() { + @Override + public void handle(Object response) { + try { + var retrieveWsdlFromCacheHandler = new ResponseHandler() { + @Override + public void handle(Object response) { + if (response == null) { + return; + } + + var definitionServiceMap = (DefinitionServiceMap) response; + + var currentProperties = (WebServiceDispatcherProperties) webServiceSender.getProperties(); + currentProperties.setWsdlDefinitionMap(definitionServiceMap); + + // Trigger private loadServiceMap() function + webServiceSender.setProperties(currentProperties); + + // Trigger population of service and port comboboxes + var serviceCombobox = SwingMagic.findComponentFollowingLabel(connectorPanel.getConnectorSettingsPanel(), "Service:"); + if (serviceCombobox instanceof MirthEditableComboBox serviceEditableCombobox) { + serviceEditableCombobox.setSelectedItem(definitionServiceMap.getMap().keySet().iterator().next()); + } + + parentFrame.setSaveEnabled(true); + } + }; + + connectorPanel + .getConnectorSettingsPanel() + .getServlet( + TLSServletInterface.class, + "Retrieving cached WSDL definition map...", + "There was an error retrieving the cached WSDL definition map.\n\n", + retrieveWsdlFromCacheHandler + ) + .getDefinition( + connectorPanel.getConnectorSettingsPanel().getChannelId(), + connectorPanel.getConnectorSettingsPanel().getChannelName(), + wsProperties.getWsdlUrl(), + wsProperties.getUsername(), + wsProperties.getPassword() + ); + } catch (ClientException e) { + log.error("Error retrieving cached WSDL definition map", e); + } + } + }; + + try { + connectorPanel + .getConnectorSettingsPanel() + .getServlet( + TLSServletInterface.class, + "Getting operations...", + "Error caching WSDL. Please check the WSDL URL and authentication settings.\n\n", + cacheWsdlHandler + ) + .cacheWsdlFromUrl( + connectorPanel.getConnectorSettingsPanel().getChannelId(), + connectorPanel.getConnectorSettingsPanel().getChannelName(), + wsProperties + ); + } catch (ClientException e) { + log.error("Error getting operations", e); + } + } + + private List getButtonsByText(String text) { + var settingsComponents = connectorPanel + .getConnectorSettingsPanel() + .getComponents(); + + return Arrays + .stream(settingsComponents) + .filter(component -> component instanceof JButton) + .map(component -> (JButton) component) + .filter(button -> button.getText().equals(text)) + .toList(); + } + + protected void fetchData() { + final var workerId = PlatformUI.MIRTH_FRAME.startWorking("Fetching data..."); + + var worker = new SwingWorker() { + private Map cryptoMap; + + public Void doInBackground() { + try {cryptoMap = PlatformUI.MIRTH_FRAME.mirthClient.getProtocolsAndCipherSuites(); + } catch (Exception e) { + PlatformUI.MIRTH_FRAME.alertThrowable(PlatformUI.MIRTH_FRAME, e, "Fetching imported certificates failed"); + } + + return null; + } + + public void done() { + supportedProtocols = Set.of( + cryptoMap.get(MirthSSLUtil.KEY_ENABLED_SERVER_PROTOCOLS) + ); + + supportedCiphers = Set.of( + cryptoMap.get(MirthSSLUtil.KEY_ENABLED_CIPHER_SUITES) + ); + + PlatformUI.MIRTH_FRAME.stopWorking(workerId); + } + }; + + worker.execute(); + } +} diff --git a/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/TLSManagerSettingsPanel.java b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/TLSManagerSettingsPanel.java new file mode 100644 index 000000000..1d54526a4 --- /dev/null +++ b/plugins/tls/client/src/main/java/org/openintegrationengine/tlsmanager/client/panel/TLSManagerSettingsPanel.java @@ -0,0 +1,278 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2023 Phosphor Icons + * Copyright (c) 2025 NovaMap Health Limited + * + * This file uses Phosphor Icons (https://github.com/phosphor-icons) + * The Phosphor Icons portion is licensed under the MIT License: + * https://github.com/phosphor-icons/phosphor-icons/blob/master/LICENSE + */ + +package org.openintegrationengine.tlsmanager.client.panel; + +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.ui.AbstractSettingsPanel; +import com.mirth.connect.client.ui.BareBonesBrowserLaunch; +import com.mirth.connect.client.ui.MirthHeadingPanel; +import com.mirth.connect.client.ui.PlatformUI; +import com.mirth.connect.client.ui.UIConstants; +import com.mirth.connect.plugins.SettingsPanelPlugin; +import net.miginfocom.swing.MigLayout; + +import javax.swing.BorderFactory; +import javax.swing.GroupLayout; +import javax.swing.ImageIcon; +import javax.swing.JEditorPane; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingWorker; +import javax.swing.UIManager; +import javax.swing.border.TitledBorder; +import javax.swing.event.HyperlinkEvent; +import java.awt.Color; +import java.awt.Font; +import java.util.concurrent.ExecutionException; + +public class TLSManagerSettingsPanel extends AbstractSettingsPanel { + + // https://github.com/phosphor-icons/core/blob/main/raw/duotone/gear-duotone.svg + private static final String SETTINGS_ICON_PATH = "images/tls_plugin_settings.png"; + + private MirthHeadingPanel mirthHeadingPanel; + private JPanel infoPanel; + private JPanel aboutPanel; + private JPanel teamPanel; + private JLabel copyright; + + private JLabel versionLabel; + + private final SettingsPanelPlugin plugin; + + private static final String tlsManagerUrl = PlatformUI.SERVER_URL + "/tls-manager"; + + public TLSManagerSettingsPanel(String tabName, SettingsPanelPlugin plugin) { + super(tabName); + + this.plugin = plugin; + + setVisibleTasks(0, 1, false); + addTask( + "openManagerInBrowser", + "Open TLS Manager", + "Launch the Web TLS Manager inside your system browser", + "", + new ImageIcon(this.getClass().getClassLoader().getResource(SETTINGS_ICON_PATH))); + initComponents(); + initLayout(); + + fetchData(); + } + + private void initComponents() { + setBackground(UIConstants.BACKGROUND_COLOR); + + versionLabel = new JLabel(); + versionLabel.setFont(new java.awt.Font(Font.SANS_SERIF, Font.BOLD, 18)); + versionLabel.setForeground(Color.WHITE); + + mirthHeadingPanel = new MirthHeadingPanel(); + GroupLayout mirthHeadingPanelLayout = new GroupLayout(mirthHeadingPanel); + mirthHeadingPanel.setLayout(mirthHeadingPanelLayout); + mirthHeadingPanelLayout.setHorizontalGroup( + mirthHeadingPanelLayout.createParallelGroup(GroupLayout.Alignment.LEADING) + .addGroup(mirthHeadingPanelLayout.createSequentialGroup() + .addContainerGap() + .addComponent(versionLabel, GroupLayout.DEFAULT_SIZE, 353, Short.MAX_VALUE) + .addContainerGap()) + ); + mirthHeadingPanelLayout.setVerticalGroup( + mirthHeadingPanelLayout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGroup(mirthHeadingPanelLayout.createSequentialGroup() + .addContainerGap() + .addComponent(versionLabel, javax.swing.GroupLayout.DEFAULT_SIZE, 29, Short.MAX_VALUE) + .addContainerGap()) + ); + + infoPanel = new JPanel(); + infoPanel.setBackground(UIConstants.BACKGROUND_COLOR); + infoPanel.setBorder( + BorderFactory.createTitledBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(204, 204, 204)), + "Where are the settings?", TitledBorder.DEFAULT_JUSTIFICATION, TitledBorder.DEFAULT_POSITION, + new Font(Font.SANS_SERIF, Font.BOLD, 11) + ) + ); + + aboutPanel = new JPanel(); + aboutPanel.setBackground(UIConstants.BACKGROUND_COLOR); + aboutPanel.setBorder( + BorderFactory.createTitledBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(204, 204, 204)), + "About the TLS Manager Plugin", TitledBorder.DEFAULT_JUSTIFICATION, TitledBorder.DEFAULT_POSITION, + new Font(Font.SANS_SERIF, Font.BOLD, 11) + ) + ); + + teamPanel = new JPanel(); + teamPanel.setBackground(UIConstants.BACKGROUND_COLOR); + teamPanel.setBorder( + BorderFactory.createTitledBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(204, 204, 204)), + "The team members involved", TitledBorder.DEFAULT_JUSTIFICATION, TitledBorder.DEFAULT_POSITION, + new Font(Font.SANS_SERIF, Font.BOLD, 11) + ) + ); + + copyright = new JLabel("© 2025 NovaMap Health Limited"); + copyright.setFont(copyright.getFont().deriveFont(Font.PLAIN, 10f)); + copyright.setForeground(new Color(120, 120, 120)); + } + + private void initLayout() { + setLayout(new MigLayout( + "hidemode 3, novisualpadding, insets 0", + "[grow]", + "[] [grow] []" + )); + + JLabel whereText1 = new JLabel( + """ + + Certificate management for the TLS Manager Plugin is done using a web-based user interface available at:

+ """ + ); + + JEditorPane whereText2 = new JEditorPane(); + whereText2.setContentType("text/html"); + whereText2.setEditable(false); + whereText2.setOpaque(false); + whereText2.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE); + Font labelFont = UIManager.getFont("Label.font"); + + String style = + ""; + + whereText2.setText( + "" + + style + + "" + + "" + tlsManagerUrl + "" + + "" + ); + + whereText2.addHyperlinkListener(e -> { + if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { + openManagerInBrowser(); + } + }); + + JLabel whereText3 = new JLabel( + """ + +
The access credentials are the same ones used to log in to this Administrator Client. + """ + ); + + JLabel aboutText = new JLabel( + """ + + Jointly sponsored by NovaMap Health Limited & Diridium Technologies Inc.
+ and donated to the Open Integration Engine initiative. + """ + ); + + JLabel teamList = new JLabel( + """ + + • Alex Frîncu
+ • Andreea Dincă
+ • Andrei Haiducu
+ • Ed Riordan
+ • Kaur Palang
+ • Paul Coyne
+ • Paul Hristea
+ • Paul Richardson

+ Thank you! + """ + ); + + infoPanel.setLayout(new MigLayout("insets 8", "[grow]")); + aboutPanel.setLayout(new MigLayout("insets 8", "[grow]")); + teamPanel.setLayout(new MigLayout("insets 8", "[grow]")); + + infoPanel.add(whereText1, "growx, wrap"); + infoPanel.add(whereText2, "growx, wrap"); + infoPanel.add(whereText3, "growx, wrap"); + aboutPanel.add(aboutText, "growx, wrap"); + teamPanel.add(teamList, "growx, wrap"); + + JPanel contentPanel = new JPanel( + new MigLayout("insets 12", "[grow]", "[] [] []") + ); + contentPanel.setOpaque(true); + contentPanel.setBackground(UIConstants.BACKGROUND_COLOR); + contentPanel.add(infoPanel, "growx, wrap"); + contentPanel.add(aboutPanel, "growx, wrap"); + contentPanel.add(teamPanel, "growx, wrap"); + contentPanel.add(copyright, "gapy 24"); + + JPanel headerWrapper = new JPanel(new MigLayout("insets 0", "[grow]", "[]")); + headerWrapper.setOpaque(true); + headerWrapper.setBackground(UIConstants.BACKGROUND_COLOR); + + headerWrapper.add( + mirthHeadingPanel, + "growx, gaptop 7, gapleft 7, gapafter 7" + ); + add(headerWrapper, "growx, wrap"); + add(contentPanel, "grow, push, wrap"); + } + + @Override + public void doRefresh() { + + } + + @Override + public boolean doSave() { + return false; + } + + public void openManagerInBrowser() { + BareBonesBrowserLaunch.openURL(tlsManagerUrl); + } + + protected final void fetchData() { + final var workerId = PlatformUI.MIRTH_FRAME.startWorking("Fetching data..."); + + var worker = new SwingWorker() { + protected String doInBackground() { + try { + return PlatformUI.MIRTH_FRAME.mirthClient.getExtensionMetaData(plugin.getPluginName()).getPluginVersion(); + } catch (ClientException e) { + throw new RuntimeException(e); + } + } + + protected void done() { + try { + var pluginVersion = get(); + versionLabel.setText("TLS Manager Plugin " + pluginVersion); + } catch (InterruptedException | ExecutionException e) { + PlatformUI.MIRTH_FRAME.alertThrowable(PlatformUI.MIRTH_FRAME, e, "Fetching failed"); + throw new RuntimeException(e); + } + + PlatformUI.MIRTH_FRAME.stopWorking(workerId); + } + }; + + worker.execute(); + } +} diff --git a/plugins/tls/client/src/main/resources/images/phosphor-gear-duotone.svg b/plugins/tls/client/src/main/resources/images/phosphor-gear-duotone.svg new file mode 100644 index 000000000..4e3e9e536 --- /dev/null +++ b/plugins/tls/client/src/main/resources/images/phosphor-gear-duotone.svg @@ -0,0 +1,63 @@ + + + + + + + + + diff --git a/plugins/tls/client/src/main/resources/images/phosphor-seal-check-duotone.svg b/plugins/tls/client/src/main/resources/images/phosphor-seal-check-duotone.svg new file mode 100644 index 000000000..8d89ba815 --- /dev/null +++ b/plugins/tls/client/src/main/resources/images/phosphor-seal-check-duotone.svg @@ -0,0 +1,49 @@ + + + + + + + + diff --git a/plugins/tls/client/src/main/resources/images/phosphor-seal-warning-duotone.svg b/plugins/tls/client/src/main/resources/images/phosphor-seal-warning-duotone.svg new file mode 100644 index 000000000..70a9f1e15 --- /dev/null +++ b/plugins/tls/client/src/main/resources/images/phosphor-seal-warning-duotone.svg @@ -0,0 +1,49 @@ + + + + + + + + diff --git a/plugins/tls/client/src/main/resources/images/tls_plugin_check.png b/plugins/tls/client/src/main/resources/images/tls_plugin_check.png new file mode 100644 index 000000000..63d748bdc Binary files /dev/null and b/plugins/tls/client/src/main/resources/images/tls_plugin_check.png differ diff --git a/plugins/tls/client/src/main/resources/images/tls_plugin_error.png b/plugins/tls/client/src/main/resources/images/tls_plugin_error.png new file mode 100644 index 000000000..d5b511f24 Binary files /dev/null and b/plugins/tls/client/src/main/resources/images/tls_plugin_error.png differ diff --git a/plugins/tls/client/src/main/resources/images/tls_plugin_settings.png b/plugins/tls/client/src/main/resources/images/tls_plugin_settings.png new file mode 100644 index 000000000..384d1eb53 Binary files /dev/null and b/plugins/tls/client/src/main/resources/images/tls_plugin_settings.png differ diff --git a/plugins/tls/docker/Caddyfile b/plugins/tls/docker/Caddyfile new file mode 100644 index 000000000..d2acc6b50 --- /dev/null +++ b/plugins/tls/docker/Caddyfile @@ -0,0 +1,25 @@ +{ + auto_https off +} + +https://valid.crl.caddy { + tls /opt/certs/crl/server1.crt /opt/certs/crl/server1.key + + respond "Hai with valid cert" 200 + } + +https://revoked.crl.caddy { + tls /opt/certs/crl/server2.crt /opt/certs/crl/server2.key + + respond "Hai with revoked cert" 200 +} + +https://mtls.caddy { + tls /opt/certs/crl/server1.crt /opt/certs/crl/server1.key { + client_auth { + mode require + } + } + + respond "Hai from mtls" 200 +} diff --git a/plugins/tls/docker/compose.yaml b/plugins/tls/docker/compose.yaml new file mode 100644 index 000000000..984d311a0 --- /dev/null +++ b/plugins/tls/docker/compose.yaml @@ -0,0 +1,55 @@ +name: tls-manager + +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "9080:80/tcp" + - "9443:443/tcp" + networks: + default: + aliases: + - valid.crl.caddy + - revoked.crl.caddy + - mtls.caddy + volumes: + - ../tools/cert-revocation/mini-ca:/opt/certs/crl:ro + - ./Caddyfile:/etc/caddy/Caddyfile:ro + + oie: + image: openintegrationengine/engine:latest + environment: + - DATABASE=postgres + - DATABASE_URL=jdbc:postgresql://db:5432/oie + - DATABASE_MAX_CONNECTIONS=20 + - DATABASE_USERNAME=oieuser + - DATABASE_PASSWORD=oieuserpw + - KEYSTORE_STOREPASS=docker_storepass + - KEYSTORE_KEYPASS=docker_keypass + - OIE_TLS_PLUGIN_PERSISTENCE_BACKEND=filesystem + - OIE_TLS_PLUGIN_FS_STOREPATH=/certs/truststore.p12 + - OIE_TLS_PLUGIN_FS_STOREPASS=changeit + ports: + - "8443:8443/tcp" + - "5005:5005/tcp" + - "6001:6001/tcp" + volumes: + - ./appdata:/opt/engine/appdata + - ./custom-extensions:/opt/engine/custom-extensions + - ./conf/log4j2.properties:/opt/engine/conf/log4j2.properties:ro + - ./custom.vmoptions:/opt/engine/conf/custom.vmoptions:ro + - ./certs:/certs + depends_on: + - db + + db: + image: postgres:17-alpine + ports: + - "5432:5432/tcp" + environment: + - POSTGRES_USER=oieuser + - POSTGRES_PASSWORD=oieuserpw + - POSTGRES_DB=oie + volumes: + - ${PWD}/pgdata:/var/lib/postgresql/data diff --git a/plugins/tls/docker/conf/log4j2.properties b/plugins/tls/docker/conf/log4j2.properties new file mode 100644 index 000000000..1bdd2b762 --- /dev/null +++ b/plugins/tls/docker/conf/log4j2.properties @@ -0,0 +1,69 @@ +# sample properties to initialize log4j +rootLogger = ERROR,stdout,fout + +# stdout appender +appender.console.type = Console +appender.console.name = stdout +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %-5p %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c: %m%n +appender.console.layout.charset = UTF-8 + +# file appender +dir.logs = logs +appender.rolling.type = RollingFile +appender.rolling.name = fout +appender.rolling.fileName = logs/mirth.log +appender.rolling.filePattern = logs/mirth.log.%i +appender.rolling.policies.type = Policies +appender.rolling.policies.size.type = SizeBasedTriggeringPolicy +appender.rolling.policies.size.size = 500KB +appender.rolling.strategy.type = DefaultRolloverStrategy +appender.rolling.strategy.max = 20 +appender.rolling.layout.type = PatternLayout +appender.rolling.layout.pattern = %-5p %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c: %m%n + +# splash screen +logger.mirth.name = com.mirth.connect.server.Mirth +logger.mirth.level = INFO + +# TLS Manager +logger.tls-manager-plugin.name = org.openintegrationengine.tlsmanager +logger.tls-manager-plugin.level = DEBUG + +# Mirth Connect server logging +logger.donkeyEngineController.name = com.mirth.connect.server.controllers.DonkeyEngineController +logger.donkeyEngineController.level = INFO +logger.recoveryTask.name = com.mirth.connect.donkey.server.channel.RecoveryTask +logger.recoveryTask.level = INFO +logger.fileReceiver.name = com.mirth.connect.connectors.file.FileReceiver +logger.fileReceiver.level = WARN + +# Mirth Connect channel logging +logger.transformer.name = transformer +logger.transformer.level = DEBUG +logger.preprocessor.name = preprocessor +logger.preprocessor.level = DEBUG +logger.postprocessor.name = postprocessor +logger.postprocessor.level = DEBUG +logger.deploy.name = deploy +logger.deploy.level = DEBUG +logger.undeploy.name = undeploy +logger.undeploy.level = DEBUG +logger.filter.name = filter +logger.filter.level = DEBUG +logger.db-connector.name = db-connector +logger.db-connector.level = DEBUG +logger.js-connector.name = js-connector +logger.js-connector.level = DEBUG +logger.attachment.name = attachment +logger.attachment.level = DEBUG +logger.batch.name = batch +logger.batch.level = DEBUG +logger.response.name = response +logger.response.level = DEBUG +logger.shutdown.name = shutdown +logger.shutdown.level = DEBUG + +# SQL Logging +logger.sql.name = java.sql +logger.sql.level = ERROR diff --git a/plugins/tls/docker/custom.vmoptions b/plugins/tls/docker/custom.vmoptions new file mode 100644 index 000000000..8fe481525 --- /dev/null +++ b/plugins/tls/docker/custom.vmoptions @@ -0,0 +1,4 @@ +-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 +--add-opens=java.base/java.io=ALL-UNNAMED +--add-opens=java.base/java.nio=ALL-UNNAMED +--add-opens=java.base/java.nio.file=ALL-UNNAMED diff --git a/plugins/tls/docker/cycle b/plugins/tls/docker/cycle new file mode 100755 index 000000000..8859d4495 --- /dev/null +++ b/plugins/tls/docker/cycle @@ -0,0 +1,5 @@ +#!/bin/sh + +docker compose down +docker compose up -d +docker compose logs -f oie \ No newline at end of file diff --git a/plugins/tls/docker/cycleOie b/plugins/tls/docker/cycleOie new file mode 100755 index 000000000..c1038b2d1 --- /dev/null +++ b/plugins/tls/docker/cycleOie @@ -0,0 +1,5 @@ +#!/bin/sh + +docker compose down oie +docker compose up -d oie +docker compose logs -f oie diff --git a/plugins/tls/docs/images/intellij_int_test_run_configuration.png b/plugins/tls/docs/images/intellij_int_test_run_configuration.png new file mode 100644 index 000000000..5cb1d1af5 Binary files /dev/null and b/plugins/tls/docs/images/intellij_int_test_run_configuration.png differ diff --git a/plugins/tls/docs/images/intellij_unit_test_run_configuration.png b/plugins/tls/docs/images/intellij_unit_test_run_configuration.png new file mode 100644 index 000000000..c486c5e37 Binary files /dev/null and b/plugins/tls/docs/images/intellij_unit_test_run_configuration.png differ diff --git a/plugins/tls/libs/compiletime/README.md b/plugins/tls/libs/compiletime/README.md new file mode 100644 index 000000000..b52d40688 --- /dev/null +++ b/plugins/tls/libs/compiletime/README.md @@ -0,0 +1 @@ +Here go any library jars that you might need during development/compilation diff --git a/plugins/tls/libs/runtime/README.md b/plugins/tls/libs/runtime/README.md new file mode 100644 index 000000000..7eabd6136 --- /dev/null +++ b/plugins/tls/libs/runtime/README.md @@ -0,0 +1,4 @@ +In these folders go library jars that the plugin needs during runtime. +For each environment respectively. + +## Do not rename the directories! diff --git a/plugins/tls/libs/runtime/client/.gitkeep b/plugins/tls/libs/runtime/client/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/tls/libs/runtime/server/.gitkeep b/plugins/tls/libs/runtime/server/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/tls/libs/runtime/server/cxf-core-4.1.3.jar b/plugins/tls/libs/runtime/server/cxf-core-4.1.3.jar new file mode 100644 index 000000000..9b68c3c62 Binary files /dev/null and b/plugins/tls/libs/runtime/server/cxf-core-4.1.3.jar differ diff --git a/plugins/tls/libs/runtime/server/cxf-rt-wsdl-4.1.3.jar b/plugins/tls/libs/runtime/server/cxf-rt-wsdl-4.1.3.jar new file mode 100644 index 000000000..19042b37f Binary files /dev/null and b/plugins/tls/libs/runtime/server/cxf-rt-wsdl-4.1.3.jar differ diff --git a/plugins/tls/libs/runtime/server/stax2-api-4.2.2.jar b/plugins/tls/libs/runtime/server/stax2-api-4.2.2.jar new file mode 100644 index 000000000..cc5844fb5 Binary files /dev/null and b/plugins/tls/libs/runtime/server/stax2-api-4.2.2.jar differ diff --git a/plugins/tls/libs/runtime/server/woodstox-core-7.1.1.jar b/plugins/tls/libs/runtime/server/woodstox-core-7.1.1.jar new file mode 100644 index 000000000..92c1631c3 Binary files /dev/null and b/plugins/tls/libs/runtime/server/woodstox-core-7.1.1.jar differ diff --git a/plugins/tls/libs/runtime/shared/.gitkeep b/plugins/tls/libs/runtime/shared/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/tls/pom.xml b/plugins/tls/pom.xml new file mode 100644 index 000000000..cc101383a --- /dev/null +++ b/plugins/tls/pom.xml @@ -0,0 +1,214 @@ + + + + + + + + 4.0.0 + + org.openintegrationengine + tlsmanager + 1.0.0 + pom + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + Original code up to and including ff753e7ff2a304a63723924fcc68c6f883c96fc2 licensed under Apache 2.0. + + + Mozilla Public License 2.0 + https://www.mozilla.org/en-US/MPL/2.0/ + repo + Modifications licensed under MPL-2.0. + + + + + server + shared + client + + + + 17 + 17 + UTF-8 + + 4.5.2 + + 5.1 + 3.7.1 + 3.0.0 + 2.1.0-SNAPSHOT + 1.18.32 + 1.7.30 + 3.7.4 + 5.13.4 + 5.19.0 + + dev + + + NovaMap Health + 4.5.2 + TLS-enabled networking Connectors & Certificate Management + TLS Manager + tls-manager + https://novamap.health + ${project.version} + + + + + repsy-default + https://repo.repsy.io/mvn/kpalang/default + + + repsy-mirthconnect + https://repo.repsy.io/mvn/kpalang/mirthconnect + + + + + + repsy-default + https://repo.repsy.io/mvn/kpalang/default + + + + + + + com.kaurpalang + mirth-plugin-maven-plugin + ${mirth-plugin-maven-plugin.version} + + + + org.projectlombok + lombok + ${lombok.version} + compile + + + + org.slf4j + slf4j-api + ${slf4j.version} + provided + + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + checkstyle.xml + true + + + + + + + + ${project.parent.artifactId}-${project.artifactId} + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + + com.puppycrawl.tools + checkstyle + 12.2.0 + + + + + + + + + org.apache.maven.plugins + maven-jarsigner-plugin + ${maven-jarsigner-plugin.version} + + + sign + + sign + + + + + ${project.parent.basedir}/certificate/keystore.jks + selfsigned + storepass + keypass + + + + + org.bsc.maven + maven-processor-plugin + ${maven-processor-plugin.version} + + + process + + process + + process-sources + + + + com.kaurpalang.mirth.annotationsplugin.processor.MirthPluginProcessor + + + + + + + + diff --git a/plugins/tls/requests.http b/plugins/tls/requests.http new file mode 100644 index 000000000..81f8e1c4c --- /dev/null +++ b/plugins/tls/requests.http @@ -0,0 +1,36 @@ +@base_address = https://localhost:8443 +@username = admin +@password = admin + +### +GET {{base_address}}/api/tlsmanager/importedcertificates +Accept: application/json +X-Requested-With: IntelliJ +Authorization: Basic {{username}} {{password}} + +### +GET {{base_address}}/api/tlsmanager/keystore +Accept: application/octet-stream +X-Requested-With: IntelliJ +Authorization: Basic {{username}} {{password}} + +>> {{$historyFolder}}/keystore.p12 + +### +POST {{base_address}}/api/tlsmanager/truststore +X-Requested-With: IntelliJ +Authorization: Basic {{username}} {{password}} +Content-Type: multipart/form-data; boundary=RequestBoundary + +--RequestBoundary +Content-Disposition: form-data; name="password" +Content-Type: text/plain + +changeit +--RequestBoundary +Content-Disposition: form-data; name="file"; filename=truststore.p12 +Content-Type: application/octet-stream + +< docker/certs/new_truststore.p12 +--RequestBoundary-- + diff --git a/plugins/tls/server/pom.xml b/plugins/tls/server/pom.xml new file mode 100644 index 000000000..46bd015ee --- /dev/null +++ b/plugins/tls/server/pom.xml @@ -0,0 +1,141 @@ + + + + + + + + 4.0.0 + + + org.openintegrationengine + tlsmanager + 1.0.0 + + + server + + + 4.0.1 + + + + + org.bouncycastle + bcpkix-jdk18on + 1.78.1 + + + + org.bouncycastle + bcprov-jdk18on + 1.78.1 + + + + org.openintegrationengine + shared + ${project.version} + + + + javax.servlet + javax.servlet-api + ${javax-servlet.version} + provided + + + + com.mirth.connect.plugins + http-server + ${mirth.version} + provided + + + + com.mirth.connect.connectors + tcp-server + ${mirth.version} + provided + + + + com.mirth.connect.connectors + ws-server + ${mirth.version} + provided + + + + com.mirth.connect.connectors + ws-shared + ${mirth.version} + provided + + + + org.apache.httpcomponents + httpclient + 4.5.13 + provided + + + + com.mirth.connect + donkey-server + ${mirth.version} + provided + + + + org.eclipse.jetty + jetty-server + 9.4.53.v20231009 + provided + + + + org.apache.commons + commons-configuration2 + 2.8.0 + test + + + + com.mirth.connect + mirth-crypto + ${mirth.version} + test + + + + org.apache.logging.log4j + log4j-core + 2.17.2 + test + + + + javax.activation + javax.activation-api + 1.2.0 + test + + + + ch.qos.logback + logback-classic + 1.2.13 + test + + + + org.apache.cxf + cxf-rt-wsdl + 4.1.3 + provided + + + diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/CertificateService.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/CertificateService.java new file mode 100644 index 000000000..a089ecb64 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/CertificateService.java @@ -0,0 +1,646 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server; + +import com.mirth.connect.client.core.api.MirthApiException; +import com.mirth.connect.connectors.http.HttpDispatcherProperties; +import com.mirth.connect.connectors.tcp.TcpDispatcherProperties; +import com.mirth.connect.connectors.ws.WebServiceDispatcherProperties; +import com.mirth.connect.donkey.model.channel.ConnectorPluginProperties; +import com.mirth.connect.model.Channel; +import com.mirth.connect.model.Connector; +import com.mirth.connect.server.controllers.ChannelController; +import com.mirth.connect.server.util.TemplateValueReplacer; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.openintegrationengine.tlsmanager.server.backend.DatabaseTrustStoreBackend; +import org.openintegrationengine.tlsmanager.server.backend.FileTrustStoreBackend; +import org.openintegrationengine.tlsmanager.server.backend.SystemTrustStoreBackend; +import org.openintegrationengine.tlsmanager.server.backend.TrustStoreBackend; +import org.openintegrationengine.tlsmanager.server.util.ConnectionUtils; +import org.openintegrationengine.tlsmanager.shared.PersistenceMode; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; +import org.openintegrationengine.tlsmanager.shared.models.LocalCertificate; +import org.openintegrationengine.tlsmanager.shared.models.TLSPluginConfiguration; +import org.openintegrationengine.tlsmanager.shared.models.TrustedCertificate; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.net.URL; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; + +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.PKCS12; + +@Slf4j +public final class CertificateService { + + @Getter + private KeyStore systemTrustStore; + + @Getter + private KeyStore externalTrustStore; + + @Getter + private KeyStore externalKeyStore; + + private TrustStoreBackend systemTrustStoreBackend; + private TrustStoreBackend extraTrustStoreBackend; + private TrustStoreBackend extraKeyStoreBackend; + + private final ChannelController channelController; + private final TemplateValueReplacer templateValueReplacer; + + public CertificateService(ChannelController channelController) { + this(new TemplateValueReplacer(), channelController); + } + + public CertificateService(TemplateValueReplacer templateValueReplacer, ChannelController channelController) { + this.templateValueReplacer = templateValueReplacer; + this.channelController = channelController; + } + + void init(TLSPluginConfiguration pluginConfiguration) { + systemTrustStoreBackend = new SystemTrustStoreBackend(); + + if (pluginConfiguration.persistenceMode() == PersistenceMode.DATABASE) { + extraTrustStoreBackend = new DatabaseTrustStoreBackend("extraTrustStore"); + extraKeyStoreBackend = new DatabaseTrustStoreBackend("extraKeyStore"); + } else if (pluginConfiguration.persistenceMode() == PersistenceMode.FILESYSTEM) { + extraTrustStoreBackend = new FileTrustStoreBackend( + pluginConfiguration.truststorePath(), + pluginConfiguration.truststorePassword() + ); + + extraKeyStoreBackend = new FileTrustStoreBackend( + pluginConfiguration.keystorePath(), + pluginConfiguration.keystorePassword() + ); + } else { + // Should not get here + throw new RuntimeException("Unsupported persistence mode: " + pluginConfiguration.persistenceMode()); + } + + extraTrustStoreBackend.init(); + extraKeyStoreBackend.init(); + + byte[] cacertsBytes = systemTrustStoreBackend.load(); + byte[] extraTrustStoreBytes = extraTrustStoreBackend.load(); + byte[] extraKeyStoreBytes = extraKeyStoreBackend.load(); + + try { + systemTrustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + externalTrustStore = KeyStore.getInstance(PKCS12); + externalKeyStore = KeyStore.getInstance(PKCS12); + } catch (KeyStoreException e) { + log.error("Error initializing CertificateService", e); + throw new RuntimeException(e); + } + + loadKeyStore(systemTrustStore, cacertsBytes, systemTrustStoreBackend.loadPassword()); + loadKeyStore(externalTrustStore, extraTrustStoreBytes, extraTrustStoreBackend.loadPassword()); + loadKeyStore(externalKeyStore, extraKeyStoreBytes, extraKeyStoreBackend.loadPassword()); + } + + KeyStore getKeyStore(String alias) { + try { + var keystore = KeyStore.getInstance(PKCS12); + keystore.load(null, new char[0]); + + if (alias == null) { + throw new IllegalArgumentException("Alias cannot be null"); + } + + if (externalKeyStore.isKeyEntry(alias)) { + var certChain = externalKeyStore.getCertificateChain(alias); + var privateKey = externalKeyStore.getKey(alias, new char[0]); + + keystore.setKeyEntry( + alias, + privateKey, + new char[0], + certChain + ); + } else { + log.warn("Alias ({}) is not a key entry", alias); + } + + return keystore; + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException | UnrecoverableKeyException e) { + log.error("Error creating a keystore", e); + throw new RuntimeException(e); + } + } + + private void loadKeyStore(KeyStore keystore, byte[] bytes, char[] password) { + try (var bais = new ByteArrayInputStream(bytes)) { + keystore.load(bais, password); + } catch (IOException | NoSuchAlgorithmException | CertificateException e) { + log.error("Error loading keystore into memory", e); + throw new RuntimeException(e); + } + } + + public void storeExtraTrustStore(byte[] keystoreBytes, char[] password) { + try (var bais = new ByteArrayInputStream(keystoreBytes)) { + externalTrustStore.load(bais, password); + extraTrustStoreBackend.persist(keystoreBytes); + } catch (CertificateException | IOException | NoSuchAlgorithmException e) { + log.error("Error overwriting truststore", e); + throw new RuntimeException(e); + } + } + + public void storeExtraKeyStore(byte[] keystoreBytes, char[] password) { + try (var bais = new ByteArrayInputStream(keystoreBytes)) { + externalKeyStore.load(bais, password); + extraKeyStoreBackend.persist(keystoreBytes); + } catch (CertificateException | IOException | NoSuchAlgorithmException e) { + log.error("Error overwriting keystore", e); + throw new RuntimeException(e); + } + } + + public Set getTrustedCertificateAliases() { + try { + return new HashSet<>(Collections.list(externalTrustStore.aliases())); + } catch (KeyStoreException e) { + log.error("Error reading alias list from loaded truststore", e); + throw new RuntimeException(e); + } + } + + public Set getLocalCertificateAliases() { + try { + return new HashSet<>(Collections.list(externalKeyStore.aliases())); + } catch (KeyStoreException e) { + log.error("Error reading alias list from loaded keystore", e); + throw new RuntimeException(e); + } + } + + public List getEncodedSystemCertificates() { + return getEncodedCertificates(systemTrustStore, systemTrustStoreBackend.loadPassword()); + } + + public List getEncodedLocalCertificates() { + return getEncodedCertificates(externalKeyStore, extraKeyStoreBackend.loadPassword()); + } + + public List getEncodedTrustedCertificates() { + return getEncodedCertificates(externalTrustStore, extraTrustStoreBackend.loadPassword()); + } + + private List getEncodedCertificates(KeyStore keyStore, char[] password) { + List certificates = new ArrayList<>(); + + try { + Enumeration aliases = keyStore.aliases(); + + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + + if (keyStore.isKeyEntry(alias)) { + LocalCertificate certificate = new LocalCertificate(alias); + String encodedCertificate = encodeCertificateChain(keyStore.getCertificateChain(alias)); + String encodedKey = encodeKey(keyStore.getKey(alias, password)); + certificate.setCertificate(encodedCertificate); + certificate.setKey(encodedKey); + certificate.setChannelsInUse(getChannelsInUse(alias)); + certificates.add(certificate); + } else if (keyStore.isCertificateEntry(alias)) { + TrustedCertificate certificate = new TrustedCertificate(alias); + String encodedCertificate = encodeCertificateChain(keyStore.getCertificate(alias)); + certificate.setCertificate(encodedCertificate); + certificate.setChannelsInUse(getChannelsInUse(alias)); + certificates.add(certificate); + } + } + return certificates; + } catch (KeyStoreException | CertificateEncodingException | NoSuchAlgorithmException | UnrecoverableKeyException e) { + throw new RuntimeException(e); + } + } + + private Set getChannelsInUse(String alias) { + final Set channelsInUse = new HashSet<>(); + final List channels = channelController.getChannels(null); + + BiConsumer addIfInUse = (TLSConnectorProperties properties, String channelName) -> { + if (properties != null) { + if (alias.equals(properties.getServerCertificateAlias())) { + channelsInUse.add(channelName); + } else if (alias.equals(properties.getClientCertificateAlias())) { + channelsInUse.add(channelName); + } else if (properties.getTrustedServerCertificates() != null + && !properties.getTrustedServerCertificates().isEmpty() + && properties.getTrustedServerCertificates().contains(alias)) { + channelsInUse.add(channelName); + } + } + }; + + for (Channel channel : channels) { + Set sourceProperties = channel.getSourceConnector().getProperties().getPluginProperties(); + + if (sourceProperties != null && !sourceProperties.isEmpty()) { + TLSConnectorProperties tlsSourceProperties = (TLSConnectorProperties) sourceProperties + .stream() + .filter(props -> props instanceof TLSConnectorProperties) + .findFirst() + .orElse(null); + + addIfInUse.accept(tlsSourceProperties, channel.getName()); + } + for (Connector destinationConnector : channel.getDestinationConnectors()) { + Set destinationProperties = destinationConnector.getProperties().getPluginProperties(); + + TLSConnectorProperties tlsDestinationProperties = (TLSConnectorProperties) destinationProperties + .stream() + .filter(props -> props instanceof TLSConnectorProperties) + .findFirst() + .orElse(null); + + addIfInUse.accept(tlsDestinationProperties, channel.getName()); + } + } + return channelsInUse; + } + + private String encodeCertificateChain(Certificate... chain) throws CertificateEncodingException { + StringBuilder pem = new StringBuilder(); + + for (Certificate cert : chain) { + String base64Cert = Base64.getMimeEncoder(64, "\n".getBytes()) + .encodeToString(cert.getEncoded()); + pem.append("-----BEGIN CERTIFICATE-----\n") + .append(base64Cert) + .append("\n-----END CERTIFICATE-----\n\n"); + } + return pem.toString(); + } + + private String encodeKey(Key key) throws CertificateEncodingException { + StringBuilder pem = new StringBuilder(); + + if (key instanceof PrivateKey) { + String base64Key = Base64.getMimeEncoder(64, "\n".getBytes()) + .encodeToString(key.getEncoded()); + pem.append("-----BEGIN PRIVATE KEY-----\n") + .append(base64Key) + .append("\n-----END PRIVATE KEY-----\n\n"); + } + return pem.toString(); + } + + public void setTrustedCertificates(List trustedCertificates) { + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + char[] password = extraTrustStoreBackend.loadPassword(); + ks.load(null, password); + + for (TrustedCertificate certificate : trustedCertificates) { + X509Certificate cert = decodeCertificate(certificate.getCertificate()); + ks.setCertificateEntry(certificate.getAlias(), cert); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ks.store(out, password); + byte[] keystoreBytes = out.toByteArray(); + storeExtraTrustStore(keystoreBytes, password); + } catch (CertificateException | IOException | KeyStoreException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private X509Certificate decodeCertificate(String certificate) throws CertificateException { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + String certContent = certificate + .replaceAll("-----BEGIN CERTIFICATE-----", "") + .replaceAll("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", ""); + byte[] certBytes = Base64.getDecoder().decode(certContent); + return (X509Certificate) cf.generateCertificate( + new java.io.ByteArrayInputStream(certBytes)); + } + + private PrivateKey decodeKey(String key) throws CertificateException, NoSuchAlgorithmException, InvalidKeySpecException, IOException { + String keyContent = key + .replaceAll("-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", "") + .replaceAll("-----END [A-Z0-9 ]*PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + byte[] keyBytes = Base64.getDecoder().decode(keyContent); + PrivateKey privateKey; + try { + privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } catch (InvalidKeySpecException e) { + if (e.getMessage().equals("java.security.InvalidKeyException: IOException : algid parse error, not a sequence")) { + // Attempt to convert to PKCS#8 + return attemptPkcs8Conversion(key); + } + throw e; + } + return privateKey; + } + + private PrivateKey attemptPkcs8Conversion(String key) throws InvalidKeySpecException, IOException { + try (PEMParser parser = new PEMParser(new StringReader(key))) { + Object obj = parser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + + if (obj instanceof PEMKeyPair) { + return converter.getKeyPair((PEMKeyPair) obj).getPrivate(); + } else if (obj instanceof PrivateKeyInfo) { + return converter.getPrivateKey((PrivateKeyInfo) obj); + } else { + throw new IllegalArgumentException("Unsupported PEM object: " + obj.getClass()); + } + } + } + + public void setLocalCertificates(List localCertificates) { + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + char[] password = extraKeyStoreBackend.loadPassword(); + ks.load(null, password); + + for (LocalCertificate certificate : localCertificates) { + X509Certificate cert = decodeCertificate(certificate.getCertificate()); + PrivateKey privateKey = decodeKey(certificate.getKey()); + ks.setKeyEntry(certificate.getAlias(), privateKey, password, new Certificate[]{cert}); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ks.store(out, password); + byte[] keystoreBytes = out.toByteArray(); + storeExtraKeyStore(keystoreBytes, password); + } catch (CertificateException | InvalidKeySpecException | IOException | KeyStoreException | + NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + public List retrieveRemoteCertificates(String urlString) { + List result = new ArrayList<>(); + HttpsURLConnection conn = null; + + try { + SSLContext sc = SSLContext.getInstance("TLS"); + + TrustManager[] trustAll = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + } + }; + + sc.init(null, trustAll, new SecureRandom()); + URL url = new URL(urlString); + conn = (HttpsURLConnection) url.openConnection(); + conn.setSSLSocketFactory(sc.getSocketFactory()); + conn.setHostnameVerifier((h, s) -> true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + conn.connect(); + Certificate[] certs = conn.getServerCertificates(); + + for (Certificate cert : certs) { + if (cert instanceof X509Certificate x509) { + TrustedCertificate certificate = new TrustedCertificate(null); + certificate.setCertificate(encodeCertificateChain(x509)); + result.add(certificate); + } + } + } catch (IOException | CertificateEncodingException | KeyManagementException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return result; + } + + public ConnectionTestResult testTcpConnection( + String channelId, + String channelName, + TcpDispatcherProperties dispatcherProperties + ) { + var oTlsPluginProperties = dispatcherProperties.getPluginProperties() + .stream() + .filter(TLSConnectorProperties.class::isInstance) + .map(TLSConnectorProperties.class::cast) + .findFirst(); + + if (oTlsPluginProperties.isEmpty()) { + log.debug("No TLS plugin properties found for testTcpConnection. Doing non-TLS test"); + // TODO Actually do the test + } + + var properties = oTlsPluginProperties.get(); + + var socketFactoryService = TLSServicePlugin.getPluginInstance().getSocketFactoryService(); + var socketFactory = socketFactoryService.getConnectorSocketFactory(properties); + try { + + String host = templateValueReplacer.replaceValues(dispatcherProperties.getRemoteAddress(), channelId, channelName); + int port = Integer.parseInt(templateValueReplacer.replaceValues(dispatcherProperties.getRemotePort(), channelId, channelName)); + int timeout = Integer.parseInt(templateValueReplacer.replaceValues(dispatcherProperties.getResponseTimeout(), channelId, channelName)); + + if (!dispatcherProperties.isOverrideLocalBinding()) { + return ConnectionUtils.testConnection( + socketFactory, + host, + port, + timeout, + null, + 0 + ); + } else { + String localAddr = templateValueReplacer.replaceValues(dispatcherProperties.getLocalAddress(), channelId, channelName); + int localPort = Integer.parseInt(templateValueReplacer.replaceValues(dispatcherProperties.getLocalPort(), channelId, channelName)); + + return ConnectionUtils.testConnection( + socketFactory, + host, + port, + timeout, + localAddr, + localPort + ); + } + } catch (Exception e) { + throw new MirthApiException(e); + } + } + + public ConnectionTestResult testHttpConnection( + String channelId, + String channelName, + HttpDispatcherProperties dispatcherProperties + ) { + final int TIMEOUT = 5000; + + var oTlsPluginProperties = dispatcherProperties.getPluginProperties() + .stream() + .filter(TLSConnectorProperties.class::isInstance) + .map(TLSConnectorProperties.class::cast) + .findFirst(); + + if (oTlsPluginProperties.isEmpty()) { + log.debug("No TLS plugin properties found for testTcpConnection. Doing non-TLS test"); + // TODO Actually do the test + } + + var properties = oTlsPluginProperties.get(); + + var socketFactoryService = TLSServicePlugin.getPluginInstance().getSocketFactoryService(); + var socketFactory = socketFactoryService.getConnectorSocketFactory(properties); + + try { + var url = new URL(templateValueReplacer.replaceValues(dispatcherProperties.getHost(), channelId, channelName)); + var port = url.getPort(); + + int computedPort; + if (port == -1) + // If no port was provided, default to port 80 or 443. + computedPort = "https".equalsIgnoreCase(url.getProtocol()) ? 443 : 80; + else + computedPort = port; + + return ConnectionUtils.testConnection( + socketFactory, + url.getHost(), + computedPort, + TIMEOUT, + null, + 0 + ); + } catch (Exception e) { + throw new MirthApiException(e); + } + } + + public ConnectionTestResult testWsConnection( + String channelId, + String channelName, + WebServiceDispatcherProperties dispatcherProperties + ) { + final int MAX_TIMEOUT = 300_000; // 5 minutes??? + + var oTlsPluginProperties = dispatcherProperties.getPluginProperties() + .stream() + .filter(TLSConnectorProperties.class::isInstance) + .map(TLSConnectorProperties.class::cast) + .findFirst(); + + if (oTlsPluginProperties.isEmpty()) { + log.debug("No TLS plugin properties found for testWsConnection. Doing non-TLS test"); + // TODO Actually do the test + } + + var properties = oTlsPluginProperties.get(); + + var socketFactoryService = TLSServicePlugin.getPluginInstance().getSocketFactoryService(); + var socketFactory = socketFactoryService.getConnectorSocketFactory(properties); + + try { + String host; + if (dispatcherProperties.getLocationURI() != null && !dispatcherProperties.getLocationURI().isBlank()) { + host = dispatcherProperties.getLocationURI(); + } else if (dispatcherProperties.getWsdlUrl() != null && !dispatcherProperties.getWsdlUrl().isBlank()) { + host = dispatcherProperties.getWsdlUrl(); + } else { + throw new Exception("Both WSDL URL and Location URI are blank. At least one must be populated in order to test connection."); + } + + var url = new URL(templateValueReplacer.replaceValues(host, channelId, channelName)); + var port = url.getPort(); + + int computedPort; + if (port == -1) + // If no port was provided, default to port 80 or 443. + computedPort = "https".equalsIgnoreCase(url.getProtocol()) ? 443 : 80; + else + computedPort = port; + + return ConnectionUtils.testConnection( + socketFactory, + url.getHost(), + computedPort, + MAX_TIMEOUT, + null, + 0 + ); + } catch (Exception e) { + throw new MirthApiException(e); + } + } + + /** + * Perform a byte-level clone of a KeyStore object + * + * @param keystore The KeyStore object to be cloned + * @return Byte-level clone of the provided KeyStore + */ + private KeyStore clone(KeyStore keystore) { + // This doesn't have to be secret. It is just here because a keystore password cannot be null + final var password = "sup3rS3cr1t!".toCharArray(); + + try (var outStream = new ByteArrayOutputStream()) { + var finalTrustStore = KeyStore.getInstance(PKCS12); + + keystore.store(outStream, password); + + try (var inStream = new ByteArrayInputStream(outStream.toByteArray())) { + finalTrustStore.load(inStream, password); + } + + return finalTrustStore; + } catch (IOException | CertificateException | KeyStoreException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/SocketFactoryService.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/SocketFactoryService.java new file mode 100644 index 000000000..74c15fbff --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/SocketFactoryService.java @@ -0,0 +1,155 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server; + +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.util.MirthSSLUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.openintegrationengine.tlsmanager.server.revocation.DualCheckerTrustManager; +import org.openintegrationengine.tlsmanager.shared.models.WeirdIntermediaryContextContainer; +import org.openintegrationengine.tlsmanager.shared.models.WeirdIntermediaryListenerContextContainer; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; + +@Slf4j +public class SocketFactoryService { + + private final ConfigurationController configurationController; + private final CertificateService certificateService; + + public SocketFactoryService( + ConfigurationController configurationController, + CertificateService certificateService + ) { + this.certificateService = certificateService; + this.configurationController = configurationController; + } + + public SSLConnectionSocketFactory getConnectorSocketFactory(TLSConnectorProperties properties) { + var contextContainer = generateTLSContextSender(properties); + return getConnectorSocketFactory(contextContainer); + } + + public SSLConnectionSocketFactory getConnectorSocketFactory(WeirdIntermediaryContextContainer contextContainer) { + // Return null to trigger building the connection with OIE's internal logic + if (contextContainer == null) return null; + + return new SSLConnectionSocketFactory( + contextContainer.sslContext(), + contextContainer.protocols(), + contextContainer.ciphers(), + contextContainer.hostnameVerifier() + ); + } + + public WeirdIntermediaryContextContainer generateTLSContextSender(TLSConnectorProperties properties) { + try { + + var dualcheckerTrustManager = new DualCheckerTrustManager( + certificateService.getExternalTrustStore(), + properties.isTrustSystemTruststore() ? certificateService.getSystemTrustStore() : null, + properties.getSubjectDnValidationMode(), + properties.getSubjectDnValidationFilter(), + properties.getOcspMode(), + properties.getCrlMode(), + null, + properties.getTrustedServerCertificates() + ); + + KeyManager[] keyManagers = null; + var clientAlias = properties.getClientCertificateAlias(); + if (clientAlias != null && !clientAlias.isBlank()) { + var keystore = certificateService.getKeyStore(clientAlias); + + var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keystore, new char[0]); + keyManagers = keyManagerFactory.getKeyManagers(); + } + + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers, new TrustManager[] { dualcheckerTrustManager }, null); + + var protocolArray = properties.isUseServerDefaultProtocols() + ? MirthSSLUtil.getEnabledHttpsProtocols(configurationController.getHttpsServerProtocols()) + : MirthSSLUtil.getEnabledHttpsProtocols(properties.getUsedProtocols().toArray(new String[0])); + + var cipherArray = properties.isUseServerDefaultCiphers() + ? MirthSSLUtil.getEnabledHttpsCipherSuites(configurationController.getHttpsCipherSuites()) + : MirthSSLUtil.getEnabledHttpsCipherSuites(properties.getUsedCiphers().toArray(new String[0])); + + var hostnameVerificationStrategy = properties.isHostnameVerificationEnabled() + ? SSLConnectionSocketFactory.getDefaultHostnameVerifier() + : NoopHostnameVerifier.INSTANCE; + + return new WeirdIntermediaryContextContainer( + sslContext, + protocolArray, + cipherArray, + hostnameVerificationStrategy + ); + } catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | UnrecoverableKeyException e) { + log.error("Error generating SSLContext", e); + throw new RuntimeException(e); + } + } + + public WeirdIntermediaryListenerContextContainer generateTLSContext(TLSConnectorProperties properties) { + var keystore = certificateService.getKeyStore(properties.getServerCertificateAlias()); + + var dualcheckerTrustManager = new DualCheckerTrustManager( + certificateService.getExternalTrustStore(), + properties.isTrustSystemTruststore() ? certificateService.getSystemTrustStore() : null, + properties.getSubjectDnValidationMode(), + properties.getSubjectDnValidationFilter(), + properties.getOcspMode(), + properties.getCrlMode(), + null, + properties.getTrustedServerCertificates() + ); + + var protocolArray = properties.isUseServerDefaultProtocols() + ? MirthSSLUtil.getEnabledHttpsProtocols(configurationController.getHttpsServerProtocols()) + : MirthSSLUtil.getEnabledHttpsProtocols(properties.getUsedProtocols().toArray(new String[0])); + + var cipherArray = properties.isUseServerDefaultCiphers() + ? MirthSSLUtil.getEnabledHttpsCipherSuites(configurationController.getHttpsCipherSuites()) + : MirthSSLUtil.getEnabledHttpsCipherSuites(properties.getUsedCiphers().toArray(new String[0])); + + var hostnameVerificationStrategy = properties.isHostnameVerificationEnabled() + ? SSLConnectionSocketFactory.getDefaultHostnameVerifier() + : NoopHostnameVerifier.INSTANCE; + + try { + var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keystore, new char[0]); + + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), new TrustManager[] { dualcheckerTrustManager }, null); + + return new WeirdIntermediaryListenerContextContainer( + protocolArray, + cipherArray, + hostnameVerificationStrategy, + keystore, + sslContext, + properties.getClientAuthMode() + ); + } catch (Exception e) { + log.error("Error generating SSLContext", e); + throw new RuntimeException(e); + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/TLSServicePlugin.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/TLSServicePlugin.java new file mode 100644 index 000000000..28a43a2d4 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/TLSServicePlugin.java @@ -0,0 +1,184 @@ +/* + * SPDX-License-Identifier: Apache-2.0 OR MPL-2.0 + * Copyright (c) 2021 Kaur Palang + * Copyright (c) 2025 NovaMap Health Limited + * Modifications from commit aee95e6c7ddfcb6762e23c3d27ac227a6b2772d4 onward licensed under MPL-2.0 + */ + +package org.openintegrationengine.tlsmanager.server; + +import com.kaurpalang.mirth.annotationsplugin.annotation.MirthServerClass; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ExtensionController; +import com.mirth.connect.server.util.TemplateValueReplacer; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.openintegrationengine.tlsmanager.server.connectorconfig.TLSHttpConfiguration; +import org.openintegrationengine.tlsmanager.server.connectorconfig.TLSTcpConfiguration; +import org.openintegrationengine.tlsmanager.server.connectorconfig.TLSWebServiceConfiguration; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; +import com.mirth.connect.model.ExtensionPermission; +import com.mirth.connect.plugins.ServicePlugin; +import com.mirth.connect.server.controllers.ControllerFactory; +import org.openintegrationengine.tlsmanager.shared.SerializationController; +import org.openintegrationengine.tlsmanager.shared.models.TLSPluginConfiguration; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +@MirthServerClass +@Slf4j +public class TLSServicePlugin implements ServicePlugin { + + public static final String PLUGIN_POINT_NAME = "TLS Manager Service Plugin"; + + @Getter + private CertificateService certificateService; + + @Getter + private SocketFactoryService socketFactoryService; + + @Getter + private WebServiceService webServiceService; + + private TLSPluginConfiguration tlsPluginConfiguration; + + @Override + public void init(Properties properties) { + var configurationController = ControllerFactory.getFactory().createConfigurationController(); + var channelController = ControllerFactory.getFactory().createChannelController(); + + this.certificateService = new CertificateService(channelController); + this.socketFactoryService = new SocketFactoryService( + configurationController, + certificateService + ); + + this.webServiceService = new WebServiceService( + socketFactoryService, + new TemplateValueReplacer() + ); + + configurationController.saveProperty( + "HTTP", + "httpConfigurationClass", + TLSHttpConfiguration.class.getCanonicalName() + ); + + configurationController.saveProperty( + "TCP", + "tcpConfigurationClass", + TLSTcpConfiguration.class.getCanonicalName() + ); + + configurationController.saveProperty( + "WS", + "wsConfigurationClass", + TLSWebServiceConfiguration.class.getCanonicalName() + ); + + SerializationController.registerSerializableClasses(); + + tlsPluginConfiguration = TLSPluginConfiguration.fromEnv(); + + if (!tlsPluginConfiguration.disableUI()) { + installWar(configurationController); + } + } + + @Override + public void update(Properties properties) { + // We don't need to do anything here. + } + + @Override + public Properties getDefaultProperties() { + var defaultProperties = new Properties(); + defaultProperties.setProperty(TLSPluginConstants.PROPERTY_TRUST_BACKEND, "database"); + + return defaultProperties; + } + + @Override + public ExtensionPermission[] getExtensionPermissions() { + return new ExtensionPermission[]{}; + } + + @Override + public Map getObjectsForSwaggerExamples() { + return new HashMap<>(); + } + + @Override + public String getPluginPointName() { + return PLUGIN_POINT_NAME; + } + + @Override + public void start() { + this.certificateService.init(tlsPluginConfiguration); + } + + @Override + public void stop() { } + + public static TLSServicePlugin getPluginInstance() { + var servicePlugin = ControllerFactory.getFactory() + .createExtensionController() + .getServicePlugins() + .get(PLUGIN_POINT_NAME); + + if (servicePlugin instanceof TLSServicePlugin tlsServicePlugin) { + return tlsServicePlugin; + } else { + // well we shouldn't really get here + var ex = new RuntimeException( + "Plugin pointname '%s' does not point to an instance of %s class".formatted( + PLUGIN_POINT_NAME, + TLSServicePlugin.class.getCanonicalName() + ) + ); + + log.error("Error fetching plugin instance", ex); + throw ex; + } + } + + private void installWar(ConfigurationController configurationController) { + var webappsPath = Path.of(configurationController.getBaseDir(), "webapps"); + var warPath = Path.of(webappsPath.toString(), "tls-manager.war"); + + if (!webappsPath.toFile().exists()) { + log.debug("Webapps directory does not exist. Creating..."); + if (!webappsPath.toFile().mkdirs()) { + throw new IllegalStateException("Failed to create webapps directory at " + webappsPath); + } + } + + var warFile = warPath.toFile(); + + if (warFile.exists()) { + log.debug("TLS Manager WAR already exists at {}. Deleting...", warPath); + if (!warFile.delete()) { + throw new IllegalStateException("Failed to delete TLS Manager WAR at " + warPath); + } + } + + var pluginDirectoryPath = Path.of(ExtensionController.getExtensionsPath(), "tls-manager", "tls-manager.war"); + + log.debug("Copying TLS Manager WAR from {} to {}", pluginDirectoryPath, warPath); + try { + Files.copy(pluginDirectoryPath, warPath); + log.debug("TLS Manager WAR copied successfully"); + } catch (IOException e) { + throw new RuntimeException( + "Failed to copy TLS Manager WAR from %s to %s".formatted(pluginDirectoryPath, warPath), + e + ); + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/WebServiceService.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/WebServiceService.java new file mode 100644 index 000000000..72d4ff798 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/WebServiceService.java @@ -0,0 +1,242 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server; + +import com.mirth.connect.client.core.api.MirthApiException; +import com.mirth.connect.connectors.ws.DefinitionServiceMap; +import com.mirth.connect.connectors.ws.WebServiceDispatcherProperties; +import com.mirth.connect.server.util.TemplateValueReplacer; +import lombok.extern.slf4j.Slf4j; +import org.apache.cxf.wsdl11.WSDLManagerImpl; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.HttpClients; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import javax.wsdl.BindingOperation; +import javax.wsdl.Definition; +import javax.wsdl.Port; +import javax.wsdl.Service; +import javax.wsdl.extensions.ExtensibilityElement; +import javax.wsdl.extensions.http.HTTPAddress; +import javax.wsdl.extensions.http.HTTPOperation; +import javax.wsdl.extensions.soap.SOAPAddress; +import javax.wsdl.extensions.soap.SOAPOperation; +import javax.wsdl.extensions.soap12.SOAP12Address; +import javax.wsdl.extensions.soap12.SOAP12Operation; +import javax.xml.namespace.QName; +import java.io.File; +import java.io.FileOutputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +public class WebServiceService { + private final SocketFactoryService socketFactoryService; + private final TemplateValueReplacer templateValueReplacer; + + private static final Map definitionCache = new HashMap<>(); + private static final Map>> wsdlInterfaceCache = new HashMap<>(); + + public WebServiceService() { + // This looks ugly, I know + this( + TLSServicePlugin.getPluginInstance().getSocketFactoryService(), + new TemplateValueReplacer() + ); + } + + public WebServiceService( + SocketFactoryService socketFactoryService, + TemplateValueReplacer templateValueReplacer + ) { + this.socketFactoryService = socketFactoryService; + this.templateValueReplacer = templateValueReplacer; + } + + public void cacheWsdlFromUrl( + String channelId, + String channelName, + WebServiceDispatcherProperties properties + ) throws Exception { + var wsdlLocation = getWsdlUrl( + channelId, + channelName, + properties.getWsdlUrl(), + properties.getUsername(), + properties.getPassword() + ); + + var connectorProperties = getTlsSenderProperties(properties); + var socketFactory = socketFactoryService.getConnectorSocketFactory(connectorProperties); + + int timeout = 30_000; // 30 seconds + var config = RequestConfig.custom() + .setConnectTimeout(timeout) + .setConnectionRequestTimeout(timeout) + .setSocketTimeout(timeout).build(); + + var clientBuilder = HttpClients.custom() + .setDefaultRequestConfig(config) + .setSSLSocketFactory(socketFactory); + + File tempWsdlFile; + try (var httpClient = clientBuilder.build()) { + var wsdlGet = new HttpGet(wsdlLocation); + var response = httpClient.execute(wsdlGet); + + tempWsdlFile = File.createTempFile("wsdl", ".tmp"); + log.debug("Writing WSDL to {}", tempWsdlFile.getAbsolutePath()); + try (var fos = new FileOutputStream(tempWsdlFile)) { + response.getEntity().writeTo(fos); + } + } + + var wsdlManager = new WSDLManagerImpl(); + var definition = wsdlManager.getDefinition(tempWsdlFile.getAbsolutePath()); + cacheWsdlInterfaces(wsdlLocation, definition); + } + + public DefinitionServiceMap getDefinition( + String channelId, + String channelName, + String wsdlUrl, + String username, + String password + ) { + try { + var wsdlLocation = getWsdlUrl(channelId, channelName, wsdlUrl, username, password); + var definition = definitionCache.get(wsdlLocation); + if (definition == null) { + throw new Exception("WSDL not cached for URL: " + wsdlLocation); + } + return definition; + } catch (Exception e) { + throw new MirthApiException(e); + } + } + + private String getWsdlUrl(String channelId, String channelName, String wsdlUrl, String username, String password) throws Exception { + wsdlUrl = templateValueReplacer.replaceValues(wsdlUrl, channelId, channelName); + username = templateValueReplacer.replaceValues(username, channelId, channelName); + password = templateValueReplacer.replaceValues(password, channelId, channelName); + + var wsdlUri = new URI(wsdlUrl); + + // add the username:password to the URL if using authentication + if (username != null + && password != null + && !username.isBlank() + && !password.isBlank() + ) { + var hostWithCredentials = "%s:%s@%s".formatted(username, password, wsdlUri.getHost()); + if (wsdlUri.getPort() > -1) { + hostWithCredentials += ":%d".formatted(wsdlUri.getPort()); + } + + wsdlUri = new URI( + wsdlUri.getScheme(), + hostWithCredentials, + wsdlUri.getPath(), + wsdlUri.getQuery(), + wsdlUri.getFragment() + ); + } + + return wsdlUri.toURL().toString(); + } + + private TLSConnectorProperties getTlsSenderProperties(WebServiceDispatcherProperties properties) { + var oTlsPluginProperties = properties.getPluginProperties() + .stream() + .filter(TLSConnectorProperties.class::isInstance) + .map(TLSConnectorProperties.class::cast) + .findFirst(); + + return oTlsPluginProperties.orElseThrow(IllegalStateException::new); + } + + private void cacheWsdlInterfaces(String wsdlUrl, Definition definition) throws Exception { + if (definition == null) { + throw new Exception("Could not find any definitions in " + wsdlUrl); + } + + var definitionServiceMap = new DefinitionServiceMap(); + + // wat... + Map> wsdlInterfaceServiceMap = new LinkedHashMap<>(); + + var serviceMap = definition.getServices(); + if (serviceMap != null && !serviceMap.isEmpty()) { + for (Object serviceObject : serviceMap.values()) { + var service = (Service) serviceObject; + log.debug("Service: {}", service); + + var definitionPortMap = new DefinitionServiceMap.DefinitionPortMap(); + Map wsdlInterfacePortMap = new LinkedHashMap<>(); + + var ports = service.getPorts(); + if (ports != null && !ports.isEmpty()) { + for (var portObject : ports.values()) { + var port = (Port) portObject; + var portQName = new QName(service.getQName().getNamespaceURI(), port.getName()).toString(); + + var locationURI = getLocationUri(port); + + var operations = new ArrayList(); + for (Object bindingOperation : port.getBinding().getBindingOperations()) { + String operationName = ((BindingOperation) bindingOperation).getName(); + operations.add(operationName); + } + + List actions = new ArrayList(); + if (port.getBinding().getBindingOperations() != null) { + for (Object bindOperationObject : port.getBinding().getBindingOperations()) { + var extensions = ((BindingOperation) bindOperationObject).getExtensibilityElements(); + if (extensions != null) { + for (Object extension : extensions) { + var extElement = (ExtensibilityElement) extension; + if (extElement instanceof SOAPOperation soapOp) { + actions.add(soapOp.getSoapActionURI()); + } else if (extElement instanceof SOAP12Operation soapOp) { + actions.add(soapOp.getSoapActionURI()); + } + } + } + } + definitionPortMap.getMap().put(portQName, new DefinitionServiceMap.PortInformation(operations, actions, locationURI)); + wsdlInterfacePortMap.put(portQName, definition); + } + } + } + definitionServiceMap.getMap().put(service.getQName().toString(), definitionPortMap); + wsdlInterfaceServiceMap.put(service.getQName().toString(), wsdlInterfacePortMap); + } + } + definitionCache.put(wsdlUrl, definitionServiceMap); + wsdlInterfaceCache.put(wsdlUrl, wsdlInterfaceServiceMap); + } + + private static String getLocationUri(Port port) { + String locationURI = null; + for (Object element : port.getExtensibilityElements()) { + if (element instanceof SOAPAddress address) { + locationURI = address.getLocationURI(); + } else if (element instanceof SOAP12Address soap12Address) { + locationURI = soap12Address.getLocationURI(); + } else if (element instanceof HTTPAddress httpAddress) { + locationURI = httpAddress.getLocationURI(); + } else if (element instanceof HTTPOperation httpOperation) { + locationURI = httpOperation.getLocationURI(); + } + } + return locationURI; + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/DatabaseTrustStoreBackend.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/DatabaseTrustStoreBackend.java new file mode 100644 index 000000000..b85333b32 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/DatabaseTrustStoreBackend.java @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.backend; + +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import lombok.extern.slf4j.Slf4j; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Base64; + +@Slf4j +public class DatabaseTrustStoreBackend implements TrustStoreBackend { + + private final ConfigurationController configurationController; + + private final String databaseColumn; + + public DatabaseTrustStoreBackend(String databaseColumn) { + this.configurationController = ControllerFactory.getFactory().createConfigurationController(); + this.databaseColumn = databaseColumn; + } + + @Override + public boolean persist(byte[] keystore) { + var encoder = Base64.getEncoder(); + var b64Keystore = encoder.encodeToString(keystore); + configurationController.saveProperty(TLSPluginConstants.PLUGIN_POINTNAME, databaseColumn, b64Keystore); + return false; + } + + @Override + public void init() { + var keystoreBytes = configurationController.getProperty(TLSPluginConstants.PLUGIN_POINTNAME, databaseColumn); + if (keystoreBytes != null) { + log.debug("Using existing keystore from config column {}", databaseColumn); + return; + } + + try { + var keystore = KeyStore.getInstance(TLSPluginConstants.PKCS12); + keystore.load(null, new char[0]); + + try (var baos = new ByteArrayOutputStream()) { + keystore.store(baos, new char[0]); + persist(baos.toByteArray()); + } + + } catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException e) { + log.error("Error initializing keystore", e); + throw new RuntimeException(e); + } + } + + @Override + public byte[] load() { + var decoder = Base64.getDecoder(); + var keystoreBytes = configurationController.getProperty(TLSPluginConstants.PLUGIN_POINTNAME, databaseColumn); + return decoder.decode(keystoreBytes); + } + + @Override + public char[] loadPassword() { + return new char[0]; + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/FileTrustStoreBackend.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/FileTrustStoreBackend.java new file mode 100644 index 000000000..b1dbfcec2 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/FileTrustStoreBackend.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.backend; + +import lombok.extern.slf4j.Slf4j; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +@Slf4j +public class FileTrustStoreBackend implements TrustStoreBackend { + + private final Path keystorePath; + private final char[] storepass; + + public FileTrustStoreBackend(String keystorePath) { + this(keystorePath, System.getenv(TLSPluginConstants.ENV_PERSISTENCE_FS_TRUSTSTOREPASS)); + } + + public FileTrustStoreBackend(String keystorePath, String storePass) { + this.keystorePath = Paths.get(keystorePath); + + if (storePass == null) { + throw new IllegalStateException("TrustStore password not set"); + } + + this.storepass = storePass.toCharArray(); + } + + @Override + public boolean persist(byte[] keystore) { + final var openOptions = new OpenOption[]{ + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.CREATE + }; + + try { + Files.write(keystorePath, keystore, openOptions); + return true; + } catch (IOException e) { + log.error("Error persisting keystore to file", e); + throw new RuntimeException(e); + } + } + + @Override + public void init() { + if (Files.exists(keystorePath)) { + log.debug("Using existing keystore at {}", keystorePath); + return; + } + + try { + var keystore = KeyStore.getInstance(TLSPluginConstants.PKCS12); + keystore.load(null, storepass); + + try (var baos = new ByteArrayOutputStream()) { + keystore.store(baos, storepass); + persist(baos.toByteArray()); + } + + } catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException e) { + log.error("Error initializing keystore", e); + throw new RuntimeException(e); + } + } + + @Override + public byte[] load() { + try { + return Files.readAllBytes(keystorePath); + } catch (IOException e) { + log.error("Error reading keystore at {}", keystorePath, e); + throw new RuntimeException(e); + } + } + + @Override + public char[] loadPassword() { + if (storepass == null) { + throw new IllegalStateException("Store password not set"); + } + return storepass; + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/SystemTrustStoreBackend.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/SystemTrustStoreBackend.java new file mode 100644 index 000000000..f684a9f4c --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/SystemTrustStoreBackend.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.backend; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class SystemTrustStoreBackend implements TrustStoreBackend { + + @Override + public boolean persist(byte[] keystore) { + throw new UnsupportedOperationException("Persisting to system cacerts is not supported"); + } + + @Override + public void init() { + // Do nothing + } + + @Override + public byte[] load() { + try { + return Files.readAllBytes(resolveTrustStorePath()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public char[] loadPassword() { + var pwd = System.getProperty("javax.net.ssl.trustStorePassword", "changeit"); + return pwd.toCharArray(); + } + + private static Path resolveTrustStorePath() { + // 1) If javax.net.ssl.trustStore is set, prefer it + var prop = System.getProperty("javax.net.ssl.trustStore"); + if (prop != null && !"NONE".equalsIgnoreCase(prop)) { + var p = Paths.get(prop); + if (Files.exists(p)) return p; + } + + // 2) Fallback to $JAVA_HOME/lib/security/jssecacerts or cacerts + var secDir = Paths.get(System.getProperty("java.home"), "lib", "security"); + var jsse = secDir.resolve("jssecacerts"); + if (Files.exists(jsse)) return jsse; + + var cacerts = secDir.resolve("cacerts"); + if (Files.exists(cacerts)) return cacerts; + + throw new IllegalStateException("Could not locate system truststore (jssecacerts/cacerts)."); + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/TrustStoreBackend.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/TrustStoreBackend.java new file mode 100644 index 000000000..360f7d927 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/backend/TrustStoreBackend.java @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 OR MPL-2.0 + * Copyright (c) 2021 Kaur Palang + * Copyright (c) 2025 NovaMap Health Limited + * Modifications from commit d2fbac7328eda7b7a68348a4adcbb3a9961868b9 onward licensed under MPL-2.0 + */ + +package org.openintegrationengine.tlsmanager.server.backend; + +public interface TrustStoreBackend { + boolean persist(byte[] keystore); + + void init(); + + byte[] load(); + + char[] loadPassword(); +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSHttpConfiguration.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSHttpConfiguration.java new file mode 100644 index 000000000..add8c87cf --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSHttpConfiguration.java @@ -0,0 +1,150 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.connectorconfig; + +import com.mirth.connect.connectors.http.DefaultHttpConfiguration; +import com.mirth.connect.connectors.http.HttpDispatcher; +import com.mirth.connect.connectors.http.HttpDispatcherProperties; +import com.mirth.connect.connectors.http.HttpReceiver; +import com.mirth.connect.donkey.model.channel.ConnectorPluginProperties; +import com.mirth.connect.donkey.server.channel.Connector; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.openintegrationengine.tlsmanager.server.CertificateService; +import org.openintegrationengine.tlsmanager.server.SocketFactoryService; +import org.openintegrationengine.tlsmanager.server.TLSServicePlugin; +import org.openintegrationengine.tlsmanager.shared.models.ClientAuthMode; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.PKCS12; + +@Slf4j +public class TLSHttpConfiguration extends DefaultHttpConfiguration { + + private final CertificateService certificateService; + private final SocketFactoryService socketFactoryService; + private final ConfigurationController configurationController; + + public TLSHttpConfiguration() { + // This looks ugly, I know + this( + ControllerFactory.getFactory().createConfigurationController(), + TLSServicePlugin.getPluginInstance().getCertificateService(), + TLSServicePlugin.getPluginInstance().getSocketFactoryService() + ); + } + + public TLSHttpConfiguration( + ConfigurationController configurationController, + CertificateService certificateService, + SocketFactoryService socketFactoryService + ) { + this.configurationController = configurationController; + this.certificateService = certificateService; + this.socketFactoryService = socketFactoryService; + } + + @Override + public void configureConnectorDeploy(Connector connector) throws Exception { + if (connector instanceof HttpDispatcher httpDispatcher) { + configureSocketFactory(httpDispatcher); + } + } + + @Override + public void configureDispatcher(HttpDispatcher connector, HttpDispatcherProperties connectorProperties) {} + + @Override + public void configureSocketFactoryRegistry(ConnectorPluginProperties properties, RegistryBuilder registry) {} + + @Override + public void configureReceiver(HttpReceiver connector) throws Exception { + var tlsConnectorProperties = getConnectorProperties(TLSConnectorProperties.class, connector); + + // If TLS manager is not enabled, delegate to OIE default + if (tlsConnectorProperties == null || !tlsConnectorProperties.isTlsManagerEnabled()) { + super.configureReceiver(connector); + + } else { + var tlsContext = socketFactoryService.generateTLSContext(tlsConnectorProperties); + + var httpConfig = new HttpConfiguration(); + httpConfig.addCustomizer(new SecureRequestCustomizer()); + httpConfig.setSendServerVersion(false); + httpConfig.setSendXPoweredBy(false); + + var sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setSslContext(tlsContext.sslContext()); + + // Clear Jetty defaults + sslContextFactory.setExcludeProtocols(); + sslContextFactory.setExcludeCipherSuites(); + + sslContextFactory.setIncludeProtocols(tlsContext.protocols()); + sslContextFactory.setIncludeCipherSuites(tlsContext.ciphers()); + + if (ClientAuthMode.REQUESTED == tlsConnectorProperties.getClientAuthMode()) { + sslContextFactory.setWantClientAuth(true); + } else if (ClientAuthMode.REQUIRED == tlsConnectorProperties.getClientAuthMode()) { + sslContextFactory.setNeedClientAuth(true); + } + + sslContextFactory.setKeyStore(tlsContext.keyStore()); + sslContextFactory.setKeyStoreType(PKCS12); + sslContextFactory.setKeyStorePassword(""); + sslContextFactory.setCertAlias(tlsConnectorProperties.getServerCertificateAlias()); + + var http11 = new HttpConnectionFactory(httpConfig); + var tls = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()); + + var listener = new ServerConnector(connector.getServer(), tls, http11); + + listener.setHost(connector.getHost()); + listener.setPort(connector.getPort()); + listener.setIdleTimeout(connector.getTimeout()); + + connector.getServer().setConnectors(new org.eclipse.jetty.server.Connector[] { listener }); + } + } + + private void configureSocketFactory(HttpDispatcher connector) { + var tlsConnectorProperties = getConnectorProperties(TLSConnectorProperties.class, connector); + + if (tlsConnectorProperties != null && tlsConnectorProperties.isTlsManagerEnabled()) { + var sslSocketFactory = socketFactoryService.getConnectorSocketFactory(tlsConnectorProperties); + if (sslSocketFactory != null) { + // FIXME + connector.getSocketFactoryRegistry().register("https", sslSocketFactory); + } + } else { + try { + super.configureSocketFactoryRegistry(null, connector.getSocketFactoryRegistry()); + } catch (Exception e) { + log.error("Error creating non-TLS socket factory", e); + throw new RuntimeException(e); + } + } + } + + private T getConnectorProperties(Class propertiesClass, Connector connector) { + return connector.getConnectorProperties().getPluginProperties() + .stream() + .filter(propertiesClass::isInstance) + .findFirst() + .map(propertiesClass::cast) + .orElse(null); + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSTcpConfiguration.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSTcpConfiguration.java new file mode 100644 index 000000000..5932191e5 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSTcpConfiguration.java @@ -0,0 +1,118 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.connectorconfig; + +import com.mirth.connect.connectors.tcp.DefaultTcpConfiguration; +import com.mirth.connect.connectors.tcp.StateAwareServerSocket; +import com.mirth.connect.connectors.tcp.StateAwareSocket; +import com.mirth.connect.connectors.tcp.TcpDispatcher; +import com.mirth.connect.connectors.tcp.TcpDispatcherProperties; +import com.mirth.connect.connectors.tcp.TcpReceiver; +import com.mirth.connect.connectors.tcp.TcpReceiverProperties; +import com.mirth.connect.donkey.server.channel.Connector; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.openintegrationengine.tlsmanager.server.SocketFactoryService; +import org.openintegrationengine.tlsmanager.server.TLSServicePlugin; +import org.openintegrationengine.tlsmanager.server.io.StateAwareTLSServerSocket; +import org.openintegrationengine.tlsmanager.server.io.StateAwareTLSSocket; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; + +@Slf4j +public class TLSTcpConfiguration extends DefaultTcpConfiguration { + + private final SocketFactoryService socketFactoryService; + + private TLSConnectorProperties tlsConnectorProperties; + + private SSLConnectionSocketFactory socketFactory; + + public TLSTcpConfiguration() { + this(TLSServicePlugin.getPluginInstance().getSocketFactoryService()); + } + + public TLSTcpConfiguration(SocketFactoryService socketFactoryService) { + this.socketFactoryService = socketFactoryService; + } + + @Override + public void configureConnectorDeploy(Connector connector) throws Exception { + boolean isServerMode; + + if (connector instanceof TcpDispatcher tcpDispatcher) { + + var dispatcherProperties = (TcpDispatcherProperties) tcpDispatcher.getConnectorProperties(); + isServerMode = dispatcherProperties.isServerMode(); + + + } else if (connector instanceof TcpReceiver tcpReceiver) { + var receiverProperties = (TcpReceiverProperties) tcpReceiver.getConnectorProperties(); + isServerMode = receiverProperties.isServerMode(); + } else { + // should not get here + throw new IllegalStateException("Unexpected connector type: %s".formatted(connector.getClass().getCanonicalName())); + } + + this.tlsConnectorProperties = getConnectorProperties(TLSConnectorProperties.class, connector); + + if (!isServerMode) { + if (tlsConnectorProperties != null && tlsConnectorProperties.isTlsManagerEnabled()) { + socketFactory = socketFactoryService.getConnectorSocketFactory(tlsConnectorProperties); + } + } + } + + @Override + public Socket createSocket() { + if (tlsConnectorProperties == null || !tlsConnectorProperties.isTlsManagerEnabled()) { + return new StateAwareSocket(); + } else { + if (socketFactory == null) { + throw new IllegalStateException("TLS for TCP connections is enabled, but socket factory is null. Possibly because no trust anchors were found."); + } + + return new StateAwareTLSSocket(socketFactory); + } + } + + @Override + public ServerSocket createServerSocket(int port, int backlog) throws IOException { + log.error("Unexpected call to createServerSocket(int, int)"); + return super.createServerSocket(port, backlog); + } + + @Override + public ServerSocket createServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException { + var createTlsSocket = tlsConnectorProperties != null && tlsConnectorProperties.isTlsManagerEnabled(); + + log.debug( + "Creating server socket. Properties null - {}; Manager enabled - {}", + tlsConnectorProperties == null, + createTlsSocket + ); + + if (createTlsSocket) { + var contextContainer = socketFactoryService.generateTLSContext(tlsConnectorProperties); + return new StateAwareTLSServerSocket(port, backlog, bindAddr, contextContainer); + } else { + return new StateAwareServerSocket(port, backlog, bindAddr); + } + } + + private T getConnectorProperties(Class propertiesClass, Connector connector) { + return connector.getConnectorProperties().getPluginProperties() + .stream() + .filter(propertiesClass::isInstance) + .findFirst() + .map(propertiesClass::cast) + .orElse(null); + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSWebServiceConfiguration.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSWebServiceConfiguration.java new file mode 100644 index 000000000..b844eff52 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/connectorconfig/TLSWebServiceConfiguration.java @@ -0,0 +1,142 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.connectorconfig; + +import com.mirth.connect.connectors.ws.DefaultWebServiceConfiguration; +import com.mirth.connect.connectors.ws.SSLSocketFactoryWrapper; +import com.mirth.connect.connectors.ws.WebServiceDispatcher; +import com.mirth.connect.connectors.ws.WebServiceDispatcherProperties; +import com.mirth.connect.connectors.ws.WebServiceReceiver; +import com.mirth.connect.donkey.server.channel.Connector; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import lombok.extern.slf4j.Slf4j; +import org.openintegrationengine.tlsmanager.server.SocketFactoryService; +import org.openintegrationengine.tlsmanager.server.TLSServicePlugin; +import org.openintegrationengine.tlsmanager.shared.models.ClientAuthMode; +import org.openintegrationengine.tlsmanager.shared.models.WeirdIntermediaryContextContainer; +import org.openintegrationengine.tlsmanager.shared.models.WeirdIntermediaryListenerContextContainer; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import javax.net.ssl.SSLSocketFactory; +import java.util.Map; + +@Slf4j +public class TLSWebServiceConfiguration extends DefaultWebServiceConfiguration { + + private final SocketFactoryService socketFactoryService; + + private WeirdIntermediaryContextContainer senderContainer; + private WeirdIntermediaryListenerContextContainer listenerContainer; + + public TLSWebServiceConfiguration() { + // This looks ugly, I know + this(TLSServicePlugin.getPluginInstance().getSocketFactoryService()); + } + + public TLSWebServiceConfiguration(SocketFactoryService socketFactoryService) { + this.socketFactoryService = socketFactoryService; + } + + @Override + public void configureConnectorDeploy(Connector connector) throws Exception { + if (connector instanceof WebServiceDispatcher webServiceDispatcher) { + configureSocketFactory(webServiceDispatcher); + } else if (connector instanceof WebServiceReceiver webServiceReceiver) { + configureSocketFactory(webServiceReceiver); + } + } + + @Override + public void configureReceiver(WebServiceReceiver connector) throws Exception { + if (listenerContainer == null) { + super.configureReceiver(connector); + return; + } + + var tlsContext = listenerContainer.sslContext(); + + var httpsServer = HttpsServer.create(); + httpsServer.setHttpsConfigurator(new HttpsConfigurator(tlsContext) { + @Override + public void configure(HttpsParameters params) { + var sslParams = tlsContext.getDefaultSSLParameters(); + + sslParams.setProtocols(listenerContainer.protocols()); + sslParams.setCipherSuites(listenerContainer.ciphers()); + + if (ClientAuthMode.REQUESTED == listenerContainer.clientAuthMode()) { + sslParams.setWantClientAuth(true); + } else if (ClientAuthMode.REQUIRED == listenerContainer.clientAuthMode()) { + sslParams.setNeedClientAuth(true); + } + + params.setSSLParameters(sslParams); + } + }); + + connector.setServer(httpsServer); + } + + @Override + public void configureDispatcher(WebServiceDispatcher connector, WebServiceDispatcherProperties connectorProperties, Map requestContext) throws Exception { + SSLSocketFactory socketFactory = new SSLSocketFactoryWrapper( + senderContainer.sslContext().getSocketFactory(), + senderContainer.protocols(), + senderContainer.ciphers() + ); + + // Wat? + requestContext.put("com.sun.xml.internal.ws.transport.https.client.SSLSocketFactory", socketFactory); + requestContext.put("com.sun.xml.ws.transport.https.client.SSLSocketFactory", socketFactory); // JAX-WS RI + } + + private void configureSocketFactory(WebServiceReceiver connector) { + var tlsConnectorProperties = connector.getConnectorProperties().getPluginProperties() + .stream() + .filter(TLSConnectorProperties.class::isInstance) + .findFirst() + .map(TLSConnectorProperties.class::cast) + .orElse(null); + + if (tlsConnectorProperties != null && tlsConnectorProperties.isTlsManagerEnabled()) { + listenerContainer = socketFactoryService.generateTLSContext(tlsConnectorProperties); + } else { + try { + super.configureConnectorDeploy(connector); + } catch (Exception e) { + log.error("Error creating non-TLS socket factory", e); + throw new RuntimeException(e); + } + } + } + + private void configureSocketFactory(WebServiceDispatcher connector) { + var tlsConnectorProperties = connector.getConnectorProperties().getPluginProperties() + .stream() + .filter(TLSConnectorProperties.class::isInstance) + .findFirst() + .map(TLSConnectorProperties.class::cast) + .orElse(null); + + if (tlsConnectorProperties != null && tlsConnectorProperties.isTlsManagerEnabled()) { + senderContainer = socketFactoryService.generateTLSContextSender(tlsConnectorProperties); + + var socketConnectionFactory = socketFactoryService.getConnectorSocketFactory(senderContainer); + if (socketConnectionFactory != null) { + connector.getSocketFactoryRegistry().register("https", socketConnectionFactory); + } + } else { + try { + super.configureConnectorDeploy(connector); + } catch (Exception e) { + log.error("Error creating non-TLS socket factory", e); + throw new RuntimeException(e); + } + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/io/StateAwareTLSServerSocket.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/io/StateAwareTLSServerSocket.java new file mode 100644 index 000000000..341f0e71f --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/io/StateAwareTLSServerSocket.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.io; + +import org.openintegrationengine.tlsmanager.shared.models.ClientAuthMode; +import org.openintegrationengine.tlsmanager.shared.models.WeirdIntermediaryListenerContextContainer; + +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; + +public class StateAwareTLSServerSocket extends ServerSocket { + + private final WeirdIntermediaryListenerContextContainer contextContainer; + private final SSLServerSocket delegate; + + public StateAwareTLSServerSocket( + int port, + int backlog, + InetAddress bindAddr, + WeirdIntermediaryListenerContextContainer contextContainer + ) throws IOException { + super(); + this.contextContainer = contextContainer; + this.delegate = createSSLServerSocket(port, backlog, bindAddr); + } + + private SSLServerSocket createSSLServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException { + var sslContext = contextContainer.sslContext(); + var socketFactory = sslContext.getServerSocketFactory(); + var sslServerSocket = (SSLServerSocket) socketFactory.createServerSocket(port, backlog, bindAddr); + + sslServerSocket.setEnabledProtocols(contextContainer.protocols()); + sslServerSocket.setEnabledCipherSuites(contextContainer.ciphers()); + + if (ClientAuthMode.REQUESTED == contextContainer.clientAuthMode()) { + sslServerSocket.setWantClientAuth(true); + } else if (ClientAuthMode.REQUIRED == contextContainer.clientAuthMode()) { + sslServerSocket.setNeedClientAuth(true); + } + + return sslServerSocket; + } + + @Override + public Socket accept() throws IOException { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (!isBound()) { + throw new SocketException("Socket is not bound yet"); + } + + var sslSocket = (SSLSocket) delegate.accept(); + sslSocket.startHandshake(); + return sslSocket; + } + + @Override + public void close() throws IOException { + if (delegate != null) { + delegate.close(); + } + super.close(); + } + + @Override + public boolean isBound() { + return delegate != null && delegate.isBound(); + } + + @Override + public boolean isClosed() { + return delegate == null || delegate.isClosed(); + } + + @Override + public int getLocalPort() { + return delegate != null ? delegate.getLocalPort() : -1; + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/io/StateAwareTLSSocket.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/io/StateAwareTLSSocket.java new file mode 100644 index 000000000..1fb723937 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/io/StateAwareTLSSocket.java @@ -0,0 +1,149 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.io; + +import com.mirth.connect.connectors.tcp.StateAwareSocketInterface; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; + +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLSocket; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PushbackInputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.util.Objects; + +@Slf4j +public class StateAwareTLSSocket extends Socket implements StateAwareSocketInterface { + + private final SSLConnectionSocketFactory socketFactory; + private SSLSocket delegate; + private boolean isClosing; + + public StateAwareTLSSocket(SSLConnectionSocketFactory socketFactory) { + super(); + this.socketFactory = socketFactory; + this.isClosing = false; + } + + public StateAwareTLSSocket(SSLSocket delegate) { + super(); + this.delegate = delegate; + this.socketFactory = null; + this.isClosing = false; + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + this.connect(endpoint, 0); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + if (endpoint instanceof InetSocketAddress inet) { + + try { + // Perform the plain TCP connection first + super.connect(endpoint, timeout); + + this.delegate = (SSLSocket) socketFactory.createLayeredSocket( + this, + inet.getHostString(), + inet.getPort(), + null + ); + + // If protocol is 1.3 read the stream to force completing the handshake + if (this.delegate.getSession().getProtocol().equals("TLSv1.3")) { + //remoteSideHasClosedInternal(this.delegate); + } + } catch (SSLHandshakeException e) { + log.warn("Failed to connect", e); + throw e; + } + + } else { + throw new IOException("Expected InetSocketAddress for TLS connection"); + } + } + + @Override + public InputStream getInputStream() throws IOException { + if (delegate != null) { + return delegate.getInputStream(); + } + + return super.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (delegate != null) { + return delegate.getOutputStream(); + } + return super.getOutputStream(); + } + + @Override + public void close() throws IOException { + log.debug("Closing TLS socket"); + if (isClosing) { + log.debug("Prevented re-entry"); + // Prevent re-entry when sslSocket tries to close the underlying socket + super.close(); + return; + } + + isClosing = true; + try { + if (delegate != null) { + log.debug("Closing delegate socket"); + delegate.close(); + } else { + log.debug("Closing self socket"); + super.close(); + } + } finally { + log.debug("Resetting closing flag"); + isClosing = false; + } + } + + @Override + public boolean remoteSideHasClosed() throws IOException { + return remoteSideHasClosedInternal( + Objects.requireNonNullElse(delegate, this) + ); + } + + private boolean remoteSideHasClosedInternal(Socket socket) throws IOException { + if (socket.isClosed()) return true; + + if (socket.isInputShutdown()) return true; + + int oldTimeout = socket.getSoTimeout(); + socket.setSoTimeout(200); + + var pbIn = new PushbackInputStream(socket.getInputStream()); + try { + int b = pbIn.read(); + if (b == -1) return true; + pbIn.unread(b); + return false; + } catch (SSLHandshakeException sslHandshakeException) { + log.trace("SSL handshake failed", sslHandshakeException); + throw sslHandshakeException; + } finally { + if (!socket.isClosed()) { + socket.setSoTimeout(oldTimeout); + } + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/revocation/DualCheckerTrustManager.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/revocation/DualCheckerTrustManager.java new file mode 100644 index 000000000..701b2a4ed --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/revocation/DualCheckerTrustManager.java @@ -0,0 +1,530 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.revocation; + +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.x509.CRLDistPoint; +import org.bouncycastle.asn1.x509.DistributionPoint; +import org.bouncycastle.asn1.x509.DistributionPointName; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; +import org.openintegrationengine.tlsmanager.server.util.TrustStoreUtils; +import org.openintegrationengine.tlsmanager.shared.models.RevocationMode; +import org.openintegrationengine.tlsmanager.shared.models.SubjectDnValidationMode; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.net.ssl.ExtendedSSLSession; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; +import javax.security.auth.x500.X500Principal; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.Socket; +import java.net.URI; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.CRL; +import java.security.cert.CertPath; +import java.security.cert.CertPathValidator; +import java.security.cert.CertStore; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.PKIXParameters; +import java.security.cert.PKIXRevocationChecker; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +public final class DualCheckerTrustManager extends X509ExtendedTrustManager { + + private final KeyStore effectiveTrustStore; + private final KeyStore systemTrustStore; + private final SubjectDnValidationMode subjectDnValidationMode; + private final String subjectDnValidationFilter; + private final RevocationMode ocspMode, crlMode; + private final Collection preloadedCrls; // optional (in addition to CRLDP) + + private final Set trustedLeafCertSet; + private final Set trustedCASet; + private final Set trustAnchorsSet; + + private final X509Certificate[] acceptedIssuers; + + private final X509ExtendedTrustManager trustManagerDelegate; + + private final CertificateFactory certificateFactory; + private final CertPathValidator certPathValidator; + + public DualCheckerTrustManager( + KeyStore effectiveTrustStore, + KeyStore systemTrustStore, + SubjectDnValidationMode subjectDnValidationMode, + String subjectDnValidationFilter, + RevocationMode ocspMode, + RevocationMode crlMode, + Collection preloadedCrls, + Set trustedAliasSet + ) { + this.effectiveTrustStore = effectiveTrustStore; + this.systemTrustStore = systemTrustStore; + this.subjectDnValidationMode = subjectDnValidationMode; + this.subjectDnValidationFilter = subjectDnValidationFilter; + this.ocspMode = ocspMode; + this.crlMode = crlMode; + this.preloadedCrls = preloadedCrls == null ? List.of() : preloadedCrls; + + this.trustedLeafCertSet = new HashSet<>(); + this.trustedCASet = new HashSet<>(); + this.trustAnchorsSet = new HashSet<>(); + initTrustedLeafSet(Objects.requireNonNullElse(trustedAliasSet, Set.of())); + + try { + this.certificateFactory = CertificateFactory.getInstance("X.509"); + this.certPathValidator = CertPathValidator.getInstance("PKIX"); + + var tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(systemTrustStore); + + trustManagerDelegate = Arrays.stream(tmf.getTrustManagers()) + .filter(X509ExtendedTrustManager.class::isInstance) + .map(X509ExtendedTrustManager.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No default X509ExtendedTrustManager found")); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize TrustManager", e); + } + + // Pre-render accepted issuers array + var acceptedIssuers = new ArrayList(); + if (systemTrustStore != null) { + acceptedIssuers.addAll(List.of(trustManagerDelegate.getAcceptedIssuers())); + } + acceptedIssuers.addAll(trustedCASet); + this.acceptedIssuers = acceptedIssuers.toArray(new X509Certificate[0]); + } + + // --- JSSE delegation --- + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + runValidations(chain, authType, null, null, true); + } catch (CertificateException e) { + log.error("Failed to check client trust", e); + throw e; + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { + try { + runValidations(chain, authType, socket, null, true); + } catch (CertificateException e) { + log.error("Failed to check client trust", e); + throw e; + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine sslEngine) throws CertificateException { + try { + runValidations(chain, authType, null, sslEngine, true); + } catch (CertificateException e) { + log.error("Failed to check client trust", e); + throw e; + } + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + runValidations(chain, authType, null, null, false); + } catch (CertificateException e) { + log.error("Failed to check server trust", e); + throw e; + } + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket s) throws CertificateException { + try { + runValidations(chain, authType, s, null, false); + } catch (CertificateException e) { + log.error("Failed to check server trust", e); + throw e; + } + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine sslEngine) throws CertificateException { + try { + runValidations(chain, authType, null , sslEngine, false); + } catch (CertificateException e) { + log.error("Failed to check server trust", e); + throw e; + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return this.acceptedIssuers; + } + + private boolean hasStapledOcsp(X509Certificate[] chain, SSLSession session) throws CertificateException { + if (session instanceof ExtendedSSLSession extendedSession) { + var statusResponses = extendedSession.getStatusResponses(); + + if (statusResponses != null && !statusResponses.isEmpty()) { + log.info("Received {} stapled OCSP response(s)", statusResponses.size()); + + for (int i = 0; i < Math.min(statusResponses.size(), chain.length); i++) { + byte[] response = statusResponses.get(i); + if (response != null && response.length > 0) { + validateStapledOcspResponse(response, chain[i]); + return true; + } + } + } + } else { + log.debug("SSLSession is not an ExtendedSSLSession"); + } + + return false; + } + + private void initTrustedLeafSet(Set trustedAliasSet) { + trustedAliasSet.forEach(alias -> { + try { + var cert = effectiveTrustStore.getCertificate(alias); + if (cert instanceof X509Certificate x509Certificate) { + if (TrustStoreUtils.isCA(x509Certificate)) { + trustedCASet.add(x509Certificate); + } else { + trustedLeafCertSet.add(x509Certificate); + } + } else { + log.debug("Truststore does not contain x509Certificate with alias {}", alias); + } + } catch (KeyStoreException e) { + throw new RuntimeException(e); + } + }); + + // Prerender trust anchors set + var anchors = trustedCASet + .stream() + .map(x509Certificate -> new TrustAnchor(x509Certificate, null)) + .collect(Collectors.toSet()); + this.trustAnchorsSet.addAll(anchors); + } + + private boolean isCertIssuedByTrustedCA(X509Certificate cert) { + try { + var certPath = this.certificateFactory.generateCertPath(List.of(cert)); + var params = new PKIXParameters(this.trustAnchorsSet); + params.setRevocationEnabled(false); + + certPathValidator.validate(certPath, params); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean isCertIssuedBySystemCAServer(X509Certificate[] chain, String authType, Socket socket, SSLEngine sslEngine) { + try { + if (socket != null) { + trustManagerDelegate.checkServerTrusted(chain, authType, socket); + } else if (sslEngine != null) { + trustManagerDelegate.checkServerTrusted(chain, authType, sslEngine); + } else { + trustManagerDelegate.checkServerTrusted(chain, authType); + } + return true; + } catch (CertificateException e) { + log.debug("Certificate chain not trusted by system cacerts", e); + return false; + } + } + + private boolean isCertIssuedBySystemCAClient(X509Certificate[] chain, String authType, Socket socket, SSLEngine sslEngine) { + try { + if (socket != null) { + trustManagerDelegate.checkClientTrusted(chain, authType, socket); + } else if (sslEngine != null) { + trustManagerDelegate.checkClientTrusted(chain, authType, sslEngine); + } else { + trustManagerDelegate.checkClientTrusted(chain, authType); + } + return true; + } catch (CertificateException e) { + log.debug("Certificate chain not trusted by system cacerts", e); + return false; + } + } + + private void runValidations(X509Certificate[] chain, String authType, Socket socket, SSLEngine sslEngine, boolean checkClientTrust) throws CertificateException { + var serverChain = List.of(chain); + + boolean isCertTrusted = false; + CertificateException lastException = null; + + // Handle system cacerts + if (systemTrustStore != null) { + if (checkClientTrust) { + if (isCertIssuedBySystemCAClient(chain, authType, socket, sslEngine)) { + isCertTrusted = true; + } else { + log.debug("Truststore does not contain system certificates to validate the remote leaf certificate"); + lastException = new CertificateException("Remote leaf certificate is not trusted"); + } + } else { + if (isCertIssuedBySystemCAServer(chain, authType, socket, sslEngine)) { + isCertTrusted = true; + } else { + log.debug("Truststore does not contain system certificates to validate the remote leaf certificate"); + lastException = new CertificateException("Remote leaf certificate is not trusted"); + } + } + } + + // Check for leaf certs + var leafCert = serverChain.get(0); + if (!isCertTrusted && trustedLeafCertSet.contains(leafCert)) { + isCertTrusted = true; + } else { + log.debug("Truststore does not contain leaf certificate"); + lastException = new CertificateException("Remote leaf certificate is not trusted"); + } + + // Check for intermediate CA certs + if (!isCertTrusted) { + if (isCertIssuedByTrustedCA(leafCert)) { + isCertTrusted = true; + } else { + log.debug("Truststore does not contain CA certs to validate the remote leaf certificate"); + lastException = new CertificateException("Remote leaf certificate is not trusted"); + } + } + + // Throw is no mechanism succeeded + if (!isCertTrusted) { + throw lastException; + } + + try { + if (subjectDnValidationMode != null && subjectDnValidationMode != SubjectDnValidationMode.NONE) { + if (subjectDnValidationFilter == null || subjectDnValidationFilter.isEmpty()) { + throw new IllegalStateException("Expected Subject DN cannot be empty"); + } + + var subject = chain[0].getSubjectX500Principal(); + + var subjectDn = subject.getName(X500Principal.RFC2253); + var expectedDn = new X500Principal(subjectDnValidationFilter).getName(X500Principal.RFC2253); + if (subjectDnValidationMode == SubjectDnValidationMode.EXACT) { + if (!subjectDn.equals(expectedDn)) { + throw new CertificateException("Subject DN does not match filter"); + } + } else if (subjectDnValidationMode == SubjectDnValidationMode.PARTIAL) { + LdapName subjectLdapName, expectedLdapName; + try { + subjectLdapName = new LdapName(subjectDn); + expectedLdapName = new LdapName(expectedDn); + } catch (InvalidNameException e) { + throw new CertificateException("Error converting DN to LdapName", e); + } + + var subjectRdns = subjectLdapName.getRdns(); + for (var expectedRdn : expectedLdapName.getRdns()) { + if (!subjectRdns.contains(expectedRdn)) { + throw new CertificateException("Subject DN does not contain expected RDN"); + } + } + } else { + throw new CertificateException("Unsupported SubjectDnValidationMode: " + subjectDnValidationMode); + } + } + + var certPath = certificateFactory.generateCertPath(serverChain); + + // OCSP-only pass (if requested) + if (ocspMode != RevocationMode.DISABLED) { + if (socket != null || sslEngine != null) { + SSLSession session; + + if (socket instanceof SSLSocket sslSocket) { + session = sslSocket.getHandshakeSession(); + } else if (sslEngine != null) { + session = sslEngine.getSession(); + } else { + throw new IllegalStateException("Expected either a Socket or SSLEngine"); + } + + var hasStapledOcsp = hasStapledOcsp(chain, session); + if (!hasStapledOcsp) { + pkixOcspOnly(certPath, ocspMode == RevocationMode.SOFT_FAIL); + } + } + } + + // CRL-only pass (if requested) + if (crlMode != RevocationMode.DISABLED) { + // Preloaded CRLs + CRLDP-fetched CRLs (HTTP) + List crls = new ArrayList<>(preloadedCrls); + crls.addAll(fetchCrlsFromCrlDP(chain)); + pkixCrlOnly(certPath, crls, crlMode == RevocationMode.SOFT_FAIL); + } + // If both are HARD_FAIL, reaching here means both passes succeeded. + + } catch (GeneralSecurityException e) { + if (e instanceof CertificateException exception) { + throw exception; + } + + throw new CertificateException("Validation error: " + e.getMessage(), e); + } + } + + // ---- Pass A: OCSP-only ---- + private void pkixOcspOnly(CertPath path, boolean softFail) throws GeneralSecurityException { + var params = new PKIXParameters(this.trustAnchorsSet); + params.setRevocationEnabled(true); + + var revocationChecker = (PKIXRevocationChecker) certPathValidator.getRevocationChecker(); + + var opts = EnumSet.of( + PKIXRevocationChecker.Option.NO_FALLBACK // don't fall back to CRLs + ); + + if (softFail) opts.add(PKIXRevocationChecker.Option.SOFT_FAIL); + + revocationChecker.setOptions(opts); + params.addCertPathChecker(revocationChecker); + + certPathValidator.validate(path, params); + } + + // ---- Pass B: CRL-only ---- + private void pkixCrlOnly(CertPath path, Collection crls, boolean softFail) throws GeneralSecurityException { + var params = new PKIXParameters(this.trustAnchorsSet); + params.setRevocationEnabled(true); + + if (crls != null && !crls.isEmpty()) { + CertStore cs = CertStore.getInstance("Collection", new CollectionCertStoreParameters(crls)); + params.addCertStore(cs); + } + + var revocationChecker = (PKIXRevocationChecker) certPathValidator.getRevocationChecker(); + + var opts = EnumSet.of( + PKIXRevocationChecker.Option.PREFER_CRLS, // CRL-first + PKIXRevocationChecker.Option.NO_FALLBACK // do NOT fall back to OCSP + ); + + if (softFail) { + opts.add(PKIXRevocationChecker.Option.SOFT_FAIL); + } + + revocationChecker.setOptions(opts); + params.addCertPathChecker(revocationChecker); + + certPathValidator.validate(path, params); + } + + private Collection fetchCrlsFromCrlDP(X509Certificate[] chain) { + List out = new ArrayList<>(); + try { + for (X509Certificate cert : chain) { + byte[] ext = cert.getExtensionValue(Extension.cRLDistributionPoints.getId()); + if (ext == null) continue; + + byte[] inner = ((DEROctetString) ASN1Primitive.fromByteArray(ext)).getOctets(); + + var crlDistPoint = CRLDistPoint.getInstance(ASN1Primitive.fromByteArray(inner)); + for (DistributionPoint p : crlDistPoint.getDistributionPoints()) { + var name = p.getDistributionPoint(); + if (name == null || name.getType() != DistributionPointName.FULL_NAME) continue; + + for (GeneralName gn : GeneralNames.getInstance(name.getName()).getNames()) { + if (gn.getTagNo() == GeneralName.uniformResourceIdentifier) { + String uri = gn.getName().toString(); + if (!uri.startsWith("http://")) continue; // keep simple; avoid HTTPS recursion + + try (InputStream in = URI.create(uri).toURL().openStream()) { + byte[] bytes = in.readAllBytes(); + byte[] der = maybeDecodePem(bytes, "X509 CRL"); + out.add(certificateFactory.generateCRL(new ByteArrayInputStream(der))); + } catch (Exception ignoreOne) {} + } + } + } + } + } catch (Exception ignore) {} + return out; + } + + private static byte[] maybeDecodePem(byte[] content, String type) { + String s = new String(content); + if (!s.contains("-----BEGIN " + type)) return content; + String base64 = s.replaceAll("-----BEGIN [^-]+-----", "") + .replaceAll("-----END [^-]+-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(base64); + } + + private void validateStapledOcspResponse(byte[] responseBytes, X509Certificate cert) + throws CertificateException { + try { + var ocspResponse = new OCSPResp(responseBytes); + + if (ocspResponse.getStatus() == OCSPResp.SUCCESSFUL) { + var basicResponse = (BasicOCSPResp) ocspResponse.getResponseObject(); + + for (var singleResp : basicResponse.getResponses()) { + var status = singleResp.getCertStatus(); + + if (status == CertificateStatus.GOOD) { + log.debug("Stapled OCSP: Certificate is GOOD"); + } else if (status instanceof RevokedStatus) { + throw new CertificateException("Certificate is REVOKED (from stapled OCSP)"); + } else { + log.warn("Stapled OCSP: Certificate status is UNKNOWN"); + } + } + } else { + log.warn("Stapled OCSP response status: {}", ocspResponse.getStatus()); + } + } catch (Exception e) { + log.error("Failed to validate stapled OCSP response", e); + if (ocspMode == RevocationMode.HARD_FAIL) { + throw new CertificateException("Invalid stapled OCSP response", e); + } + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/servlets/TLSServlet.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/servlets/TLSServlet.java new file mode 100644 index 000000000..8ba8bb4ba --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/servlets/TLSServlet.java @@ -0,0 +1,196 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.servlets; + +import com.kaurpalang.mirth.annotationsplugin.annotation.MirthApiProvider; +import com.kaurpalang.mirth.annotationsplugin.type.ApiProviderType; +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.core.api.MirthApiException; +import com.mirth.connect.connectors.http.HttpDispatcherProperties; +import com.mirth.connect.connectors.tcp.TcpDispatcherProperties; +import com.mirth.connect.connectors.ws.DefinitionServiceMap; +import com.mirth.connect.connectors.ws.WebServiceDispatcherProperties; +import com.mirth.connect.server.api.DontCheckAuthorized; +import com.mirth.connect.server.api.MirthServlet; +import lombok.extern.slf4j.Slf4j; +import org.openintegrationengine.tlsmanager.server.CertificateService; +import org.openintegrationengine.tlsmanager.server.TLSServicePlugin; +import org.openintegrationengine.tlsmanager.server.WebServiceService; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; +import org.openintegrationengine.tlsmanager.shared.models.LocalCertificate; +import org.openintegrationengine.tlsmanager.shared.models.TrustedCertificate; +import org.openintegrationengine.tlsmanager.shared.servlet.TLSServletInterface; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.List; +import java.util.Set; + +@Slf4j +@MirthApiProvider(type = ApiProviderType.SERVER_CLASS) +public class TLSServlet extends MirthServlet implements TLSServletInterface { + + private final CertificateService certificateService; + private final WebServiceService webServiceService; + + public TLSServlet(@Context HttpServletRequest request, @Context SecurityContext sc) { + this( + request, + sc, + TLSServicePlugin.getPluginInstance().getCertificateService(), + TLSServicePlugin.getPluginInstance().getWebServiceService() + ); + } + + public TLSServlet( + @Context HttpServletRequest request, + @Context SecurityContext sc, + CertificateService certificateService, + WebServiceService webServiceService + ) { + super(request, sc, TLSPluginConstants.PLUGIN_POINTNAME); + + this.certificateService = certificateService; + this.webServiceService = webServiceService; + } + + @Override + public Set getPublicCertificates() { + return certificateService.getTrustedCertificateAliases(); + } + + @Override + public Set getClientCertificates() { + return certificateService.getLocalCertificateAliases(); + } + + @Override + public byte[] getKeystore() { + var keystore = certificateService.getExternalTrustStore(); + + try (var baos = new ByteArrayOutputStream()) { + keystore.store(baos, "changeit".toCharArray()); + return baos.toByteArray(); + } catch (IOException | NoSuchAlgorithmException | CertificateException | KeyStoreException e) { + throw new RuntimeException(e); + } + } + + @Override + @DontCheckAuthorized + public String setTruststore(InputStream inputStream, String password) { + + if (!isUserAuthorized(false)) { + isUserAuthorized(true); + throw new WebApplicationException(Response.Status.FORBIDDEN); + } + + byte[] trustStoreBytes; + try { + trustStoreBytes = inputStream.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + certificateService.storeExtraTrustStore(trustStoreBytes, password.toCharArray()); + return "timmis"; + } + + @Override + public List getSystemCertificates() { + return certificateService.getEncodedSystemCertificates(); + } + + @Override + public List getLocalCertificates() { + return certificateService.getEncodedLocalCertificates(); + } + + @Override + public void setLocalCertificates(List localCertificates) { + certificateService.setLocalCertificates(localCertificates); + } + + @Override + public List getTrustedCertificates() { + return certificateService.getEncodedTrustedCertificates(); + } + + @Override + public void setTrustedCertificates(List trustedCertificates) { + certificateService.setTrustedCertificates(trustedCertificates); + } + + @Override + public List getRemoteCertificates(String url) { + return certificateService.retrieveRemoteCertificates(url); + } + + @Override + public ConnectionTestResult testTcpConnection(String channelId, String channelName, TcpDispatcherProperties dispatcherProperties) throws ClientException { + return certificateService.testTcpConnection(channelId, channelName, dispatcherProperties); + } + + @Override + public ConnectionTestResult testHttpsConnection(String channelId, String channelName, HttpDispatcherProperties dispatcherProperties) throws ClientException { + return certificateService.testHttpConnection(channelId, channelName, dispatcherProperties); + } + + @Override + public ConnectionTestResult testWsConnection(String channelId, String channelName, WebServiceDispatcherProperties wsDispatcherProperties) throws ClientException { + return certificateService.testWsConnection(channelId, channelName, wsDispatcherProperties); + } + + @Override + public Object cacheWsdlFromUrl( + String channelId, + String channelName, + WebServiceDispatcherProperties properties + ) { + try { + webServiceService.cacheWsdlFromUrl(channelId, channelName, properties); + return null; + } catch (Exception e) { + throw new MirthApiException(e); + } + } + + @Override + public boolean isWsdlCached( + String channelId, + String channelName, + String wsdlUrl, + String username, + String password + ) { + return false; + } + + @Override + public DefinitionServiceMap getDefinition( + String channelId, + String channelName, + String wsdlUrl, + String username, + String password + ) { + try { + return webServiceService.getDefinition(channelId, channelName, wsdlUrl, username, password); + } catch (Exception e) { + throw new MirthApiException(e); + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/util/ConnectionUtils.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/util/ConnectionUtils.java new file mode 100644 index 000000000..0e62e51c5 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/util/ConnectionUtils.java @@ -0,0 +1,200 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.util; + +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpHost; +import org.apache.http.conn.SchemePortResolver; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.conn.DefaultSchemePortResolver; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; + +import javax.net.ssl.SSLSocket; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.time.Instant; +import java.util.Arrays; + +@Slf4j +public class ConnectionUtils { + + + public static ConnectionTestResult testConnection( + SSLConnectionSocketFactory socketFactory, + String host, + int port, + int timeout, + String localAddr, + int localPort + ) { + return testConnection( + socketFactory, + null, + host, + port, + timeout, + localAddr, + localPort + ); + } + + public static ConnectionTestResult testConnection( + SSLConnectionSocketFactory socketFactory, + Socket socket, + String host, + int port, + int timeout, + String localAddr, + int localPort + ) { + var startTime = Instant.now(); + + if ( + host == null + || host.isEmpty() + || (port < 0) + || (port > 65534) + ) { + return ConnectionTestResult.builder() + .timestamp(startTime) + .message("Invalid host or port") + .build(); + } + + var target = HttpHost.create(host); + + InetSocketAddress remoteAddress = new InetSocketAddress(host, port); + + InetSocketAddress localAddress = null; + if (localAddr != null) { + if ( + localAddr.isEmpty() + || localPort <= 0 + || localPort > 65535 + ) { + return ConnectionTestResult.builder() + .timestamp(startTime) + .requestedAddress(host) + .message("Invalid local host or port") + .build(); + } + + localAddress = new InetSocketAddress(localAddr, localPort); + } + + try ( + var sslSocket = (SSLSocket) socketFactory.connectSocket( + timeout, + socket, + target, + remoteAddress, + localAddress, + null + ) + ) { + var sess = sslSocket.getSession(); + + if (log.isDebugEnabled()) { + // Handshake is done if we got here. Inspect what happened: + log.debug("Protocol: {}", sess.getProtocol()); + log.debug("Cipher: {}", sess.getCipherSuite()); + log.debug("Peer: {}", sess.getPeerPrincipal()); + + // Did we actually present a client cert? + var localCerts = sess.getLocalCertificates(); // null => none presented + var localPrinc = sess.getLocalPrincipal(); // null => none presented + log.debug("Client cert presented? {}", localPrinc != null); + } + + //isSocketAlive(sslSocket); + + return ConnectionTestResult.builder() + .success(true) + .timestamp(startTime) + .requestedAddress(host) + .protocol(sess.getProtocol()) + .cipherSuite(sess.getCipherSuite()) + .sessionId(ConnectionTestResult.bytesToHex(sess.getId())) + .peerHost(sess.getPeerHost()) + .peerPort(sess.getPeerPort()) + .sessionValid(sess.isValid()) + .sessionCreationTime(Instant.ofEpochMilli(sess.getCreationTime())) + .sessionLastAccessedTime(Instant.ofEpochMilli(sess.getLastAccessedTime())) + .supportedProtocols(Arrays.asList(sslSocket.getSupportedProtocols())) + .enabledProtocols(Arrays.asList(sslSocket.getEnabledProtocols())) + .supportedCipherSuites(Arrays.asList(sslSocket.getSupportedCipherSuites())) + .enabledCipherSuites(Arrays.asList(sslSocket.getEnabledCipherSuites())) + .certificates(Arrays.asList(sess.getPeerCertificates())) + .chosenProtocol(sess.getProtocol()) + .chosenCipherSuite(sess.getCipherSuite()) + .build(); + + } catch (Exception e) { + log.error("Error connecting to host: {}", host, e); + + var result = ConnectionTestResult.builder() + .success(false) + .timestamp(startTime) + .requestedAddress(host) + .exceptionName(e.getClass().getCanonicalName()) + .exceptionMessage(e.getMessage()); + + if (e.getCause() != null) { + result.causeName(e.getCause().getClass().getCanonicalName()); + result.causeMessage(e.getCause().getMessage()); + } + + return result.build(); + } + } + + /** + * Performs a lightweight check to see if the given {@link Socket} appears alive. + *

+ * This method verifies local socket state and performs a short read with a timeout + * to detect EOF or I/O errors. It returns {@code true} if the connection seems open + * and responsive, or {@code false} if it is closed, reset, or reaches end-of-stream. + *

+ * Note that TCP cannot guarantee remote liveness without actual I/O, so this result + * is best-effort only. + * + * @param socket the socket to test (not {@code null}) + */ + + private static void isSocketAlive(Socket socket) throws IOException { + log.trace("Checking socket liveness"); + int oldTimeOut = socket.getSoTimeout(); + socket.setSoTimeout(100); // 100ms read timeout + + log.trace("Set socket timeout to 100ms"); + + var in = socket.getInputStream(); + + try { + if (in.available() > 0 || in.read() >= 0) { + // Data received (or connection still healthy) + log.debug("Socket alive (data or no EOF)"); + } else { + // read() == -1 → remote closed cleanly + log.debug("Socket dead (EOF)"); + } + } catch (SocketTimeoutException e) { + // no data within timeout → probably still open + log.debug("Socket alive (idle)"); + throw e; + } catch (IOException e) { + // network error, RST, etc. + log.debug("Socket dead (idle)", e); + throw e; + } finally { + if (!socket.isClosed()) { + socket.setSoTimeout(oldTimeOut); + } + } + } +} diff --git a/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/util/TrustStoreUtils.java b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/util/TrustStoreUtils.java new file mode 100644 index 000000000..eb303db59 --- /dev/null +++ b/plugins/tls/server/src/main/java/org/openintegrationengine/tlsmanager/server/util/TrustStoreUtils.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.util; + +import java.security.cert.X509Certificate; + +public class TrustStoreUtils { + + /** + * Determines whether the given X.509 certificate is a Certificate Authority (CA) + * certificate based on its BasicConstraints extension. + * + *

A certificate is considered a CA certificate if its + * {@link X509Certificate#getBasicConstraints()} value is greater than or equal + * to zero. According to RFC 5280, the BasicConstraints extension must be present + * and set to a non-negative path length value for a certificate to be treated + * as a CA. A return value of {@code -1} indicates that the certificate is not a + * CA (i.e., it is an end-entity or leaf certificate).

+ * + *

This method does not examine the KeyUsage extension; it relies solely on + * BasicConstraints, which is the authoritative indicator for CA certificates.

+ * + * @param cert the X.509 certificate to evaluate (must not be {@code null}) + * @return {@code true} if the certificate is a CA certificate; + * {@code false} if it is a leaf/end-entity certificate + */ + public static boolean isCA(X509Certificate cert) { + return cert.getBasicConstraints() >= 0; + } +} diff --git a/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/CertificateServiceTest.java b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/CertificateServiceTest.java new file mode 100644 index 000000000..f25fd2cf2 --- /dev/null +++ b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/CertificateServiceTest.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openintegrationengine.tlsmanager.shared.PersistenceMode; +import org.openintegrationengine.tlsmanager.shared.models.TLSPluginConfiguration; + +@ExtendWith(MockitoExtension.class) +public class CertificateServiceTest { + + private CertificateService certificateService; + + //@BeforeEach + public void setUp() { + certificateService = new CertificateService(null); + } + + //@Test + public void testSetTrustStore() { + var pluginConfiguration = new TLSPluginConfiguration( + PersistenceMode.FILESYSTEM, + "/path/to", + "truststorePass", + "/path/to", + "keystorePass", + false + ); + + certificateService.init(pluginConfiguration); + } +} diff --git a/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/misc/IntegrationTest.java b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/misc/IntegrationTest.java new file mode 100644 index 000000000..19d18c2e7 --- /dev/null +++ b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/misc/IntegrationTest.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.misc; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ + ElementType.TYPE, + ElementType.METHOD +}) +@Retention(RetentionPolicy.RUNTIME) +@Test +@Tag("integrationTest") // Used for separating tests +public @interface IntegrationTest { +} diff --git a/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/misc/UnitTest.java b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/misc/UnitTest.java new file mode 100644 index 000000000..03d00fa32 --- /dev/null +++ b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/misc/UnitTest.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.misc; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ + ElementType.TYPE, + ElementType.METHOD +}) +@Retention(RetentionPolicy.RUNTIME) +@Test +@Tag("unitTest") // Used for separating tests +public @interface UnitTest { +} diff --git a/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/MockConfigurationController.java b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/MockConfigurationController.java new file mode 100644 index 000000000..a9d07a15c --- /dev/null +++ b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/MockConfigurationController.java @@ -0,0 +1,342 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.util; + +import com.mirth.commons.encryption.Digester; +import com.mirth.commons.encryption.Encryptor; +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.model.ChannelDependency; +import com.mirth.connect.model.ChannelMetadata; +import com.mirth.connect.model.ChannelTag; +import com.mirth.connect.model.DatabaseSettings; +import com.mirth.connect.model.DriverInfo; +import com.mirth.connect.model.EncryptionSettings; +import com.mirth.connect.model.PasswordRequirements; +import com.mirth.connect.model.PublicServerSettings; +import com.mirth.connect.model.ServerConfiguration; +import com.mirth.connect.model.ServerSettings; +import com.mirth.connect.model.UpdateSettings; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.util.ConfigurationProperty; +import com.mirth.connect.util.ConnectionTestResponse; +import org.apache.commons.configuration2.PropertiesConfiguration; + +import java.util.Calendar; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +public class MockConfigurationController extends ConfigurationController { + @Override + public void initializeSecuritySettings() { + + } + + @Override + public void initializeDatabaseSettings() { + + } + + @Override + public void migrateKeystore() { + + } + + @Override + public void updatePropertiesConfiguration(PropertiesConfiguration propertiesConfiguration) { + + } + + @Override + public Encryptor getEncryptor() { + return null; + } + + @Override + public Digester getDigester() { + return null; + } + + @Override + public String getDatabaseType() { + return ""; + } + + @Override + public String getServerId() { + return ""; + } + + @Override + public String getServerName() { + return ""; + } + + @Override + public String getServerTimezone(Locale locale) { + return ""; + } + + @Override + public Calendar getServerTime() { + return null; + } + + @Override + public List getAvailableCharsetEncodings() throws ControllerException { + return List.of(); + } + + @Override + public String getBaseDir() { + return ""; + } + + @Override + public String getConfigurationDir() { + return ""; + } + + @Override + public String getApplicationDataDir() { + return ""; + } + + @Override + public ServerSettings getServerSettings() throws ControllerException { + return null; + } + + @Override + public EncryptionSettings getEncryptionSettings() throws ControllerException { + return null; + } + + @Override + public DatabaseSettings getDatabaseSettings() throws ControllerException { + return null; + } + + @Override + public void setServerSettings(ServerSettings serverSettings) throws ControllerException { + + } + + @Override + public PublicServerSettings getPublicServerSettings() throws ControllerException { + return null; + } + + @Override + public UpdateSettings getUpdateSettings() throws ControllerException { + return null; + } + + @Override + public void setUpdateSettings(UpdateSettings updateSettings) throws ControllerException { + + } + + @Override + public String generateGuid() { + return ""; + } + + @Override + public List getDatabaseDrivers() throws ControllerException { + return List.of(); + } + + @Override + public void setDatabaseDrivers(List list) throws ControllerException { + + } + + @Override + public String getServerVersion() { + return ""; + } + + @Override + public String getBuildDate() { + return ""; + } + + @Override + public int getMaxInactiveSessionInterval() { + return 0; + } + + @Override + public String[] getHttpsClientProtocols() { + return new String[0]; + } + + @Override + public String[] getHttpsServerProtocols() { + return new String[] { + "TLSv1.3", "TLSv1.2", "SSLv2Hello" + }; + } + + @Override + public String[] getHttpsCipherSuites() { + return new String[] { + "TLS_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384", "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_DHE_DSS_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256", "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_DHE_DSS_WITH_AES_128_GCM_SHA256", "TLS_EMPTY_RENEGOTIATION_INFO_SCSV" + }; + } + + @Override + public boolean isStartupDeploy() { + return false; + } + + @Override + public int getStatsUpdateInterval() { + return 0; + } + + @Override + public Integer getRhinoLanguageVersion() { + return 0; + } + + @Override + public int getStartupLockSleep() { + return 0; + } + + @Override + public ServerConfiguration getServerConfiguration() throws ControllerException { + return null; + } + + @Override + public void setServerConfiguration(ServerConfiguration serverConfiguration, boolean b, boolean b1) throws ControllerException { + + } + + @Override + public PasswordRequirements getPasswordRequirements() { + return null; + } + + @Override + public boolean isBypasswordEnabled() { + return false; + } + + @Override + public boolean checkBypassword(String s) { + return false; + } + + @Override + public int getStatus() { + return 0; + } + + @Override + public int getStatus(boolean b) { + return 0; + } + + @Override + public void setStatus(int i) { + + } + + @Override + public Map getConfigurationMap() { + return Map.of(); + } + + @Override + public Map getConfigurationProperties() throws ControllerException { + return Map.of(); + } + + @Override + public void setConfigurationProperties(Map map, boolean b) throws ControllerException { + + } + + @Override + public Properties getPropertiesForGroup(String s, Set set) { + return null; + } + + @Override + public void removePropertiesForGroup(String s) { + + } + + @Override + public String getProperty(String s, String s1) { + return ""; + } + + @Override + public void saveProperty(String s, String s1, String s2) { + + } + + @Override + public void removeProperty(String s, String s1) { + + } + + @Override + public String getResources() { + return ""; + } + + @Override + public void setResources(String s) { + + } + + @Override + public Set getChannelDependencies() { + return Set.of(); + } + + @Override + public void setChannelDependencies(Set set) { + + } + + @Override + public Map getChannelMetadata() { + return Map.of(); + } + + @Override + public void setChannelMetadata(Map map) { + + } + + @Override + public ConnectionTestResponse sendTestEmail(Properties properties) throws Exception { + return null; + } + + @Override + public void setChannelTags(Set set) { + + } + + @Override + public Set getChannelTags() { + return Set.of(); + } +} diff --git a/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/MockDestinationConnector.java b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/MockDestinationConnector.java new file mode 100644 index 000000000..ee9a4edbf --- /dev/null +++ b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/MockDestinationConnector.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.util; + +import com.mirth.connect.donkey.model.channel.ConnectorProperties; +import com.mirth.connect.donkey.model.message.ConnectorMessage; +import com.mirth.connect.donkey.model.message.Response; +import com.mirth.connect.donkey.server.ConnectorTaskException; +import com.mirth.connect.donkey.server.channel.DestinationConnector; + +public class MockDestinationConnector extends DestinationConnector { + @Override + public void replaceConnectorProperties(ConnectorProperties connectorProperties, ConnectorMessage connectorMessage) { + + } + + @Override + public Response send(ConnectorProperties connectorProperties, ConnectorMessage connectorMessage) throws InterruptedException { + return null; + } + + @Override + public void onDeploy() throws ConnectorTaskException { + + } + + @Override + public void onUndeploy() throws ConnectorTaskException { + + } + + @Override + public void onStart() throws ConnectorTaskException { + + } + + @Override + public void onStop() throws ConnectorTaskException { + + } + + @Override + public void onHalt() throws ConnectorTaskException { + + } +} diff --git a/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/Statics.java b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/Statics.java new file mode 100644 index 000000000..46b0348d0 --- /dev/null +++ b/plugins/tls/server/src/test/java/org/openintegrationengine/tlsmanager/server/util/Statics.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.server.util; + +public final class Statics { + public static String[] protocols() { + return new String[] { + "TLSv1.3", "TLSv1.2", "SSLv2Hello" + }; + } + + public static String[] cipherSuites() { + return new String[] { + "TLS_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384", "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_DHE_DSS_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256", "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_DHE_DSS_WITH_AES_128_GCM_SHA256", "TLS_EMPTY_RENEGOTIATION_INFO_SCSV" + }; + } +} diff --git a/plugins/tls/server/src/test/resources/logback-test.xml b/plugins/tls/server/src/test/resources/logback-test.xml new file mode 100644 index 000000000..1a6e593c0 --- /dev/null +++ b/plugins/tls/server/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + diff --git a/plugins/tls/shared/pom.xml b/plugins/tls/shared/pom.xml new file mode 100644 index 000000000..3ad16e84a --- /dev/null +++ b/plugins/tls/shared/pom.xml @@ -0,0 +1,89 @@ + + + + + + + + 4.0.0 + + + org.openintegrationengine + tlsmanager + 1.0.0 + + + + 2.1.11 + 2.1.1 + + + + + com.mirth.connect + mirth-client-core + ${mirth.version} + + + + com.mirth.connect + mirth-server + ${mirth.version} + + + + io.swagger.core.v3 + swagger-annotations + ${swagger.version} + + + + javax.ws.rs + javax.ws.rs-api + ${javax.version} + + + + com.mirth.connect.plugins + http-shared + ${mirth.version} + + + + com.mirth.connect.connectors + tcp-shared + ${mirth.version} + + + + com.mirth.connect.connectors + ws-shared + ${mirth.version} + + + + com.mirth.connect + donkey-model + ${mirth.version} + provided + + + + org.glassfish.jersey.media + jersey-media-multipart + 2.22.1 + provided + + + + com.thoughtworks.xstream + xstream + 1.4.20 + provided + + + + shared + diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/Pair.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/Pair.java new file mode 100644 index 000000000..3166753df --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/Pair.java @@ -0,0 +1,10 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared; + +public record Pair ( + A a, B b +) {} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/PersistenceMode.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/PersistenceMode.java new file mode 100644 index 000000000..d79fc390e --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/PersistenceMode.java @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared; + +public enum PersistenceMode { + DATABASE, + FILESYSTEM +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/SerializationController.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/SerializationController.java new file mode 100644 index 000000000..972c6fec8 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/SerializationController.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared; + +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; +import org.openintegrationengine.tlsmanager.shared.models.LocalCertificate; +import org.openintegrationengine.tlsmanager.shared.models.TrustedCertificate; +import org.openintegrationengine.tlsmanager.shared.properties.TLSConnectorProperties; + +import java.util.List; + +public class SerializationController { + + private static final List types = List.of( + TLSConnectorProperties.class.getCanonicalName(), + TrustedCertificate.class.getCanonicalName(), + LocalCertificate.class.getCanonicalName(), + ConnectionTestResult.class.getCanonicalName() + ); + + private static final Class[] classes = new Class[]{ + TrustedCertificate.class, + LocalCertificate.class + }; + + private static final List wildcardTypes = List.of(); + private static final List typeHierarchies = List.of(); + + // Register our property classes with XStream to prevent ForbiddenClassException + public static void registerSerializableClasses() { + ObjectXMLSerializer.getInstance().allowTypes(types, wildcardTypes, typeHierarchies); + ObjectXMLSerializer.getInstance().processAnnotations(classes); + } + + private SerializationController() { + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/TLSPluginConstants.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/TLSPluginConstants.java new file mode 100644 index 000000000..053be4ff1 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/TLSPluginConstants.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 OR MPL-2.0 + * Copyright (c) 2021 Kaur Palang + * Copyright (c) 2025 NovaMap Health Limited + * Modifications from commit d2fbac7328eda7b7a68348a4adcbb3a9961868b9 onward licensed under MPL-2.0 + */ + +package org.openintegrationengine.tlsmanager.shared; + +public final class TLSPluginConstants { + public static final String PLUGIN_POINTNAME = "TLS Manager"; + + public static final String SETTINGS_TABNAME_MAIN = "TLS Settings"; + + public static final String PROPERTY_TRUST_BACKEND = "trust.backend"; + + public static final String ENV_PERSISTENCE_BACKEND = "OIE_TLS_PLUGIN_PERSISTENCE_BACKEND"; + public static final String ENV_PERSISTENCE_FS_TRUSTSTOREPATH = "OIE_TLS_PLUGIN_FS_TRUSTSTOREPATH"; + public static final String ENV_PERSISTENCE_FS_TRUSTSTOREPASS = "OIE_TLS_PLUGIN_FS_TRUSTSTOREPASS"; + public static final String ENV_PERSISTENCE_FS_KEYSTOREPASS = "OIE_TLS_PLUGIN_FS_KEYSTOREPASS"; + public static final String ENV_PERSISTENCE_FS_KEYSTOREPATH = "OIE_TLS_PLUGIN_FS_KEYSTOREPATH"; + + public static final String ENV_SHOULD_DISABLE_UI = "OIE_TLS_PLUGIN_DISABLE_UI"; + + public static final String PKCS12 = "PKCS12"; + + // This ain't no joke + public static final String TLS_SENDER_CONNECTOR_PROPERTIES_PLUGIN_POINT_NAME = "TLS Sender Connector Properties Plugin"; + public static final String TLS_LISTENER_CONNECTOR_PROPERTIES_PLUGIN_POINT_NAME = "TLS Listener Connector Properties Plugin"; + public static final String TLS_LISTENER_PROPERTIES_PLUGIN_POINT_NAME = "TLS Connector Properties Plugin"; + + public static final String TLS_TASK_PLUGIN_POINT_NAME = "TLS Manager Tasks"; + + private TLSPluginConstants() {} +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/ClientAuthMode.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/ClientAuthMode.java new file mode 100644 index 000000000..bc2caba33 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/ClientAuthMode.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import lombok.Getter; + +public enum ClientAuthMode implements DisplayTextEnum { + NONE("None"), + REQUESTED("Requested"), + REQUIRED("Required"); + + @Getter + private final String displayText; + + ClientAuthMode(String displayText) { + this.displayText = displayText; + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/ConnectionTestResult.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/ConnectionTestResult.java new file mode 100644 index 000000000..daca92fa8 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/ConnectionTestResult.java @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import lombok.Builder; +import lombok.Getter; + +import java.security.MessageDigest; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.List; + +@Builder +@Getter +public final class ConnectionTestResult { + private Boolean success; + private String message; + private Instant timestamp; + private String requestedAddress; + + private String protocol; + private String cipherSuite; + private String sessionId; + private String peerHost; + private Integer peerPort; + private Boolean sessionValid; + private Instant sessionCreationTime; + private Instant sessionLastAccessedTime; + + private List supportedProtocols; + private List enabledProtocols; + private String chosenProtocol; + + private List supportedCipherSuites; + private List enabledCipherSuites; + private String chosenCipherSuite; + + private List certificates; + + private String exceptionName; + private String exceptionMessage; + private String causeName; + private String causeMessage; + + public static String bytesToHex(byte[] bytes) { + if (bytes == null || bytes.length == 0) return ""; + + var hexString = new StringBuilder(); + for (byte b : bytes) { + hexString.append(String.format("%02X", b)); + } + return hexString.toString(); + } + + public static int getKeySize(X509Certificate cert) { + try { + if (cert.getPublicKey().getAlgorithm().equals("RSA")) { + return ((java.security.interfaces.RSAPublicKey) cert.getPublicKey()).getModulus().bitLength(); + } else if (cert.getPublicKey().getAlgorithm().equals("EC")) { + return ((java.security.interfaces.ECPublicKey) cert.getPublicKey()).getParams().getOrder().bitLength(); + } + } catch (Exception e) { + // Ignore + } + return -1; + } + + public static String getCertificateFingerprint(X509Certificate cert, String algorithm) { + try { + var md = MessageDigest.getInstance(algorithm); + byte[] digest = md.digest(cert.getEncoded()); + var sb = new StringBuilder(); + for (int i = 0; i < digest.length; i++) { + if (i > 0) sb.append(":"); + sb.append(String.format("%02X", digest[i])); + } + return sb.toString(); + } catch (Exception e) { + return "Unable to calculate fingerprint"; + } + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/DisplayTextEnum.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/DisplayTextEnum.java new file mode 100644 index 000000000..9f5473bd5 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/DisplayTextEnum.java @@ -0,0 +1,10 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +public interface DisplayTextEnum { + String getDisplayText(); +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/LocalCertificate.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/LocalCertificate.java new file mode 100644 index 000000000..df177034b --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/LocalCertificate.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import com.thoughtworks.xstream.annotations.XStreamAlias; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@XStreamAlias("localCertificate") +public class LocalCertificate extends TrustedCertificate implements Serializable { + + public LocalCertificate(String alias) { + super(alias); + } + + private String key; +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/RevocationMode.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/RevocationMode.java new file mode 100644 index 000000000..05521bb23 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/RevocationMode.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import lombok.Getter; + +public enum RevocationMode implements DisplayTextEnum { + DISABLED("Disabled"), + SOFT_FAIL("Soft Fail"), + HARD_FAIL("Hard Fail"); + + @Getter + private final String displayText; + + RevocationMode(String displayText) { + this.displayText = displayText; + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/SubjectDnValidationMode.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/SubjectDnValidationMode.java new file mode 100644 index 000000000..1937b69e6 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/SubjectDnValidationMode.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import lombok.Getter; + +public enum SubjectDnValidationMode implements DisplayTextEnum { + NONE("None"), + PARTIAL("Partial"), + EXACT("Exact"); + + @Getter + private final String displayText; + + SubjectDnValidationMode(String displayText) { + this.displayText = displayText; + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/TLSPluginConfiguration.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/TLSPluginConfiguration.java new file mode 100644 index 000000000..1f275e96e --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/TLSPluginConfiguration.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.openintegrationengine.tlsmanager.shared.PersistenceMode; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; + +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.ENV_PERSISTENCE_FS_KEYSTOREPASS; +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.ENV_PERSISTENCE_FS_KEYSTOREPATH; +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.ENV_PERSISTENCE_FS_TRUSTSTOREPASS; +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.ENV_PERSISTENCE_FS_TRUSTSTOREPATH; +import static org.openintegrationengine.tlsmanager.shared.TLSPluginConstants.ENV_SHOULD_DISABLE_UI; + +@Slf4j +public record TLSPluginConfiguration( + PersistenceMode persistenceMode, + String truststorePath, + String truststorePassword, + String keystorePath, + String keystorePassword, + boolean disableUI +) { + public static TLSPluginConfiguration fromEnv() { + var conf = new TLSPluginConfiguration( + getPersistenceMode(), + readKeyFromEnv(ENV_PERSISTENCE_FS_TRUSTSTOREPATH, false), + readKeyFromEnv(ENV_PERSISTENCE_FS_TRUSTSTOREPASS, false), + readKeyFromEnv(ENV_PERSISTENCE_FS_KEYSTOREPATH, false), + readKeyFromEnv(ENV_PERSISTENCE_FS_KEYSTOREPASS, false), + Boolean.parseBoolean(readKeyFromEnv(ENV_SHOULD_DISABLE_UI, false)) + ); + + log.debug("Using configuration: {}", conf); + + return conf; + } + + private static PersistenceMode getPersistenceMode() { + var persistenceModeFromEnv = readKeyFromEnv(TLSPluginConstants.ENV_PERSISTENCE_BACKEND, false); + + PersistenceMode persistenceMode; + if (persistenceModeFromEnv == null) { + log.debug("No persistence mode environment variable set, defaulting to \"database\""); + persistenceMode = PersistenceMode.DATABASE; + } else { + persistenceMode = PersistenceMode.valueOf(persistenceModeFromEnv.toUpperCase()); + } + + log.info("Using persistence mode {}", persistenceMode); + + return persistenceMode; + } + + private static String readKeyFromEnv(String key, boolean isRequired) { + var keyFromEnv = System.getenv(key); + if (keyFromEnv == null && isRequired) { + throw new IllegalStateException("Environment variable (%s) is not set".formatted(key)); + } + + return keyFromEnv; + } + + @NotNull + @Override + public String toString() { + return "%s[persistenceMode=%s, truststorePath=%s, truststorePassword=%s, keystorePath=%s, keystorePassword=%s, disableUI=%s]".formatted( + this.getClass().getSimpleName(), + persistenceMode, + truststorePath, + anonymize(truststorePassword), + keystorePath, + anonymize(keystorePassword), + disableUI + ); + } + + public static String anonymize(String input) { + if (input == null || input.length() <= 2) { + return input; // too short to anonymize meaningfully + } + return input.charAt(0) + "***" + input.charAt(input.length() - 1); + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/TrustedCertificate.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/TrustedCertificate.java new file mode 100644 index 000000000..a699e4cd5 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/TrustedCertificate.java @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import com.thoughtworks.xstream.annotations.XStreamAlias; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.util.Set; + +@Getter +@Setter +@XStreamAlias("trustedCertificate") +public class TrustedCertificate implements Serializable { + + public TrustedCertificate(String alias) { + this.alias = alias; + } + + private String alias; + private String certificate; + private Set channelsInUse; +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/WeirdIntermediaryContextContainer.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/WeirdIntermediaryContextContainer.java new file mode 100644 index 000000000..e4f17f5b7 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/WeirdIntermediaryContextContainer.java @@ -0,0 +1,16 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; + +public record WeirdIntermediaryContextContainer ( + SSLContext sslContext, + String[] protocols, + String[] ciphers, + HostnameVerifier hostnameVerifier +) {} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/WeirdIntermediaryListenerContextContainer.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/WeirdIntermediaryListenerContextContainer.java new file mode 100644 index 000000000..81cddcea8 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/models/WeirdIntermediaryListenerContextContainer.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.models; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import java.security.KeyStore; + +public record WeirdIntermediaryListenerContextContainer( + String[] protocols, + String[] ciphers, + HostnameVerifier hostnameVerifier, + KeyStore keyStore, + SSLContext sslContext, + ClientAuthMode clientAuthMode +) {} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/AbstractTLSConnectorProperties.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/AbstractTLSConnectorProperties.java new file mode 100644 index 000000000..d27aec519 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/AbstractTLSConnectorProperties.java @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.properties; + +import com.mirth.connect.donkey.model.channel.ConnectorPluginProperties; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.openintegrationengine.tlsmanager.shared.models.RevocationMode; +import org.openintegrationengine.tlsmanager.shared.models.SubjectDnValidationMode; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +@Getter +@Setter +@ToString +public abstract class AbstractTLSConnectorProperties extends ConnectorPluginProperties { + protected boolean isTlsManagerEnabled; + + protected boolean trustSystemTruststore; + protected Set trustedServerCertificates; + + // Certificate revocation modes + protected RevocationMode crlMode; + protected RevocationMode ocspMode; + + protected SubjectDnValidationMode subjectDnValidationMode; + protected String subjectDnValidationFilter; + + // Protocols + protected boolean isUseServerDefaultProtocols; + protected Set usedProtocols; + + // Ciphers + protected boolean isUseServerDefaultCiphers; + protected Set usedCiphers; + + protected AbstractTLSConnectorProperties() { + isTlsManagerEnabled = false; + + trustSystemTruststore = true; + trustedServerCertificates = Collections.emptySet(); + + subjectDnValidationMode = SubjectDnValidationMode.NONE; + subjectDnValidationFilter = null; + + crlMode = RevocationMode.HARD_FAIL; + ocspMode = RevocationMode.HARD_FAIL; + + isUseServerDefaultProtocols = true; + usedProtocols = Collections.emptySet(); + + isUseServerDefaultCiphers = true; + usedCiphers = Collections.emptySet(); + } + + protected AbstractTLSConnectorProperties(AbstractTLSConnectorProperties props) { + isTlsManagerEnabled = props.isTlsManagerEnabled(); + + trustSystemTruststore = props.isTrustSystemTruststore(); + trustedServerCertificates = Objects.requireNonNullElse( + props.getTrustedServerCertificates(), + Collections.emptySet() + ); + + subjectDnValidationMode = Objects.requireNonNullElse( + props.getSubjectDnValidationMode(), + SubjectDnValidationMode.NONE + ); + subjectDnValidationFilter = props.getSubjectDnValidationFilter(); + + crlMode = Objects.requireNonNullElse(props.getCrlMode(), RevocationMode.HARD_FAIL); + ocspMode = Objects.requireNonNullElse(props.getOcspMode(), RevocationMode.HARD_FAIL); + + isUseServerDefaultProtocols = props.isUseServerDefaultProtocols(); + usedProtocols = Objects.requireNonNullElse( + props.getUsedProtocols(), + Collections.emptySet() + ); + + isUseServerDefaultCiphers = props.isUseServerDefaultCiphers(); + usedCiphers = Objects.requireNonNullElse( + props.getUsedCiphers(), + Collections.emptySet() + ); + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSConnectorProperties.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSConnectorProperties.java new file mode 100644 index 000000000..2f8c4f100 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSConnectorProperties.java @@ -0,0 +1,142 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.properties; + +import com.mirth.connect.donkey.model.channel.ConnectorPluginProperties; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; +import org.openintegrationengine.tlsmanager.shared.models.ClientAuthMode; +import org.openintegrationengine.tlsmanager.shared.models.RevocationMode; +import org.openintegrationengine.tlsmanager.shared.models.SubjectDnValidationMode; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@AllArgsConstructor +@ToString +public class TLSConnectorProperties extends ConnectorPluginProperties { + + protected boolean isTlsManagerEnabled; + + protected boolean trustSystemTruststore; + protected Set trustedServerCertificates; + + // Certificate revocation modes + protected RevocationMode crlMode; + protected RevocationMode ocspMode; + + protected SubjectDnValidationMode subjectDnValidationMode; + protected String subjectDnValidationFilter; + + // Protocols + protected boolean isUseServerDefaultProtocols; + protected Set usedProtocols; + + // Ciphers + protected boolean isUseServerDefaultCiphers; + protected Set usedCiphers; + + // Server mode properties + private String serverCertificateAlias; + private ClientAuthMode clientAuthMode; + + // Client mode properties + private boolean isHostnameVerificationEnabled; + private String clientCertificateAlias; + + public TLSConnectorProperties() { + isTlsManagerEnabled = false; + + trustSystemTruststore = true; + trustedServerCertificates = Collections.emptySet(); + + subjectDnValidationMode = SubjectDnValidationMode.NONE; + subjectDnValidationFilter = null; + + crlMode = RevocationMode.HARD_FAIL; + ocspMode = RevocationMode.HARD_FAIL; + + isUseServerDefaultProtocols = true; + usedProtocols = Collections.emptySet(); + + isUseServerDefaultCiphers = true; + usedCiphers = Collections.emptySet(); + + // Server mode properties + serverCertificateAlias = null; + clientAuthMode = ClientAuthMode.NONE; + + // Client mode properties + isHostnameVerificationEnabled = true; + clientCertificateAlias = null; + } + + public TLSConnectorProperties(TLSConnectorProperties props) { + isTlsManagerEnabled = props.isTlsManagerEnabled(); + + trustSystemTruststore = props.isTrustSystemTruststore(); + trustedServerCertificates = Objects.requireNonNullElse( + props.getTrustedServerCertificates(), + Collections.emptySet() + ); + + subjectDnValidationMode = Objects.requireNonNullElse( + props.getSubjectDnValidationMode(), + SubjectDnValidationMode.NONE + ); + subjectDnValidationFilter = props.getSubjectDnValidationFilter(); + + crlMode = Objects.requireNonNullElse(props.getCrlMode(), RevocationMode.HARD_FAIL); + ocspMode = Objects.requireNonNullElse(props.getOcspMode(), RevocationMode.HARD_FAIL); + + isUseServerDefaultProtocols = props.isUseServerDefaultProtocols(); + usedProtocols = Objects.requireNonNullElse( + props.getUsedProtocols(), + Collections.emptySet() + ); + + isUseServerDefaultCiphers = props.isUseServerDefaultCiphers(); + usedCiphers = Objects.requireNonNullElse( + props.getUsedCiphers(), + Collections.emptySet() + ); + + // Server mode properties + serverCertificateAlias = props.getServerCertificateAlias(); + clientAuthMode = Objects.requireNonNullElse( + props.getClientAuthMode(), + ClientAuthMode.NONE + ); + + // Client mode properties + isHostnameVerificationEnabled = props.isHostnameVerificationEnabled(); + clientCertificateAlias = props.getClientCertificateAlias(); + } + + @Override + public String getName() { + return TLSPluginConstants.TLS_LISTENER_PROPERTIES_PLUGIN_POINT_NAME; + } + + @Override + public TLSConnectorProperties clone() { + return new TLSConnectorProperties(this); + } + + @Override + public Map getPurgedProperties() { + return Map.of(); + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSHttpDispatcherProperties.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSHttpDispatcherProperties.java new file mode 100644 index 000000000..541179f6e --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSHttpDispatcherProperties.java @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.properties; + +import com.mirth.connect.donkey.model.channel.ConnectorProperties; +import com.mirth.connect.donkey.model.channel.DestinationConnectorProperties; +import com.mirth.connect.donkey.model.channel.DestinationConnectorPropertiesInterface; +import com.mirth.connect.donkey.util.DonkeyElement; + +public class TLSHttpDispatcherProperties extends ConnectorProperties implements DestinationConnectorPropertiesInterface { + + private DestinationConnectorProperties destinationConnectorProperties; + + public TLSHttpDispatcherProperties() { + destinationConnectorProperties = new DestinationConnectorProperties(true); + } + + public TLSHttpDispatcherProperties(TLSHttpDispatcherProperties properties) { + super(properties); + destinationConnectorProperties = new DestinationConnectorProperties(true); + } + + @Override + public String getProtocol() { + return "HTTP"; + } + + @Override + public String getName() { + return "HTTP Sender"; + } + + @Override + public String toFormattedString() { + return ""; + } + + @Override + public boolean equals(Object o) { + return false; + } + + @Override + public DestinationConnectorProperties getDestinationConnectorProperties() { + return destinationConnectorProperties; + } + + @Override + public boolean canValidateResponse() { + return false; + } + + @Override + public ConnectorProperties clone() { + return new TLSHttpDispatcherProperties(this); + } + + @Override + public void migrate3_0_1(DonkeyElement donkeyElement) { } + + @Override + public void migrate3_0_2(DonkeyElement donkeyElement) { } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSListenerProperties.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSListenerProperties.java new file mode 100644 index 000000000..db8c19fe6 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSListenerProperties.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.properties; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; +import org.openintegrationengine.tlsmanager.shared.models.ClientAuthMode; + +import java.util.Map; +import java.util.Objects; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@AllArgsConstructor +@ToString(callSuper = true) +public class TLSListenerProperties extends AbstractTLSConnectorProperties { + + private String serverCertificateAlias; + + private ClientAuthMode clientAuthMode; + + public TLSListenerProperties() { + super(); + + serverCertificateAlias = null; + + clientAuthMode = ClientAuthMode.NONE; + } + + public TLSListenerProperties(TLSListenerProperties props) { + super(props); + + serverCertificateAlias = props.getServerCertificateAlias(); + + clientAuthMode = Objects.requireNonNullElse( + props.getClientAuthMode(), + ClientAuthMode.NONE + ); + } + + @Override + public String getName() { + return TLSPluginConstants.TLS_LISTENER_CONNECTOR_PROPERTIES_PLUGIN_POINT_NAME; + } + + @Override + public TLSListenerProperties clone() { + return new TLSListenerProperties(this); + } + + @Override + public Map getPurgedProperties() { + return Map.of(); + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSSenderProperties.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSSenderProperties.java new file mode 100644 index 000000000..15638bad4 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/properties/TLSSenderProperties.java @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.properties; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; + +import java.util.Map; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@AllArgsConstructor +@ToString(callSuper = true) +public class TLSSenderProperties extends AbstractTLSConnectorProperties { + + private boolean isServerCertificateValidationEnabled; + + private boolean isHostnameVerificationEnabled; + private String clientCertificateAlias; + + public TLSSenderProperties() { + super(); + + isServerCertificateValidationEnabled = false; + + isHostnameVerificationEnabled = true; + clientCertificateAlias = null; + } + + public TLSSenderProperties(TLSSenderProperties props) { + super(props); + + isServerCertificateValidationEnabled = props.isServerCertificateValidationEnabled(); + + isHostnameVerificationEnabled = props.isHostnameVerificationEnabled(); + clientCertificateAlias = props.getClientCertificateAlias(); + } + + @Override + public String getName() { + return TLSPluginConstants.TLS_SENDER_CONNECTOR_PROPERTIES_PLUGIN_POINT_NAME; + } + + @Override + public TLSSenderProperties clone() { + return new TLSSenderProperties(this); + } + + @Override + public Map getPurgedProperties() { + return Map.of(); + } +} diff --git a/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/servlet/TLSServletInterface.java b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/servlet/TLSServletInterface.java new file mode 100644 index 000000000..c3b8c4e42 --- /dev/null +++ b/plugins/tls/shared/src/main/java/org/openintegrationengine/tlsmanager/shared/servlet/TLSServletInterface.java @@ -0,0 +1,484 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright (c) 2025 NovaMap Health Limited + */ + +package org.openintegrationengine.tlsmanager.shared.servlet; + +import com.kaurpalang.mirth.annotationsplugin.annotation.MirthApiProvider; +import com.kaurpalang.mirth.annotationsplugin.type.ApiProviderType; +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.core.Operation; +import com.mirth.connect.client.core.api.BaseServletInterface; +import com.mirth.connect.client.core.api.MirthOperation; +import com.mirth.connect.client.core.api.Param; +import com.mirth.connect.connectors.http.HttpDispatcherProperties; +import com.mirth.connect.connectors.tcp.TcpDispatcherProperties; +import com.mirth.connect.connectors.ws.DefinitionServiceMap; +import com.mirth.connect.connectors.ws.WebServiceDispatcherProperties; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.glassfish.jersey.media.multipart.FormDataParam; +import org.openintegrationengine.tlsmanager.shared.TLSPluginConstants; +import org.openintegrationengine.tlsmanager.shared.models.ConnectionTestResult; +import org.openintegrationengine.tlsmanager.shared.models.LocalCertificate; +import org.openintegrationengine.tlsmanager.shared.models.TrustedCertificate; + +import javax.ws.rs.Consumes; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import java.io.InputStream; +import java.util.List; +import java.util.Set; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM; +import static javax.ws.rs.core.MediaType.APPLICATION_XML; + +@Path("/tlsmanager") +@Tag(name = TLSPluginConstants.PLUGIN_POINTNAME) +@Consumes({APPLICATION_XML, APPLICATION_JSON}) +@Produces({APPLICATION_XML, APPLICATION_JSON}) +@MirthApiProvider(type = ApiProviderType.SERVLET_INTERFACE) +public interface TLSServletInterface extends BaseServletInterface { + + @GET + @Path("/importedcertificates") + @Produces({APPLICATION_XML, APPLICATION_JSON}) + @ApiResponse(responseCode = "200", description = "Found the information", + content = { + @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = Set.class)), + @Content(mediaType = APPLICATION_XML, schema = @Schema(implementation = Set.class)) + }) + @MirthOperation( + name = "getImportedCertificates", + display = "Get list of imported certificates", + type = Operation.ExecuteType.ASYNC + ) + Set getPublicCertificates(); + + @GET + @Path("/clientcertificates") + @Produces({APPLICATION_XML, APPLICATION_JSON}) + @ApiResponse(responseCode = "200", description = "Found the information", + content = { + @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = Set.class)), + @Content(mediaType = APPLICATION_XML, schema = @Schema(implementation = Set.class)) + }) + @MirthOperation( + name = "getClientCertificates", + display = "Get list of client certificates", + type = Operation.ExecuteType.ASYNC + ) + Set getClientCertificates(); + + @GET + @Path("/keystore") + @Produces({APPLICATION_OCTET_STREAM}) + @ApiResponse( + responseCode = "200", + description = "Retrieve current additional keystore as byte array", + content = { + @Content(mediaType = APPLICATION_OCTET_STREAM, schema = @Schema(type = "string", format = "binary")), + }) + @MirthOperation( + name = "getTlsKeystore", + display = "Retrieve current additional keystore as byte array", + type = Operation.ExecuteType.ASYNC + ) + byte[] getKeystore(); + + @POST + @Path("/truststore") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @io.swagger.v3.oas.annotations.Operation( + summary = "Overwrite the in use truststore" + ) + @MirthOperation( + name = "setTlsKeystore", + display = "Write the additional truststore from the given byte array", + type = Operation.ExecuteType.ASYNC + ) + String setTruststore( + @Param("inputStream") + @Parameter( + description = "The truststore file to upload.", + schema = @Schema(description = "The truststore file to upload.", type = "string", format = "binary") + ) + @FormDataParam("file") InputStream inputStream, + + @Param("password") + @Parameter(description = "Truststore password") + @Schema(description = "Truststore password", type = "string") + @FormDataParam("password") + String password + ) throws ClientException; + + @GET + @Path("/systemCertificates") + @Produces({APPLICATION_XML, APPLICATION_JSON}) + @ApiResponse( + responseCode = "200", + description = "Retrieve certificates from system truststore", + content = { + @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = List.class)), + @Content(mediaType = APPLICATION_XML, schema = @Schema(implementation = List.class)) + }) + @MirthOperation( + name = "getSystemCertificates", + display = "Get the certificates from the system truststore", + type = Operation.ExecuteType.ASYNC + ) + List getSystemCertificates(); + + @GET + @Path("/localCertificates") + @Produces({APPLICATION_XML, APPLICATION_JSON}) + @ApiResponse( + responseCode = "200", + description = "Retrieve certificate/key pairs from current additional keystore", + content = { + @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = List.class)), + @Content(mediaType = APPLICATION_XML, schema = @Schema(implementation = List.class)) + }) + @MirthOperation( + name = "getLocalCertificates", + display = "Get the certificate/key pairs from the keystore", + type = Operation.ExecuteType.ASYNC + ) + List getLocalCertificates(); + + @PUT + @Path("/localCertificates") + @Consumes({APPLICATION_XML, APPLICATION_JSON}) + @io.swagger.v3.oas.annotations.Operation( + summary = "Overwrite the local certificates within the in use keystore" + ) + @MirthOperation( + name = "setLocalCertificates", + display = "Write the keystore from the given certificate/key pair list", + type = Operation.ExecuteType.ASYNC + ) + void setLocalCertificates( + @Param("localCertificates") + @RequestBody(description = "The list of certificate/key pairs to write to the keystore.", required = true, content = { + @Content(mediaType = MediaType.APPLICATION_XML), + @Content(mediaType = MediaType.APPLICATION_JSON) + }) + List localCertificates + ); + + @GET + @Path("/trustedCertificates") + @Produces({APPLICATION_XML, APPLICATION_JSON}) + @ApiResponse( + responseCode = "200", + description = "Retrieve certificates from current additional truststore", + content = { + @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = List.class)), + @Content(mediaType = APPLICATION_XML, schema = @Schema(implementation = List.class)) + }) + @MirthOperation( + name = "getTrustedCertificates", + display = "Get the certificates from the truststore", + type = Operation.ExecuteType.ASYNC + ) + List getTrustedCertificates(); + + @PUT + @Path("/trustedCertificates") + @Consumes({APPLICATION_XML, APPLICATION_JSON}) + @io.swagger.v3.oas.annotations.Operation( + summary = "Overwrite the trusted certificates within the in use truststore" + ) + @MirthOperation( + name = "setTrustedCertificates", + display = "Write the additional truststore from the given certificate list", + type = Operation.ExecuteType.ASYNC + ) + void setTrustedCertificates( + @Param("trustedCertificates") + @RequestBody(description = "The list of certificates to write to the truststore.", required = true, content = { + @Content(mediaType = MediaType.APPLICATION_XML), + @Content(mediaType = MediaType.APPLICATION_JSON) + }) + List trustedCertificates + ); + + @GET + @Path("/remoteCertificates") + @ApiResponse( + responseCode = "200", + description = "Retrieve certificates served from a URL", + content = { + @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = List.class)), + @Content(mediaType = APPLICATION_XML, schema = @Schema(implementation = List.class)) + }) + @MirthOperation( + name = "getRemoteCertificates", + display = "Retrieve the list of certificates served at a certain URL", + type = Operation.ExecuteType.ASYNC + ) + List getRemoteCertificates( + @Param("url") + @Parameter( + description = "The URL which to query for served certificates", + schema = @Schema(type = "string") + ) + @QueryParam("url") String url + ); + + @POST + @Path("/testTcpConnection") + @io.swagger.v3.oas.annotations.Operation( + summary = "Tests whether a connection can be successfully established to the destination endpoint." + ) + @ApiResponse( + content = {@Content( + mediaType = "application/xml", + examples = {@ExampleObject( + name = "connection_test_response_http", + ref = "../apiexamples/connection_test_response_http_xml" + )} + ), @Content( + mediaType = "application/json", + examples = {@ExampleObject( + name = "connection_test_response_http", + ref = "../apiexamples/connection_test_response_http_json" + )} + )} + ) + @MirthOperation( + name = "testTcpConnection", + display = "Test TLS Connection in TCP Senders", + type = Operation.ExecuteType.ASYNC, + auditable = false + ) + ConnectionTestResult testTcpConnection( + @Param("channelId") + @Parameter(description = "The ID of the channel.", required = true) + @QueryParam("channelId") String channelId, + @Param("channelName") + @Parameter(description = "The name of the channel.", required = true) + @QueryParam("channelName") String channelName, + @Param("properties") + @RequestBody(description = "The TCP Sender properties to use.", required = true, content = { + @Content( + mediaType = "application/xml", + examples = { + @ExampleObject(name = "http_dispatcher_properties", ref = "../apiexamples/http_dispatcher_properties_xml") + } + ), + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "http_dispatcher_properties", ref = "../apiexamples/http_dispatcher_properties_json") + } + ) + }) TcpDispatcherProperties httpDispatcherProperties + ) throws ClientException; + + @POST + @Path("/testHttpsConnection") + @io.swagger.v3.oas.annotations.Operation( + summary = "Tests whether a connection can be successfully established to the destination endpoint." + ) + @ApiResponse( + content = {@Content( + mediaType = "application/xml", + examples = {@ExampleObject( + name = "connection_test_response_http", + ref = "../apiexamples/connection_test_response_http_xml" + )} + ), @Content( + mediaType = "application/json", + examples = {@ExampleObject( + name = "connection_test_response_http", + ref = "../apiexamples/connection_test_response_http_json" + )} + )} + ) + @MirthOperation( + name = "testHttpsConnection", + display = "Test TLS Connection in HTTP Senders", + type = Operation.ExecuteType.ASYNC, + auditable = false + ) + ConnectionTestResult testHttpsConnection( + @Param("channelId") + @Parameter(description = "The ID of the channel.", required = true) + @QueryParam("channelId") String channelId, + @Param("channelName") + @Parameter(description = "The name of the channel.", required = true) + @QueryParam("channelName") String channelName, + @Param("properties") + @RequestBody(description = "The HTTP Sender properties to use.", required = true, content = { + @Content( + mediaType = "application/xml", + examples = { + @ExampleObject(name = "http_dispatcher_properties", ref = "../apiexamples/http_dispatcher_properties_xml") + } + ), + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "http_dispatcher_properties", ref = "../apiexamples/http_dispatcher_properties_json") + } + ) + }) HttpDispatcherProperties httpDispatcherProperties + ) throws ClientException; + + @POST + @Path("/testWsConnection") + @io.swagger.v3.oas.annotations.Operation( + summary = "Tests whether a connection can be successfully established to the destination endpoint." + ) + @ApiResponse( + content = {@Content( + mediaType = "application/xml", + examples = {@ExampleObject( + name = "connection_test_response_http", + ref = "../apiexamples/connection_test_response_http_xml" + )} + ), @Content( + mediaType = "application/json", + examples = {@ExampleObject( + name = "connection_test_response_http", + ref = "../apiexamples/connection_test_response_http_json" + )} + )} + ) + @MirthOperation( + name = "testWsConnection", + display = "Test TLS Connection in Web Service Sender", + type = Operation.ExecuteType.ASYNC, + auditable = false + ) + ConnectionTestResult testWsConnection( + @Param("channelId") + @Parameter(description = "The ID of the channel.", required = true) + @QueryParam("channelId") String channelId, + @Param("channelName") + @Parameter(description = "The name of the channel.", required = true) + @QueryParam("channelName") String channelName, + @Param("properties") + @RequestBody(description = "The WebService Sender properties to use.", required = true) WebServiceDispatcherProperties wsDispatcherProperties + ) throws ClientException; + + @POST + @Path("/_cacheWsdlFromUrl") + @io.swagger.v3.oas.annotations.Operation( + summary = "Downloads the WSDL at the specified URL and caches the web service definition tree." + ) + @MirthOperation( + name = "cacheWsdlFromUrl", + display = "Download and cache WSDL", + type = Operation.ExecuteType.ASYNC, + auditable = false + ) + Object cacheWsdlFromUrl( + @Param("channelId") + @Parameter(description = "The ID of the channel.", required = true) + @QueryParam("channelId") + String channelId, + + @Param("channelName") + @Parameter(description = "The name of the channel.") + @QueryParam("channelName") + String channelName, + + @Param("properties") + @RequestBody( + description = "The Web Service Sender properties to use. These properties can be found in the exported channel's XML file. Copy the data from the opening tag <destinationConnectorProperties> to the closing tag </wsdlDefinitionMap> (including the tags). Paste over the information below between the opening and closing tags for <com.mirth.connect.connectors.ws.WebServiceDispatcherProperties>.", + required = true, + content = {@Content(mediaType = "application/xml"), @Content(mediaType = "application/json")} + ) WebServiceDispatcherProperties properties + ) throws ClientException; + + @POST + @Path("/_isWsdlCached") + @Consumes({"application/x-www-form-urlencoded"}) + @io.swagger.v3.oas.annotations.Operation( + summary = "Returns true if the definition tree for the WSDL is cached by the server." + ) + @MirthOperation( + name = "isWsdlCached", + display = "Check if WSDL is cached", + type = Operation.ExecuteType.ASYNC, + auditable = false + ) + boolean isWsdlCached( + @Param("channelId") + @Parameter(description = "The ID of the channel.", required = true, schema = @Schema(description = "The ID of the channel.")) + @FormParam("channelId") + String channelId, + + @Param("channelName") + @Parameter(description = "The name of the channel.", schema = @Schema(description = "The name of the channel.")) + @FormParam("channelName") + String channelName, + + @Param("wsdlUrl") + @Parameter(description = "The full URL to the WSDL describing the web service method to be called.", required = true, schema = @Schema(description = "The full URL to the WSDL describing the web service method to be called.")) + @FormParam("wsdlUrl") + String wsdlUrl, + + @Param("username") + @Parameter(description = "Username used to authenticate to the web server.", schema = @Schema(description = "Username used to authenticate to the web server.")) + @FormParam("username") + String username, + + @Param(value = "password", excludeFromAudit = true) + @Parameter(description = "Password used to authenticate to the web server.", schema = @Schema(description = "Password used to authenticate to the web server.")) + @FormParam("password") + String password + ) throws ClientException; + + @POST + @Path("/getDefinition") + @Consumes({"application/x-www-form-urlencoded"}) + @io.swagger.v3.oas.annotations.Operation( + summary = "Retrieves the definition service map corresponding to the specified WSDL." + ) + @MirthOperation( + name = "getDefinition", + display = "Get WSDL Definition", + type = Operation.ExecuteType.ASYNC, + auditable = false + ) + DefinitionServiceMap getDefinition( + @Param("channelId") + @Parameter(description = "The ID of the channel.", required = true, schema = @Schema(description = "The ID of the channel.")) + @FormParam("channelId") + String channelId, + + @Param("channelName") + @Parameter(description = "The name of the channel.", schema = @Schema(description = "The name of the channel.")) + @FormParam("channelName") + String channelName, + + @Param("wsdlUrl") @Parameter(description = "The full URL to the WSDL describing the web service method to be called.", required = true, schema = @Schema(description = "The full URL to the WSDL describing the web service method to be called.")) + @FormParam("wsdlUrl") + String wsdlUrl, + + @Param("username") @Parameter(description = "Username used to authenticate to the web server.", schema = @Schema(description = "Username used to authenticate to the web server.")) + @FormParam("username") + String username, + + @Param(value = "password",excludeFromAudit = true) + @Parameter(description = "Password used to authenticate to the web server.", schema = @Schema(description = "Password used to authenticate to the web server.")) + @FormParam("password") + String password + ) throws ClientException; +} diff --git a/plugins/tls/tools/cert-revocation/README.md b/plugins/tls/tools/cert-revocation/README.md new file mode 100644 index 000000000..c0b3b5a60 --- /dev/null +++ b/plugins/tls/tools/cert-revocation/README.md @@ -0,0 +1,37 @@ +# CRL setup validation tools + +This tool directory is meant to test and validate the correct use of revoked certificates. The included scripts +generate a CA, issue two TLS certificates, and revoke one of them. Also generated are a bunch of additional files. + +## Setup + +### `/etc/hosts` + +In order to send hostname requests to the Caddy server, the following entries should be added to `/etc/hosts` + +```text +127.0.0.1 valid.crl.caddy +127.0.0.1 revoked.crl.caddy +``` + +## Usage + +The `./minilab.sh` generates the necessary file bundle. Run this first. This script does not have to be run again. + +**Important file paths:** +- `tools/cert-revocation/mini-ca/demoCA/certs/ca.crt` + - The Root CA certificate +- `tools/cert-revocation/mini-ca/demoCA/crl/ca.crl` + - The revocation list file +- `tools/cert-revocation/mini-ca/server1.crt` and `tools/cert-revocation/mini-ca/server1.key` + - Certificate and private key for the valid cert +- `tools/cert-revocation/mini-ca/server2.crt` and `tools/cert-revocation/mini-ca/server2.key` + - Certificate and private key for the revoked cert +- `tools/cert-revocation/mini-ca/truststore.jks` + - `JKS` keystore with the rot CA cert for easier Java usage + +To validate the correctness of the generated bundle separately from the plugin and Java code, run `./verify.sh`. + +The bundle is designed to be used with the [Caddy container](../../docker/compose.yaml) to serve two HTTP endpoints: +1. `https://valid.crl.caddy` - is served with a valid TLS certificate and responds with `HTTP 200` and `Hai with valid cert` +2. `https://revoked.crl.caddy` - is served with a revoked TLS certificate and responds with `HTTP 200` and `Hai with revoked cert` diff --git a/plugins/tls/tools/cert-revocation/minilab.sh b/plugins/tls/tools/cert-revocation/minilab.sh new file mode 100755 index 000000000..6117dfc11 --- /dev/null +++ b/plugins/tls/tools/cert-revocation/minilab.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# fresh workspace +WORKDIR="$(pwd)/mini-ca" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR"/demoCA/{certs,crl,newcerts,private} +touch "$WORKDIR"/demoCA/index.txt +echo 1000 > "$WORKDIR"/demoCA/serial +echo 1000 > "$WORKDIR"/demoCA/crlnumber + +# minimal OpenSSL CA config +cat > "$WORKDIR/openssl.cnf" <<'EOF' +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = ./demoCA +certs = $dir/certs +crl_dir = $dir/crl +database = $dir/index.txt +new_certs_dir = $dir/newcerts +certificate = $dir/certs/ca.crt +serial = $dir/serial +crlnumber = $dir/crlnumber +crl = $dir/crl/ca.crl +private_key = $dir/private/ca.key +RANDFILE = $dir/private/.rand + +# policy: keep it permissive for testing +policy = policy_loose +x509_extensions = v3_end_entity +copy_extensions = copy +default_md = sha256 +default_days = 825 +unique_subject = no +email_in_dn = no +name_opt = ca_default +cert_opt = ca_default +default_crl_days = 30 + +[ policy_loose ] +commonName = supplied +organizationName = optional +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationalUnitName = optional + +[ req ] +default_bits = 2048 +distinguished_name = req_dn +string_mask = utf8only +default_md = sha256 +prompt = no + +[ req_dn ] +CN = placeholder + +# CA cert extensions +[v3_ca] +basicConstraints = critical, CA:true, pathlen:0 +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer + +# End-entity cert extensions +[v3_end_entity] +basicConstraints = critical, CA:false +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth, clientAuth +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +crlDistributionPoints = URI:http://example.test/crl/ca.crl + +# CRL extensions +[ crl_ext ] +authorityKeyIdentifier = keyid:always +EOF + +pushd "$WORKDIR" >/dev/null + +# 1) Create CA key + self-signed cert +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out demoCA/private/ca.key +openssl req -new -x509 -key demoCA/private/ca.key -sha256 -days 3650 \ + -subj "/C=EE/O=Test CA/CN=Test Root CA" \ + -config openssl.cnf -extensions v3_ca \ + -out demoCA/certs/ca.crt + +# 2) Create two end-entity keys + CSRs +for n in 1 2; do + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server${n}.key + openssl req -new -key server${n}.key \ + -subj "/C=EE/O=Test Org/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:valid.crl.caddy,DNS:revoked.crl.caddy,DNS:mtls.caddy,IP:127.0.0.1" \ + -config openssl.cnf -out server${n}.csr +done + +# 3) Issue two certs using the CA (keeps the CA index/database updated) +openssl ca -batch -config openssl.cnf -extensions v3_end_entity \ + -in server1.csr -out server1.crt +openssl ca -batch -config openssl.cnf -extensions v3_end_entity \ + -in server2.csr -out server2.crt + +# 4) Revoke one of them (server2) +openssl ca -config openssl.cnf -revoke server2.crt -crl_reason cessationOfOperation + +# 5) Generate CRL (PEM), plus a DER copy (handy for Java) +openssl ca -config openssl.cnf -gencrl -crldays 30 -out demoCA/crl/ca.crl +openssl crl -in demoCA/crl/ca.crl -outform DER -out demoCA/crl/ca.crl.der + +# quick local verification examples +echo "== Basic verify (no CRL check):" +openssl verify -CAfile demoCA/certs/ca.crt server1.crt server2.crt || true + +echo "== Verify WITH CRL check (server2 should fail):" +openssl verify -crl_check -CAfile demoCA/certs/ca.crt -CRLfile demoCA/crl/ca.crl server1.crt server2.crt || true + +# Generate a truststore for easy Java stuffs +keytool -importcert -noprompt -trustcacerts \ + -file demoCA/certs/ca.crt \ + -alias test-root-ca \ + -keystore truststore.p12 -storepass changeit + +popd >/dev/null + +echo "Done. Artifacts in: $WORKDIR" diff --git a/plugins/tls/tools/cert-revocation/verify.sh b/plugins/tls/tools/cert-revocation/verify.sh new file mode 100755 index 000000000..9bb0cb624 --- /dev/null +++ b/plugins/tls/tools/cert-revocation/verify.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +function validate() { + local url=$1 + openssl s_client -connect "$url" -showcerts < /dev/null \ + | sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' \ + > peer.crt + + openssl verify \ + -CAfile mini-ca/demoCA/certs/ca.crt \ + -CRLfile mini-ca/demoCA/crl/ca.crl \ + -crl_check peer.crt + + rm peer.crt +} + +echo "######################" +echo +echo "Validating the valid certificate" +echo +echo "######################" +echo +validate "valid.crl.caddy:9443" +echo "^^^^^^^^^^^^" +echo "The last line should say \"peer.crt: OK\"" + +echo +echo "====================================================================" +echo +echo "######################" +echo +echo "Validating the revoked certificate" +echo +echo "######################" +echo +validate "revoked.crl.caddy:9443" +echo "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^" +echo "The last line should say \"error peer.crt: verification failed\"" diff --git a/plugins/tls/web-ui/.dockerignore b/plugins/tls/web-ui/.dockerignore new file mode 100644 index 000000000..77ea1d5ad --- /dev/null +++ b/plugins/tls/web-ui/.dockerignore @@ -0,0 +1,17 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.nyc_output +coverage +.vscode +.idea +*.log +.DS_Store +Thumbs.db diff --git a/plugins/tls/web-ui/.gitignore b/plugins/tls/web-ui/.gitignore new file mode 100644 index 000000000..25ec08344 --- /dev/null +++ b/plugins/tls/web-ui/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.cursor/ +project-setup.md +scratchpad.md +/dashboard +.env +*.crt +*.key +*.csr +*.srl +*.pem +/tls-manager +/development \ No newline at end of file diff --git a/plugins/tls/web-ui/.nvmrc b/plugins/tls/web-ui/.nvmrc new file mode 100644 index 000000000..e28b3a48d --- /dev/null +++ b/plugins/tls/web-ui/.nvmrc @@ -0,0 +1 @@ +v22.7.0 \ No newline at end of file diff --git a/plugins/tls/web-ui/DOCKER.md b/plugins/tls/web-ui/DOCKER.md new file mode 100644 index 000000000..f575c80d3 --- /dev/null +++ b/plugins/tls/web-ui/DOCKER.md @@ -0,0 +1,117 @@ +# Docker Setup for Settings Dashboard + +This document explains how to build and run the Settings Dashboard using Docker. + +## Quick Start + +### Option 1: Using Docker Compose (Recommended) +```bash +# Build and start the application +docker-compose up --build + +# Access the application at: http://localhost:3000/dashboard +``` + +### Option 2: Using Docker Commands +```bash +# Build the image +docker build -t settings-dashboard . + +# Run the container +docker run -p 3000:3000 settings-dashboard + +# Access the application at: http://localhost:3000/dashboard +``` + +### Option 3: Using Helper Scripts +```bash +# Make the script executable (first time only) +chmod +x docker-scripts.sh + +# Build and run +./docker-scripts.sh build +./docker-scripts.sh run + +# Or use docker-compose +./docker-scripts.sh compose-up +``` + +## Configuration + +### Environment Variables + +You can customize the application behavior using environment variables: + +- `PORT`: Port to run the server on (default: 3000) +- `API_TARGET`: Backend API URL to proxy requests to (default: https://oie-test.quantis.health) + +### Example with Custom API Target +```bash +docker run -p 3000:3000 -e API_TARGET=https://your-api-server.com settings-dashboard +``` + +Or with docker-compose, modify the `docker-compose.yml`: +```yaml +environment: + - API_TARGET=https://your-api-server.com +``` + +## Architecture + +The Docker setup includes: + +1. **Multi-stage Build**: + - Builder stage: Installs all dependencies and builds the React app + - Production stage: Only includes production dependencies and built files + +2. **Express Server**: + - Serves static files from the built React app + - Proxies API requests to the backend + - Handles client-side routing + - Sets proper MIME types for JavaScript modules + +3. **Security Features**: + - Runs as non-root user + - Health checks + - Proper cookie handling for cross-origin requests + +## Troubleshooting + +### MIME Type Errors +The production server explicitly sets the correct MIME types for JavaScript modules to prevent the "Expected a JavaScript-or-Wasm module script" error. + +### API Proxy Issues +If you're having issues with API requests: +1. Check that the `API_TARGET` environment variable is set correctly +2. Verify the backend server is accessible from the container +3. Check the container logs: `docker logs ` + +### Port Conflicts +If port 3000 is already in use: +```bash +docker run -p 3001:3000 settings-dashboard +# Access at: http://localhost:3001/dashboard +``` + +## Development vs Production + +- **Development**: Use `npm run dev` for local development with Vite +- **Production**: Use Docker for production deployment + +The Docker setup is optimized for production with: +- Smaller image size (multi-stage build) +- Security best practices +- Proper static file serving +- API proxying +- Health checks + +## File Structure + +``` +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Docker Compose configuration +├── docker-scripts.sh # Helper scripts +├── server.prod.js # Production Express server +├── .dockerignore # Files to exclude from Docker build +└── DOCKER.md # This documentation +``` diff --git a/plugins/tls/web-ui/Dockerfile b/plugins/tls/web-ui/Dockerfile new file mode 100644 index 000000000..b6af96b99 --- /dev/null +++ b/plugins/tls/web-ui/Dockerfile @@ -0,0 +1,51 @@ +# Multi-stage build for production +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install all dependencies (including dev dependencies for build) +RUN npm ci + +# Copy source code +COPY . . + +# Build the React app +RUN npm run build + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install only production dependencies +RUN npm ci --only=production && npm cache clean --force + +# Copy built application from builder stage +COPY --from=builder /app/tls-manager ./tls-manager + +# Copy production server +COPY server.prod.js ./ + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 + +# Change ownership of the app directory +RUN chown -R nextjs:nodejs /app +USER nextjs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/tls-manager', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" + +# Start the production server +CMD ["npm", "start"] diff --git a/plugins/tls/web-ui/README.md b/plugins/tls/web-ui/README.md new file mode 100644 index 000000000..1d360b4d2 --- /dev/null +++ b/plugins/tls/web-ui/README.md @@ -0,0 +1,119 @@ +# Settings Dashboard (TLS / Certificates) + +A React + Vite + MUI + Tailwind v4 dashboard for managing certificate stores and PKI-related settings. + +## Stack +- React 18+ with functional components and hooks +- Vite 7 +- MUI (Material UI) + Tailwind CSS v4 (via layers) +- React Router DOM v6/7 +- Node >= 20 + +## Quick start +```bash +# Install dependencies +npm install + +# Start dev server (http://localhost:5173/dashboard) +npm run dev + +# Lint +npm run lint + +# Build static assets (output in ./dashboard) +npm run build + +# Preview production build +npm run preview +``` + +### Environment +- Create a `.env` file if needed: + - `VITE_API_BASE_URL=https://oie-test.quantis.health/api` + +Axios is configured with `withCredentials=true`, so successful login at `/users/_login` will set the `JSESSIONID` cookie in the browser (CORS must allow credentials). + +## Routing & Auth +- BrowserRouter `basename` is `/dashboard`. +- Routes: + - `/login` (public) + - `/tls` (protected) +- `AuthContext` provides `login()` and `logout()`; `ProtectedRoute` guards private routes. +- Unauthenticated users are redirected to `/login`. + +## Layout +- `DashboardLayout` uses a top AppBar only (no drawer). The app content renders beneath it. + +## TLS Manager UI +- Page: `src/pages/TlsManagement.jsx` +- Features a tabbed interface with 3 stores and count chips: + 1. Native Java Certificate Store (read-only) + 2. Additional Trusted Certificates + 3. Private Key Store +- Selected tab persists in the URL via `?tab=`. +- Search input filters the visible list for the active store. +- Cards: certificates are displayed as cards (not a table) showing: + - Name/alias, type (Root/Intermediate/End-entity) + - Subject, Issuer + - Valid From/To + - Fingerprint (SHA‑1) + - Status pill: Valid / Expiring soon (30 days) / Expired + - Actions: View Details, Export (placeholder) + +### Reusable components +- `TabsWithCounts` — Tabs with icon + label + count, full width +- `TabPanel` — Conditional content wrapper for tabs +- `StoreToolbar` — Title, optional warning, action buttons +- `SearchInput` — Debounced search with icon +- `StatusPill` — Validity indicator (30-day threshold) +- `CertificateCard` — Presentational card for a certificate +- `CertificateList` — Responsive grid of cards +- Hook: `useCertificates` — fetches once, returns counts and per-store filtered lists + +## Data & Services +- Mock service: `src/services/tlsService.js` + - Returns a mixed list across stores with fields: `alias`, `name`, `type`, `subject`, `issuer`, `validFrom`, `validTo`, `fingerprintSha1`, `hasPrivateKey`, `store` (`native|trusted|private`). +- Replace with a real API by switching the implementation in `tlsService.js` to use Axios. + +## Environment +- Vite base path is `/dashboard/` (see `vite.config.js`). +- API base URL is centralized in `src/services/api.js` and reads `import.meta.env.VITE_API_BASE_URL`. + +## Build & Deploy +- `vite build` outputs to `./dashboard`. +- Serve the folder under a path matching `/dashboard/` (e.g., Nginx location or app server context path). +- If reverse proxying, ensure the base path is preserved for static assets. + +## Security & PKCS#12 handling (design notes) +- PKCS#12 bundles (.p12/.pfx) typically contain certificates and private keys protected by a password. +- Recommended flow: + - Parse on the server (Java KeyStore, OpenSSL, Python cryptography, or Node/OpenSSL) and return safe JSON (subjects, issuers, validity, fingerprints, chain, aliases). Do not return private key material to the client. + - Client displays the parsed items using the existing card components. +- Optional client-side parsing (prototype only): use a pure JS library (e.g., node-forge) to parse in-memory after prompting for the password; never upload the password; do not persist or log sensitive data. +- Error cases to handle: wrong password, empty/unsupported bags, multiple key entries, duplicated aliases. + +## Conventions +- Functional components, hooks, and one component per file +- Tailwind utilities for layout spacing; MUI `sx` for component overrides +- Avoid inline styles for static styling +- Keep files short (<300 lines) and split into subcomponents when needed + +## Project structure (key folders) +``` +src/ + components/ # Reusable UI components + context/ # Auth context + ProtectedRoute + layout/ # Dashboard layout (AppBar) + pages/ # Route pages (Login, TlsManagement) + services/ # Data fetching (mock service for now) + hooks/ # Custom hooks (useCertificates) +``` + +## Development tips +- Use `console.debug` for quick diagnostics. +- When swapping to real APIs, centralize Axios setup (base URL, interceptors) and error handling. +- Keep secrets out of logs and never send private keys to the frontend. + +--- +Maintainers: update this README when adding routes, API contracts, or new modules. + diff --git a/plugins/tls/web-ui/docker-compose.yml b/plugins/tls/web-ui/docker-compose.yml new file mode 100644 index 000000000..0c32d5085 --- /dev/null +++ b/plugins/tls/web-ui/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + settings-dashboard: + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + # Override the API target if needed + # - API_TARGET=https://your-api-server.com + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/dashboard', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/plugins/tls/web-ui/docker-scripts.sh b/plugins/tls/web-ui/docker-scripts.sh new file mode 100755 index 000000000..3525a4f60 --- /dev/null +++ b/plugins/tls/web-ui/docker-scripts.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Docker helper scripts for settings-dashboard + +case "$1" in + "build") + echo "🔨 Building Docker image..." + docker build -t settings-dashboard . + ;; + "run") + echo "🚀 Running Docker container..." + docker run -p 3000:3000 --name settings-dashboard-container settings-dashboard + ;; + "run-detached") + echo "🚀 Running Docker container in background..." + docker run -d -p 3000:3000 --name settings-dashboard-container settings-dashboard + ;; + "stop") + echo "🛑 Stopping Docker container..." + docker stop settings-dashboard-container + ;; + "remove") + echo "🗑️ Removing Docker container..." + docker rm settings-dashboard-container + ;; + "logs") + echo "📋 Showing Docker container logs..." + docker logs -f settings-dashboard-container + ;; + "clean") + echo "🧹 Cleaning up Docker resources..." + docker stop settings-dashboard-container 2>/dev/null || true + docker rm settings-dashboard-container 2>/dev/null || true + docker rmi settings-dashboard 2>/dev/null || true + ;; + "compose-up") + echo "🚀 Starting with docker-compose..." + docker-compose up --build + ;; + "compose-down") + echo "🛑 Stopping docker-compose..." + docker-compose down + ;; + *) + echo "Usage: $0 {build|run|run-detached|stop|remove|logs|clean|compose-up|compose-down}" + echo "" + echo "Commands:" + echo " build - Build the Docker image" + echo " run - Run the container (foreground)" + echo " run-detached - Run the container (background)" + echo " stop - Stop the running container" + echo " remove - Remove the container" + echo " logs - Show container logs" + echo " clean - Clean up all Docker resources" + echo " compose-up - Start with docker-compose" + echo " compose-down - Stop docker-compose" + exit 1 + ;; +esac diff --git a/plugins/tls/web-ui/eslint.config.js b/plugins/tls/web-ui/eslint.config.js new file mode 100644 index 000000000..cee1e2c78 --- /dev/null +++ b/plugins/tls/web-ui/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/plugins/tls/web-ui/index.html b/plugins/tls/web-ui/index.html new file mode 100644 index 000000000..851e47332 --- /dev/null +++ b/plugins/tls/web-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + OIE Dashboard + + +
+ + + diff --git a/plugins/tls/web-ui/package-lock.json b/plugins/tls/web-ui/package-lock.json new file mode 100644 index 000000000..e60514f11 --- /dev/null +++ b/plugins/tls/web-ui/package-lock.json @@ -0,0 +1,4989 @@ +{ + "name": "settings-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "settings-dashboard", + "version": "0.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.2", + "@mui/material": "^7.3.2", + "axios": "^1.12.2", + "dayjs": "^1.11.18", + "express": "^5.1.0", + "http-proxy-middleware": "^3.0.5", + "jsrsasign": "^11.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.1" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "tailwindcss": "^4.1.13", + "vite": "^7.1.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz", + "integrity": "sha512-AOyfHjyDKVPGJJFtxOlept3EYEdLoar/RvssBTWVAvDJGIE676dLi2oT/Kx+FoVXFoA/JdV7DEMq/BVWV3KHRw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.2.tgz", + "integrity": "sha512-TZWazBjWXBjR6iGcNkbKklnwodcwj0SrChCNHc9BhD9rBgET22J1eFhHsEmvSvru9+opDy3umqAimQjokhfJlQ==", + "dependencies": { + "@babel/runtime": "^7.28.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz", + "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/core-downloads-tracker": "^7.3.2", + "@mui/system": "^7.3.2", + "@mui/types": "^7.4.6", + "@mui/utils": "^7.3.2", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.1.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.2.tgz", + "integrity": "sha512-ha7mFoOyZGJr75xeiO9lugS3joRROjc8tG1u4P50dH0KR7bwhHznVMcYg7MouochUy0OxooJm/OOSpJ7gKcMvg==", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/utils": "^7.3.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.2.tgz", + "integrity": "sha512-PkJzW+mTaek4e0nPYZ6qLnW5RGa0KN+eRTf5FA2nc7cFZTeM+qebmGibaTLrgQBy3UpcpemaqfzToBNkzuxqew==", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.2.tgz", + "integrity": "sha512-9d8JEvZW+H6cVkaZ+FK56R53vkJe3HsTpcjMUtH8v1xK6Y1TjzHdZ7Jck02mGXJsE6MQGWVs3ogRHTQmS9Q/rA==", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/private-theming": "^7.3.2", + "@mui/styled-engine": "^7.3.2", + "@mui/types": "^7.4.6", + "@mui/utils": "^7.3.2", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", + "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", + "dependencies": { + "@babel/runtime": "^7.28.3" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz", + "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/types": "^7.4.6", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.34", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", + "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz", + "integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz", + "integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz", + "integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz", + "integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz", + "integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz", + "integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz", + "integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz", + "integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz", + "integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz", + "integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz", + "integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz", + "integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz", + "integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz", + "integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz", + "integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz", + "integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz", + "integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz", + "integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz", + "integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz", + "integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz", + "integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.13.tgz", + "integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "tailwindcss": "4.1.13" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/http-proxy": { + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", + "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "dependencies": { + "undici-types": "~7.12.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "node_modules/@types/react": { + "version": "19.1.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", + "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", + "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.3", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.34", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.3.tgz", + "integrity": "sha512-mcE+Wr2CAhHNWxXN/DdTI+n4gsPc5QpXpWnyCQWiQYIYZX+ZMJ8juXZgjRa/0/YPJo/NSsgW15/YgmI4nbysYw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", + "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.8.2", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", + "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.218", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", + "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsrsasign": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.0.tgz", + "integrity": "sha512-Ov74K9GihaK9/9WncTe1mPmvrO7Py665TUfUKvraXBpu+xcTWitrtuOwcjf4KMU9maPaYn0OuaWy0HOzy/GBXg==", + "funding": { + "url": "https://github.com/kjur/jsrsasign#donations" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.1.tgz", + "integrity": "sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.1.tgz", + "integrity": "sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==", + "dependencies": { + "react-router": "7.9.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz", + "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.2", + "@rollup/rollup-android-arm64": "4.50.2", + "@rollup/rollup-darwin-arm64": "4.50.2", + "@rollup/rollup-darwin-x64": "4.50.2", + "@rollup/rollup-freebsd-arm64": "4.50.2", + "@rollup/rollup-freebsd-x64": "4.50.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.2", + "@rollup/rollup-linux-arm-musleabihf": "4.50.2", + "@rollup/rollup-linux-arm64-gnu": "4.50.2", + "@rollup/rollup-linux-arm64-musl": "4.50.2", + "@rollup/rollup-linux-loong64-gnu": "4.50.2", + "@rollup/rollup-linux-ppc64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-musl": "4.50.2", + "@rollup/rollup-linux-s390x-gnu": "4.50.2", + "@rollup/rollup-linux-x64-gnu": "4.50.2", + "@rollup/rollup-linux-x64-musl": "4.50.2", + "@rollup/rollup-openharmony-arm64": "4.50.2", + "@rollup/rollup-win32-arm64-msvc": "4.50.2", + "@rollup/rollup-win32-ia32-msvc": "4.50.2", + "@rollup/rollup-win32-x64-msvc": "4.50.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", + "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/plugins/tls/web-ui/package.json b/plugins/tls/web-ui/package.json new file mode 100644 index 000000000..63d5badf1 --- /dev/null +++ b/plugins/tls/web-ui/package.json @@ -0,0 +1,41 @@ +{ + "name": "settings-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "start": "node server.prod.js", + "start:dev": "node server.js" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.2", + "@mui/material": "^7.3.2", + "axios": "^1.12.2", + "dayjs": "^1.11.18", + "express": "^5.1.0", + "http-proxy-middleware": "^3.0.5", + "jsrsasign": "^11.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.1" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "tailwindcss": "^4.1.13", + "vite": "^7.1.2" + } +} diff --git a/plugins/tls/web-ui/public/oie_logo_bottom_text.svg b/plugins/tls/web-ui/public/oie_logo_bottom_text.svg new file mode 100644 index 000000000..94aa2188f --- /dev/null +++ b/plugins/tls/web-ui/public/oie_logo_bottom_text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/tls/web-ui/public/vite.svg b/plugins/tls/web-ui/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/plugins/tls/web-ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/tls/web-ui/server.prod.js b/plugins/tls/web-ui/server.prod.js new file mode 100644 index 000000000..9d85704be --- /dev/null +++ b/plugins/tls/web-ui/server.prod.js @@ -0,0 +1,80 @@ +import express from 'express' +import { createProxyMiddleware } from 'http-proxy-middleware' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const app = express() +const PORT = process.env.PORT || 3000 + +// API target - MANDATORY environment variable +const API_TARGET = process.env.API_TARGET + +if (!API_TARGET) { + console.error('❌ ERROR: API_TARGET environment variable is required but not set!') + console.error('Please set the API_TARGET environment variable to your backend API URL.') + console.error('Example: API_TARGET=https://your-api-server.com/api') + process.exit(1) +} + +// Serve static files from the dashboard directory +app.use('/tls-manager', express.static(path.join(__dirname, 'tls-manager'), { + // Set proper MIME types for JavaScript modules + setHeaders: (res, filePath) => { + if (filePath.endsWith('.js')) { + res.setHeader('Content-Type', 'application/javascript') + } else if (filePath.endsWith('.mjs')) { + res.setHeader('Content-Type', 'application/javascript') + } else if (filePath.endsWith('.css')) { + res.setHeader('Content-Type', 'text/css') + } + } +})) + +// Proxy API requests to the backend +app.use('/api', createProxyMiddleware({ + target: API_TARGET, + changeOrigin: true, + secure: false, // for local https dev + logger: console, + on: { + proxyReq(proxyReq, req, res) { + console.log('➡️', req.method, req.url); + }, + proxyRes(proxyRes, req, res) { + console.log('⬅️', proxyRes.statusCode); + }, + error(err, req, res) { + console.error('❌', err.message); + res.status(500).json({ error: 'Proxy error' }); + } + } +})) + +// Redirect root to dashboard +app.get('/', (req, res) => { + res.redirect('/tls-manager') +}) + +// Handle client-side routing - serve index.html for dashboard routes +app.get('/tls-manager', (req, res) => { + res.sendFile(path.join(__dirname, 'tls-manager', 'index.html')) +}) + +// Catch-all route for client-side routing (must be last) +app.use((req, res) => { + if (req.path.startsWith('/tls-manager')) { + res.sendFile(path.join(__dirname, 'tls-manager', 'index.html')) + } else { + res.status(404).send('Not Found') + } +}) + +app.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 Server running on http://0.0.0.0:${PORT}`) + console.log(`📁 Serving static files from: ${path.join(__dirname, 'tls-manager')}`) + console.log(`🔗 Proxying API requests to: ${API_TARGET}`) + console.log(`🌐 Access your app at: http://localhost:${PORT}/tls-manager`) +}) diff --git a/plugins/tls/web-ui/specs.md b/plugins/tls/web-ui/specs.md new file mode 100644 index 000000000..a48c69bf8 --- /dev/null +++ b/plugins/tls/web-ui/specs.md @@ -0,0 +1,216 @@ +# Settings Dashboard - Technical Specifications + +## Project Overview + +A React-based certificate management dashboard built with Vite, Material-UI, and Tailwind CSS v4. The application provides comprehensive SSL/TLS certificate management with import, verification, and display capabilities. + +## Technology Stack + +- **Frontend**: React 18+ with functional components and hooks +- **Build Tool**: Vite with base path `/dashboard/` +- **UI Framework**: Material-UI (MUI) for components +- **Styling**: Tailwind CSS v4 for utilities +- **Routing**: React Router DOM v6 +- **Date Handling**: dayjs for robust date operations +- **Cryptography**: jsrsasign for certificate parsing and validation (supports RSA, ECDSA, DSA, Ed25519) +- **State Management**: React Context and custom hooks + +## Architecture + +### Core Components + +**Layout Components:** +- `DashboardLayout.jsx` - Main layout with top bar (no sidebar) +- `ProtectedRoute.jsx` - Route protection for authenticated users +- `AuthContext.jsx` - Authentication state management + +**Certificate Management:** +- `TlsManagement.jsx` - Main certificate management page with tabbed interface +- `CertificateList.jsx` - Responsive grid layout for certificate display +- `CertificateCard.jsx` - Individual certificate card component +- `StatusPill.jsx` - Certificate validity status indicator + +**Import System:** +- `ImportCertificateDialogContent.jsx` - Main import dialog orchestrator +- `UserInputsSection.jsx` - Form inputs and file uploads +- `CertificateDetailsSection.jsx` - Live certificate details display +- `CertificateVerificationSection.jsx` - Certificate verification results +- `MobileCertificateSection.jsx` - Mobile-responsive certificate display + +**Details & Verification:** +- `CertificateDetailsDialog.jsx` - Comprehensive certificate information viewer +- `useCertificateImport.js` - Custom hook for import logic and state management + +### Data Flow + +**Certificate Storage:** +- Internal memory store with localStorage persistence +- Three certificate stores: `native`, `trusted`, `private` +- Base64-encoded PEM format for certificates and private keys + +**API Integration:** +- GET `/tlsmanager/certificates` - Fetch all certificates +- PUT `/tlsmanager/certificates` - Update certificate stores +- Simulated API delays (300ms) for realistic behavior + +## Key Features + +### Certificate Import System + +**Multi-Format Support:** +- PEM certificate import (paste or file upload) +- Private key import for private store +- Automatic certificate parsing and validation +- Real-time certificate details display + +**Import Workflow:** +1. User selects target store (trusted/private) +2. Provides certificate (paste/upload) and optional private key +3. Live certificate details appear immediately +4. Auto-verification runs automatically +5. Alias conflict detection with warnings +6. Final verification before import +7. Confirmation dialog for existing aliases + +**Validation Features:** +- Certificate chain validation +- Private key matching verification +- Certificate status checking (valid/expired/expiring) +- Fingerprint generation (SHA-1/SHA-256) +- Subject Alternative Names extraction + +### User Interface + +**Responsive Design:** +- Two-column layout for import dialog (desktop) +- Mobile-responsive stacked layout +- Responsive certificate grid (1-4 columns based on screen size) +- Consistent Material-UI theming + +**Certificate Display:** +- Card-based layout with status indicators +- Real-time validity status with dayjs +- Comprehensive certificate information +- Export and view details functionality + +**Status Management:** +- Color-coded status pills (Valid/Expiring/Expired) +- Automatic status calculation with configurable thresholds +- Date validation and error handling +- Timezone-aware date operations + +### Security & Validation + +**Certificate Verification:** +- Chain validation with signature verification +- Private key matching for certificate pairs +- Comprehensive error reporting +- Security-focused validation logic + +**Data Integrity:** +- Base64 encoding for secure storage +- PEM format validation +- Certificate fingerprint verification +- Private key format validation + +## Technical Implementation + +### Custom Hooks + +**`useCertificateImport`:** +- Centralized import logic and state management +- Auto-completion for certificate aliases +- Real-time conflict detection +- Verification orchestration + +**`useCertificates`:** +- Certificate data fetching and filtering +- Store-specific data management +- Search and filtering capabilities + +### Utility Functions + +**Certificate Processing:** +- `certificateUtils.js` - PEM conversion and parsing +- `verificationUtils.js` - Comprehensive certificate verification +- `dateUtils.js` - Date formatting and manipulation + +**Service Layer:** +- `tlsService.js` - API integration and data persistence +- `authService.js` - Authentication management +- `api.js` - HTTP client configuration + +### State Management + +**Authentication:** +- Context-based authentication state +- Protected route implementation +- Login/logout functionality + +**Certificate Management:** +- Local state for UI interactions +- Persistent storage with localStorage +- Real-time updates and synchronization + +## Development Guidelines + +### Code Organization + +**Component Structure:** +- Single responsibility principle +- Reusable component design +- Custom hooks for business logic +- Clear prop interfaces + +**Styling Approach:** +- Material-UI components with sx props +- Tailwind utilities for layout and spacing +- Consistent color scheme and theming +- Responsive design patterns + +### Best Practices + +**React Patterns:** +- Functional components with hooks +- Custom hooks for logic reuse +- Proper state management +- Performance optimization + +**Code Quality:** +- ESLint configuration +- TypeScript-ready structure +- Comprehensive error handling +- User-friendly error messages + +## Deployment Configuration + +**Build Settings:** +- Vite base path: `/dashboard/` +- Asset optimization +- Production-ready build +- Environment variable support + +**Browser Support:** +- Modern browser compatibility +- ES6+ feature support +- Responsive design +- Accessibility considerations + +## Future Enhancements + +**Planned Features:** +- Real API integration (currently using internal store) +- Advanced certificate filtering +- Bulk operations +- Certificate renewal notifications +- Advanced security features + +**Technical Improvements:** +- Performance optimization +- Enhanced error handling +- Advanced validation rules +- Improved mobile experience + +--- + +*This specification document provides a comprehensive overview of the Settings Dashboard project, covering architecture, features, implementation details, and development guidelines.* diff --git a/plugins/tls/web-ui/src/App.css b/plugins/tls/web-ui/src/App.css new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/tls/web-ui/src/App.jsx b/plugins/tls/web-ui/src/App.jsx new file mode 100644 index 000000000..6916e3b9f --- /dev/null +++ b/plugins/tls/web-ui/src/App.jsx @@ -0,0 +1,44 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import ProtectedRoute from './context/ProtectedRoute' +import { useAuth } from './context/AuthContext' +import { NotificationProvider } from './context/NotificationContext' +import DashboardLayout from './layout/DashboardLayout' +import Login from './pages/Login' +import TlsManagement from './pages/TlsManagement' + +export default function App() { + const { isAuthenticated } = useAuth() + + return ( + + + + : + } + /> + + + + + + } + /> + } + /> + } + /> + + + + ) +} diff --git a/plugins/tls/web-ui/src/assets/oie_logo_bottom_text.svg b/plugins/tls/web-ui/src/assets/oie_logo_bottom_text.svg new file mode 100644 index 000000000..94aa2188f --- /dev/null +++ b/plugins/tls/web-ui/src/assets/oie_logo_bottom_text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/tls/web-ui/src/assets/react.svg b/plugins/tls/web-ui/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/plugins/tls/web-ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/tls/web-ui/src/components/CertificateCard.jsx b/plugins/tls/web-ui/src/components/CertificateCard.jsx new file mode 100644 index 000000000..c8c966d77 --- /dev/null +++ b/plugins/tls/web-ui/src/components/CertificateCard.jsx @@ -0,0 +1,248 @@ +import React from 'react' +import { Paper, Box, Typography, Stack, Button, Divider, Chip, Tooltip } from '@mui/material' +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined' +import ImportExportOutlinedIcon from '@mui/icons-material/ImportExportOutlined' +import EditOutlinedIcon from '@mui/icons-material/EditOutlined' +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined' +import StatusPill from './StatusPill' + +export default function CertificateCard({ certificate, onViewDetails, onExport, onEditAlias, onRemove, showPrivateKeys = false }) { + const { + name, + type, + subject, + issuer, + validFrom, + validTo, + fingerprintSha1, + hasPrivateKey, + rawPrivateKey, + channelsInUse, + } = certificate + + return ( + + {/* Main Content Area - grows to fill available space */} + + + + + + + + + + + {name} + + + {type} + + + + + + + + + Subject: + + {subject} + + + + + Issuer: + + {issuer} + + + + + + Valid From: + {validFrom} + + + Valid To: + {validTo} + + + + + Fingerprint (SHA-1): + + {fingerprintSha1} + + + + {/* Channels in Use Section */} + {channelsInUse && channelsInUse.length > 0 && ( + + Channels in Use: {channelsInUse.length} + + )} + + {/* Private Key Section */} + {showPrivateKeys && hasPrivateKey && rawPrivateKey && ( + <> + + + Private Key (Base64): + + {rawPrivateKey} + + + + )} + + + + + {/* Button Area - fixed at bottom */} + + + + + {/* */} + {/* Edit Alias button - only show for trusted and private stores */} + {(certificate.store === 'trusted' || certificate.store === 'private') && ( + + )} + {/* Remove button - only show for trusted and private stores */} + {(certificate.store === 'trusted' || certificate.store === 'private') && ( + + )} + + + + ) +} + + diff --git a/plugins/tls/web-ui/src/components/CertificateChainSelector.jsx b/plugins/tls/web-ui/src/components/CertificateChainSelector.jsx new file mode 100644 index 000000000..166cb38cd --- /dev/null +++ b/plugins/tls/web-ui/src/components/CertificateChainSelector.jsx @@ -0,0 +1,85 @@ +import React from 'react' +import { + Box, + RadioGroup, + Radio, + FormControlLabel, + FormControl, + Typography, + Alert, + Paper +} from '@mui/material' + +export default function CertificateChainSelector({ + certificates = [], + selectedIndex = null, + onSelect, + loading = false +}) { + if (certificates.length === 0) { + return null + } + + return ( + + + Select a certificate to import: + + + + onSelect(parseInt(e.target.value, 10))} + sx={{ display: 'flex', flexDirection: 'column', gap: 1 }} + > + {certificates.map((cert, index) => ( + onSelect(index)} + > + } + label={ + + + {cert.alias || `Certificate ${index + 1}`} + + {cert.error && ( + + {cert.subject || cert.error} + + )} + + } + sx={{ margin: 0, width: '100%' }} + /> + + ))} + + + + ) +} + diff --git a/plugins/tls/web-ui/src/components/CertificateDetailsDialog.jsx b/plugins/tls/web-ui/src/components/CertificateDetailsDialog.jsx new file mode 100644 index 000000000..5300239c6 --- /dev/null +++ b/plugins/tls/web-ui/src/components/CertificateDetailsDialog.jsx @@ -0,0 +1,579 @@ +import React, { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + Divider, + Chip, + Stack, + Paper, + Grid, + IconButton, + Alert, + CircularProgress, + Accordion, + AccordionSummary, + AccordionDetails, + List, + ListItem, + ListItemText, +} from '@mui/material' +import { Visibility, VisibilityOff, ExpandMore, ExpandLess, CheckCircle, Error, Warning } from '@mui/icons-material' +import { formatDate } from '../utils/dateUtils.js' +import { verifyCertificate } from '../utils/verificationUtils.js' +import { base64ToPem, base64ToPrivateKeyPem } from '../utils/certificateUtils.js' + +// Threshold for showing expand/collapse buttons +const ITEMS_THRESHOLD = 6 + +export default function CertificateDetailsDialog({ open, onClose, certificate }) { + const [showPrivateKey, setShowPrivateKey] = useState(false) + const [verificationResult, setVerificationResult] = useState(null) + const [isVerifying, setIsVerifying] = useState(false) + const [sanExpanded, setSanExpanded] = useState(false) + const [channelsExpanded, setChannelsExpanded] = useState(false) + + // Early return after all hooks + if (!certificate) return null + + // Extract parsed data + const { parsedCertificate, rawCertificate, channelsInUse } = certificate + + // Calculate total SAN count for determining if expand button should show + const sanCount = (parsedCertificate?.subjectAltNames?.dns?.length || 0) + + (parsedCertificate?.subjectAltNames?.ip?.length || 0) + + (parsedCertificate?.subjectAltNames?.uri?.length || 0) + + (parsedCertificate?.subjectAltNames?.email?.length || 0) + + (parsedCertificate?.subjectAltNames?.dn?.length || 0) + + // Show expand buttons based on item count (simple and reliable) + const showSanExpandButton = sanCount > ITEMS_THRESHOLD + const showChannelsExpandButton = (channelsInUse?.length || 0) > ITEMS_THRESHOLD + + const getStatusColor = (validFrom, validTo) => { + const now = new Date() + const validFromDate = new Date(validFrom) + const validToDate = new Date(validTo) + + if (now < validFromDate) return 'warning' + if (now > validToDate) return 'error' + + // Check if expiring within 30 days + const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + if (validToDate < thirtyDaysFromNow) return 'warning' + + return 'success' + } + + const getStatusText = (validFrom, validTo) => { + const now = new Date() + const validFromDate = new Date(validFrom) + const validToDate = new Date(validTo) + + if (now < validFromDate) return 'Not Yet Valid' + if (now > validToDate) return 'Expired' + + // Check if expiring within 30 days + const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + if (validToDate < thirtyDaysFromNow) return 'Expiring Soon' + + return 'Valid' + } + + + const formatExtensions = (extensions) => { + if (!extensions || extensions.length === 0) return [] + + return extensions.map(ext => ({ + name: ext.name, + value: ext.names?.join(', '), + critical: ext.critical || false + })) + } + + const handleVerifyCertificate = async () => { + setIsVerifying(true) + try { + // Convert Base64 certificate to PEM format + const pemCertificate = base64ToPem(rawCertificate) + + // If private key is available, include it in verification + const privateKeyPem = certificate.hasPrivateKey && certificate.rawPrivateKey + ? base64ToPrivateKeyPem(certificate.rawPrivateKey) + : null + + const result = verifyCertificate(pemCertificate, privateKeyPem) + setVerificationResult(result) + } catch (error) { + setVerificationResult({ + success: false, + error: `Verification failed: ${error.message}` + }) + } finally { + setIsVerifying(false) + } + } + + return ( + + + + Certificate Details + + + + + + + {/* Basic Information */} + + Basic Information + + + Alias + {certificate.alias} + + + Type + {certificate.type} + + + Store + + {certificate.store} + + + + Has Private Key + + {certificate.hasPrivateKey ? 'Yes' : 'No'} + + + + + + {/* Subject Information */} + + Subject + + {parsedCertificate?.subjectStr || 'Unknown'} + + + + {/* Subject Alternative Names */} + {parsedCertificate?.subjectAltNames && + (parsedCertificate.subjectAltNames.dns?.length > 0 || + parsedCertificate.subjectAltNames.ip?.length > 0 || + parsedCertificate.subjectAltNames.uri?.length > 0 || + parsedCertificate.subjectAltNames.email?.length > 0 || + parsedCertificate.subjectAltNames.dn?.length > 0) && ( + + + Subject Alternative Names + {showSanExpandButton && ( + + )} + + + + {parsedCertificate.subjectAltNames.dns?.length > 0 && ( + + + DNS Names: + + + {parsedCertificate.subjectAltNames.dns.map((dns, index) => ( + + ))} + + + )} + {parsedCertificate.subjectAltNames.ip?.length > 0 && ( + + + IP Addresses: + + + {parsedCertificate.subjectAltNames.ip.map((ip, index) => ( + + ))} + + + )} + {parsedCertificate.subjectAltNames.uri?.length > 0 && ( + + + URIs: + + + {parsedCertificate.subjectAltNames.uri.map((uri, index) => ( + + ))} + + + )} + {parsedCertificate.subjectAltNames.email?.length > 0 && ( + + + Email Addresses: + + + {parsedCertificate.subjectAltNames.email.map((email, index) => ( + + ))} + + + )} + {parsedCertificate.subjectAltNames.dn?.length > 0 && ( + + + Distinguished Names: + + + {parsedCertificate.subjectAltNames.dn.map((dn, index) => ( + + {dn} + + ))} + + + )} + + + + )} + + {/* Issuer Information */} + + Issuer + + {parsedCertificate?.issuerStr || 'Unknown'} + + + + {/* Validity Period */} + + Validity Period + + + Valid From + {certificate.validFrom} + + + Valid To + {certificate.validTo} + + + + + {/* Fingerprint */} + + Fingerprint + SHA-1 + + {certificate.fingerprintSha1} + + + + {/* Extensions */} + {parsedCertificate?.extensions && parsedCertificate.extensions.length > 0 && ( + + Extensions + + {formatExtensions(parsedCertificate.extensions).map((ext, index) => ( + + + + {ext.name} + + {ext.critical && ( + + )} + + + {ext.value} + + + ))} + + + )} + + {/* Channels in Use */} + {channelsInUse && channelsInUse.length > 0 && ( + + + Channels in Use ({channelsInUse.length}) + {showChannelsExpandButton && ( + + )} + + + + {channelsInUse.map((channel, index) => ( + + ))} + + + + )} + + {/* Raw Certificate (Collapsible) */} + + Raw Certificate (Base64) + + {rawCertificate} + + + + {/* Private Key (if available) */} + {certificate.hasPrivateKey && certificate.rawPrivateKey && ( + + + Private Key (Base64) + setShowPrivateKey(!showPrivateKey)} + size="small" + color="primary" + title={showPrivateKey ? 'Hide private key' : 'Show private key'} + > + {showPrivateKey ? : } + + + {showPrivateKey && ( + + {certificate.rawPrivateKey} + + )} + + )} + + {/* Certificate Verification */} + + + Certificate Verification + + + + {verificationResult && ( + + {verificationResult.success ? ( + + Certificate verification completed successfully! + + ) : ( + + {verificationResult.error} + + )} + + {verificationResult.success && ( + + {/* Chain Validation Results */} + {verificationResult.chainValidation && ( + + }> + + {verificationResult.chainValidation.isValid ? ( + + ) : ( + + )} + + Chain Validation {verificationResult.chainValidation.isValid ? 'Passed' : 'Failed'} + + + + + {verificationResult.chainValidation.errors.length > 0 && ( + + + Errors: + + + {verificationResult.chainValidation.errors.map((error, index) => ( + + + + ))} + + + )} + {verificationResult.chainValidation.warnings.length > 0 && ( + + + Warnings: + + + {verificationResult.chainValidation.warnings.map((warning, index) => ( + + + + ))} + + + )} + {verificationResult.chainValidation.details.length > 0 && ( + + + Details: + + + {verificationResult.chainValidation.details.map((detail, index) => ( + + + + ))} + + + )} + + + )} + + {/* Private Key Validation */} + {verificationResult.keyValidation && ( + + }> + + {verificationResult.keyValidation.isValid ? ( + + ) : ( + + )} + + Private Key Validation {verificationResult.keyValidation.isValid ? 'Passed' : 'Failed'} + + + + + + {verificationResult.keyValidation.message} + + + + )} + + {/* Certificate Chain Details */} + {verificationResult.chainDetails && verificationResult.chainDetails.length > 1 && ( + + }> + + Certificate Chain ({verificationResult.chainDetails.length} certificates) + + + + + {verificationResult.chainDetails.map((cert, index) => ( + + + {cert.type} (Certificate #{cert.index}) + + + Subject: {cert.subject} + + + Issuer: {cert.issuer} + + + Valid: {cert.validFrom} - {cert.validTo} + + + ))} + + + + )} + + )} + + )} + + + + + + + + + ) +} diff --git a/plugins/tls/web-ui/src/components/CertificateDetailsSection.jsx b/plugins/tls/web-ui/src/components/CertificateDetailsSection.jsx new file mode 100644 index 000000000..b393ad783 --- /dev/null +++ b/plugins/tls/web-ui/src/components/CertificateDetailsSection.jsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react' +import { + Box, + Typography, + Stack, + Chip, + Button, + Collapse +} from '@mui/material' +import { Info, ExpandMore, ExpandLess } from '@mui/icons-material' + +const CertificateDetailsSection = ({ certificateDetails }) => { + const [sanExpanded, setSanExpanded] = useState(false) + + if (!certificateDetails) return null + + return ( + + + + Certificate Details + + + + + + Subject + + {certificateDetails.subjectStr || 'Unknown'} + + + + + Issuer + + {certificateDetails.issuerStr || 'Unknown'} + + + + + Type + + + + + Serial Number + + {certificateDetails.serialNumber || 'Unknown'} + + + + + Validity Period + + From: {certificateDetails.validFrom || 'Unknown'} + + + To: {certificateDetails.validTo || 'Unknown'} + + + + + SHA-1 Fingerprint + + {certificateDetails.fingerprintSha1 || 'Unknown'} + + + + {/* Subject Alternative Names */} + {certificateDetails.subjectAltNames && + (certificateDetails.subjectAltNames.dns?.length > 0 || + certificateDetails.subjectAltNames.ip?.length > 0 || + certificateDetails.subjectAltNames.uri?.length > 0 || + certificateDetails.subjectAltNames.email?.length > 0 || + certificateDetails.subjectAltNames.dn?.length > 0) && ( + + + + Subject Alternative Names + + + + + + {certificateDetails.subjectAltNames.dns?.length > 0 && ( + + + DNS Names: + + + {certificateDetails.subjectAltNames.dns.map((dns, index) => ( + + ))} + + + )} + {certificateDetails.subjectAltNames.ip?.length > 0 && ( + + + IP Addresses: + + + {certificateDetails.subjectAltNames.ip.map((ip, index) => ( + + ))} + + + )} + {certificateDetails.subjectAltNames.uri?.length > 0 && ( + + + URIs: + + + {certificateDetails.subjectAltNames.uri.map((uri, index) => ( + + ))} + + + )} + {certificateDetails.subjectAltNames.email?.length > 0 && ( + + + Email Addresses: + + + {certificateDetails.subjectAltNames.email.map((email, index) => ( + + ))} + + + )} + {certificateDetails.subjectAltNames.dn?.length > 0 && ( + + + Distinguished Names: + + + {certificateDetails.subjectAltNames.dn.map((dn, index) => ( + + {dn} + + ))} + + + )} + + + + )} + + + + ) +} + +export default CertificateDetailsSection diff --git a/plugins/tls/web-ui/src/components/CertificateList.jsx b/plugins/tls/web-ui/src/components/CertificateList.jsx new file mode 100644 index 000000000..f0b34f2c9 --- /dev/null +++ b/plugins/tls/web-ui/src/components/CertificateList.jsx @@ -0,0 +1,38 @@ +import React from 'react' +import { Box, CircularProgress, Typography, Alert, Grid, Stack } from '@mui/material' +import CertificateCard from './CertificateCard' + +export default function CertificateList({ rows, loading, error, emptyText = 'No certificates found.', onViewDetails, onExport, onEditAlias, onRemove, showPrivateKeys = false }) { + if (loading) { + return ( + + + Loading certificates… + + ) + } + if (error) return {error} + if (!rows || rows.length === 0) return {emptyText} + + return ( + + {rows.map((row) => ( + + + + ))} + + ) +} + + diff --git a/plugins/tls/web-ui/src/components/CertificateVerificationSection.jsx b/plugins/tls/web-ui/src/components/CertificateVerificationSection.jsx new file mode 100644 index 000000000..0575359a6 --- /dev/null +++ b/plugins/tls/web-ui/src/components/CertificateVerificationSection.jsx @@ -0,0 +1,177 @@ +import React from 'react' +import { + Box, + Typography, + Stack, + Button, + Alert, + Accordion, + AccordionSummary, + AccordionDetails, + List, + ListItem, + ListItemText, + CircularProgress +} from '@mui/material' +import { + Security, + CheckCircle, + Error, + ExpandMore +} from '@mui/icons-material' + +const CertificateVerificationSection = ({ + verificationResult, + isVerifying, + onVerify, + pemText +}) => { + return ( + + + + + Certificate Verification + + + + {verificationResult && ( + + {verificationResult.success ? ( + + Certificate verification completed successfully! + + ) : ( + + {verificationResult.error} + + )} + + {verificationResult.success && ( + + {/* Chain Validation Results */} + {verificationResult.chainValidation && ( + + }> + + {verificationResult.chainValidation.isValid ? ( + + ) : ( + + )} + + Chain Validation {verificationResult.chainValidation.isValid ? 'Passed' : 'Failed'} + + + + + {verificationResult.chainValidation.errors.length > 0 && ( + + + Errors: + + + {verificationResult.chainValidation.errors.map((error, index) => ( + + + + ))} + + + )} + {verificationResult.chainValidation.warnings.length > 0 && ( + + + Warnings: + + + {verificationResult.chainValidation.warnings.map((warning, index) => ( + + + + ))} + + + )} + {verificationResult.chainValidation.details.length > 0 && ( + + + Details: + + + {verificationResult.chainValidation.details.map((detail, index) => ( + + + + ))} + + + )} + + + )} + + {/* Private Key Validation */} + {verificationResult.keyValidation && ( + + }> + + {verificationResult.keyValidation.isValid ? ( + + ) : ( + + )} + + Private Key Validation {verificationResult.keyValidation.isValid ? 'Passed' : 'Failed'} + + + + + + {verificationResult.keyValidation.message} + + + + )} + + {/* Certificate Chain Details */} + {verificationResult.chainDetails && verificationResult.chainDetails.length > 1 && ( + + }> + + Certificate Chain ({verificationResult.chainDetails.length} certificates) + + + + + {verificationResult.chainDetails.map((cert, index) => ( + + + {cert.type} (Certificate #{cert.index}) + + + Subject: {cert.subject} + + + Issuer: {cert.issuer} + + + Valid: {cert.validFrom} - {cert.validTo} + + + ))} + + + + )} + + )} + + )} + + ) +} + +export default CertificateVerificationSection diff --git a/plugins/tls/web-ui/src/components/ChannelsInUseWarning.jsx b/plugins/tls/web-ui/src/components/ChannelsInUseWarning.jsx new file mode 100644 index 000000000..856a5a4af --- /dev/null +++ b/plugins/tls/web-ui/src/components/ChannelsInUseWarning.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react' +import { + Alert, + Typography, + Stack, + Chip, + Button, + Box +} from '@mui/material' +import { ExpandMore, ExpandLess } from '@mui/icons-material' + +// Number of channels threshold to show expand/collapse button +const CHANNELS_THRESHOLD = 6 + +/** + * Reusable component for displaying channels in use warning + * Shows channels in an expandable area when there are many channels + * @param {Array} channelsInUse - Array of channel names + * @param {string} severity - Alert severity ('warning' or 'error') + * @param {string} message - Custom message to display (optional) + */ +export default function ChannelsInUseWarning({ + channelsInUse, + severity = 'warning', + message +}) { + const [expanded, setExpanded] = useState(false) + + if (!channelsInUse || channelsInUse.length === 0) { + return null + } + + // Show expand button if there are more than threshold channels + const showExpandButton = channelsInUse.length > CHANNELS_THRESHOLD + + return ( + + + This certificate is currently in use by the following channels ({channelsInUse.length}): + + + + {channelsInUse.map((channel, index) => ( + + ))} + + + {showExpandButton && ( + + + + )} + {message && ( + + {message} + + )} + + ) +} + diff --git a/plugins/tls/web-ui/src/components/ConfirmReplaceCertificateDialog.jsx b/plugins/tls/web-ui/src/components/ConfirmReplaceCertificateDialog.jsx new file mode 100644 index 000000000..da22da56b --- /dev/null +++ b/plugins/tls/web-ui/src/components/ConfirmReplaceCertificateDialog.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + Typography, + Paper, + Grid +} from '@mui/material' +import ChannelsInUseWarning from './ChannelsInUseWarning' + +/** + * A reusable confirmation dialog for replacing an existing certificate. + * Shows details of the certificate that will be replaced when existingCertificateInfo is provided. + * + * @param {boolean} open - Dialog visibility + * @param {function} onClose - Cancel handler + * @param {function} onConfirm - Confirm handler + * @param {string} alias - The alias being replaced + * @param {string} [store] - Store name for the message (optional) + * @param {boolean} loading - Loading state for button + * @param {object} [existingCertificateInfo] - Object with { alias, subject, issuer } to show details panel + */ +export default function ConfirmReplaceCertificateDialog({ + open, + onClose, + onConfirm, + alias, + store, + loading = false, + existingCertificateInfo = null +}) { + const storeText = store ? ` in the ${store} store` : '' + + return ( + + + Replace Existing Certificate + + + + A certificate with the alias "{alias}" already exists{storeText}. This will replace the existing certificate. Are you sure you want to continue? + + + {existingCertificateInfo && ( + <> + + + Certificate that will be replaced: + + + + Alias + {existingCertificateInfo.alias} + + + Subject + + {existingCertificateInfo.subject} + + + + Issuer + + {existingCertificateInfo.issuer} + + + + + + + + )} + + + + + + + ) +} + diff --git a/plugins/tls/web-ui/src/components/EditAliasDialog.jsx b/plugins/tls/web-ui/src/components/EditAliasDialog.jsx new file mode 100644 index 000000000..053140106 --- /dev/null +++ b/plugins/tls/web-ui/src/components/EditAliasDialog.jsx @@ -0,0 +1,200 @@ +import React, { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Box, + Typography, + Paper, + Grid, + Alert +} from '@mui/material' +import { useAliasEdit } from '../hooks/useAliasEdit' +import { updateCertificateAlias } from '../services/tlsService' +import ChannelsInUseWarning from './ChannelsInUseWarning' +import ConfirmReplaceCertificateDialog from './ConfirmReplaceCertificateDialog' + +export default function EditAliasDialog({ + open, + onClose, + certificate, + currentCertificates = null, + onSuccess +}) { + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + + const { + newAlias, + aliasWarning, + loading, + apiError, + existingCertificateInfo, + handleAliasChange, + validate, + checkAliasExists, + setLoading, + setApiError + } = useAliasEdit(certificate?.alias || '', certificate?.store, currentCertificates) + + const handleSubmit = async () => { + if (!validate()) return + + // Check if alias already exists + const aliasExists = checkAliasExists(newAlias) + if (aliasExists) { + setShowConfirmDialog(true) + return + } + + // Proceed with alias update + await performAliasUpdate() + } + + const performAliasUpdate = async () => { + setLoading(true) + setApiError(null) + try { + const result = await updateCertificateAlias( + certificate.store, + certificate.alias, + newAlias, + currentCertificates + ) + if (result.success) { + onSuccess?.(result.data) + onClose() + } else { + setApiError(result.error || 'Failed to update alias') + } + } catch (error) { + setApiError(error.message || 'Failed to update alias') + } finally { + setLoading(false) + } + } + + const handleConfirmReplace = async () => { + setShowConfirmDialog(false) + await performAliasUpdate() + } + + const handleCancelReplace = () => { + setShowConfirmDialog(false) + } + + const handleClose = () => { + setShowConfirmDialog(false) + setApiError(null) + onClose() + } + + if (!certificate) return null + + return ( + <> + + Edit Certificate Alias + + + {apiError && ( + setApiError(null)} sx={{ mb: 2 }}> + {apiError} + + )} + + {/* Channels in Use Warning */} + + + {/* Alias Input */} + + + {/* Certificate Info Display */} + + Certificate Information + + + Current Alias + {certificate.alias} + + + Store + + {certificate.store} + + + + Subject + + {certificate.subject} + + + + Issuer + + {certificate.issuer} + + + + + + + + + + + + + + + {/* Confirmation Dialog for Replacing Existing Certificate */} + + + ) +} diff --git a/plugins/tls/web-ui/src/components/ImportCertificateChainDialogContent.jsx b/plugins/tls/web-ui/src/components/ImportCertificateChainDialogContent.jsx new file mode 100644 index 000000000..d649f585f --- /dev/null +++ b/plugins/tls/web-ui/src/components/ImportCertificateChainDialogContent.jsx @@ -0,0 +1,353 @@ +import React, { useState, useEffect, useRef } from 'react' +import { + Box, + Stack, + Button, + TextField, + Typography, + Alert, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions +} from '@mui/material' +import { parseCertificateChainFromPem } from '../utils/certificateUtils.js' +import TrustedCertificateImportForm from './TrustedCertificateImportForm' +import CertificateChainSelector from './CertificateChainSelector' +import ConfirmReplaceCertificateDialog from './ConfirmReplaceCertificateDialog' +import { updateCertificates } from '../services/tlsService.js' +import { verifyCertificate } from '../utils/verificationUtils.js' + +export default function ImportCertificateChainDialogContent({ + targetStore = 'trusted', + currentCertificates = null, + onCancel, + onSuccess, +}) { + const [pemText, setPemText] = useState('') + const [file, setFile] = useState(null) + const [parseError, setParseError] = useState(null) + const [certificates, setCertificates] = useState([]) + const [selectedCertificateIndex, setSelectedCertificateIndex] = useState(null) + const [selectedCertificatePem, setSelectedCertificatePem] = useState(null) + const [importLoading, setImportLoading] = useState(false) + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const [showValidationDialog, setShowValidationDialog] = useState(false) + const [validationError, setValidationError] = useState(null) + const [existingCertificateInfo, setExistingCertificateInfo] = useState(null) + const [confirmAlias, setConfirmAlias] = useState('') + const formRef = useRef(null) + const fileInputRef = useRef(null) + + // Parse certificate chain when PEM text changes + useEffect(() => { + if (pemText.trim()) { + const parsed = parseCertificateChainFromPem(pemText) + if (parsed.length === 0) { + setParseError('No valid certificates found in the provided text') + setCertificates([]) + setSelectedCertificateIndex(null) + setSelectedCertificatePem(null) + } else { + setParseError(null) + setCertificates(parsed) + // Auto-select first certificate if available + if (parsed.length > 0) { + setSelectedCertificateIndex(0) + setSelectedCertificatePem(parsed[0].certificate) + } + } + } else { + setParseError(null) + setCertificates([]) + setSelectedCertificateIndex(null) + setSelectedCertificatePem(null) + } + }, [pemText]) + + // Update selected certificate PEM when index changes + useEffect(() => { + if (selectedCertificateIndex !== null && certificates[selectedCertificateIndex]) { + setSelectedCertificatePem(certificates[selectedCertificateIndex].certificate) + setImportLoading(false) // Reset loading when certificate selection changes + } + }, [selectedCertificateIndex, certificates]) + + const handleFileUpload = (e) => { + const uploadedFile = e.target.files?.[0] + if (!uploadedFile) { + return + } + + // Validate file extension + const name = (uploadedFile.name || '').toLowerCase() + if (!(name.endsWith('.pem') || name.endsWith('.crt'))) { + setParseError('Please select a .pem or .crt file.') + setFile(null) + return + } + + setFile(uploadedFile) + setParseError(null) + + const reader = new FileReader() + reader.onload = (event) => { + const fileContent = event.target?.result + if (fileContent) { + setPemText(fileContent) + } + } + reader.onerror = () => { + setParseError('Failed to read file') + setFile(null) + } + reader.readAsText(uploadedFile) + } + + const handleCertificateSelect = (index) => { + setSelectedCertificateIndex(index) + const selectedCert = certificates[index] + setSelectedCertificatePem(selectedCert.certificate) + } + + const fileAccept = '.pem,.crt' + + const performFinalVerification = async (pemText) => { + try { + const verificationResult = await verifyCertificate(pemText, null) + + if (!verificationResult.success) { + setValidationError(verificationResult.error || 'Certificate validation failed') + setShowValidationDialog(true) + return false + } + return true + } catch (error) { + setValidationError('Certificate validation failed: ' + error.message) + setShowValidationDialog(true) + return false + } + } + + const performImport = async (alias, pemText) => { + setImportLoading(true) + try { + const result = await updateCertificates('trusted', { + alias, + pemText, + }, currentCertificates) + if (result.success) { + setImportLoading(false) + onSuccess?.(result.data) + } else { + setImportLoading(false) + if (formRef.current) { + formRef.current.setApiError(result.error || 'Failed to import certificate') + } + } + } catch (error) { + setImportLoading(false) + if (formRef.current) { + formRef.current.setApiError(error.message || 'Failed to import certificate') + } + } + } + + const handleSubmit = async () => { + if (!formRef.current) return + + if (!formRef.current.validate()) return + + // Check if alias already exists + const aliasExists = formRef.current.checkAliasExists() + if (aliasExists) { + // Find the existing certificate info for the confirmation dialog + const alias = formRef.current.alias + setConfirmAlias(alias) + const existingCert = currentCertificates?.find(c => + c.alias.toLowerCase() === alias.toLowerCase() && + c.store === targetStore + ) + setExistingCertificateInfo(existingCert || null) + setShowConfirmDialog(true) + return + } + + // Final verification before import + const verificationPassed = await performFinalVerification(formRef.current.pemText) + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport(formRef.current.alias, formRef.current.pemText) + } + + const handleConfirmReplace = async () => { + setShowConfirmDialog(false) + + if (!formRef.current) return + + // Final verification before import + const verificationPassed = await performFinalVerification(formRef.current.pemText) + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport(formRef.current.alias, formRef.current.pemText) + } + + return ( + + {/* PEM Input Section */} + + + + + + {file ? file.name : 'No file selected'} + + + + setPemText(e.target.value)} + error={!!parseError} + helperText={parseError || 'Paste certificate or upload a file'} + multiline + minRows={4} + maxRows={7} + fullWidth + autoFocus + /> + + {certificates.length > 0 && ( + + Found {certificates.length} certificate{certificates.length > 1 ? 's' : ''} in the chain + + )} + + + {/* Certificate List and Import Details - Vertical Layout */} + {certificates.length > 0 && ( + + {/* Top Section - Certificate List */} + + + {/* Bottom Section - Import Certificate Details */} + + {selectedCertificatePem ? ( + + ) : ( + + Select a certificate from the list to view details and import + + )} + + + )} + + {/* Fixed buttons at bottom - only show when certificate is selected */} + {certificates.length > 0 && selectedCertificatePem && ( + + + + + )} + + {/* Confirmation Dialog for Replacing Existing Certificate */} + setShowConfirmDialog(false)} + onConfirm={handleConfirmReplace} + alias={confirmAlias} + store={targetStore} + loading={importLoading} + existingCertificateInfo={existingCertificateInfo} + /> + + {/* Validation Error Dialog */} + setShowValidationDialog(false)} + aria-labelledby="validation-dialog-title" + aria-describedby="validation-dialog-description" + > + + Certificate Validation Failed + + + + {validationError} + + + + + + + + + ) +} + diff --git a/plugins/tls/web-ui/src/components/ImportFromUrlDialogContent.jsx b/plugins/tls/web-ui/src/components/ImportFromUrlDialogContent.jsx new file mode 100644 index 000000000..34e39d9d3 --- /dev/null +++ b/plugins/tls/web-ui/src/components/ImportFromUrlDialogContent.jsx @@ -0,0 +1,346 @@ +import React, { useState, useEffect, useRef } from 'react' +import { + Box, + Stack, + Button, + TextField, + Alert, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions +} from '@mui/material' +import { fetchRemoteCertificates, updateCertificates } from '../services/tlsService.js' +import TrustedCertificateImportForm from './TrustedCertificateImportForm' +import CertificateChainSelector from './CertificateChainSelector' +import ConfirmReplaceCertificateDialog from './ConfirmReplaceCertificateDialog' +import { verifyCertificate } from '../utils/verificationUtils.js' + +export default function ImportFromUrlDialogContent({ + targetStore = 'trusted', + currentCertificates = null, + onCancel, + onSuccess, +}) { + const [url, setUrl] = useState('') + const [urlError, setUrlError] = useState('') + const [loading, setLoading] = useState(false) + const [fetchError, setFetchError] = useState(null) + const [certificates, setCertificates] = useState([]) + const [selectedCertificateIndex, setSelectedCertificateIndex] = useState(null) + const [selectedCertificatePem, setSelectedCertificatePem] = useState(null) + const [importLoading, setImportLoading] = useState(false) + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const [showValidationDialog, setShowValidationDialog] = useState(false) + const [validationError, setValidationError] = useState(null) + const [existingCertificateInfo, setExistingCertificateInfo] = useState(null) + const [confirmAlias, setConfirmAlias] = useState('') + const formRef = useRef(null) + + const validateUrl = (urlValue) => { + if (!urlValue.trim()) { + setUrlError('URL is required') + return false + } + if (!urlValue.startsWith('https://')) { + setUrlError('URL must start with https://') + return false + } + try { + new URL(urlValue) + setUrlError('') + return true + } catch (e) { + setUrlError('Invalid URL format') + return false + } + } + + const handleUrlChange = (e) => { + const newUrl = e.target.value + setUrl(newUrl) + if (urlError) { + validateUrl(newUrl) + } + } + + const handleFetchCertificates = async () => { + if (!validateUrl(url)) { + return + } + + setLoading(true) + setFetchError(null) + setCertificates([]) + setSelectedCertificateIndex(null) + setSelectedCertificatePem(null) + + try { + const fetchedCerts = await fetchRemoteCertificates(url) + if (fetchedCerts.length === 0) { + setFetchError('No certificates found at the specified URL') + setLoading(false) + return + } + setCertificates(fetchedCerts) + // Auto-select first certificate if available + if (fetchedCerts.length > 0) { + setSelectedCertificateIndex(0) + setSelectedCertificatePem(fetchedCerts[0].certificate) + } + } catch (error) { + setFetchError(error.message || 'Failed to fetch certificates from URL') + } finally { + setLoading(false) + } + } + + const handleCertificateSelect = (index) => { + setSelectedCertificateIndex(index) + const selectedCert = certificates[index] + setSelectedCertificatePem(selectedCert.certificate) + } + + // Update selected certificate PEM when index changes + useEffect(() => { + if (selectedCertificateIndex !== null && certificates[selectedCertificateIndex]) { + setSelectedCertificatePem(certificates[selectedCertificateIndex].certificate) + setImportLoading(false) // Reset loading when certificate selection changes + } + }, [selectedCertificateIndex, certificates]) + + const performFinalVerification = async (pemText) => { + try { + const verificationResult = await verifyCertificate(pemText, null) + + if (!verificationResult.success) { + setValidationError(verificationResult.error || 'Certificate validation failed') + setShowValidationDialog(true) + return false + } + return true + } catch (error) { + setValidationError('Certificate validation failed: ' + error.message) + setShowValidationDialog(true) + return false + } + } + + const performImport = async (alias, pemText) => { + setImportLoading(true) + try { + const result = await updateCertificates('trusted', { + alias, + pemText, + }, currentCertificates) + if (result.success) { + setImportLoading(false) + onSuccess?.(result.data) + } else { + setImportLoading(false) + if (formRef.current) { + formRef.current.setApiError(result.error || 'Failed to import certificate') + } + } + } catch (error) { + setImportLoading(false) + if (formRef.current) { + formRef.current.setApiError(error.message || 'Failed to import certificate') + } + } + } + + const handleSubmit = async () => { + if (!formRef.current) return + + if (!formRef.current.validate()) return + + // Check if alias already exists + const aliasExists = formRef.current.checkAliasExists() + if (aliasExists) { + // Find the existing certificate info for the confirmation dialog + const alias = formRef.current.alias + setConfirmAlias(alias) + const existingCert = currentCertificates?.find(c => + c.alias.toLowerCase() === alias.toLowerCase() && + c.store === targetStore + ) + setExistingCertificateInfo(existingCert || null) + setShowConfirmDialog(true) + return + } + + // Final verification before import + const verificationPassed = await performFinalVerification(formRef.current.pemText) + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport(formRef.current.alias, formRef.current.pemText) + } + + const handleConfirmReplace = async () => { + setShowConfirmDialog(false) + + if (!formRef.current) return + + // Final verification before import + const verificationPassed = await performFinalVerification(formRef.current.pemText) + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport(formRef.current.alias, formRef.current.pemText) + } + + return ( + + {/* URL Input Section */} + + + validateUrl(url)} + error={!!urlError} + helperText={urlError || 'Enter a valid HTTPS URL to fetch certificates'} + fullWidth + disabled={loading} + autoFocus + /> + + + + {fetchError && ( + {fetchError} + )} + + + {/* Certificate List and Import Details - Vertical Layout */} + {certificates.length > 0 && ( + + {/* Top Section - Certificate List */} + + + {/* Bottom Section - Import Certificate Details */} + + {selectedCertificatePem ? ( + + ) : ( + + Select a certificate from the list to view details and import + + )} + + + )} + + {/* Fixed buttons at bottom - only show when certificate is selected */} + {certificates.length > 0 && selectedCertificatePem && ( + + + + + )} + + {/* Confirmation Dialog for Replacing Existing Certificate */} + setShowConfirmDialog(false)} + onConfirm={handleConfirmReplace} + alias={confirmAlias} + store={targetStore} + loading={importLoading} + existingCertificateInfo={existingCertificateInfo} + /> + + {/* Validation Error Dialog */} + setShowValidationDialog(false)} + aria-labelledby="validation-dialog-title" + aria-describedby="validation-dialog-description" + > + + Certificate Validation Failed + + + + {validationError} + + + + + + + + + ) +} + diff --git a/plugins/tls/web-ui/src/components/ImportPrivateCertificateDialog.jsx b/plugins/tls/web-ui/src/components/ImportPrivateCertificateDialog.jsx new file mode 100644 index 000000000..3d943984f --- /dev/null +++ b/plugins/tls/web-ui/src/components/ImportPrivateCertificateDialog.jsx @@ -0,0 +1,324 @@ +import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react' +import { + Box, + Stack, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions +} from '@mui/material' +import { useCertificateImport } from '../hooks/useCertificateImport' +import CertificateDetailsSection from './CertificateDetailsSection' +import CertificateVerificationSection from './CertificateVerificationSection' +import UserInputsSection from './UserInputsSection' +import MobileCertificateSection from './MobileCertificateSection' +import ConfirmReplaceCertificateDialog from './ConfirmReplaceCertificateDialog' +import { updateCertificates } from '../services/tlsService.js' +import { verifyCertificate } from '../utils/verificationUtils.js' + +const ImportPrivateCertificateDialog = forwardRef(function ImportPrivateCertificateDialog({ + currentCertificates = null, + onCancel, + onSubmit, + onSuccess, + initialPemText = null, + readOnlyPem = false, + hideButtons = false, +}, ref) { + const targetStore = 'private' + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const [showValidationDialog, setShowValidationDialog] = useState(false) + const [validationError, setValidationError] = useState(null) + + const { + // State + alias, + pemText, + privateKeyText, + file, + privateKeyFile, + loading, + apiError, + errors, + certificateDetails, + verificationResult, + isVerifying, + existingCertificates, + aliasWarning, + existingCertificateInfo, + + // Refs + fileInputRef, + privateKeyFileInputRef, + certFileAccept, + keyFileAccept, + + // Actions + setLoading, + setApiError, + setPemText, + parseCertificateDetails, + + // Handlers + handleVerifyCertificate, + handleFileUpload, + handlePrivateKeyFileUpload, + handlePemTextChange, + handlePrivateKeyTextChange, + handleAliasChange, + validate, + loadExistingCertificates, + checkAliasExists + } = useCertificateImport(targetStore, currentCertificates) + + // Load existing certificates on component mount + useEffect(() => { + loadExistingCertificates() + }, [loadExistingCertificates]) + + // Pre-populate PEM text if initialPemText is provided + useEffect(() => { + if (initialPemText && initialPemText.trim()) { + // Always update when initialPemText changes (for URL import flow) + setPemText(initialPemText) + parseCertificateDetails(initialPemText) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialPemText]) + + // Reusable verification function + const performFinalVerification = async () => { + try { + const privateKeyPem = privateKeyText.trim() ? privateKeyText : null + + const verificationResult = await verifyCertificate(pemText, privateKeyPem) + + if (!verificationResult.success) { + setValidationError(verificationResult.error || 'Certificate validation failed') + setShowValidationDialog(true) + return false + } + return true + } catch (error) { + setValidationError('Certificate validation failed: ' + error.message) + setShowValidationDialog(true) + return false + } + } + + const handleSubmit = async () => { + if (!validate()) return + + // Check if alias already exists + const aliasExists = checkAliasExists(alias) + if (aliasExists) { + setShowConfirmDialog(true) + return + } + + // Final verification before import + const verificationPassed = await performFinalVerification() + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport() + } + + const performImport = async () => { + setLoading(true) + setApiError(null) + try { + const result = await updateCertificates(targetStore, { + alias, + pemText, + privateKeyText, + }, currentCertificates) + if (result.success) { + onSuccess?.(result.data) + onSubmit?.() + } else { + setApiError(result.error || 'Failed to import certificate') + } + } catch (error) { + setApiError(error.message || 'Failed to import certificate') + } finally { + setLoading(false) + } + } + + const handleConfirmReplace = async () => { + setShowConfirmDialog(false) + + // Final verification before import + const verificationPassed = await performFinalVerification() + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport() + } + + const handleCancelReplace = () => { + setShowConfirmDialog(false) + } + + // Determine if import button should be disabled + const isImportDisabled = () => { + // Disable if loading + if (loading) return true + + // Disable if there are validation errors + if (errors.pemText || errors.alias || errors.privateKeyText || errors.file || errors.privateKeyFile) { + return true + } + + // Disable if verification has been attempted and failed + if (verificationResult && verificationResult.success === false) { + return true + } + + // Disable if required fields are missing + if (!pemText.trim() || !alias.trim() || (targetStore === 'private' && !privateKeyText.trim())) { + return true + } + + return false + } + + // Expose handleSubmit and loading state via ref + useImperativeHandle(ref, () => ({ + handleSubmit, + loading + })) + + return ( + + + + {/* Left Column - User Inputs */} + + {/* Right Column - Certificate Details & Verification */} + + + + + + + + {/* Mobile Certificate Details & Verification */} + + + + {/* Fixed buttons at bottom */} + {!hideButtons && ( + + + + + )} + + {/* Confirmation Dialog for Replacing Existing Certificate */} + + + {/* Validation Error Dialog */} + setShowValidationDialog(false)} + aria-labelledby="validation-dialog-title" + aria-describedby="validation-dialog-description" + > + + Certificate Validation Failed + + + + {validationError} + + + + + + + + ) +}) + +export default ImportPrivateCertificateDialog + diff --git a/plugins/tls/web-ui/src/components/ImportTrustedCertificateDialog.jsx b/plugins/tls/web-ui/src/components/ImportTrustedCertificateDialog.jsx new file mode 100644 index 000000000..c1686dc21 --- /dev/null +++ b/plugins/tls/web-ui/src/components/ImportTrustedCertificateDialog.jsx @@ -0,0 +1,321 @@ +import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react' +import { + Box, + Stack, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions +} from '@mui/material' +import { useCertificateImport } from '../hooks/useCertificateImport' +import CertificateDetailsSection from './CertificateDetailsSection' +import CertificateVerificationSection from './CertificateVerificationSection' +import UserInputsSection from './UserInputsSection' +import MobileCertificateSection from './MobileCertificateSection' +import ConfirmReplaceCertificateDialog from './ConfirmReplaceCertificateDialog' +import { updateCertificates } from '../services/tlsService.js' +import { verifyCertificate } from '../utils/verificationUtils.js' + +const ImportTrustedCertificateDialog = forwardRef(function ImportTrustedCertificateDialog({ + currentCertificates = null, + onCancel, + onSubmit, + onSuccess, + initialPemText = null, + readOnlyPem = false, + hideButtons = false, +}, ref) { + const targetStore = 'trusted' + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const [showValidationDialog, setShowValidationDialog] = useState(false) + const [validationError, setValidationError] = useState(null) + + const { + // State + alias, + pemText, + privateKeyText, + file, + privateKeyFile, + loading, + apiError, + errors, + certificateDetails, + verificationResult, + isVerifying, + existingCertificates, + aliasWarning, + existingCertificateInfo, + + // Refs + fileInputRef, + privateKeyFileInputRef, + certFileAccept, + keyFileAccept, + + // Actions + setLoading, + setApiError, + setPemText, + parseCertificateDetails, + + // Handlers + handleVerifyCertificate, + handleFileUpload, + handlePrivateKeyFileUpload, + handlePemTextChange, + handlePrivateKeyTextChange, + handleAliasChange, + validate, + loadExistingCertificates, + checkAliasExists + } = useCertificateImport(targetStore, currentCertificates) + + // Load existing certificates on component mount + useEffect(() => { + loadExistingCertificates() + }, [loadExistingCertificates]) + + // Pre-populate PEM text if initialPemText is provided + useEffect(() => { + if (initialPemText && initialPemText.trim()) { + // Always update when initialPemText changes (for URL import flow) + setPemText(initialPemText) + parseCertificateDetails(initialPemText) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialPemText]) + + // Reusable verification function + const performFinalVerification = async () => { + try { + const verificationResult = await verifyCertificate(pemText, null) + + if (!verificationResult.success) { + setValidationError(verificationResult.error || 'Certificate validation failed') + setShowValidationDialog(true) + return false + } + return true + } catch (error) { + setValidationError('Certificate validation failed: ' + error.message) + setShowValidationDialog(true) + return false + } + } + + const handleSubmit = async () => { + if (!validate()) return + + // Check if alias already exists + const aliasExists = checkAliasExists(alias) + if (aliasExists) { + setShowConfirmDialog(true) + return + } + + // Final verification before import + const verificationPassed = await performFinalVerification() + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport() + } + + const performImport = async () => { + setLoading(true) + setApiError(null) + try { + const result = await updateCertificates(targetStore, { + alias, + pemText, + }, currentCertificates) + if (result.success) { + onSuccess?.(result.data) + onSubmit?.() + } else { + setApiError(result.error || 'Failed to import certificate') + } + } catch (error) { + setApiError(error.message || 'Failed to import certificate') + } finally { + setLoading(false) + } + } + + const handleConfirmReplace = async () => { + setShowConfirmDialog(false) + + // Final verification before import + const verificationPassed = await performFinalVerification() + if (!verificationPassed) return + + // Proceed with import if verification passes + await performImport() + } + + const handleCancelReplace = () => { + setShowConfirmDialog(false) + } + + // Determine if import button should be disabled + const isImportDisabled = () => { + // Disable if loading + if (loading) return true + + // Disable if there are validation errors + if (errors.pemText || errors.alias || errors.file) { + return true + } + + // Disable if verification has been attempted and failed + if (verificationResult && verificationResult.success === false) { + return true + } + + // Disable if required fields are missing + if (!pemText.trim() || !alias.trim()) { + return true + } + + return false + } + + // Expose handleSubmit and loading state via ref + useImperativeHandle(ref, () => ({ + handleSubmit, + loading + })) + + return ( + + + + {/* Left Column - User Inputs */} + + {/* Right Column - Certificate Details & Verification */} + + + + + + + + {/* Mobile Certificate Details & Verification */} + + + + {/* Fixed buttons at bottom */} + {!hideButtons && ( + + + + + )} + + {/* Confirmation Dialog for Replacing Existing Certificate */} + + + {/* Validation Error Dialog */} + setShowValidationDialog(false)} + aria-labelledby="validation-dialog-title" + aria-describedby="validation-dialog-description" + > + + Certificate Validation Failed + + + + {validationError} + + + + + + + + ) +}) + +export default ImportTrustedCertificateDialog + diff --git a/plugins/tls/web-ui/src/components/MobileCertificateSection.jsx b/plugins/tls/web-ui/src/components/MobileCertificateSection.jsx new file mode 100644 index 000000000..caf233946 --- /dev/null +++ b/plugins/tls/web-ui/src/components/MobileCertificateSection.jsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Box, Stack } from '@mui/material' +import CertificateDetailsSection from './CertificateDetailsSection' +import CertificateVerificationSection from './CertificateVerificationSection' + +const MobileCertificateSection = ({ + certificateDetails, + verificationResult, + isVerifying, + onVerify, + pemText +}) => { + return ( + + + + + + + ) +} + +export default MobileCertificateSection diff --git a/plugins/tls/web-ui/src/components/RemoveCertificateDialog.jsx b/plugins/tls/web-ui/src/components/RemoveCertificateDialog.jsx new file mode 100644 index 000000000..5d47a769a --- /dev/null +++ b/plugins/tls/web-ui/src/components/RemoveCertificateDialog.jsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + Box, + Typography, + Paper, + Grid, + Alert, + Stack +} from '@mui/material' +import { Warning } from '@mui/icons-material' +import { removeCertificate } from '../services/tlsService' +import ChannelsInUseWarning from './ChannelsInUseWarning' + +export default function RemoveCertificateDialog({ + open, + onClose, + certificate, + currentCertificates = null, + onSuccess +}) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleRemove = async () => { + if (!certificate) return + + setLoading(true) + setError(null) + + try { + const result = await removeCertificate(certificate.store, certificate.alias, currentCertificates) + if (result.success) { + onSuccess?.(result.data) + onClose() + } else { + setError(result.error || 'Failed to remove certificate') + } + } catch (error) { + setError(error.message || 'Failed to remove certificate') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setError(null) + onClose() + } + + if (!certificate) return null + + return ( + + + + + Remove Certificate + + + + + {error && ( + + {error} + + )} + + {/* Certificate Info Display */} + + Certificate Information + + + Alias + {certificate.alias} + + + Store + + {certificate.store} + + + + Subject + + {certificate.subject} + + + + Issuer + + {certificate.issuer} + + + + + + {/* Channels in Use Warning */} + + + {/* Warning Message */} + + + This action cannot be undone. The certificate will be permanently removed from the {certificate.store} store. + + + + + Are you sure you want to remove this certificate? + + + + + + + + + ) +} diff --git a/plugins/tls/web-ui/src/components/SearchInput.jsx b/plugins/tls/web-ui/src/components/SearchInput.jsx new file mode 100644 index 000000000..045943d4d --- /dev/null +++ b/plugins/tls/web-ui/src/components/SearchInput.jsx @@ -0,0 +1,35 @@ +import React, { useEffect, useState } from 'react' +import { TextField, InputAdornment } from '@mui/material' +import SearchIcon from '@mui/icons-material/Search' + +export default function SearchInput({ value, onChange, placeholder = 'Search certificates by alias or subject…', delay = 250 }) { + const [internal, setInternal] = useState(value ?? '') + + useEffect(() => setInternal(value ?? ''), [value]) + + useEffect(() => { + const id = setTimeout(() => onChange?.(internal), delay) + return () => clearTimeout(id) + }, [internal, delay, onChange]) + + return ( + setInternal(e.target.value)} + placeholder={placeholder} + fullWidth + size="small" + slotProps={{ + input: { + startAdornment: ( + + + + ) + } + }} + /> + ) +} + + diff --git a/plugins/tls/web-ui/src/components/StatusPill.jsx b/plugins/tls/web-ui/src/components/StatusPill.jsx new file mode 100644 index 000000000..a0be1a75e --- /dev/null +++ b/plugins/tls/web-ui/src/components/StatusPill.jsx @@ -0,0 +1,49 @@ +import React, { useMemo } from 'react' +import { Chip } from '@mui/material' +import dayjs from 'dayjs' + +function computeStatus(validFrom, validTo, thresholdDays = 30) { + const now = dayjs() + + // Parse dates with dayjs for better handling + const start = validFrom ? dayjs(validFrom) : null + const end = validTo ? dayjs(validTo) : null + + // Validate that dates are actually valid + if (start && !start.isValid()) { + return { label: 'Invalid start date', color: 'error' } + } + if (end && !end.isValid()) { + return { label: 'Invalid end date', color: 'error' } + } + + // Check if certificate is not yet valid + if (start && start.isAfter(now)) { + const daysUntilValid = start.diff(now, 'day') + return { label: `Valid in ${daysUntilValid} days`, color: 'info' } + } + + // Check if certificate is expired + if (end && end.isBefore(now)) { + return { label: 'Expired', color: 'error' } + } + + // Check if certificate is expiring soon + if (end) { + const daysLeft = end.diff(now, 'day') + if (daysLeft <= thresholdDays && daysLeft >= 0) { + return { label: `Expires in ${daysLeft} days`, color: 'warning' } + } + } + + return { label: 'Valid', color: 'success' } +} + +export default function StatusPill({ validFrom, validTo, thresholdDays = 30 }) { + const status = useMemo(() => computeStatus(validFrom, validTo, thresholdDays), [validFrom, validTo, thresholdDays]) + return +} + +export { computeStatus } + + diff --git a/plugins/tls/web-ui/src/components/StoreToolbar.jsx b/plugins/tls/web-ui/src/components/StoreToolbar.jsx new file mode 100644 index 000000000..d9c532b5c --- /dev/null +++ b/plugins/tls/web-ui/src/components/StoreToolbar.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { Box, Stack, Typography, Button, Alert } from '@mui/material' + +export default function StoreToolbar({ title, warning, actions = [] }) { + return ( + + + {title} + + {actions.map((a) => ( + + ))} + + + {warning ? {warning} : null} + + ) +} + + diff --git a/plugins/tls/web-ui/src/components/TabPanel.jsx b/plugins/tls/web-ui/src/components/TabPanel.jsx new file mode 100644 index 000000000..d47715779 --- /dev/null +++ b/plugins/tls/web-ui/src/components/TabPanel.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import { Box } from '@mui/material' + +export default function TabPanel({ children, value, index, sx }) { + if (value !== index) return null + return ( + {children} + ) +} + + diff --git a/plugins/tls/web-ui/src/components/TabsWithCounts.jsx b/plugins/tls/web-ui/src/components/TabsWithCounts.jsx new file mode 100644 index 000000000..e5c316767 --- /dev/null +++ b/plugins/tls/web-ui/src/components/TabsWithCounts.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import { Tabs, Tab, Chip, Box, Stack } from '@mui/material' + +export default function TabsWithCounts({ value, onChange, tabs }) { + return ( + + {tabs.map((tab, index) => ( + + {tab.icon ? {tab.icon} : null} + {tab.label} + + + } + value={index} + /> + ))} + + ) +} + + diff --git a/plugins/tls/web-ui/src/components/TrustedCertificateImportForm.jsx b/plugins/tls/web-ui/src/components/TrustedCertificateImportForm.jsx new file mode 100644 index 000000000..75f24f568 --- /dev/null +++ b/plugins/tls/web-ui/src/components/TrustedCertificateImportForm.jsx @@ -0,0 +1,143 @@ +import React, { useEffect, useImperativeHandle, forwardRef } from 'react' +import { + Box, + Stack +} from '@mui/material' +import { useCertificateImport } from '../hooks/useCertificateImport' +import CertificateDetailsSection from './CertificateDetailsSection' +import CertificateVerificationSection from './CertificateVerificationSection' +import UserInputsSection from './UserInputsSection' +import MobileCertificateSection from './MobileCertificateSection' + +const TrustedCertificateImportForm = forwardRef(function TrustedCertificateImportForm({ + currentCertificates = null, + initialPemText = null, + readOnlyPem = false +}, ref) { + const targetStore = 'trusted' + + const { + // State + alias, + pemText, + privateKeyText, + file, + privateKeyFile, + apiError, + errors, + certificateDetails, + verificationResult, + isVerifying, + existingCertificates, + aliasWarning, + + // Refs + fileInputRef, + privateKeyFileInputRef, + certFileAccept, + keyFileAccept, + + // Actions + setLoading, + setApiError, + setPemText, + parseCertificateDetails, + + // Handlers + handleVerifyCertificate, + handleFileUpload, + handlePrivateKeyFileUpload, + handlePemTextChange, + handlePrivateKeyTextChange, + handleAliasChange, + validate, + loadExistingCertificates, + checkAliasExists + } = useCertificateImport(targetStore, currentCertificates) + + // Load existing certificates on component mount + useEffect(() => { + loadExistingCertificates() + }, [loadExistingCertificates]) + + // Pre-populate PEM text if initialPemText is provided + useEffect(() => { + if (initialPemText && initialPemText.trim()) { + // Always update when initialPemText changes (for URL import flow) + setPemText(initialPemText) + parseCertificateDetails(initialPemText) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialPemText]) + + // Expose form state and methods via ref + useImperativeHandle(ref, () => ({ + alias, + pemText, + validate, + checkAliasExists: () => checkAliasExists(alias), + apiError, + setApiError + })) + + return ( + + {/* Left Column - User Inputs */} + + {/* Right Column - Certificate Details & Verification */} + + + + + + + + {/* Mobile Certificate Details & Verification */} + + + ) +}) + +export default TrustedCertificateImportForm + diff --git a/plugins/tls/web-ui/src/components/UserInputsSection.jsx b/plugins/tls/web-ui/src/components/UserInputsSection.jsx new file mode 100644 index 000000000..ee5bde2c1 --- /dev/null +++ b/plugins/tls/web-ui/src/components/UserInputsSection.jsx @@ -0,0 +1,155 @@ +import React from 'react' +import { + Box, + Stack, + TextField, + Button, + Typography, + FormHelperText, + Alert +} from '@mui/material' + +const UserInputsSection = ({ + // State + alias, + pemText, + privateKeyText, + file, + privateKeyFile, + apiError, + errors, + targetStore, + aliasWarning, + readOnlyPem = false, + showPrivateKeyFields = false, + + // Refs + fileInputRef, + privateKeyFileInputRef, + certFileAccept, + keyFileAccept, + + // Handlers + handleAliasChange, + handlePemTextChange, + handlePrivateKeyTextChange, + handleFileUpload, + handlePrivateKeyFileUpload, + setApiError +}) => { + return ( + + + {apiError && ( + setApiError(null)}> + {apiError} + + )} + + + + {!readOnlyPem && ( + <> + + + + + {file ? file.name : 'No file selected'} + + + {errors.file && {errors.file}} + + )} + + + + {showPrivateKeyFields && ( + <> + + + + + {privateKeyFile ? privateKeyFile.name : 'No private key file selected'} + + + {errors.privateKeyFile && {errors.privateKeyFile}} + + + + )} + + + ) +} + +export default UserInputsSection diff --git a/plugins/tls/web-ui/src/context/AuthContext.jsx b/plugins/tls/web-ui/src/context/AuthContext.jsx new file mode 100644 index 000000000..257494321 --- /dev/null +++ b/plugins/tls/web-ui/src/context/AuthContext.jsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { loginWithCredentials } from '../services/authService' + +const STORAGE_KEY = 'auth:isAuthenticated' + +const AuthContext = createContext({ + isAuthenticated: false, + login: async (_credentials) => {}, + logout: () => {}, +}) + +export function AuthProvider({ children }) { + // Initialize synchronously from localStorage to avoid redirect flash + const [isAuthenticated, setIsAuthenticated] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + return saved === 'true' + } catch (_) { + return false + } + }) + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, isAuthenticated ? 'true' : 'false') + } catch (_) {} + }, [isAuthenticated]) + + const login = async ({ username, password }) => { + await loginWithCredentials({ username, password }) + setIsAuthenticated(true) + } + const logout = () => setIsAuthenticated(false) + + const value = useMemo(() => ({ isAuthenticated, login, logout }), [isAuthenticated]) + + return ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext) +} + +export default AuthContext diff --git a/plugins/tls/web-ui/src/context/NotificationContext.jsx b/plugins/tls/web-ui/src/context/NotificationContext.jsx new file mode 100644 index 000000000..655e478ff --- /dev/null +++ b/plugins/tls/web-ui/src/context/NotificationContext.jsx @@ -0,0 +1,78 @@ +import React, { createContext, useContext, useState, useEffect } from 'react' +import { Snackbar, Alert } from '@mui/material' +import { notificationService } from '../services/notificationService' + +const NotificationContext = createContext() + +export const useNotification = () => { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider') + } + return context +} + +export const NotificationProvider = ({ children }) => { + const [notification, setNotification] = useState({ + open: false, + message: '', + severity: 'info' // 'success', 'error', 'warning', 'info' + }) + + const showNotification = (message, severity = 'info') => { + setNotification({ + open: true, + message, + severity + }) + } + + const hideNotification = () => { + setNotification(prev => ({ + ...prev, + open: false + })) + } + + const showSuccess = (message) => showNotification(message, 'success') + const showError = (message) => showNotification(message, 'error') + const showWarning = (message) => showNotification(message, 'warning') + const showInfo = (message) => showNotification(message, 'info') + + // Subscribe to notification service so services/utils can trigger notifications + useEffect(() => { + const unsubscribe = notificationService.subscribe((message, severity) => { + showNotification(message, severity) + }) + return unsubscribe + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + + {notification.message} + + + + ) +} diff --git a/plugins/tls/web-ui/src/context/ProtectedRoute.jsx b/plugins/tls/web-ui/src/context/ProtectedRoute.jsx new file mode 100644 index 000000000..c679b6ee3 --- /dev/null +++ b/plugins/tls/web-ui/src/context/ProtectedRoute.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from './AuthContext' + +export default function ProtectedRoute({ children }) { + const { isAuthenticated } = useAuth() + const location = useLocation() + + if (!isAuthenticated) { + // Preserve search params when redirecting to login + return + } + + return children +} diff --git a/plugins/tls/web-ui/src/hooks/useAliasEdit.js b/plugins/tls/web-ui/src/hooks/useAliasEdit.js new file mode 100644 index 000000000..d3068ae97 --- /dev/null +++ b/plugins/tls/web-ui/src/hooks/useAliasEdit.js @@ -0,0 +1,109 @@ +import { useState, useEffect } from 'react' +import { fetchCertificates } from '../services/tlsService' + +export const useAliasEdit = (currentAlias, currentStore, currentCertificates = null) => { + + // State management + const [newAlias, setNewAlias] = useState('') + const [existingCertificates, setExistingCertificates] = useState([]) + const [aliasWarning, setAliasWarning] = useState(null) + const [loading, setLoading] = useState(false) + const [apiError, setApiError] = useState(null) + const [existingCertificateInfo, setExistingCertificateInfo] = useState(null) + + // Load existing certificates to check for alias conflicts + const loadExistingCertificates = async () => { + try { + // Use provided currentCertificates if available, otherwise fetch + if (currentCertificates && Array.isArray(currentCertificates)) { + setExistingCertificates(currentCertificates) + } else { + const certificates = await fetchCertificates() + setExistingCertificates(certificates) + } + } catch (error) { + // Service already shows notification, no need to show again + } + } + + // Check if alias already exists within the same store (excluding current certificate) + const checkAliasExists = (aliasToCheck) => { + if (!aliasToCheck.trim()) { + setAliasWarning(null) + setExistingCertificateInfo(null) + return false + } + + // Only check certificates in the same store, excluding the current certificate + const existingCert = existingCertificates.find(cert => + cert.store === currentStore && + cert.alias.toLowerCase() === aliasToCheck.toLowerCase() && + cert.alias.toLowerCase() !== currentAlias.toLowerCase() + ) + + if (existingCert) { + setAliasWarning('This alias is already in use in this store') + setExistingCertificateInfo(existingCert) + return true + } else { + setAliasWarning(null) + setExistingCertificateInfo(null) + return false + } + } + + // Handle alias change + const handleAliasChange = (e) => { + const newValue = e.target.value + setNewAlias(newValue) + checkAliasExists(newValue) + } + + // Validation logic + const validate = () => { + if (!newAlias.trim()) { + setApiError('Alias is required.') + return false + } + if (newAlias.trim() === currentAlias) { + setApiError('New alias must be different from current alias.') + return false + } + return true + } + + // Initialize with current alias and reset warning state + useEffect(() => { + setNewAlias(currentAlias) + setAliasWarning(null) + setExistingCertificateInfo(null) + }, [currentAlias]) + + // Load existing certificates on mount or when currentCertificates changes + useEffect(() => { + loadExistingCertificates() + // Reset warning state when certificates are reloaded (dialog opened fresh) + setAliasWarning(null) + setExistingCertificateInfo(null) + }, [currentCertificates]) + + return { + // State + newAlias, + aliasWarning, + loading, + apiError, + existingCertificates, + existingCertificateInfo, + + // Actions + setLoading, + setApiError, + + // Handlers + handleAliasChange, + validate, + checkAliasExists, + loadExistingCertificates + } +} diff --git a/plugins/tls/web-ui/src/hooks/useCertificateImport.js b/plugins/tls/web-ui/src/hooks/useCertificateImport.js new file mode 100644 index 000000000..4c31f3462 --- /dev/null +++ b/plugins/tls/web-ui/src/hooks/useCertificateImport.js @@ -0,0 +1,335 @@ +import { useState, useRef, useEffect } from 'react' +import { parseCertificate, getSuggestedAlias, isValidPemCertificate, isValidPemPrivateKey } from '../utils/certificateUtils' +import { verifyCertificate } from '../utils/verificationUtils' +import { fetchCertificates } from '../services/tlsService' +import { useNotification } from '../context/NotificationContext' + +export const useCertificateImport = (targetStore, currentCertificates = null) => { + const { showError } = useNotification() + + // State management + const [alias, setAlias] = useState('') + const [pemText, setPemText] = useState('') + const [privateKeyText, setPrivateKeyText] = useState('') + const [file, setFile] = useState(null) + const [privateKeyFile, setPrivateKeyFile] = useState(null) + const [loading, setLoading] = useState(false) + const [apiError, setApiError] = useState(null) + const [errors, setErrors] = useState({}) + const [certificateDetails, setCertificateDetails] = useState(null) + const [verificationResult, setVerificationResult] = useState(null) + const [isVerifying, setIsVerifying] = useState(false) + const [existingCertificates, setExistingCertificates] = useState(currentCertificates || []) + const [aliasWarning, setAliasWarning] = useState(null) + const [existingCertificateInfo, setExistingCertificateInfo] = useState(null) + + // Refs + const fileInputRef = useRef(null) + const privateKeyFileInputRef = useRef(null) + + const certFileAccept = '.pem,.crt' + const keyFileAccept = '.pem,.key' + + // Load existing certificates to check for alias conflicts + const loadExistingCertificates = async () => { + // If currentCertificates were provided, use them instead of fetching + if (currentCertificates && currentCertificates.length > 0) { + setExistingCertificates(currentCertificates) + return + } + + try { + const certificates = await fetchCertificates() + setExistingCertificates(certificates) + } catch (error) { + // Service already shows notification, no need to show again + } + } + + // Update existingCertificates when currentCertificates prop changes + useEffect(() => { + if (currentCertificates && currentCertificates.length > 0) { + setExistingCertificates(currentCertificates) + } + }, [currentCertificates]) + + // Check if alias already exists within the target store + const checkAliasExists = (aliasToCheck) => { + if (!aliasToCheck.trim()) { + setAliasWarning(null) + setExistingCertificateInfo(null) + return false + } + + const existingCert = existingCertificates.find(cert => + cert.store === targetStore && + cert.alias.toLowerCase() === aliasToCheck.toLowerCase() + ) + + if (existingCert) { + setAliasWarning('This alias is already in use in this store') + setExistingCertificateInfo(existingCert) + return true + } else { + setAliasWarning(null) + setExistingCertificateInfo(null) + return false + } + } + + + + // Parse certificate details when PEM text changes + const parseCertificateDetails = async (pemText) => { + if (!pemText.trim()) { + setCertificateDetails(null) + setVerificationResult(null) + setErrors((prev) => ({ ...prev, pemText: undefined })) + return + } + + // Validate certificate format + if (!isValidPemCertificate(pemText)) { + setCertificateDetails(null) + setVerificationResult(null) + setErrors((prev) => ({ ...prev, pemText: 'Invalid certificate format. Content must contain a valid certificate (BEGIN CERTIFICATE).' })) + return + } + + try { + const details = parseCertificate(pemText) + setCertificateDetails(details) + + // Clear any previous errors + setErrors((prev) => ({ ...prev, pemText: undefined })) + + // Auto-complete alias if it's empty + if (!alias.trim()) { + const suggestedAlias = getSuggestedAlias(details) + if (suggestedAlias) { + setAlias(suggestedAlias) + // Check for conflicts immediately after setting the suggested alias + checkAliasExists(suggestedAlias) + } + } else { + // If alias is already set, check for conflicts + checkAliasExists(alias) + } + + // Auto-verify certificate + await performAutoVerification(pemText) + } catch (error) { + showError('Failed to parse certificate details. Please check the certificate format.') + setCertificateDetails(null) + setVerificationResult(null) + setErrors((prev) => ({ ...prev, pemText: 'Invalid certificate format. Content must contain a valid certificate (BEGIN CERTIFICATE).' })) + } + } + + // Perform auto-verification + const performAutoVerification = async (pemText, privateKeyPem = null) => { + if (!pemText.trim()) return + + setIsVerifying(true) + try { + // Use provided private key or current state + const keyToUse = privateKeyPem !== null ? privateKeyPem : + (targetStore === 'private' && privateKeyText.trim() ? privateKeyText : null) + + const result = await verifyCertificate(pemText, keyToUse) + setVerificationResult(result) + } catch (error) { + setVerificationResult({ + success: false, + error: `Auto-verification failed: ${error.message}` + }) + } finally { + setIsVerifying(false) + } + } + + // Handle certificate verification + const handleVerifyCertificate = async () => { + if (!pemText.trim()) return + + setIsVerifying(true) + try { + const result = await verifyCertificate(pemText, privateKeyText || null) + setVerificationResult(result) + } catch (error) { + setVerificationResult({ + success: false, + error: error.message || 'Verification failed' + }) + } finally { + setIsVerifying(false) + } + } + + // Validation logic + const validate = () => { + const nextErrors = {} + if (!pemText.trim()) { + nextErrors.pemText = 'PEM content is required.' + } + if (!alias.trim()) { + nextErrors.alias = 'Alias is required.' + } + if (targetStore === 'private' && !privateKeyText.trim()) { + nextErrors.privateKeyText = 'Private key is required for private store.' + } + setErrors(nextErrors) + return Object.keys(nextErrors).length === 0 + } + + // Handle file upload + const handleFileUpload = async (e) => { + try { + const f = e.target.files && e.target.files[0] + setFile(f || null) + if (!f) return + const name = (f.name || '').toLowerCase() + if (!(name.endsWith('.pem') || name.endsWith('.crt'))) { + setErrors((prev) => ({ ...prev, file: 'Please select a .pem or .crt file.' })) + return + } + const text = await f.text() + + // Validate certificate format + if (!isValidPemCertificate(text)) { + setErrors((prev) => ({ ...prev, file: 'Invalid certificate format. File must contain a valid certificate (BEGIN CERTIFICATE).', pemText: 'Invalid certificate format. File must contain a valid certificate (BEGIN CERTIFICATE).' })) + setPemText(text) // Still set the text so user can see what was uploaded + setCertificateDetails(null) + setVerificationResult(null) + return + } + + setPemText(text) + await parseCertificateDetails(text) // This will now auto-verify and check for alias conflicts + setErrors((prev) => ({ ...prev, file: undefined, pemText: undefined })) + } catch (err) { + setErrors((prev) => ({ ...prev, file: 'Failed to read file.' })) + } + } + + // Handle private key file upload + const handlePrivateKeyFileUpload = async (e) => { + try { + const f = e.target.files && e.target.files[0] + setPrivateKeyFile(f || null) + if (!f) return + const name = (f.name || '').toLowerCase() + if (!(name.endsWith('.pem') || name.endsWith('.key'))) { + setErrors((prev) => ({ ...prev, privateKeyFile: 'Please select a .pem or .key file.' })) + return + } + const text = await f.text() + + // Validate private key format + if (!isValidPemPrivateKey(text)) { + setErrors((prev) => ({ ...prev, privateKeyFile: 'Invalid private key format. File must contain a valid private key (BEGIN PRIVATE KEY or BEGIN RSA PRIVATE KEY).', privateKeyText: 'Invalid private key format. File must contain a valid private key (BEGIN PRIVATE KEY or BEGIN RSA PRIVATE KEY).' })) + setPrivateKeyText(text) // Still set the text so user can see what was uploaded + setVerificationResult(null) + return + } + + setPrivateKeyText(text) + + // Auto-verify if certificate is already present + if (pemText.trim()) { + await performAutoVerification(pemText, text) + } + + setErrors((prev) => ({ ...prev, privateKeyFile: undefined, privateKeyText: undefined })) + } catch (err) { + setErrors((prev) => ({ ...prev, privateKeyFile: 'Failed to read file.' })) + } + } + + // Handle PEM text change + const handlePemTextChange = async (e) => { + setPemText(e.target.value) + await parseCertificateDetails(e.target.value) // This will now auto-verify and check for alias conflicts + } + + // Handle private key text change + const handlePrivateKeyTextChange = async (e) => { + const newPrivateKeyText = e.target.value + setPrivateKeyText(newPrivateKeyText) + + // Validate private key format if text is provided + if (newPrivateKeyText.trim()) { + if (!isValidPemPrivateKey(newPrivateKeyText)) { + setErrors((prev) => ({ ...prev, privateKeyText: 'Invalid private key format. Content must contain a valid private key (BEGIN PRIVATE KEY or BEGIN RSA PRIVATE KEY).' })) + setVerificationResult(null) + return + } else { + setErrors((prev) => ({ ...prev, privateKeyText: undefined })) + } + } else { + setErrors((prev) => ({ ...prev, privateKeyText: undefined })) + } + + // Auto-verify if certificate is already present + if (pemText.trim()) { + await performAutoVerification(pemText, newPrivateKeyText) + } + } + + // Handle alias change + const handleAliasChange = (e) => { + const newAlias = e.target.value + setAlias(newAlias) + checkAliasExists(newAlias) + } + + return { + // State + alias, + pemText, + privateKeyText, + file, + privateKeyFile, + loading, + apiError, + errors, + certificateDetails, + verificationResult, + isVerifying, + existingCertificates, + aliasWarning, + existingCertificateInfo, + + // Refs + fileInputRef, + privateKeyFileInputRef, + certFileAccept, + keyFileAccept, + + // Actions + setAlias, + setPemText, + setPrivateKeyText, + setFile, + setPrivateKeyFile, + setLoading, + setApiError, + setErrors, + setCertificateDetails, + setVerificationResult, + setIsVerifying, + + // Handlers + handleVerifyCertificate, + handleFileUpload, + handlePrivateKeyFileUpload, + handlePemTextChange, + handlePrivateKeyTextChange, + handleAliasChange, + parseCertificateDetails, + validate, + loadExistingCertificates, + checkAliasExists, + performAutoVerification + } +} diff --git a/plugins/tls/web-ui/src/hooks/useCertificates.js b/plugins/tls/web-ui/src/hooks/useCertificates.js new file mode 100644 index 000000000..a8d21bc35 --- /dev/null +++ b/plugins/tls/web-ui/src/hooks/useCertificates.js @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { fetchSystemCertificates, fetchTrustedCertificates, fetchLocalCertificates } from '../services/tlsService' + +function normalize(text) { + return (text || '').toString().toLowerCase() +} + +/** + * Hook to manage certificates with preloading of all tabs + * @param {string} tabKey - The active tab key ('native', 'trusted', or 'private') + * @returns {Object} Certificate data and utilities + */ +export default function useCertificates(tabKey = 'native') { + + // Store certificates per tab + const [certificatesByTab, setCertificatesByTab] = useState({ + native: [], + trusted: [], + private: [], + }) + + // Track loading state per tab + const [loadingByTab, setLoadingByTab] = useState({ + native: false, + trusted: false, + private: false, + }) + + // Track error state per tab + const [errorByTab, setErrorByTab] = useState({ + native: '', + trusted: '', + private: '', + }) + + // Use refs to track state without causing re-renders or dependency issues + const certificatesByTabRef = useRef(certificatesByTab) + const loadingByTabRef = useRef(loadingByTab) + + // Keep refs in sync with state + useEffect(() => { + certificatesByTabRef.current = certificatesByTab + }, [certificatesByTab]) + + useEffect(() => { + loadingByTabRef.current = loadingByTab + }, [loadingByTab]) + + // Map tab keys to fetch functions + const fetchFunctions = { + native: fetchSystemCertificates, + trusted: fetchTrustedCertificates, + private: fetchLocalCertificates, + } + + const fetchByTab = useCallback(async (key, force = false) => { + // Check current state using refs to avoid stale closures + if (!force && (loadingByTabRef.current[key] || certificatesByTabRef.current[key].length > 0)) { + return + } + + setLoadingByTab((prev) => ({ ...prev, [key]: true })) + setErrorByTab((prev) => ({ ...prev, [key]: '' })) + + try { + const fetchFn = fetchFunctions[key] + if (!fetchFn) { + throw new Error(`Unknown tab key: ${key}`) + } + + const data = await fetchFn() + setCertificatesByTab((prev) => ({ ...prev, [key]: data })) + } catch (e) { + // Service already shows notification, just update error state + setErrorByTab((prev) => ({ ...prev, [key]: `Failed to load certificates` })) + } finally { + setLoadingByTab((prev) => ({ ...prev, [key]: false })) + } + }, []) // Empty dependencies - use refs to access current state + + // Preload all certificate types on mount + useEffect(() => { + const tabKeys = ['native', 'trusted', 'private'] + + // Fetch all certificate types in parallel + Promise.allSettled( + tabKeys.map(async (key) => { + // Only fetch if not already loaded or loading + if (!loadingByTabRef.current[key] && certificatesByTabRef.current[key].length === 0) { + await fetchByTab(key) + } + }) + ) + }, [fetchByTab]) // fetchByTab is stable (useCallback with empty deps), but included for correctness + + // Fetch certificates when tab changes (fallback for manual refresh) + useEffect(() => { + if (tabKey && fetchFunctions[tabKey]) { + // Only fetch if not already loaded (preload may have already loaded it) + if (!loadingByTabRef.current[tabKey] && certificatesByTabRef.current[tabKey].length === 0) { + fetchByTab(tabKey) + } + } + }, [tabKey, fetchByTab]) + + // Get current tab's certificates + const currentTabCertificates = certificatesByTab[tabKey] || [] + const currentLoading = loadingByTab[tabKey] || false + const currentError = errorByTab[tabKey] || '' + + // Combine all certificates for counts and filtering + const all = useMemo(() => { + return [ + ...certificatesByTab.native, + ...certificatesByTab.trusted, + ...certificatesByTab.private, + ] + }, [certificatesByTab]) + + const filterBy = (storeKey, search) => { + const q = normalize(search) + const tabCertificates = certificatesByTab[storeKey] || [] + return tabCertificates.filter((c) => { + if (!q) return true + return normalize(c.alias).includes(q) || normalize(c.name).includes(q) || normalize(c.subject).includes(q) + }) + } + + const counts = useMemo(() => ({ + native: certificatesByTab.native.length, + trusted: certificatesByTab.trusted.length, + private: certificatesByTab.private.length, + }), [certificatesByTab]) + + // Refetch function for current tab + const refetch = async () => { + if (tabKey && fetchFunctions[tabKey]) { + // Clear current tab's data and force reload + setCertificatesByTab((prev) => ({ ...prev, [tabKey]: [] })) + await fetchByTab(tabKey, true) + } + } + + // Get certificates by store name (for update operations) + const getCertificatesByStore = useCallback((store) => { + // Map store names to tab keys + const storeToTabMap = { + 'trusted': 'trusted', + 'private': 'private', + 'native': 'native' + } + const tabKey = storeToTabMap[store] + return tabKey ? certificatesByTab[tabKey] || [] : [] + }, [certificatesByTab]) + + return { + all, + loading: currentLoading, + error: currentError, + counts, + filterBy, + refetch, + getCertificatesByStore, + certificatesByTab // Also expose directly for convenience + } +} + + diff --git a/plugins/tls/web-ui/src/index.css b/plugins/tls/web-ui/src/index.css new file mode 100644 index 000000000..ae65e721f --- /dev/null +++ b/plugins/tls/web-ui/src/index.css @@ -0,0 +1,24 @@ + +@layer theme, base, mui, components, utilities; +@import "tailwindcss"; + +@theme inline { + --color-primary: rgb(var(--mui-palette-primary-mainChannel)); + --color-background-default: rgb(var(--mui-palette-background-defaultChannel)); + --font-body1: var(--mui-font-body1); + --color-novamap-orange: #f06421; +} +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/plugins/tls/web-ui/src/layout/DashboardLayout.jsx b/plugins/tls/web-ui/src/layout/DashboardLayout.jsx new file mode 100644 index 000000000..fb0cf9e9a --- /dev/null +++ b/plugins/tls/web-ui/src/layout/DashboardLayout.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import { AppBar, Toolbar, IconButton, Box } from '@mui/material' +import logo from '../assets/oie_logo_bottom_text.svg' +import LogoutIcon from '@mui/icons-material/Logout'; +import { useAuth } from '../context/AuthContext'; + +export default function DashboardLayout({ children }) { + const { logout } = useAuth() + + return ( + + theme.zIndex.appBar, background: 'linear-gradient(0deg, white 16%, rgb(254, 213, 216) 100%)' }}> + + + + + + + + + + + + + {children} + + + + ) +} diff --git a/plugins/tls/web-ui/src/main.jsx b/plugins/tls/web-ui/src/main.jsx new file mode 100644 index 000000000..a11f2f56c --- /dev/null +++ b/plugins/tls/web-ui/src/main.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { StyledEngineProvider } from '@mui/material/styles' +import GlobalStyles from '@mui/material/GlobalStyles' +import CssBaseline from '@mui/material/CssBaseline' +import App from './App' +import './index.css' +import { AuthProvider } from './context/AuthContext' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + + + +) \ No newline at end of file diff --git a/plugins/tls/web-ui/src/pages/Login.jsx b/plugins/tls/web-ui/src/pages/Login.jsx new file mode 100644 index 000000000..ad36ae073 --- /dev/null +++ b/plugins/tls/web-ui/src/pages/Login.jsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react' +import { Box, Button, TextField, Typography, Stack, Paper, InputAdornment } from '@mui/material' +import { useNavigate, useLocation } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import logo from '../assets/oie_logo_bottom_text.svg' +import PersonOutlineIcon from '@mui/icons-material/PersonOutline' +import KeyIcon from '@mui/icons-material/Key' + +export default function Login() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + const location = useLocation() + const { login } = useAuth() + + const handleSubmit = async (e) => { + e.preventDefault() + if (!username || !password) { + setError('Please enter username and password') + return + } + setLoading(true) + setError('') + try { + await login({ username, password }) + // Preserve search params when redirecting after login + const from = location.state?.from + const redirectTo = from?.pathname || '/tls' + const search = from?.search || '' + navigate(redirectTo + search, { replace: true }) + } catch (err) { + const msg = err?.message || 'Login failed. Please try again.' + setError(msg) + // eslint-disable-next-line no-console + console.debug('[Login] failed', { msg, err }) + } finally { + setLoading(false) + } + } + + return ( + + + + + + setUsername(e.target.value)} + fullWidth + autoComplete="username" + slotProps={{ + input: { + startAdornment: ( + + + + ), + } + }} + /> + setPassword(e.target.value)} + fullWidth + autoComplete="current-password" + slotProps={{ + input: { + startAdornment: ( + + + + ), + } + }} + /> + {error ? {error} : null} + + + + + + ) +} diff --git a/plugins/tls/web-ui/src/pages/TlsManagement.jsx b/plugins/tls/web-ui/src/pages/TlsManagement.jsx new file mode 100644 index 000000000..5b0a6e043 --- /dev/null +++ b/plugins/tls/web-ui/src/pages/TlsManagement.jsx @@ -0,0 +1,295 @@ +import React, { useMemo, useState } from 'react' +import { Box, Paper, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material' +import { useSearchParams } from 'react-router-dom' +import TabsWithCounts from '../components/TabsWithCounts' +import TabPanel from '../components/TabPanel' +import StoreToolbar from '../components/StoreToolbar' +import SearchInput from '../components/SearchInput' +import CertificateList from '../components/CertificateList' +import useCertificates from '../hooks/useCertificates' +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import VpnKeyIcon from '@mui/icons-material/VpnKey' +import ImportTrustedCertificateDialog from '../components/ImportTrustedCertificateDialog' +import ImportPrivateCertificateDialog from '../components/ImportPrivateCertificateDialog' +import ImportFromUrlDialogContent from '../components/ImportFromUrlDialogContent' +import ImportCertificateChainDialogContent from '../components/ImportCertificateChainDialogContent' +import CertificateDetailsDialog from '../components/CertificateDetailsDialog' +import EditAliasDialog from '../components/EditAliasDialog' +import RemoveCertificateDialog from '../components/RemoveCertificateDialog' +import { useNotification } from '../context/NotificationContext' + +export default function TlsManagement() { + const [params, setParams] = useSearchParams() + const tabKeys = ['native', 'trusted', 'private'] + const urlTab = params.get('tab') + // Derive tabKey directly from URL - single source of truth, no state needed + const tabKey = useMemo(() => { + return urlTab && tabKeys.includes(urlTab) ? urlTab : 'native' + }, [urlTab, tabKeys]) + + const { all, counts, filterBy, loading, error, refetch, getCertificatesByStore } = useCertificates(tabKey) + const { showSuccess, showError } = useNotification() + const [search, setSearch] = useState('') + + const [dialogOpen, setDialogOpen] = useState(false) + const [dialogTitle, setDialogTitle] = useState('') + const [dialogType, setDialogType] = useState(null) // 'text' | 'import-certificate' | null + const [dialogProps, setDialogProps] = useState({}) + + const [detailsDialogOpen, setDetailsDialogOpen] = useState(false) + const [selectedCertificate, setSelectedCertificate] = useState(null) + const [showPrivateKeys, setShowPrivateKeys] = useState(false) + + const [editAliasDialogOpen, setEditAliasDialogOpen] = useState(false) + const [certificateToEdit, setCertificateToEdit] = useState(null) + + const [removeDialogOpen, setRemoveDialogOpen] = useState(false) + const [certificateToRemove, setCertificateToRemove] = useState(null) + + const openDialog = ({ type, title, props = {} }) => { + setDialogTitle(title) + setDialogType(type) + setDialogProps(props) + setDialogOpen(true) + } + + const closeDialog = () => { + setDialogOpen(false) + setDialogType(null) + setDialogProps({}) + } + + const handleImportSuccess = () => { + // Refresh the certificate data after successful import + refetch() + closeDialog() + } + + const handleViewDetails = (certificate) => { + setSelectedCertificate(certificate) + setDetailsDialogOpen(true) + } + + const handleCloseDetails = () => { + setDetailsDialogOpen(false) + setSelectedCertificate(null) + } + + const handleTogglePrivateKeys = () => { + setShowPrivateKeys(!showPrivateKeys) + } + + const handleExport = (certificate) => { + // TODO: Implement certificate export functionality + console.log('Export certificate:', certificate) + } + + const handleEditAlias = (certificate) => { + setCertificateToEdit(certificate) + setEditAliasDialogOpen(true) + } + + const handleCloseEditAlias = () => { + setEditAliasDialogOpen(false) + setCertificateToEdit(null) + } + + const handleAliasEditSuccess = () => { + // Refresh the certificate data after successful alias edit + refetch() + handleCloseEditAlias() + } + + const handleRemove = (certificate) => { + setCertificateToRemove(certificate) + setRemoveDialogOpen(true) + } + + const handleCloseRemove = () => { + setRemoveDialogOpen(false) + setCertificateToRemove(null) + } + + const handleRemoveSuccess = () => { + // Refresh the certificate data after successful removal + refetch() + handleCloseRemove() + showSuccess(`Certificate "${certificateToRemove?.alias}" has been removed successfully`) + } + + const openImportDialog = () => { + const targetStore = tabKey === 'trusted' ? 'trusted' : 'private' + if (targetStore === 'trusted') { + // Use certificate chain import for trusted store + openDialog({ type: 'import-certificate-chain', title: 'Import Certificate Chain', props: { targetStore } }) + } else { + // Use regular import for private store + openDialog({ type: 'import-certificate', title: 'Import Key Pair (PEM)', props: { targetStore } }) + } + } + + const onTabChange = (_e, newIndex) => { + const newKey = tabKeys[newIndex] + setSearch('') + setParams((prev) => { + const p = new URLSearchParams(prev) + p.set('tab', newKey) + return p + }, { replace: true }) + // tabKey will automatically update from URL via useMemo + } + + const tabIndex = useMemo(() => Math.max(0, tabKeys.indexOf(tabKey)), [tabKey]) + const visibleRows = useMemo(() => filterBy(tabKey, search), [filterBy, tabKey, search]) + + const tabs = [ + { key: 'native', label: 'Native Java Certificate Store', count: counts.native, icon: }, + { key: 'trusted', label: 'Additional Trusted Certificates', count: counts.trusted, icon: }, + { key: 'private', label: 'Local Key Pairs', count: counts.private, icon: }, + ] + + const toolbarByTab = { + native: { + title: 'Native Java Certificate Store', + warning: 'Read-only system store', + actions: [], + }, + trusted: { + title: 'Additional Trusted Certificates', + actions: [ + { key: 'import', label: 'Import Certificate', color: 'info', onClick: () => openImportDialog() }, + { key: 'import-url', label: 'Import from URL', color: 'info', onClick: () => openDialog({ type: 'import-from-url', title: 'Import Certificate from URL', props: { targetStore: 'trusted' } }) }, + + ], + }, + private: { + title: 'Local Key Pairs', + actions: [ + { key: 'show-private-keys', label: showPrivateKeys ? 'Hide Private Keys' : 'Show Private Keys', color: 'warning', onClick: handleTogglePrivateKeys }, + { key: 'import-cert', label: 'Import Key Pair', color: 'info', onClick: () => openImportDialog() }, + + ], + }, + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {dialogTitle} + + {dialogType === 'import-certificate-chain' && dialogProps.targetStore === 'trusted' && ( + + )} + {dialogType === 'import-certificate' && dialogProps.targetStore === 'private' && ( + closeDialog()} + onSuccess={handleImportSuccess} + /> + )} + {dialogType === 'import-from-url' && ( + + )} + {dialogType === 'text' && ( + {dialogProps.text} + )} + + {dialogType === 'text' && ( + + + + )} + + + + + + + + + + ) +} diff --git a/plugins/tls/web-ui/src/services/api.js b/plugins/tls/web-ui/src/services/api.js new file mode 100644 index 000000000..223fe599b --- /dev/null +++ b/plugins/tls/web-ui/src/services/api.js @@ -0,0 +1,76 @@ +import axios from 'axios' + +export const api = axios.create({ + // Use same-origin '/api' in dev with Vite proxy; fallback to absolute BASE_URL when building + baseURL: '/', + withCredentials: true, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + timeout: 15000, +}) + +// Dev-friendly request logging (masks password) +api.interceptors.request.use((config) => { + try { + let dataPreview = null + const isFormData = typeof FormData !== 'undefined' && config.data instanceof FormData + const isUrlParams = typeof URLSearchParams !== 'undefined' && config.data instanceof URLSearchParams + if (isUrlParams) { + const safe = {} + for (const [k, v] of config.data.entries()) { + safe[k] = String(k).toLowerCase() === 'password' ? '***' : v + } + dataPreview = safe + } else if (isFormData) { + const safe = {} + for (const [k, v] of config.data.entries()) { + safe[k] = String(k).toLowerCase() === 'password' ? '***' : (typeof v === 'string' ? v : '[file]') + } + dataPreview = safe + } else if (config.data && typeof config.data === 'object') { + const safe = { ...config.data } + if ('password' in safe) safe.password = '***' + dataPreview = safe + } + // eslint-disable-next-line no-console + console.debug('[API] request', { method: config.method, url: config.url, data: dataPreview }) + } catch (_) {} + return config +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + // eslint-disable-next-line no-console + console.debug('[API] error', { + url: error?.config?.url, + status: error?.response?.status, + data: error?.response?.data, + }) + + // Handle 401 Unauthorized errors - clear auth and redirect to login + if (error?.response?.status === 401) { + const STORAGE_KEY = 'auth:isAuthenticated' + + // Clear authentication state from localStorage + try { + localStorage.removeItem(STORAGE_KEY) + } catch (_) { + // Ignore localStorage errors + } + + // Redirect to login page if not already there + const currentPath = window.location.pathname + if (!currentPath.includes('/login')) { + window.location.href = '/tls-manager/login' + } + } + + return Promise.reject(error) + } +) + +export default api + + diff --git a/plugins/tls/web-ui/src/services/authService.js b/plugins/tls/web-ui/src/services/authService.js new file mode 100644 index 000000000..5bea5485d --- /dev/null +++ b/plugins/tls/web-ui/src/services/authService.js @@ -0,0 +1,44 @@ +import api from './api' + +function parseLoginXml(xmlString) { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(xmlString, 'application/xml') + const status = doc.querySelector('status')?.textContent || '' + const message = doc.querySelector('message')?.textContent || '' + return { status, message } + } catch (e) { + return { status: '', message: 'Failed to parse login response' } + } +} + +export async function loginWithCredentials({ username, password }) { + // Use application/x-www-form-urlencoded (safelisted for CORS; many Mirth setups expect it) + const body = new URLSearchParams() + body.set('username', username) + body.set('password', password) + + // The endpoint returns XML and sets JSESSIONID cookie; withCredentials ensures the browser stores it + const response = await api.post('/api/users/_login', body, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/xml, text/xml' }, + responseType: 'text', + transformResponse: [(data) => data], + }) + + const { status, message } = parseLoginXml(response.data || '') + const success = String(status).toUpperCase() === 'SUCCESS' + + // Useful debug info in dev tools + // eslint-disable-next-line no-console + console.debug('[Auth] login response', { status, message, setCookie: response.headers?.['set-cookie'] }) + + if (!success) { + const error = new Error(message || 'Login failed') + error.code = 'LOGIN_FAILED' + throw error + } + + return { success: true } +} + + diff --git a/plugins/tls/web-ui/src/services/notificationService.js b/plugins/tls/web-ui/src/services/notificationService.js new file mode 100644 index 000000000..cdc520527 --- /dev/null +++ b/plugins/tls/web-ui/src/services/notificationService.js @@ -0,0 +1,47 @@ +/** + * Notification Service - Can be used outside React components + * This service provides a way to show notifications from services and utils + * It uses an event emitter pattern to communicate with the NotificationProvider + */ + +class NotificationService { + constructor() { + this.listeners = [] + } + + // Subscribe to notifications (called by NotificationProvider) + subscribe(callback) { + this.listeners.push(callback) + // Return unsubscribe function + return () => { + this.listeners = this.listeners.filter(listener => listener !== callback) + } + } + + // Show notification (can be called from anywhere) + showNotification(message, severity = 'info') { + this.listeners.forEach(listener => { + listener(message, severity) + }) + } + + showSuccess(message) { + this.showNotification(message, 'success') + } + + showError(message) { + this.showNotification(message, 'error') + } + + showWarning(message) { + this.showNotification(message, 'warning') + } + + showInfo(message) { + this.showNotification(message, 'info') + } +} + +// Export singleton instance +export const notificationService = new NotificationService() + diff --git a/plugins/tls/web-ui/src/services/tlsService.js b/plugins/tls/web-ui/src/services/tlsService.js new file mode 100644 index 000000000..adfd7270e --- /dev/null +++ b/plugins/tls/web-ui/src/services/tlsService.js @@ -0,0 +1,685 @@ +/** + * TLS Service - Certificate Management + * + * Currently using internal store for development. + * + * TO SWITCH TO REAL API: + * 1. Uncomment the api import line below + * 2. In fetchCertificates(): comment out the "INTERNAL STORE" section and uncomment the "REAL API" section + * 3. In updateCertificates(): comment out the "INTERNAL STORE" section and uncomment the "REAL API" section + * 4. Remove or comment out the internal store variables and helper functions at the bottom + */ + +import { parseCertificate, getSuggestedAlias } from '../utils/certificateUtils.js' +import { api } from './api.js' +import { notificationService } from './notificationService.js' + +// === INTERNAL STORE (remove when switching to real API) === +// Internal store to simulate API - starts empty +let internalStore = { + systemCertificates: [], + certificates: [], + pairs: [] +} + +// Load from localStorage if available +const STORAGE_KEY = 'tls-manager-store' +const CHANNEL_ASSIGNMENTS_KEY = 'tls-manager-channel-assignments' + +try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + internalStore = JSON.parse(stored) + } +} catch (e) { + console.warn('Failed to load from localStorage:', e) +} + +// Save to localStorage +function saveToStorage() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(internalStore)) + } catch (e) { + console.warn('Failed to save to localStorage:', e) + } +} + +/** + * Fetch system certificates (native store) + * @returns {Promise} Array of parsed certificate objects + */ +export async function fetchSystemCertificates() { + try { + const response = await api.get('/api/tlsmanager/systemCertificates') + const data = response.data + + // Handle response structure: { list: { trustedCertificate: [{ alias, certificate }] } } or { list: { trustedCertificate: {} } } + const certificates = [] + const certList = data?.list?.trustedCertificate + + // Handle both array and object formats + let certArray = [] + if (Array.isArray(certList)) { + certArray = certList + } else if (certList && typeof certList === 'object') { + // If it's a single object, wrap it in an array + certArray = [certList] + } + + for (const cert of certArray) { + // Skip certificates with missing or empty certificate data + if (!cert.certificate || !cert.certificate.trim()) { + console.warn(`Skipping certificate with empty certificate data for alias: ${cert.alias || 'unknown'}`) + continue + } + + const parsed = await parseCertificate(cert.certificate) + + // Skip certificates that failed to parse (they have an error field) + if (parsed.error) { + console.warn(`Failed to parse certificate for alias "${cert.alias}": ${parsed.error}`) + // Still include it in the list but mark it as invalid + certificates.push({ + alias: cert.alias, + name: cert.alias, + type: 'Invalid', + subject: `Parse Error: ${parsed.error}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + hasPrivateKey: false, + store: 'native', + rawCertificate: cert.certificate, + parsedCertificate: parsed, + }) + continue + } + + certificates.push({ + alias: cert.alias.toString(), + name: parsed.subject?.CN || cert.alias, + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + hasPrivateKey: false, + store: 'native', + rawCertificate: cert.certificate, + parsedCertificate: parsed, + }) + } + + return certificates + } catch (error) { + const errorMessage = 'Failed to fetch system certificates from server' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +/** + * Fetch remote certificates from a URL + * @param {string} url - The URL to fetch certificates from (must be https://) + * @returns {Promise} Array of certificate objects with PEM text and parsed details + */ +export async function fetchRemoteCertificates(url) { + try { + if (!url || typeof url !== 'string' || !url.startsWith('https://')) { + throw new Error('URL must be a valid HTTPS URL') + } + + const response = await api.get('/api/tlsmanager/remoteCertificates', { + params: { url } + }) + const data = response.data + + // Handle response structure: { list: { trustedCertificate: [{ certificate: "..." }] } } + const certificates = [] + const certList = data?.list?.trustedCertificate + + // Handle both array and object formats + let certArray = [] + if (Array.isArray(certList)) { + certArray = certList + } else if (certList && typeof certList === 'object') { + // If it's a single object, wrap it in an array + certArray = [certList] + } + + for (const cert of certArray) { + // Skip certificates with missing or empty certificate data + if (!cert.certificate || !cert.certificate.trim()) { + console.warn('Skipping remote certificate with empty certificate data') + continue + } + + try { + const parsed = await parseCertificate(cert.certificate) + + // Handle parse errors gracefully + if (parsed.error) { + certificates.push({ + certificate: cert.certificate, + name: 'Invalid Certificate', + type: 'Invalid', + subject: `Parse Error: ${parsed.error}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + parsedCertificate: parsed, + error: parsed.error + }) + continue + } + + certificates.push({ + alias: getSuggestedAlias(parsed), + certificate: cert.certificate, + name: parsed.subject?.CN || 'Unknown', + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + parsedCertificate: parsed + }) + } catch (parseError) { + console.warn('Failed to parse remote certificate:', parseError) + certificates.push({ + certificate: cert.certificate, + name: 'Parse Error', + type: 'Invalid', + subject: `Parse Error: ${parseError.message}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + error: parseError.message + }) + } + } + + return certificates + } catch (error) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to fetch remote certificates from server' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +/** + * Fetch trusted certificates + * @returns {Promise} Array of parsed certificate objects + */ +export async function fetchTrustedCertificates() { + try { + const response = await api.get('/api/tlsmanager/trustedCertificates') + const data = response.data + + // Handle response structure: { list: { trustedCertificate: [{ alias, certificate }] } } + const certificates = [] + const certList = data?.list?.trustedCertificate || [] + + // Handle both array and object formats + let certArray = [] + if (Array.isArray(certList)) { + certArray = certList + } else if (certList && typeof certList === 'object') { + // If it's a single object, wrap it in an array + certArray = [certList] + } + + for (const cert of certArray) { + // Skip certificates with missing or empty certificate data + if (!cert.certificate || !cert.certificate.trim()) { + console.warn(`Skipping trusted certificate with empty certificate data for alias: ${cert.alias || 'unknown'}`) + continue + } + + const parsed = await parseCertificate(cert.certificate) + // Use channelsInUse from API response (channelsInUse.string is an array) + const channelsInUse = getChannelsInUse(cert) + + // Handle parse errors gracefully + if (parsed.error) { + console.warn(`Failed to parse trusted certificate for alias "${cert.alias}": ${parsed.error}`) + certificates.push({ + alias: cert.alias, + name: cert.alias, + type: 'Invalid', + subject: `Parse Error: ${parsed.error}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + hasPrivateKey: false, + store: 'trusted', + channelsInUse: channelsInUse, + rawCertificate: cert.certificate, + parsedCertificate: parsed, + }) + continue + } + + certificates.push({ + alias: cert.alias.toString(), + name: parsed.subject?.CN || cert.alias, + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + hasPrivateKey: false, + store: 'trusted', + channelsInUse: channelsInUse, + rawCertificate: cert.certificate, + parsedCertificate: parsed, + }) + } + + return certificates + } catch (error) { + const errorMessage = 'Failed to fetch trusted certificates from server' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +/** + * Fetch local certificates (private store) + * @returns {Promise} Array of parsed certificate objects with private keys + */ +export async function fetchLocalCertificates() { + try { + const response = await api.get('/api/tlsmanager/localCertificates') + const data = response.data + + // Handle response structure: { list: { localCertificate: [{ alias, certificate, key }] } } + const certificates = [] + const certList = data?.list?.localCertificate || [] + + // Handle both array and object formats + let certArray = [] + if (Array.isArray(certList)) { + certArray = certList + } else if (certList && typeof certList === 'object') { + // If it's a single object, wrap it in an array + certArray = [certList] + } + + for (const cert of certArray) { + // Skip certificates with missing or empty certificate data + if (!cert.certificate || !cert.certificate.trim()) { + console.warn(`Skipping local certificate with empty certificate data for alias: ${cert.alias || 'unknown'}`) + continue + } + + const parsed = await parseCertificate(cert.certificate) + // Use channelsInUse from API response (channelsInUse.string is an array) + const channelsInUse = getChannelsInUse(cert) + + // Handle parse errors gracefully + if (parsed.error) { + console.warn(`Failed to parse local certificate for alias "${cert.alias}": ${parsed.error}`) + certificates.push({ + alias: cert.alias, + name: cert.alias, + type: 'Invalid', + subject: `Parse Error: ${parsed.error}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + hasPrivateKey: true, + store: 'private', + channelsInUse: channelsInUse, + rawCertificate: cert.certificate, + rawPrivateKey: cert.key, // Include private key in response + parsedCertificate: parsed, + }) + continue + } + + certificates.push({ + alias: cert.alias.toString(), + name: parsed.subject?.CN || cert.alias, + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + hasPrivateKey: true, + store: 'private', + channelsInUse: channelsInUse, + rawCertificate: cert.certificate, + rawPrivateKey: cert.key, // Include private key in response + parsedCertificate: parsed, + }) + } + + return certificates + } catch (error) { + const errorMessage = 'Failed to fetch local certificates from server' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +function getChannelsInUse(cert) { + if (typeof cert.channelsInUse?.string === 'string') { + return [cert.channelsInUse.string] + } + return cert.channelsInUse?.string || [] +} + +// Legacy function - kept for backward compatibility, but should use tab-specific functions instead +export async function fetchCertificates() { + try { + // === INTERNAL STORE (for development) === + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 300)) + + const data = internalStore + + // === REAL API (uncomment when API is ready) === + // const response = await api.get('/api/tlsmanager/certificates') + // const data = response.data + + const certificates = [] + + // Map systemCertificates to native store + if (data.systemCertificates) { + for (const cert of data.systemCertificates) { + const parsed = await parseCertificate(cert.certificate) + certificates.push({ + alias: cert.alias, + name: parsed.subject?.CN || cert.alias, + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + hasPrivateKey: false, + store: 'native', + rawCertificate: cert.certificate, + parsedCertificate: parsed, + }) + } + } + + // Map certificates to trusted store + if (data.certificates) { + for (const cert of data.certificates) { + const parsed = await parseCertificate(cert.certificate) + // Use channelsInUse from API response (channelsInUse.string is an array) + const channelsInUse = cert.channelsInUse?.string || [] + certificates.push({ + alias: cert.alias, + name: parsed.subject?.CN || cert.alias, + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + hasPrivateKey: false, + store: 'trusted', + channelsInUse: channelsInUse, + rawCertificate: cert.certificate, + parsedCertificate: parsed, + }) + } + } + + // Map pairs to private store + if (data.pairs) { + for (const pair of data.pairs) { + const parsed = await parseCertificate(pair.certificate) + // Use channelsInUse from API response (channelsInUse.string is an array) + const channelsInUse = pair.channelsInUse?.string || [] + certificates.push({ + alias: pair.alias, + name: parsed.subject?.CN || pair.alias, + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + hasPrivateKey: true, + store: 'private', + channelsInUse: channelsInUse, + rawCertificate: pair.certificate, + rawPrivateKey: pair.privateKey, // Include private key in response + parsedCertificate: parsed, + }) + } + } + + return certificates + } catch (error) { + const errorMessage = 'Failed to fetch certificates from server' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +export async function updateCertificates(targetStore, certificateData, currentCertificates = null) { + try { + const { alias, pemText, privateKeyText } = certificateData + + let certificates = currentCertificates + + // If currentCertificates not provided, fetch from API + if (!certificates) { + if (targetStore === 'trusted') { + certificates = await fetchTrustedCertificates() + } else if (targetStore === 'private') { + certificates = await fetchLocalCertificates() + } else { + throw new Error('Invalid store type') + } + } + + // Check if certificate with same alias exists + const existingIndex = certificates.findIndex(c => c.alias === alias) + + // Update or add certificate in the array + if (existingIndex >= 0) { + // Update existing certificate - preserve other fields, update certificate data + certificates[existingIndex] = { + ...certificates[existingIndex], + alias, + rawCertificate: pemText, // Update with new PEM + ...(targetStore === 'private' && privateKeyText ? { rawPrivateKey: privateKeyText } : {}) + } + } else { + // Add new certificate + const newCert = { + alias, + rawCertificate: pemText, + ...(targetStore === 'private' && privateKeyText ? { rawPrivateKey: privateKeyText } : {}) + } + certificates.push(newCert) + } + + // Reconstruct API payload format + let payload + if (targetStore === 'trusted') { + payload = { + list: { + trustedCertificate: certificates.map(cert => ({ + alias: cert.alias, + certificate: cert.rawCertificate // Use rawCertificate (PEM format) + })) + } + } + const response = await api.put('/api/tlsmanager/trustedCertificates', payload) + return { success: true, data: response.data || { alias, targetStore } } + } else if (targetStore === 'private') { + payload = { + list: { + localCertificate: certificates.map(cert => ({ + alias: cert.alias, + certificate: cert.rawCertificate, // Use rawCertificate (PEM format) + key: cert.rawPrivateKey // Use rawPrivateKey (PEM format) + })) + } + } + const response = await api.put('/api/tlsmanager/localCertificates', payload) + return { success: true, data: response.data || { alias, targetStore } } + } else { + throw new Error('Invalid store type') + } + } catch (error) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update certificates' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +export async function updateCertificateAlias(store, oldAlias, newAlias, currentCertificates = null) { + try { + let certificates = currentCertificates + + // If currentCertificates not provided, fetch from API + if (!certificates) { + if (store === 'trusted') { + certificates = await fetchTrustedCertificates() + } else if (store === 'private') { + certificates = await fetchLocalCertificates() + } else { + throw new Error('Invalid store type') + } + } + + // Find certificate by old alias + const certIndex = certificates.findIndex(c => c.alias === oldAlias) + if (certIndex < 0) { + throw new Error('Certificate not found') + } + + // Update only the alias field + certificates[certIndex] = { + ...certificates[certIndex], + alias: newAlias + } + + // Reconstruct API payload format + let payload + if (store === 'trusted') { + payload = { + list: { + trustedCertificate: certificates.map(cert => ({ + alias: cert.alias, + certificate: cert.rawCertificate // Use rawCertificate (PEM format) + })) + } + } + const response = await api.put('/api/tlsmanager/trustedCertificates', payload) + return { success: true, data: response.data || { store, oldAlias, newAlias } } + } else if (store === 'private') { + payload = { + list: { + localCertificate: certificates.map(cert => ({ + alias: cert.alias, + certificate: cert.rawCertificate, // Use rawCertificate (PEM format) + key: cert.rawPrivateKey // Use rawPrivateKey (PEM format) + })) + } + } + const response = await api.put('/api/tlsmanager/localCertificates', payload) + return { success: true, data: response.data || { store, oldAlias, newAlias } } + } else { + throw new Error('Invalid store type') + } + } catch (error) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update certificate alias' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +export async function removeCertificate(store, alias, currentCertificates = null) { + try { + let certificates = currentCertificates + + // If currentCertificates not provided, fetch from API + if (!certificates) { + if (store === 'trusted') { + certificates = await fetchTrustedCertificates() + } else if (store === 'private') { + certificates = await fetchLocalCertificates() + } else { + throw new Error('Invalid store type') + } + } + + // Remove certificate from array by alias + const certIndex = certificates.findIndex(c => c.alias === alias) + if (certIndex < 0) { + throw new Error('Certificate not found') + } + + // Remove the certificate + certificates.splice(certIndex, 1) + + // Reconstruct API payload format + let payload + if (store === 'trusted') { + payload = { + list: { + trustedCertificate: certificates.map(cert => ({ + alias: cert.alias, + certificate: cert.rawCertificate // Use rawCertificate (PEM format) + })) + } + } + const response = await api.put('/api/tlsmanager/trustedCertificates', payload) + return { success: true, data: response.data || { store, alias } } + } else if (store === 'private') { + payload = { + list: { + localCertificate: certificates.map(cert => ({ + alias: cert.alias, + certificate: cert.rawCertificate, // Use rawCertificate (PEM format) + key: cert.rawPrivateKey // Use rawPrivateKey (PEM format) + })) + } + } + const response = await api.put('/api/tlsmanager/localCertificates', payload) + return { success: true, data: response.data || { store, alias } } + } else { + throw new Error('Invalid store type') + } + } catch (error) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to remove certificate' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +// === INTERNAL STORE HELPER FUNCTIONS (remove when switching to real API) === +// Helper function to clear the internal store (useful for testing) +export function clearInternalStore() { + internalStore = { + systemCertificates: [], + certificates: [], + pairs: [] + } + saveToStorage() + console.log('[Internal Store] Cleared') +} + +// Helper function to get current store state (for debugging) +export function getInternalStore() { + return { ...internalStore } +} diff --git a/plugins/tls/web-ui/src/utils/certificateUtils.js b/plugins/tls/web-ui/src/utils/certificateUtils.js new file mode 100644 index 000000000..96f0c6ebc --- /dev/null +++ b/plugins/tls/web-ui/src/utils/certificateUtils.js @@ -0,0 +1,641 @@ +import { X509, KEYUTIL, KJUR, zulutodate } from 'jsrsasign' +import { parseCertificateChain } from './verificationUtils.js' +import { notificationService } from '../services/notificationService.js' + +/** + * Convert X509 time string to Date object using jsrsasign utility + * @param {string} timeStr - X509 time format string (YYYYMMDDHHmmssZ or YYMMDDHHmmssZ) + * @returns {Date} Date object + */ +export function convertX509TimeToDate(timeStr) { + if (!timeStr) return null + + try { + // Convert the YYMMDDhhmmssZ string to an ISO-like format + const isoString = zulutodate(timeStr) + + // The t2d output is in 'YYYY/MM/DD hh:mm:ss GMT' format, which Date() can parse + return new Date(isoString) + } catch (error) { + return null + } +} + +/** + * Parse Distinguished Name string to object + * Input: "CN=example.com, O=Org, C=US" + * Output: { CN: "example.com", O: "Org", C: "US" } + * @param {string} dnString - DN string + * @returns {Object} Parsed DN object + */ +export function parseDNString(dnString) { + const attrs = {} + if (!dnString) return attrs + + // Split by comma, but handle quoted values + const parts = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < dnString.length; i++) { + const char = dnString[i] + if (char === '"') { + inQuotes = !inQuotes + current += char + } else if (char === ',' && !inQuotes) { + parts.push(current.trim()) + current = '' + } else { + current += char + } + } + if (current.trim()) { + parts.push(current.trim()) + } + + parts.forEach(part => { + const equalIndex = part.indexOf('=') + if (equalIndex > 0) { + const key = part.substring(0, equalIndex).trim() + let value = part.substring(equalIndex + 1).trim() + // Remove quotes if present + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1) + } + attrs[key] = value + } + }) + + return attrs +} + +/** + * Format DN object to string (for compatibility) + * @param {Object} dnObj - DN object with attributes + * @returns {string} Formatted DN string + */ +export function formatDNFromObject(dnObj) { + if (!dnObj || typeof dnObj !== 'object') return 'Unknown' + + const parts = [] + const attributes = ['CN', 'OU', 'O', 'L', 'ST', 'C', 'emailAddress'] + + // Add preferred attributes in order + for (const attr of attributes) { + if (dnObj[attr]) { + parts.push(`${attr}=${dnObj[attr]}`) + } + } + + // Add any remaining attributes + Object.keys(dnObj).forEach(key => { + if (!attributes.includes(key) && dnObj[key]) { + parts.push(`${key}=${dnObj[key]}`) + } + }) + + return parts.length > 0 ? parts.join(', ') : 'Unknown' +} + +/** + * Parse a Base64-encoded PEM certificate and extract relevant information + * @param {string} base64Pem - Base64-encoded PEM certificate + * @returns {Object} Parsed certificate information + */ +export function parseCertificate(base64Pem) { + try { + const pemString = base64Pem + + // Parse the PEM certificate + const cert = new X509() + cert.readCertPEM(pemString) + + // Extract subject information + const subjectStr = cert.getSubjectString() + const subject = parseDNString(subjectStr) + const subjectFormatted = formatDNFromObject(subject) + + // Extract issuer information + const issuerStr = cert.getIssuerString() + const issuer = parseDNString(issuerStr) + const issuerFormatted = formatDNFromObject(issuer) + + // Determine certificate type + const type = determineCertificateType(cert) + + // Format validity dates + const notBefore = convertX509TimeToDate(cert.getNotBefore()) + const notAfter = convertX509TimeToDate(cert.getNotAfter()) + const validFrom = formatDate(notBefore) + const validTo = formatDate(notAfter) + + // Calculate SHA-1 fingerprint + const derHex = cert.hex // DER-encoded certificate as hex string + const fingerprintSha1 = KJUR.crypto.Util.hashHex(derHex, 'sha1').toUpperCase() + + // Get serial number + const serialNumber = cert.getSerialNumberHex() || cert.getSerialNumber() + + // Get version + const version = cert.getVersion() + + // Get extensions (for compatibility, create a simplified structure) + // Wrap extension access in try-catch since some certificates may not have all extensions + const extensions = [] + + let basicConstraints = null + try { + basicConstraints = cert.getExtBasicConstraints() + if (basicConstraints) { + extensions.push({ name: basicConstraints.extname, cA: basicConstraints.cA, critical: basicConstraints.critical }) + } + } catch (e) { + // Extension doesn't exist or can't be read - skip + } + + let keyUsage = null + try { + keyUsage = cert.getExtKeyUsage() + + if (keyUsage && keyUsage.names && Array.isArray(keyUsage.names)) { + extensions.push({ name: keyUsage.extname, names: keyUsage.names, critical: keyUsage.critical }) + } + } catch (e) { + // Extension doesn't exist or can't be read - skip + } + + let extKeyUsage = null + try { + extKeyUsage = cert.getExtExtKeyUsage() + // console.log(extKeyUsage, fingerprintSha1) + if (extKeyUsage) { + extensions.push({ + name: extKeyUsage.extname, + names: extKeyUsage.array, + critical: extKeyUsage.critical + }) + } + } catch (e) { + // Extension doesn't exist or can't be read - skip + } + + // Extract Subject Alternative Names (SAN) + let subjectAltName = null + let subjectAltNames = { + dns: [], + ip: [], + uri: [], + email: [], + dn: [] + } + try { + subjectAltName = cert.getExtSubjectAltName() + if (subjectAltName && subjectAltName.array && Array.isArray(subjectAltName.array)) { + // Process SAN array and group by type + // GeneralName is a union type: { dns: string } | { ip: string } | { uri: string } | { rfc822: string } | { dn: X500Name } | { other: ... } | undefined + subjectAltName.array.forEach(item => { + // Each item is a GeneralName union type - check each possible property + if (item && typeof item === 'object') { + if (item.dns) { + // DNS name + subjectAltNames.dns.push(item.dns) + } else if (item.ip) { + // IP address + subjectAltNames.ip.push(item.ip) + } else if (item.uri) { + // URI + subjectAltNames.uri.push(item.uri) + } else if (item.rfc822) { + // RFC 822 email address + subjectAltNames.email.push(item.rfc822) + } else if (item.dn) { + // Distinguished Name (X500Name object) + // X500Name has a str property for string representation + if (item.dn.str) { + subjectAltNames.dn.push(item.dn.str) + } else if (typeof item.dn === 'string') { + // Fallback if it's already a string + subjectAltNames.dn.push(item.dn) + } else if (item.dn.array && Array.isArray(item.dn.array)) { + // Format DN from array structure if str is not available + const dnParts = [] + item.dn.array.forEach(dnPart => { + if (Array.isArray(dnPart) && dnPart.length > 0) { + const dnObj = dnPart[0] + if (dnObj && dnObj.value) { + const attrName = dnObj.type || 'UNKNOWN' + dnParts.push(`${attrName}=${dnObj.value}`) + } + } + }) + if (dnParts.length > 0) { + subjectAltNames.dn.push(dnParts.join(', ')) + } + } + } + // Note: item.other is not currently handled, but could be added if needed + } + }) + + } + } catch (e) { + // Extension doesn't exist or can't be read - skip + } + + return { + subject, + subjectStr: subjectFormatted, + issuer, + issuerStr: issuerFormatted, + type, + validFrom, + validTo, + fingerprintSha1, + serialNumber, + version, + extensions, + subjectAltNames, + raw: cert + } + } catch (error) { + return { + subject: null, + subjectStr: 'Parse Error', + issuer: null, + issuerStr: 'Parse Error', + type: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + error: error.message + } + } +} + +/** + * Format a Distinguished Name (DN) object to string + * @param {Object} dn - Distinguished Name object (from node-forge or parsed DN object) + * @returns {string} Formatted DN string + */ +function formatDN(dn) { + if (!dn) return 'Unknown' + + // If it's a string, return it directly + if (typeof dn === 'string') { + return dn + } + + // If it has getField method (node-forge style), use that + if (typeof dn.getField === 'function') { + const parts = [] + const attributes = ['CN', 'OU', 'O', 'L', 'ST', 'C', 'emailAddress'] + + for (const attr of attributes) { + const field = dn.getField(attr) + if (field) { + const value = field.value || field + parts.push(`${attr}=${value}`) + } + } + + const allAttrs = dn.attributes || [] + for (const attr of allAttrs) { + if (!attributes.includes(attr.name)) { + parts.push(`${attr.name}=${attr.value}`) + } + } + + return parts.join(', ') + } + + // Otherwise, treat as parsed DN object + return formatDNFromObject(dn) +} + +/** + * Determine certificate type based on extensions and usage + * @param {Object} cert - Certificate object (X509 from jsrsasign) + * @returns {string} Certificate type + */ +function determineCertificateType(cert) { + try { + // Check for CA certificate + let basicConstraints = null + try { + basicConstraints = cert.getExtBasicConstraints() + } catch (e) { + // Extension doesn't exist or can't be read - continue + } + + // Check for keyUsage with keyCertSign + let keyUsage = null + try { + keyUsage = cert.getExtKeyUsage() + } catch (e) { + // Extension doesn't exist or can't be read - continue + } + + const isCA = (basicConstraints && basicConstraints.ca) || + (keyUsage && keyUsage.names && Array.isArray(keyUsage.names) && keyUsage.names.includes('keyCertSign')) + + if (isCA) { + // Determine if Root CA or Intermediate by checking if self-signed + const subject = cert.getSubjectString() + const issuer = cert.getIssuerString() + + if (subject === issuer) { + return 'Root CA' + } + return 'Intermediate' + } + + // Check for server certificate + // getExtExtKeyUsage() returns array of OID strings + let extKeyUsage = null + try { + extKeyUsage = cert.getExtExtKeyUsage() + } catch (e) { + // Extension doesn't exist or can't be read - continue + } + + if (extKeyUsage && Array.isArray(extKeyUsage)) { + if (extKeyUsage.includes('1.3.6.1.5.5.7.3.1')) { // serverAuth OID + return 'Server Certificate' + } + // Check for client certificate + if (extKeyUsage.includes('1.3.6.1.5.5.7.3.2')) { // clientAuth OID + return 'Client Certificate' + } + } + } catch (error) { + // If any unexpected error occurs, log it and return default + console.warn('Error determining certificate type:', error) + } + + return 'End-entity' +} + + +/** + * Format a date object to YYYY-MM-DD string + * @param {Date|string} date - Date object or ASN1 time string + * @returns {string} Formatted date string + */ +function formatDate(date) { + if (!date) return 'Unknown' + + // If it's a string (X509 time), convert to Date first + let dateObj = date + if (typeof date === 'string') { + dateObj = convertX509TimeToDate(date) + if (!dateObj) return 'Unknown' + } + + const year = dateObj.getFullYear() + const month = String(dateObj.getMonth() + 1).padStart(2, '0') + const day = String(dateObj.getDate()).padStart(2, '0') + + return `${year}-${month}-${day}` +} + +/** + * Validate if a string contains valid PEM certificate data + * @param {string} pemString - PEM certificate string + * @returns {boolean} True if valid PEM certificate + */ +export function isValidPemCertificate(pemString) { + try { + // Check if it contains certificate markers + if (!pemString.includes('-----BEGIN CERTIFICATE-----') || + !pemString.includes('-----END CERTIFICATE-----')) { + return false + } + + // Try to parse it + const cert = new X509() + cert.readCertPEM(pemString) + + return true + } catch (error) { + return false + } +} + +/** + * Validate if a string contains valid PEM private key data + * @param {string} pemString - PEM private key string + * @returns {boolean} True if valid PEM private key + */ +export function isValidPemPrivateKey(pemString) { + try { + // Check if it contains private key markers (support multiple formats) + const hasPrivateKeyMarkers = ( + (pemString.includes('-----BEGIN PRIVATE KEY-----') && pemString.includes('-----END PRIVATE KEY-----')) || + (pemString.includes('-----BEGIN RSA PRIVATE KEY-----') && pemString.includes('-----END RSA PRIVATE KEY-----')) || + (pemString.includes('-----BEGIN EC PRIVATE KEY-----') && pemString.includes('-----END EC PRIVATE KEY-----')) || + (pemString.includes('-----BEGIN DSA PRIVATE KEY-----') && pemString.includes('-----END DSA PRIVATE KEY-----')) + ) + + if (!hasPrivateKeyMarkers) { + return false + } + + // Try to parse it as a private key + const privateKey = KEYUTIL.getKey(pemString) + + return !!privateKey + } catch (error) { + return false + } +} + +/** + * Convert PEM string to Base64-encoded format + * @param {string} pemString - PEM certificate string + * @returns {string} Base64-encoded certificate + */ +export function pemToBase64(pemString) { + try { + // Remove PEM headers and footers + const base64Content = pemString + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/\s/g, '') // Remove whitespace + + return base64Content + } catch (error) { + const errorMessage = 'Invalid PEM format' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +/** + * Convert PEM private key string to Base64-encoded format + * @param {string} pemString - PEM private key string + * @returns {string} Base64-encoded private key + */ +export function privateKeyPemToBase64(pemString) { + try { + // Remove all possible private key headers and footers + const base64Content = pemString + .replace(/-----BEGIN PRIVATE KEY-----/g, '') + .replace(/-----END PRIVATE KEY-----/g, '') + .replace(/-----BEGIN RSA PRIVATE KEY-----/g, '') + .replace(/-----END RSA PRIVATE KEY-----/g, '') + .replace(/-----BEGIN EC PRIVATE KEY-----/g, '') + .replace(/-----END EC PRIVATE KEY-----/g, '') + .replace(/-----BEGIN DSA PRIVATE KEY-----/g, '') + .replace(/-----END DSA PRIVATE KEY-----/g, '') + .replace(/\s/g, '') // Remove whitespace + + return base64Content + } catch (error) { + const errorMessage = 'Invalid private key PEM format' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +/** + * Convert Base64-encoded certificate to PEM format + * @param {string} base64Cert - Base64-encoded certificate + * @returns {string} PEM certificate string + */ +export function base64ToPem(base64Cert) { + try { + // Add PEM headers + const pemString = `-----BEGIN CERTIFICATE-----\n${base64Cert}\n-----END CERTIFICATE-----` + return pemString + } catch (error) { + const errorMessage = 'Invalid Base64 format' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + +/** + * Convert Base64-encoded private key to PEM format + * @param {string} base64Key - Base64-encoded private key + * @returns {string} PEM private key string + */ +export function base64ToPrivateKeyPem(base64Key) { + try { + // Add PEM headers for private key + const pemString = `-----BEGIN PRIVATE KEY-----\n${base64Key}\n-----END PRIVATE KEY-----` + return pemString + } catch (error) { + const errorMessage = 'Invalid Base64 private key format' + notificationService.showError(errorMessage) + throw new Error(errorMessage) + } +} + + +// Get suggested alias from certificate details +export function getSuggestedAlias(details) { + if (!details) return null + + // Try to get CN from subject + const subjectStr = details.subjectStr || '' + const cnMatch = subjectStr.match(/CN=([^,]+)/) + if (cnMatch && cnMatch[1]) { + return cnMatch[1].trim() + } + + // Try to get first DNS name from SAN + if (details.raw && details.raw.extensions) { + const sanExtension = details.raw.extensions.find(ext => ext.name === 'subjectAltName') + if (sanExtension && sanExtension.altNames) { + const dnsName = sanExtension.altNames.find(altName => altName.type === 2) // DNS type + if (dnsName && dnsName.value) { + return dnsName.value.trim() + } + } + } + + // Fallback to first part of subject + const firstPart = subjectStr.split(',')[0] + if (firstPart && firstPart.includes('=')) { + return firstPart.split('=')[1]?.trim() + } + + return null +} + +/** + * Parse a certificate chain from PEM text and return array of certificate objects + * @param {string} pemText - PEM certificate text (can contain multiple certificates) + * @returns {Array} Array of certificate objects with structure: { certificate: pem, alias, subject, issuer, ... } + */ +export function parseCertificateChainFromPem(pemText) { + if (!pemText || !pemText.trim()) { + return [] + } + + try { + // Parse the certificate chain using verificationUtils + const chainCertificates = parseCertificateChain(pemText) + + if (chainCertificates.length === 0) { + return [] + } + + // Parse each certificate to get details + const certificates = [] + chainCertificates.forEach((chainCert, index) => { + try { + const parsed = parseCertificate(chainCert.pem) + + // Handle parse errors gracefully + if (parsed.error) { + certificates.push({ + certificate: chainCert.pem, + alias: `Certificate ${index + 1}`, + name: 'Invalid Certificate', + type: 'Invalid', + subject: `Parse Error: ${parsed.error}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + parsedCertificate: parsed, + error: parsed.error + }) + return + } + + certificates.push({ + certificate: chainCert.pem, + alias: getSuggestedAlias(parsed) || `Certificate ${index + 1}`, + name: parsed.subject?.CN || 'Unknown', + type: parsed.type || 'Unknown', + subject: parsed.subjectStr || 'Unknown', + issuer: parsed.issuerStr || 'Unknown', + validFrom: parsed.validFrom, + validTo: parsed.validTo, + fingerprintSha1: parsed.fingerprintSha1, + parsedCertificate: parsed + }) + } catch (parseError) { + notificationService.showWarning(`Failed to parse certificate ${index + 1} in chain: ${parseError.message}`) + certificates.push({ + certificate: chainCert.pem, + alias: `Certificate ${index + 1}`, + name: 'Parse Error', + type: 'Invalid', + subject: `Parse Error: ${parseError.message}`, + issuer: 'Unknown', + validFrom: 'Unknown', + validTo: 'Unknown', + fingerprintSha1: 'Unknown', + error: parseError.message + }) + } + }) + + return certificates + } catch (error) { + return [] + } +} diff --git a/plugins/tls/web-ui/src/utils/dateUtils.js b/plugins/tls/web-ui/src/utils/dateUtils.js new file mode 100644 index 000000000..c8b01311f --- /dev/null +++ b/plugins/tls/web-ui/src/utils/dateUtils.js @@ -0,0 +1,51 @@ +/** + * Format a date to a readable string + * @param {Date|string} date - Date object or date string + * @returns {string} Formatted date string + */ +export function formatDate(date) { + if (!date) return 'Unknown' + + const dateObj = typeof date === 'string' ? new Date(date) : date + + if (isNaN(dateObj.getTime())) return 'Invalid Date' + + return dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + +/** + * Check if a date is within a certain number of days from now + * @param {Date|string} date - Date to check + * @param {number} days - Number of days + * @returns {boolean} True if within the specified days + */ +export function isWithinDays(date, days) { + if (!date) return false + + const dateObj = typeof date === 'string' ? new Date(date) : date + const now = new Date() + const diffTime = dateObj.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + + return diffDays <= days && diffDays >= 0 +} + +/** + * Check if a date has passed + * @param {Date|string} date - Date to check + * @returns {boolean} True if the date has passed + */ +export function isExpired(date) { + if (!date) return false + + const dateObj = typeof date === 'string' ? new Date(date) : date + const now = new Date() + + return dateObj.getTime() < now.getTime() +} diff --git a/plugins/tls/web-ui/src/utils/verificationUtils.js b/plugins/tls/web-ui/src/utils/verificationUtils.js new file mode 100644 index 000000000..b69e95d6a --- /dev/null +++ b/plugins/tls/web-ui/src/utils/verificationUtils.js @@ -0,0 +1,572 @@ +import { X509, KEYUTIL, KJUR, RSAKey } from 'jsrsasign' +import { convertX509TimeToDate, parseDNString, isValidPemCertificate, isValidPemPrivateKey } from './certificateUtils.js' + +/** + * Parse a certificate chain from PEM text (supports multiple certificates) + * @param {string} certText - PEM certificate text (can contain multiple certificates) + * @returns {Array} Array of certificate objects with pem and cert properties + */ +export function parseCertificateChain(certText) { + const certificates = [] + const certRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g + const matches = certText.match(certRegex) + + if (matches) { + matches.forEach(certPem => { + try { + const cert = new X509() + cert.readCertPEM(certPem) + certificates.push({ pem: certPem, cert: cert }) + } catch (e) { + // Failed to parse certificate - skip it + } + }) + } + + return certificates +} + +/** + * Validate a certificate chain for proper ordering and signatures + * @param {Array} certificates - Array of certificate objects + * @returns {Object} Validation result with isValid, errors, warnings, and details + */ +export function validateCertificateChain(certificates) { + const validation = { + isValid: true, + errors: [], + warnings: [], + details: [] + } + + if (certificates.length === 1) { + validation.details.push('Single certificate provided - no chain validation needed') + return validation + } + + // Check chain order and signatures + for (let i = 0; i < certificates.length - 1; i++) { + const cert = certificates[i].cert + const issuerCert = certificates[i + 1].cert + const certPem = certificates[i].pem + const issuerPem = certificates[i + 1].pem + + validation.details.push(`Checking certificate ${i + 1} against issuer certificate ${i + 2}`) + + // Check if issuer name matches + const certIssuer = cert.getIssuerString() + const issuerSubject = issuerCert.getSubjectString() + + if (certIssuer !== issuerSubject) { + validation.isValid = false + validation.errors.push(`Certificate ${i + 1} issuer "${certIssuer}" does not match certificate ${i + 2} subject "${issuerSubject}"`) + } else { + validation.details.push(`✓ Issuer names match for certificates ${i + 1} and ${i + 2}`) + } + + // Verify signature + try { + const isSignatureValid = cert.verifySignature(issuerPem) // jsrsasign requires PEM string + if (isSignatureValid) { + validation.details.push(`✓ Certificate ${i + 1} signature verified by certificate ${i + 2}`) + } else { + validation.isValid = false + validation.errors.push(`Certificate ${i + 1} signature verification failed against certificate ${i + 2}`) + } + } catch (error) { + validation.isValid = false + validation.errors.push(`Error verifying certificate ${i + 1} signature: ${error.message}`) + } + + // Check validity periods + const certNotBefore = convertX509TimeToDate(cert.getNotBefore()) + const certNotAfter = convertX509TimeToDate(cert.getNotAfter()) + const issuerNotBefore = convertX509TimeToDate(issuerCert.getNotBefore()) + const issuerNotAfter = convertX509TimeToDate(issuerCert.getNotAfter()) + + if (certNotBefore < issuerNotBefore) { + validation.warnings.push(`Certificate ${i + 1} valid from date is before its issuer's valid from date`) + } + if (certNotAfter > issuerNotAfter) { + validation.warnings.push(`Certificate ${i + 1} expires after its issuer certificate ${i + 2}`) + } + } + + // Check if root is self-signed + const rootCert = certificates[certificates.length - 1].cert + const rootPem = certificates[certificates.length - 1].pem + const rootIssuer = rootCert.getIssuerString() + const rootSubject = rootCert.getSubjectString() + + if (rootIssuer === rootSubject) { + try { + const isSelfSigned = rootCert.verifySignature(rootPem) + if (isSelfSigned) { + validation.details.push('✓ Root certificate is properly self-signed') + } else { + validation.warnings.push('Root certificate appears self-signed but signature verification failed') + } + } catch (error) { + validation.warnings.push(`Error verifying root certificate self-signature: ${error.message}`) + } + } else { + validation.warnings.push('Root certificate is not self-signed - chain may be incomplete') + } + + // Check certificate purposes and constraints + certificates.forEach((certObj, index) => { + const cert = certObj.cert + let basicConstraints = null + try { + basicConstraints = cert.getExtBasicConstraints() + } catch (e) { + // Extension doesn't exist or can't be read - continue with null + } + + if (index === 0) { + // End entity certificate + if (basicConstraints && basicConstraints.ca) { // lowercase 'ca' in jsrsasign + validation.warnings.push('End entity certificate has CA flag set to true') + } + } else { + // CA certificates + if (!basicConstraints || !basicConstraints.ca) { // lowercase 'ca' in jsrsasign + validation.warnings.push(`Certificate ${index + 1} should be a CA but basicConstraints CA flag is not set`) + } + + if (basicConstraints && typeof basicConstraints.pathLenConstraint === 'number') { + const remainingCAs = certificates.length - index - 2 // Exclude self and count remaining CAs + if (remainingCAs > basicConstraints.pathLenConstraint) { + validation.errors.push(`Certificate ${index + 1} pathLenConstraint (${basicConstraints.pathLenConstraint}) exceeded by chain depth`) + validation.isValid = false + } + } + } + }) + + return validation +} + +/** + * Validate if a private key matches a certificate + * @param {Object} certObj - Certificate object with cert property (X509 object) + * @param {string} keyPem - PEM private key string + * @returns {Object} Validation result with isValid and message + */ +export function validatePrivateKey(certObj, keyPem) { + try { + // Parse private key using KEYUTIL (handles all formats automatically) + const privateKey = KEYUTIL.getKey(keyPem) + if (!privateKey) { + return { isValid: false, message: 'Failed to parse private key' } + } + + // Get public key from certificate + const certPubKeyPem = certObj.cert.getPublicKey() + const certPubKey = KEYUTIL.getKey(certPubKeyPem) + if (!certPubKey) { + return { isValid: false, message: 'Failed to parse certificate public key' } + } + + // Extract public key from private key object + // For RSA: private key has n and e (public components) + // For EC: private key has x and y (public point coordinates) + let pubKeyFromPrivate = null + try { + // Try to create a public key PEM from the private key + // For RSA keys, we can construct a public key object from n and e + if (privateKey.n && privateKey.e) { + // RSA key - construct public key object + pubKeyFromPrivate = new RSAKey() + pubKeyFromPrivate.setPublic(privateKey.n, privateKey.e) + const pubKeyPemFromPrivate = KEYUTIL.getPEM(pubKeyFromPrivate) + + // Compare public keys (PEM format comparison) + if (certPubKeyPem === pubKeyPemFromPrivate) { + return { isValid: true, message: 'Private key matches the certificate!' } + } + } else if (privateKey.curve && privateKey.x && privateKey.y) { + // EC key - construct public key object + pubKeyFromPrivate = new KJUR.crypto.ECDSA({ curve: privateKey.curve, pub: { x: privateKey.x, y: privateKey.y } }) + const pubKeyPemFromPrivate = KEYUTIL.getPEM(pubKeyFromPrivate) + + // Compare public keys (PEM format comparison) + if (certPubKeyPem === pubKeyPemFromPrivate) { + return { isValid: true, message: 'Private key matches the certificate!' } + } + } + } catch (constructError) { + // If constructing public key fails, fall through to signature verification + console.debug('Could not construct public key from private key:', constructError) + } + + // Primary method: Signature verification (works for both RSA and EC) + try { + const testData = 'test-data-for-validation' + + // Determine signature algorithm based on key type + let sigAlg = 'SHA256withRSA' + if (privateKey.curve) { + // EC key - use ECDSA + sigAlg = 'SHA256withECDSA' + } + + const sig = new KJUR.crypto.Signature({ alg: sigAlg }) + sig.init(privateKey) + sig.updateString(testData) + const signature = sig.sign() + + const verifier = new KJUR.crypto.Signature({ alg: sigAlg }) + verifier.init(certPubKey) + verifier.updateString(testData) + const isValid = verifier.verify(signature) + + if (isValid) { + return { isValid: true, message: 'Private key matches the certificate!' } + } else { + return { isValid: false, message: 'Private key does not match the certificate' } + } + } catch (signError) { + // If signature verification fails, try fingerprint comparison + const certKeyFingerprint = getPublicKeyFingerprint(certPubKey) + const privateKeyFingerprint = getPrivateKeyFingerprint(privateKey) + + if (certKeyFingerprint === privateKeyFingerprint) { + return { isValid: true, message: 'Private key matches the certificate!' } + } else { + return { isValid: false, message: 'Private key does not match the certificate' } + } + } + + } catch (error) { + return { isValid: false, message: `Error validating private key: ${error.message}` } + } +} + +/** + * Get certificate status (valid, expired, not yet valid) + * @param {Object} cert - Certificate object (X509 from jsrsasign) + * @returns {string} Status message + */ +export function getCertStatus(cert) { + const now = new Date() + const notBefore = convertX509TimeToDate(cert.getNotBefore()) + const notAfter = convertX509TimeToDate(cert.getNotAfter()) + + if (now < notBefore) { + return '⏳ Not yet valid' + } else if (now > notAfter) { + return '⚠️ Expired' + } else { + return '✅ Valid' + } +} + +/** + * Get certificate fingerprint + * @param {Object} cert - Certificate object (X509 from jsrsasign) + * @param {string} algorithm - Hash algorithm ('sha1' or 'sha256') + * @returns {string} Formatted fingerprint + */ +export function getFingerprint(cert, algorithm = 'sha1') { + const derHex = cert.hex // DER-encoded certificate as hex string + const fingerprint = KJUR.crypto.Util.hashHex(derHex, algorithm) + return fingerprint.toUpperCase().replace(/(.{2})/g, '$1:').slice(0, -1) +} + +/** + * Get Subject Alternative Names from certificate + * @param {Object} cert - Certificate object (X509 from jsrsasign) + * @returns {Array} Array of SAN strings + */ +export function getSANs(cert) { + try { + const sans = cert.getExtSubjectAltName() + if (!sans || !Array.isArray(sans)) { + return [] + } + + // jsrsasign returns array of arrays: [[type, value], ...] + // type: 2=DNS, 7=IP, 1=Email + return sans.map((sanEntry) => { + const [type, value] = Array.isArray(sanEntry) ? sanEntry : [sanEntry.type, sanEntry.value] + switch (type) { + case 2: return 'DNS: ' + value + case 7: return 'IP: ' + value + case 1: return 'Email: ' + value + default: return 'Other: ' + value + } + }) + } catch (error) { + return [] + } +} + +/** + * Get key size from certificate + * @param {Object} cert - Certificate object (X509 from jsrsasign) + * @returns {number|string} Key size in bits or 'Unknown' + */ +export function getKeySize(cert) { + try { + const pubKeyPem = cert.getPublicKey() + const pubKeyObj = KEYUTIL.getKey(pubKeyPem) + + if (pubKeyObj) { + // For RSA + if (pubKeyObj.n) { + return pubKeyObj.n.bitLength() + } + // For EC + if (pubKeyObj.curve) { + // Map curve names to bit sizes + const curveMap = { + 'secp256r1': 256, + 'secp384r1': 384, + 'secp521r1': 521, + 'secp256k1': 256, + 'prime256v1': 256, + 'P-256': 256, + 'P-384': 384, + 'P-521': 521, + } + return curveMap[pubKeyObj.curve] || 'Unknown' + } + } + } catch (error) { + // Error getting key size - return unknown + } + return 'Unknown' +} + +/** + * Get Distinguished Name as string + * @param {string} dnString - Distinguished Name string (from X509.getSubjectString() or getIssuerString()) + * @returns {string} Formatted DN string + */ +function getDistinguishedName(dnString) { + // Already a string from jsrsasign, just return it + return dnString || 'Unknown' +} + +/** + * Get public key fingerprint + * @param {Object} publicKey - Public key object (from KEYUTIL) + * @returns {string} SHA-256 fingerprint + */ +function getPublicKeyFingerprint(publicKey) { + try { + // Convert public key to PEM and then to hex for hashing + const pubKeyPem = KEYUTIL.getPEM(publicKey) + // Remove PEM headers and whitespace, then convert base64 to hex + const base64Content = pubKeyPem + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s/g, '') + + // Convert base64 to hex + const hexContent = KJUR.crypto.Util.b64toHex(base64Content) + const fingerprint = KJUR.crypto.Util.hashHex(hexContent, 'sha256') + return fingerprint + } catch (error) { + // Fallback: use key object properties + try { + let keyHex = '' + if (publicKey.n && publicKey.e) { + // RSA key + keyHex = publicKey.n.toString(16) + publicKey.e.toString(16) + } else if (publicKey.x && publicKey.y) { + // EC key + keyHex = publicKey.x.toString(16) + publicKey.y.toString(16) + } + return KJUR.crypto.Util.hashHex(keyHex, 'sha256') + } catch (e) { + return '' + } + } +} + +/** + * Get private key fingerprint (by extracting public key components) + * @param {Object} privateKey - Private key object (from KEYUTIL) + * @returns {string} SHA-256 fingerprint + */ +function getPrivateKeyFingerprint(privateKey) { + try { + // Extract public key components from private key + let publicKeyComponents = null + + if (privateKey.n && privateKey.e) { + // RSA key - extract public components + publicKeyComponents = new RSAKey() + publicKeyComponents.setPublic(privateKey.n, privateKey.e) + } else if (privateKey.curve && privateKey.x && privateKey.y) { + // EC key - extract public point + publicKeyComponents = new KJUR.crypto.ECDSA({ curve: privateKey.curve, pub: { x: privateKey.x, y: privateKey.y } }) + } + + if (publicKeyComponents) { + return getPublicKeyFingerprint(publicKeyComponents) + } + + // Fallback: use private key PEM directly + const privateKeyPem = KEYUTIL.getPEM(privateKey, 'PKCS8PRV') + const base64Content = privateKeyPem + .replace(/-----BEGIN PRIVATE KEY-----/g, '') + .replace(/-----END PRIVATE KEY-----/g, '') + .replace(/\s/g, '') + const hexContent = KJUR.crypto.Util.b64toHex(base64Content) + return KJUR.crypto.Util.hashHex(hexContent, 'sha256') + } catch (error) { + return '' + } +} + +/** + * Get subject field value from certificate + * @param {Object} cert - Certificate object (X509 from jsrsasign) + * @param {string} field - Field name (CN, O, C, etc.) + * @param {string} type - 'subject' or 'issuer' + * @returns {string} Field value or 'Not specified' + */ +export function getSubjectField(cert, field, type = 'subject') { + const dnString = type === 'subject' ? cert.getSubjectString() : cert.getIssuerString() + const attrs = parseDNString(dnString) + return attrs[field] || 'Not specified' +} + +/** + * Comprehensive certificate verification + * @param {string} certText - PEM certificate text + * @param {string} keyText - Optional PEM private key text + * @returns {Object} Complete verification results + */ +export function verifyCertificate(certText, keyText = null) { + try { + // First, validate certificate format + if (!certText || !certText.trim()) { + return { + success: false, + error: 'Invalid certificate. Make sure the file is a .pem.' + } + } + + if (!isValidPemCertificate(certText)) { + return { + success: false, + error: 'Invalid certificate. Make sure the file is a .pem.' + } + } + + // If private key is provided, validate its format first + if (keyText && keyText.trim()) { + if (!isValidPemPrivateKey(keyText)) { + return { + success: false, + error: 'Invalid private key. Make sure the file is a .key.' + } + } + } + + // Parse certificates + const certificates = parseCertificateChain(certText) + + if (certificates.length === 0) { + return { + success: false, + error: 'Invalid certificate. Make sure the file is a .pem.' + } + } + + // Get certificate details + const primaryCert = certificates[0].cert + const notBefore = convertX509TimeToDate(primaryCert.getNotBefore()) + const notAfter = convertX509TimeToDate(primaryCert.getNotAfter()) + + // Get signature algorithm + const sigAlg = primaryCert.getSignatureAlgorithmName() || 'Unknown' + + // Get public key algorithm + const pubKeyPem = primaryCert.getPublicKey() + const pubKeyObj = KEYUTIL.getKey(pubKeyPem) + let pubKeyAlg = 'RSA' + if (pubKeyObj) { + if (pubKeyObj.curve) { + pubKeyAlg = 'ECDSA' + } else if (pubKeyObj.alg && pubKeyObj.alg.includes('ECDSA')) { + pubKeyAlg = 'ECDSA' + } + } + + const certDetails = { + subject: getSubjectField(primaryCert, 'CN'), + issuer: getSubjectField(primaryCert, 'CN', 'issuer'), + serialNumber: primaryCert.getSerialNumberHex() || primaryCert.getSerialNumber(), + validFrom: notBefore ? notBefore.toISOString() : 'Unknown', + validTo: notAfter ? notAfter.toISOString() : 'Unknown', + status: getCertStatus(primaryCert), + signatureAlgorithm: sigAlg, + publicKeyAlgorithm: pubKeyAlg, + keySize: getKeySize(primaryCert), + fingerprintSha1: getFingerprint(primaryCert, 'sha1'), + fingerprintSha256: getFingerprint(primaryCert, 'sha256'), + sans: getSANs(primaryCert) + } + + // Validate certificate chain + const chainValidation = validateCertificateChain(certificates) + + // Validate private key if provided + let keyValidation = null + if (keyText && keyText.trim()) { + keyValidation = validatePrivateKey(certificates[0], keyText) + + // Check if private key matches certificate + if (!keyValidation || !keyValidation.isValid) { + // Private key format is already validated above, so if validation fails here, it's a mismatch + return { + success: false, + certificates, + certDetails, + chainValidation, + keyValidation, + error: 'Certificate does not match private key.' + } + } + } + + // Determine overall success based on validation results + const chainValid = chainValidation && chainValidation.isValid + const keyValid = !keyText || !keyText.trim() || (keyValidation && keyValidation.isValid) + const overallSuccess = chainValid && keyValid + + return { + success: overallSuccess, + certificates, + certDetails, + chainValidation, + keyValidation, + chainDetails: certificates.length > 1 ? certificates.map((certObj, index) => { + const certNotBefore = convertX509TimeToDate(certObj.cert.getNotBefore()) + const certNotAfter = convertX509TimeToDate(certObj.cert.getNotAfter()) + return { + index: index + 1, + type: index === 0 ? 'End Entity' : index === certificates.length - 1 ? 'Root CA' : 'Intermediate CA', + subject: getSubjectField(certObj.cert, 'CN'), + issuer: getSubjectField(certObj.cert, 'CN', 'issuer'), + validFrom: certNotBefore ? certNotBefore.toDateString() : 'Unknown', + validTo: certNotAfter ? certNotAfter.toDateString() : 'Unknown' + } + }) : null, + error: !overallSuccess ? + (!chainValid ? 'Certificate chain validation failed' : 'Private key validation failed') : + null + } + + } catch (error) { + return { + success: false, + error: `Error parsing certificate: ${error.message}` + } + } +} diff --git a/plugins/tls/web-ui/static/web.xml b/plugins/tls/web-ui/static/web.xml new file mode 100644 index 000000000..795ee8a36 --- /dev/null +++ b/plugins/tls/web-ui/static/web.xml @@ -0,0 +1,22 @@ + + + + + index.html + + + + default + / + + + + + 404 + /index.html + + diff --git a/plugins/tls/web-ui/tailwind.config.cjs b/plugins/tls/web-ui/tailwind.config.cjs new file mode 100644 index 000000000..492fb9f79 --- /dev/null +++ b/plugins/tls/web-ui/tailwind.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + content: [ + './index.html', + './src/**/*.{js,jsx,ts,tsx}' + ], + theme: { + extend: {}, + }, + plugins: [], + } \ No newline at end of file diff --git a/plugins/tls/web-ui/vite.config.js b/plugins/tls/web-ui/vite.config.js new file mode 100644 index 000000000..1544eac24 --- /dev/null +++ b/plugins/tls/web-ui/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + base: '/tls-manager/', + server: { + allowedHosts: ['localhost', '127.0.0.1', '0.0.0.0', '778ded44be8d.ngrok-free.app'], + proxy: { + "/api": { + target: "https://oie-test.quantis.health", + changeOrigin: true, + secure: true, + }, + }, + + }, + build: { + outDir: 'tls-manager', + emptyOutDir: true, + }, +}) diff --git a/server/mirth-build.xml b/server/mirth-build.xml index 73dda90fd..94b5b3d7f 100644 --- a/server/mirth-build.xml +++ b/server/mirth-build.xml @@ -33,6 +33,21 @@ --> + + + + + + + + + + + + + + + @@ -126,7 +141,7 @@ - +